diff --git a/.env.example b/.env.example index 570f6c6..bf9d3ff 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ OMO_PULSE_API_PORT=4301 # Set to "true" in CI/CD environments to prevent Playwright from reusing existing servers # In local development, leave unset (defaults to false) CI= + +# Telegram Notifications (optional) +# Get token from @BotFather, chat ID from @userinfobot or the getUpdates API +# When both are set, omo-pulse pushes a pinned status message to the chat +# and sends alert notifications for question/error/plan_complete transitions. +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= diff --git a/README.md b/README.md index 7daf633..623501b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Runtime: Bun](https://img.shields.io/badge/Runtime-Bun-%23f9f1e1?logo=bun)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue?logo=typescript)](https://www.typescriptlang.org/) [![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey)](https://github.com/sponsors/EZotoff) +[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/ezotoff) ![Dashboard โ€” multi-project view with session activity and token usage](docs/screenshots/details-collapsed.png) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index cc507b3..fa9243e 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -22,16 +22,6 @@ vi.mock("../ingest/sources-registry", () => ({ getSourceById: vi.fn(() => null), })) -vi.mock("../server/multi-project", () => ({ - createMultiProjectService: vi.fn(() => ({ - getMultiProjectPayload: vi.fn(async (): Promise => ({ - projects: [], - serverNowMs: Date.now(), - pollIntervalMs: 2000, - })), - })), -})) - vi.mock("../ingest/session", () => ({ getStorageRoots: vi.fn(() => ({ session: "/tmp/session", @@ -63,7 +53,6 @@ vi.mock("../ingest/sqlite-derive", () => ({ // Import AFTER mocking // --------------------------------------------------------------------------- import { createApi } from "../server/api" -import { createMultiProjectService } from "../server/multi-project" import type { ProjectSnapshot } from "../types" // --------------------------------------------------------------------------- @@ -83,6 +72,8 @@ function makeProjectSnapshot(overrides: Partial = {}): ProjectS sessionId: "ses_abc", status: "idle", }, + sessions: [], + aggregateStatus: "idle", planProgress: { name: "plan-1", completed: 3, @@ -93,6 +84,7 @@ function makeProjectSnapshot(overrides: Partial = {}): ProjectS planStale: false, planComplete: false, }, + unintiatedPlans: [], timeSeries: { windowMs: 300000, bucketMs: 2000, @@ -127,12 +119,13 @@ describe("API routes", () => { serverNowMs: Date.now(), pollIntervalMs: 2000, })), + invalidate: vi.fn(), } - vi.mocked(createMultiProjectService).mockReturnValue(mockService) app = createApi({ storageRoot: "/tmp/test-storage", storageBackend: { kind: "sqlite", dataDir: "/tmp", sqlitePath: "/tmp/test.db" }, + multiProjectService: mockService, version: "1.0.0-test", }) }) diff --git a/src/__tests__/question-bridge.test.ts b/src/__tests__/question-bridge.test.ts index be20b9c..716536c 100644 --- a/src/__tests__/question-bridge.test.ts +++ b/src/__tests__/question-bridge.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { afterEach, describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { deriveBackgroundTasks } from "../ingest/background-tasks" import { getMainSessionView, type OpenCodeStorageRoots, type SessionMetadata, type StoredMessageMeta, type StoredToolPart } from "../ingest/session" @@ -32,6 +32,8 @@ function makeTempStorage(): OpenCodeStorageRoots { } afterEach(() => { + vi.resetModules() + vi.doUnmock("../ingest/storage-backend") while (tempDirs.length > 0) { const dir = tempDirs.pop() if (dir) fs.rmSync(dir, { recursive: true, force: true }) @@ -163,4 +165,115 @@ describe("background question bridge", () => { expect(view.status).toBe("question") expect(view.currentTool).toBe("mcp_question") }) + + it("surfaces question for SQLite background tasks and main-session fallback", async () => { + vi.doMock("../ingest/storage-backend", () => { + const mainSessionMeta: SessionMetadata = { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: 900_000, updated: 999_000 }, + } + const childSessionMeta: SessionMetadata = { + id: "ses-child", + projectID: "proj-1", + directory: "/tmp/project", + parentID: "ses-main", + title: "Ask the user (@atlas subagent)", + time: { created: 999_100, updated: 999_900 }, + } + const mainMeta: StoredMessageMeta = { + id: "msg-main", + sessionID: "ses-main", + role: "assistant", + time: { created: 999_000, completed: 999_100 }, + agent: "build", + } + const childMeta: StoredMessageMeta = { + id: "msg-child", + sessionID: "ses-child", + role: "assistant", + time: { created: 999_900 }, + agent: "atlas", + } + const mainTaskPart: PersistedToolPart = { + id: "part-main", + sessionID: "ses-main", + messageID: "msg-main", + type: "tool", + callID: "call-main", + tool: "background_task", + state: { + status: "completed", + input: { + description: "Ask the user", + run_in_background: true, + subagent_type: "atlas", + }, + metadata: { sessionId: "ses-child" }, + time: { start: 999_050 }, + }, + } + const childQuestionPart: StoredToolPart = { + id: "part-child", + sessionID: "ses-child", + messageID: "msg-child", + type: "tool", + callID: "call-child", + tool: "mcp_question", + state: { + status: "pending", + input: {}, + }, + } + + return { + readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta, childSessionMeta] })), + readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [{ id: "ses-child" }] })), + readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { + if (sessionId === "ses-main") return { ok: true as const, rows: [mainMeta] } + if (sessionId === "ses-child") return { ok: true as const, rows: [childMeta] } + return { ok: true as const, rows: [] } + }), + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { + const rows: StoredToolPart[] = [] + if (messageIds.includes("msg-main")) rows.push(mainTaskPart) + if (messageIds.includes("msg-child")) rows.push(childQuestionPart) + return { ok: true as const, rows } + }), + } + }) + + const { deriveBackgroundTasksSqlite, getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const tasksResult = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs: 1_000_000, + }) + + expect(tasksResult.ok).toBe(true) + if (!tasksResult.ok) throw new Error("expected sqlite background tasks") + expect(tasksResult.value[0]?.status).toBe("question") + expect(tasksResult.value[0]?.lastTool).toBe("mcp_question") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: 900_000, updated: 999_000 }, + }, + nowMs: 1_000_000, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).toBe("question") + expect(viewResult.value.currentTool).toBe("mcp_question") + }) }) diff --git a/src/__tests__/session-inclusion.test.ts b/src/__tests__/session-inclusion.test.ts index b002378..9b3419c 100644 --- a/src/__tests__/session-inclusion.test.ts +++ b/src/__tests__/session-inclusion.test.ts @@ -19,19 +19,19 @@ type SessionRow = { type ActivePartRow = { tool: string - status: string + status?: string } -type ErrorCountRow = { - cnt: number +type TerminalPartRow = { + status: string + time_created: number } type AssistantMessageRow = { - role: string - time_completed?: number + time_completed: number | null } -type QueryRows = SessionRow[] | ActivePartRow[] | ErrorCountRow[] | AssistantMessageRow[] +type QueryRows = SessionRow[] | ActivePartRow[] | TerminalPartRow[] | AssistantMessageRow[] type MockStatement = { all: (...params: unknown[]) => QueryRows @@ -44,7 +44,7 @@ type MockDatabase = { type MockDbConfig = { sessionRows?: SessionRow[] activePartsBySession?: Record - errorCountsBySession?: Record + terminalPartsBySession?: Record assistantMessagesBySession?: Record throwOnQuery?: boolean } @@ -64,15 +64,15 @@ function createMockDb(config: MockDbConfig = {}): MockDatabase { return config.sessionRows ?? [] } - if (sql.includes("state_status = 'pending' OR state_status = 'running'")) { + if (sql.includes("'pending', 'running'")) { return sessionId ? (config.activePartsBySession?.[sessionId] ?? []) : [] } - if (sql.includes("state_status = 'error'")) { - return [{ cnt: sessionId ? (config.errorCountsBySession?.[sessionId] ?? 0) : 0 }] + if (sql.includes("'error', 'completed'")) { + return sessionId ? (config.terminalPartsBySession?.[sessionId] ?? []) : [] } - if (sql.includes("FROM message")) { + if (sql.includes("json_extract(data, '$.role') = 'assistant'")) { return sessionId ? (config.assistantMessagesBySession?.[sessionId] ?? []) : [] } @@ -273,7 +273,7 @@ describe("findIncludedSessionsSqlite", () => { expect(result.map((session) => session.id)).toEqual(["stale-question"]) }) - it("keeps stale error sessions included beyond the normal idle window", () => { + it("excludes stale error sessions once error and activity are both stale", () => { const now = Date.now() const result = runFindIncludedSessionsSqlite( createMockDb({ @@ -293,15 +293,15 @@ describe("findIncludedSessionsSqlite", () => { time_updated: now - 120000, }, ], - errorCountsBySession: { - "stale-error": 1, + terminalPartsBySession: { + "stale-error": [{ status: "error", time_created: now - 120000 }], }, }), "/home/user/project", 60000, ) - expect(result.map((session) => session.id)).toEqual(["stale-error"]) + expect(result.map((session) => session.id)).toEqual([]) }) it("does not treat generic mc_* tools as question status", () => { @@ -511,8 +511,8 @@ describe("findIncludedSessionsSqlite", () => { activePartsBySession: { "question-session": [{ tool: "mcp_question", status: "pending" }], }, - errorCountsBySession: { - "error-session": 1, + terminalPartsBySession: { + "error-session": [{ status: "error", time_created: now - 30000 }], }, }), "/home/user/project", diff --git a/src/ingest/activity-status.ts b/src/ingest/activity-status.ts index cb1fd2a..4b8dc62 100644 --- a/src/ingest/activity-status.ts +++ b/src/ingest/activity-status.ts @@ -2,6 +2,7 @@ import { TASK_TOOL_NAMES } from "./tool-names" export const ACTIVE_STALE_MS = 10 * 60_000 export const ACTIVE_BUSY_WINDOW_MS = 60_000 +export const ERROR_STALE_MS = 60_000 // Errors become stale after 1 minute export const BACKGROUND_RUNNING_WINDOW_MS = 15_000 export const BACKGROUND_QUEUE_STALE_MS = 15 * 60_000 @@ -22,3 +23,16 @@ export function resolveLastUpdatedTime(primary: number | null, fallback: number export function shouldSuppressStaleToolActivity(toolName: string, hasFreshActivity: boolean): boolean { return !hasFreshActivity && TASK_TOOL_NAMES.has(toolName) } + +export function getTerminalErrorMessageCreatedAt(opts: { + orderedMessages: readonly T[] + getCreatedAt: (message: T) => number | null + hasErrorPart: (message: T) => boolean +}): number | null { + for (const message of opts.orderedMessages) { + const createdAt = opts.getCreatedAt(message) + if (typeof createdAt !== "number") continue + return opts.hasErrorPart(message) ? createdAt : null + } + return null +} diff --git a/src/ingest/session-diff.ts b/src/ingest/session-diff.ts new file mode 100644 index 0000000..6683a77 --- /dev/null +++ b/src/ingest/session-diff.ts @@ -0,0 +1,128 @@ +import type { PlanStatus, SessionStatus, SessionSummary, SoundConfig } from "../types" + +export type SessionStatusMap = Map + +export type SessionStatusChange = { + from: SessionStatus + to: SessionStatus +} + +export type SessionStatusDiff = { + newSessions: SessionStatusMap + changedSessions: Map + removedSessions: Set + planCompleted: boolean +} + +export type SessionDiffOptions = { + prevPlanStatus?: PlanStatus + currPlanStatus?: PlanStatus +} + +export type SoundPlaybackDecision = { + playWaiting: boolean + playAllClear: boolean + playAttention: boolean + playQuestion: boolean +} + +const ACTIVE_SESSION_STATUSES = new Set(["busy", "running_tool", "thinking"]) + +function hasStatus(map: SessionStatusMap | Map, target: SessionStatus): boolean { + for (const value of map.values()) { + if (typeof value === "string") { + if (value === target) return true + continue + } + + if (value.to === target) return true + } + + return false +} + +function hasIdleFromActive(changedSessions: Map): boolean { + for (const change of changedSessions.values()) { + if (change.to === "idle" && ACTIVE_SESSION_STATUSES.has(change.from)) { + return true + } + } + + return false +} + +export function buildSessionStatusMap(sessions: SessionSummary[]): SessionStatusMap { + const sessionStatusMap: SessionStatusMap = new Map() + + for (const session of sessions) { + sessionStatusMap.set(session.sessionId, session.status) + } + + return sessionStatusMap +} + +export function diffSessionStatuses( + prev: SessionStatusMap, + curr: SessionStatusMap, + options: SessionDiffOptions = {}, +): SessionStatusDiff { + const newSessions: SessionStatusMap = new Map() + const changedSessions = new Map() + const removedSessions = new Set() + + for (const [sessionId, status] of curr) { + const prevStatus = prev.get(sessionId) + + if (prevStatus === undefined) { + newSessions.set(sessionId, status) + continue + } + + if (prevStatus !== status) { + changedSessions.set(sessionId, { from: prevStatus, to: status }) + } + } + + for (const sessionId of prev.keys()) { + if (!curr.has(sessionId)) { + removedSessions.add(sessionId) + } + } + + return { + newSessions, + changedSessions, + removedSessions, + planCompleted: options.prevPlanStatus === "in progress" && options.currPlanStatus === "complete", + } +} + +export function shouldPlaySound(diff: SessionStatusDiff, config: SoundConfig): SoundPlaybackDecision { + if (!config.enabled) { + return { + playWaiting: false, + playAllClear: false, + playAttention: false, + playQuestion: false, + } + } + + const playQuestion = config.onQuestion + ? hasStatus(diff.newSessions, "question") || hasStatus(diff.changedSessions, "question") + : false + + const playAttention = config.onSessionError + ? hasStatus(diff.newSessions, "error") || hasStatus(diff.changedSessions, "error") + : false + + const playWaiting = config.onSessionIdle ? hasIdleFromActive(diff.changedSessions) : false + + const playAllClear = config.onPlanComplete ? diff.planCompleted : false + + return { + playWaiting, + playAllClear, + playAttention, + playQuestion, + } +} diff --git a/src/ingest/session-inclusion.ts b/src/ingest/session-inclusion.ts index 7cfb49d..af930f4 100644 --- a/src/ingest/session-inclusion.ts +++ b/src/ingest/session-inclusion.ts @@ -1,7 +1,7 @@ import * as path from "node:path" import { Database } from "bun:sqlite" import { realpathSafe } from "./paths" -import { ACTIVE_BUSY_WINDOW_MS } from "./activity-status" +import { ACTIVE_BUSY_WINDOW_MS, ERROR_STALE_MS } from "./activity-status" import type { SessionMetadata } from "./session" import { QUESTION_TOOL_NAMES } from "./tool-names" @@ -34,14 +34,14 @@ function normalizePath(dir: string): string { */ function deriveSessionStatus(db: Database, session: SessionMetadata, nowMs: number): string { try { - // Check for active tool (pending or running) const activeParts = db .query( - `SELECT tool, status FROM part - WHERE session_id = ? AND (state_status = 'pending' OR state_status = 'running') - ORDER BY created DESC LIMIT 1` + `SELECT json_extract(data, '$.tool') as tool + FROM part + WHERE session_id = ? AND json_extract(data, '$.state.status') IN ('pending', 'running') + ORDER BY time_created DESC LIMIT 1` ) - .all(session.id) as Array<{ tool: string; status: string }> + .all(session.id) as Array<{ tool: string }> if (activeParts.length > 0) { if (QUESTION_TOOL_NAMES.has(activeParts[0].tool)) { @@ -50,42 +50,41 @@ function deriveSessionStatus(db: Database, session: SessionMetadata, nowMs: numb return "running_tool" } - // Check for error tool - const errorParts = db + const lastTerminal = db .query( - `SELECT COUNT(*) as cnt FROM part - WHERE session_id = ? AND state_status = 'error' - LIMIT 1` + `SELECT time_created, json_extract(data, '$.state.status') as status + FROM part + WHERE session_id = ? AND json_extract(data, '$.state.status') IN ('error', 'completed') + ORDER BY time_created DESC LIMIT 1` ) - .all(session.id) as Array<{ cnt: number }> + .all(session.id) as Array<{ time_created: number; status: string }> - if (errorParts.length > 0 && errorParts[0].cnt > 0) { + const lastUpdated = session.time.updated ?? session.time.created ?? 0 + const ageMs = nowMs - lastUpdated + const isStaleActivity = ageMs > ACTIVE_BUSY_WINDOW_MS + const latestTerminalStatus = lastTerminal[0]?.status + const latestTerminalAt = lastTerminal[0]?.time_created + const isTerminalErrorStale = typeof latestTerminalAt !== "number" || (nowMs - latestTerminalAt > ERROR_STALE_MS) + + if (!isStaleActivity && latestTerminalStatus === "error" && !isTerminalErrorStale) { return "error" } - // Check for recent assistant message (thinking) const recentMessages = db .query( - `SELECT role, time_completed FROM message - WHERE session_id = ? AND role = 'assistant' - ORDER BY created DESC LIMIT 1` + `SELECT json_extract(data, '$.time.completed') as time_completed + FROM message + WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant' + ORDER BY time_created DESC LIMIT 1` ) - .all(session.id) as Array<{ role: string; time_completed?: number }> + .all(session.id) as Array<{ time_completed: number | null }> - if ( - recentMessages.length > 0 && - recentMessages[0].role === "assistant" && - recentMessages[0].time_completed === undefined - ) { + if (recentMessages.length > 0 && recentMessages[0].time_completed === null) { return "thinking" } - // Default: distinguish busy vs idle based on canonical ACTIVE_BUSY_WINDOW_MS threshold - const lastUpdated = session.time.updated ?? session.time.created ?? 0 - const ageMs = nowMs - lastUpdated return ageMs <= ACTIVE_BUSY_WINDOW_MS ? "busy" : "idle" } catch { - // On any error, return unknown return "unknown" } } @@ -124,6 +123,7 @@ export function findIncludedSessionsSqlite( }> const sessions: SessionMetadata[] = [] + const statusCache = new Map() for (const row of sessionRows) { if (typeof row.id !== "string" || typeof row.directory !== "string") continue @@ -144,11 +144,13 @@ export function findIncludedSessionsSqlite( time: { created: timeCreated, updated: timeUpdated }, } + const status = deriveSessionStatus(db, meta, nowMs) if ( isSessionIncluded(meta, idleWindowMs, nowMs) || - isAttentionStatus(deriveSessionStatus(db, meta, nowMs)) + isAttentionStatus(status) ) { sessions.push(meta) + statusCache.set(meta.id, status) } } @@ -156,8 +158,8 @@ export function findIncludedSessionsSqlite( // Then recency: most recent activity first (time.updated DESC) // Finally stable tie-breaker: id ascending sessions.sort((a, b) => { - const aStatus = deriveSessionStatus(db, a, nowMs) - const bStatus = deriveSessionStatus(db, b, nowMs) + const aStatus = statusCache.get(a.id) ?? "unknown" + const bStatus = statusCache.get(b.id) ?? "unknown" const aSeverity = STATUS_SEVERITY[aStatus] ?? 6 const bSeverity = STATUS_SEVERITY[bStatus] ?? 6 diff --git a/src/ingest/session.ts b/src/ingest/session.ts index 2194e7b..a661ca9 100644 --- a/src/ingest/session.ts +++ b/src/ingest/session.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs" import * as path from "node:path" import { ACTIVE_BUSY_WINDOW_MS, + ERROR_STALE_MS, hasFreshMainSessionActivity, resolveLastUpdatedTime, shouldSuppressStaleToolActivity, @@ -283,23 +284,29 @@ function readLastToolPart(partStorage: string, messageID: string): { tool: strin return null } -function hasErrorToolPart(partStorage: string, messageID: string): boolean { +function messageTerminalToolStatus(partStorage: string, messageID: string): "error" | "completed" | null { const partDir = path.join(partStorage, messageID) - if (!fs.existsSync(partDir)) return false + if (!fs.existsSync(partDir)) return null + let hasError = false + let hasCompleted = false const files = fs.readdirSync(partDir).filter((f) => f.endsWith(".json")) for (const file of files) { try { const content = fs.readFileSync(path.join(partDir, file), "utf8") const part = JSON.parse(content) as Partial - if (part.type === "tool" && part.state?.status === "error") { - return true + if (part.type === "tool") { + if (part.state?.status === "error") hasError = true + else if (part.state?.status === "completed") hasCompleted = true } } catch { continue } } - return false + + if (hasError) return "error" + if (hasCompleted) return "completed" + return null } export function getMainSessionView(opts: { @@ -332,11 +339,14 @@ export function getMainSessionView(opts: { } } - let hasErrorTool = false + let latestTerminalStatus: "error" | "completed" | null = null + let latestTerminalAt: number | null = null if (!activeTool) { for (const meta of recentMetas) { - if (hasErrorToolPart(opts.storage.part, meta.id)) { - hasErrorTool = true + const terminal = messageTerminalToolStatus(opts.storage.part, meta.id) + if (terminal !== null) { + latestTerminalStatus = terminal + latestTerminalAt = typeof meta.time?.created === "number" ? meta.time.created : null break } } @@ -344,6 +354,7 @@ export function getMainSessionView(opts: { const hasFreshActivity = hasFreshMainSessionActivity(lastUpdated, nowMs) const isStaleActivity = typeof lastUpdated === "number" && !hasFreshActivity + const isTerminalErrorStale = typeof latestTerminalAt !== "number" || (nowMs - latestTerminalAt > ERROR_STALE_MS) let status: MainSessionView["status"] = "unknown" if (activeTool?.status === "pending" || activeTool?.status === "running") { @@ -354,7 +365,7 @@ export function getMainSessionView(opts: { } } - if (status === "unknown" && !isStaleActivity && hasErrorTool) { + if (status === "unknown" && !isStaleActivity && latestTerminalStatus === "error" && !isTerminalErrorStale) { status = "error" } else if (status === "unknown" && !isStaleActivity && recent?.role === "assistant" && typeof recent?.time?.created === "number" && typeof recent?.time?.completed !== "number") { status = "thinking" @@ -368,7 +379,9 @@ export function getMainSessionView(opts: { mainSessionId: opts.sessionId, nowMs, }) - const questionTask = bgTasks.find((t) => t.status === "question") + const questionTask = bgTasks.find( + (t) => t.status === "question" || ((t.status === "running" || t.status === "queued") && QUESTION_TOOL_NAMES.has(t.lastTool ?? "")) + ) if (questionTask) { status = "question" if (!activeTool) activeTool = { tool: questionTask.lastTool ?? "question", status: "running" } diff --git a/src/ingest/sqlite-derive.ts b/src/ingest/sqlite-derive.ts index 1c769bc..4c6377f 100644 --- a/src/ingest/sqlite-derive.ts +++ b/src/ingest/sqlite-derive.ts @@ -1,6 +1,8 @@ +import type { Database } from "bun:sqlite" import { ACTIVE_BUSY_WINDOW_MS, BACKGROUND_RUNNING_WINDOW_MS, + ERROR_STALE_MS, hasFreshMainSessionActivity, resolveLastUpdatedTime, shouldSuppressStaleToolActivity, @@ -43,6 +45,55 @@ const SERIES_ORDER: Array> = [ { id: "background-total", label: "Background tasks (total)", tone: "muted" }, ] +function normalizeSessionIds(values: Array): string[] { + const sessionIds: string[] = [] + const seen = new Set() + + for (const value of values) { + if (typeof value !== "string") continue + const id = value.trim() + if (!id || seen.has(id)) continue + seen.add(id) + sessionIds.push(id) + } + + return sessionIds +} + +function createEmptyTimeSeriesPayload(opts: { + nowMs: number + windowMs: number + bucketMs: number +}): TimeSeriesPayload { + const buckets = Math.floor(opts.windowMs / opts.bucketMs) + + return { + windowMs: opts.windowMs, + bucketMs: opts.bucketMs, + buckets, + anchorMs: Math.floor(opts.nowMs / opts.bucketMs) * opts.bucketMs, + serverNowMs: opts.nowMs, + series: SERIES_ORDER.map((series) => ({ + ...series, + values: zeroBuckets(buckets), + })), + } +} + +function mergeTimeSeriesPayload(target: TimeSeriesPayload, source: TimeSeriesPayload): void { + const targetSeries = new Map(target.series.map((series) => [series.id, series] as const)) + + for (const series of source.series) { + const existing = targetSeries.get(series.id) + if (!existing) continue + + const limit = Math.min(existing.values.length, series.values.length) + for (let index = 0; index < limit; index += 1) { + existing.values[index] += series.values[index] ?? 0 + } + } +} + function readStartTimeFromToolPart(part: unknown): number | null { if (!part || typeof part !== "object") return null const rec = part as Record @@ -167,17 +218,20 @@ function readSessionMessagesAndParts(opts: { sqlitePath: string sessionId: string limit: number + db?: Database }): SqliteDeriveResult<{ metas: StoredMessageMeta[]; partsByMessage: Map }> { const metasResult = readRecentMessageMetasSqlite({ sqlitePath: opts.sqlitePath, sessionId: opts.sessionId, limit: opts.limit, + db: opts.db, }) if (!metasResult.ok) return metasResult const messageIds = metasResult.rows.map((meta) => meta.id) const partsResult = readToolPartsForMessagesSqlite({ sqlitePath: opts.sqlitePath, messageIds, + db: opts.db, }) if (!partsResult.ok) return partsResult @@ -317,10 +371,12 @@ export function pickActiveSessionIdSqlite(opts: { sqlitePath: string projectRoot: string boulderSessionIds?: string[] + db?: Database }): SqliteDeriveResult { const metasResult = readMainSessionMetasSqlite({ sqlitePath: opts.sqlitePath, directoryFilter: opts.projectRoot, + db: opts.db, }) if (!metasResult.ok) return metasResult @@ -354,7 +410,7 @@ export function pickActiveSessionIdSqlite(opts: { const ids = opts.boulderSessionIds ?? [] for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i] - const messages = readRecentMessageMetasSqlite({ sqlitePath: opts.sqlitePath, sessionId: id, limit: 1 }) + const messages = readRecentMessageMetasSqlite({ sqlitePath: opts.sqlitePath, sessionId: id, limit: 1, db: opts.db }) if (!messages.ok) return messages if (messages.rows.length === 0) continue @@ -378,12 +434,14 @@ export function getMainSessionViewSqlite(opts: { sessionId: string sessionMeta?: SessionMetadata | null nowMs?: number + db?: Database }): SqliteDeriveResult { const nowMs = opts.nowMs ?? Date.now() const session = readSessionMessagesAndParts({ sqlitePath: opts.sqlitePath, sessionId: opts.sessionId, limit: 200, + db: opts.db, }) if (!session.ok) return session @@ -406,20 +464,25 @@ export function getMainSessionViewSqlite(opts: { if (activeTool) break } - let hasErrorTool = false + let latestTerminalStatus: "error" | "completed" | null = null + let latestTerminalAt: number | null = null if (!activeTool) { - for (const meta of session.value.metas) { + findLastTerminal: for (const meta of session.value.metas) { const parts = session.value.partsByMessage.get(meta.id) ?? [] - const errorPart = parts.find((part) => part.state.status === "error") - if (errorPart) { - hasErrorTool = true - break + for (let i = parts.length - 1; i >= 0; i -= 1) { + const status = parts[i]?.state.status + if (status === "error" || status === "completed") { + latestTerminalStatus = status + latestTerminalAt = typeof meta.time?.created === "number" ? meta.time.created : null + break findLastTerminal + } } } } const hasFreshActivity = hasFreshMainSessionActivity(lastUpdated, nowMs) const isStaleActivity = typeof lastUpdated === "number" && !hasFreshActivity + const isTerminalErrorStale = typeof latestTerminalAt !== "number" || (nowMs - latestTerminalAt > ERROR_STALE_MS) let status: MainSessionView["status"] = "unknown" if (activeTool?.status === "pending" || activeTool?.status === "running") { @@ -430,7 +493,7 @@ export function getMainSessionViewSqlite(opts: { } } - if (status === "unknown" && !isStaleActivity && hasErrorTool) { + if (status === "unknown" && !isStaleActivity && latestTerminalStatus === "error" && !isTerminalErrorStale) { status = "error" } else if (status === "unknown" && !isStaleActivity && recent?.role === "assistant" && typeof recent.time?.created === "number" && typeof recent.time?.completed !== "number") { status = "thinking" @@ -473,16 +536,18 @@ export function deriveBackgroundTasksSqlite(opts: { sqlitePath: string mainSessionId: string nowMs?: number + db?: Database }): SqliteDeriveResult { const nowMs = opts.nowMs ?? Date.now() const main = readSessionMessagesAndParts({ sqlitePath: opts.sqlitePath, sessionId: opts.mainSessionId, limit: 200, + db: opts.db, }) if (!main.ok) return main - const allSessionMetasResult = readAllSessionMetasSqlite({ sqlitePath: opts.sqlitePath }) + const allSessionMetasResult = readAllSessionMetasSqlite({ sqlitePath: opts.sqlitePath, db: opts.db }) if (!allSessionMetasResult.ok) return allSessionMetasResult const allSessionMetas = allSessionMetasResult.rows const sessionMetaById = new Map(allSessionMetas.map((m) => [m.id, m] as const)) @@ -501,6 +566,7 @@ export function deriveBackgroundTasksSqlite(opts: { sqlitePath: opts.sqlitePath, sessionId, limit: 200, + db: opts.db, }) if (!loaded.ok) return loaded backgroundMessageCache.set(sessionId, loaded.value.metas) @@ -648,12 +714,36 @@ export function deriveBackgroundTasksSqlite(opts: { return { ok: true, value: rows } } +export function deriveBackgroundTasksSqliteForSessions(opts: { + sqlitePath: string + mainSessionIds?: Array + nowMs?: number + db?: Database +}): SqliteDeriveResult { + const sessionIds = normalizeSessionIds(opts.mainSessionIds ?? []) + const rows: BackgroundTaskRow[] = [] + + for (const sessionId of sessionIds) { + const result = deriveBackgroundTasksSqlite({ + sqlitePath: opts.sqlitePath, + mainSessionId: sessionId, + nowMs: opts.nowMs, + db: opts.db, + }) + if (!result.ok) return result + rows.push(...result.value) + } + + return { ok: true, value: rows } +} + export function deriveTimeSeriesActivitySqlite(opts: { sqlitePath: string mainSessionId: string | null nowMs?: number windowMs?: number bucketMs?: number + db?: Database }): SqliteDeriveResult { const windowMs = opts.windowMs ?? 300_000 const bucketMs = opts.bucketMs ?? 2_000 @@ -668,7 +758,7 @@ export function deriveTimeSeriesActivitySqlite(opts: { const atlas = zeroBuckets(buckets) const background = zeroBuckets(buckets) - const allSessionMetas = readAllSessionMetasSqlite({ sqlitePath: opts.sqlitePath }) + const allSessionMetas = readAllSessionMetasSqlite({ sqlitePath: opts.sqlitePath, db: opts.db }) if (!allSessionMetas.ok) return allSessionMetas const perSessionCache = new Map }>() @@ -679,6 +769,7 @@ export function deriveTimeSeriesActivitySqlite(opts: { sqlitePath: opts.sqlitePath, sessionId, limit: 200, + db: opts.db, }) if (!loaded.ok) return loaded perSessionCache.set(sessionId, loaded.value) @@ -757,23 +848,42 @@ export function deriveTimeSeriesActivitySqlite(opts: { } } -export function deriveTokenUsageSqlite(opts: { +export function deriveTimeSeriesActivitySqliteForSessions(opts: { sqlitePath: string - mainSessionId: string | null - backgroundSessionIds?: Array -}): SqliteDeriveResult> { - const sessionIds: string[] = [] - const seen = new Set() - const push = (value: unknown): void => { - if (typeof value !== "string") return - const id = value.trim() - if (!id || seen.has(id)) return - seen.add(id) - sessionIds.push(id) + mainSessionIds?: Array + nowMs?: number + windowMs?: number + bucketMs?: number + db?: Database +}): SqliteDeriveResult { + const nowMs = opts.nowMs ?? Date.now() + const windowMs = opts.windowMs ?? 300_000 + const bucketMs = opts.bucketMs ?? 2_000 + const payload = createEmptyTimeSeriesPayload({ nowMs, windowMs, bucketMs }) + const sessionIds = normalizeSessionIds(opts.mainSessionIds ?? []) + + for (const sessionId of sessionIds) { + const result = deriveTimeSeriesActivitySqlite({ + sqlitePath: opts.sqlitePath, + mainSessionId: sessionId, + nowMs, + windowMs, + bucketMs, + db: opts.db, + }) + if (!result.ok) return result + mergeTimeSeriesPayload(payload, result.value) } - push(opts.mainSessionId) - for (const id of opts.backgroundSessionIds ?? []) push(id) + return { ok: true, value: payload } +} + +export function deriveTokenUsageSqliteForSessions(opts: { + sqlitePath: string + sessionIds?: Array + db?: Database +}): SqliteDeriveResult> { + const sessionIds = normalizeSessionIds(opts.sessionIds ?? []) const metas: unknown[] = [] for (const sessionId of sessionIds) { @@ -781,6 +891,7 @@ export function deriveTokenUsageSqlite(opts: { sqlitePath: opts.sqlitePath, sessionId, limit: TOKEN_USAGE_MESSAGE_LIMIT, + db: opts.db, }) if (!result.ok) return result metas.push(...result.rows) @@ -792,14 +903,29 @@ export function deriveTokenUsageSqlite(opts: { } } +export function deriveTokenUsageSqlite(opts: { + sqlitePath: string + mainSessionId: string | null + backgroundSessionIds?: Array + db?: Database +}): SqliteDeriveResult> { + return deriveTokenUsageSqliteForSessions({ + sqlitePath: opts.sqlitePath, + sessionIds: [opts.mainSessionId, ...(opts.backgroundSessionIds ?? [])], + db: opts.db, + }) +} + export function deriveToolCallsSqlite(opts: { sqlitePath: string sessionId: string + db?: Database }): SqliteDeriveResult { const metasResult = readRecentMessageMetasSqlite({ sqlitePath: opts.sqlitePath, sessionId: opts.sessionId, limit: MAX_TOOL_CALL_MESSAGES, + db: opts.db, }) if (!metasResult.ok) return metasResult @@ -807,6 +933,7 @@ export function deriveToolCallsSqlite(opts: { const existsResult = readSessionExistsSqlite({ sqlitePath: opts.sqlitePath, sessionId: opts.sessionId, + db: opts.db, }) if (!existsResult.ok) return existsResult return { @@ -822,6 +949,7 @@ export function deriveToolCallsSqlite(opts: { const partsResult = readToolPartsForMessagesSqlite({ sqlitePath: opts.sqlitePath, messageIds: metasResult.rows.map((meta) => meta.id), + db: opts.db, }) if (!partsResult.ok) return partsResult @@ -878,10 +1006,12 @@ export function deriveToolCallsSqlite(opts: { export function deriveTodosSqlite(opts: { sqlitePath: string sessionId: string + db?: Database }): SqliteDeriveResult { const result = readTodosSqlite({ sqlitePath: opts.sqlitePath, sessionId: opts.sessionId, + db: opts.db, }) if (!result.ok) return result @@ -890,3 +1020,24 @@ export function deriveTodosSqlite(opts: { value: result.rows, } } + +export function deriveTodosSqliteForSessions(opts: { + sqlitePath: string + sessionIds?: Array + db?: Database +}): SqliteDeriveResult { + const sessionIds = normalizeSessionIds(opts.sessionIds ?? []) + const rows: TodoItem[] = [] + + for (const sessionId of sessionIds) { + const result = deriveTodosSqlite({ + sqlitePath: opts.sqlitePath, + sessionId, + db: opts.db, + }) + if (!result.ok) return result + rows.push(...result.value) + } + + return { ok: true, value: rows } +} diff --git a/src/ingest/storage-backend.ts b/src/ingest/storage-backend.ts index 84cfdfc..a500a2b 100644 --- a/src/ingest/storage-backend.ts +++ b/src/ingest/storage-backend.ts @@ -62,6 +62,25 @@ function withReadonlyDb(sqlitePath: string, fn: (db: BunDatabase) => T): { ok } } +/** + * Run a query against a pre-opened DB (no open/close overhead) or fall back + * to opening a fresh readonly connection via `withReadonlyDb`. + */ +function withDbOrOpen( + db: BunDatabase | undefined, + sqlitePath: string, + fn: (db: BunDatabase) => T, +): { ok: true; value: T } | { ok: false; reason: SqliteReadFailureReason } { + if (db) { + try { + return { ok: true, value: fn(db) } + } catch (error) { + return { ok: false, reason: classifySqliteError(error) } + } + } + return withReadonlyDb(sqlitePath, fn) +} + function asFiniteNumber(value: unknown): number | null { if (typeof value !== "number") return null return Number.isFinite(value) ? value : null @@ -82,6 +101,7 @@ function isToolStatus(value: unknown): value is StoredToolPart["state"]["status" export function readMainSessionMetasSqlite(opts: { sqlitePath: string directoryFilter?: string + db?: BunDatabase }): SqliteReadResult { const directoryNeedle = typeof opts.directoryFilter === "string" && opts.directoryFilter.length > 0 ? (() => { @@ -91,7 +111,7 @@ export function readMainSessionMetasSqlite(opts: { })() : null - const result = withReadonlyDb(opts.sqlitePath, (db) => + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => db .query("SELECT id, project_id, parent_id, directory, title, time_created, time_updated FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC, id DESC") .all() as Array<{ @@ -141,8 +161,9 @@ export function readMainSessionMetasSqlite(opts: { export function readAllSessionMetasSqlite(opts: { sqlitePath: string + db?: BunDatabase }): SqliteReadResult { - const result = withReadonlyDb(opts.sqlitePath, (db) => + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => db .query("SELECT id, project_id, parent_id, directory, title, time_created, time_updated FROM session ORDER BY time_updated DESC, id DESC") .all() as Array<{ @@ -187,8 +208,9 @@ export function readAllSessionMetasSqlite(opts: { export function readSessionExistsSqlite(opts: { sqlitePath: string sessionId: string + db?: BunDatabase }): SqliteReadResult<{ sessionId: string }> { - const result = withReadonlyDb(opts.sqlitePath, (db) => + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => db .query("SELECT id FROM session WHERE id = ? LIMIT 1") .get(opts.sessionId) as { id?: unknown } | null @@ -204,8 +226,9 @@ export function readRecentMessageMetasSqlite(opts: { sqlitePath: string sessionId: string limit: number + db?: BunDatabase }): SqliteReadResult { - const result = withReadonlyDb(opts.sqlitePath, (db) => + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => db .query("SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT ?") .all(opts.sessionId, opts.limit) as Array<{ @@ -267,12 +290,13 @@ export function readRecentMessageMetasSqlite(opts: { export function readToolPartsForMessagesSqlite(opts: { sqlitePath: string messageIds: string[] + db?: BunDatabase }): SqliteReadResult { if (opts.messageIds.length === 0) return { ok: true, rows: [] } const placeholders = opts.messageIds.map(() => "?").join(",") const sql = `SELECT id, message_id, session_id, time_created, data FROM part WHERE message_id IN (${placeholders}) ORDER BY message_id ASC, time_created ASC, id ASC` - const result = withReadonlyDb(opts.sqlitePath, (db) => + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => db.query(sql).all(...opts.messageIds) as Array<{ id: unknown message_id: unknown @@ -337,8 +361,9 @@ export type TodoItem = { export function readTodosSqlite(opts: { sqlitePath: string sessionId: string + db?: BunDatabase }): SqliteReadResult { - const result = withReadonlyDb(opts.sqlitePath, (db) => { + const result = withDbOrOpen(opts.db, opts.sqlitePath, (db) => { try { return db .query("SELECT content, status, priority, position FROM todo WHERE session_id = ? ORDER BY position ASC") diff --git a/src/server/api.ts b/src/server/api.ts index 949a0d0..3eed73e 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -2,36 +2,31 @@ import { Hono } from "hono" import * as path from "node:path" import * as fs from "node:fs" import { homedir } from "node:os" -import { - addOrUpdateSource, - deleteSourceById, - getDefaultSourceId, - listSources, - updateSourceLabelById, -} from "../ingest/sources-registry" +import { listSources, getDefaultSourceId, addOrUpdateSource, updateSourceLabelById, deleteSourceById } from "../ingest/sources-registry" import { getStorageRoots, getMessageDir } from "../ingest/session" import { assertAllowedPath } from "../ingest/paths" import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "../ingest/tool-calls" import { deriveToolCallsSqlite } from "../ingest/sqlite-derive" import type { StorageBackend } from "../ingest/storage-backend" -import { createMultiProjectService } from "./multi-project" +import type { DashboardMultiProjectPayload, TelegramServiceStatus } from "../types" const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +export type MultiProjectService = { + getMultiProjectPayload: () => Promise + invalidate: () => void +} + export function createApi(opts: { storageRoot: string storageBackend: StorageBackend - pollIntervalMs?: number + multiProjectService: MultiProjectService + telegramStatus?: () => TelegramServiceStatus version?: string }): Hono { const api = new Hono() const version = opts.version ?? "0.0.0" - - const multiProjectService = createMultiProjectService({ - storageRoot: opts.storageRoot, - storageBackend: opts.storageBackend, - pollIntervalMs: opts.pollIntervalMs, - }) + const multiProjectService = opts.multiProjectService const invalidateProjects = (): void => { multiProjectService.invalidate() } @@ -89,30 +84,29 @@ export function createApi(opts: { return c.json({ ok: true, sourceId }) }) + // --------------------------------------------------------------------------- + // PUT /sources/:sourceId โ€” update project label + // --------------------------------------------------------------------------- api.put("/sources/:sourceId", async (c) => { const sourceId = c.req.param("sourceId") const body = await c.req.json<{ label?: string }>() - - if (body.label !== undefined && typeof body.label !== "string") { - return c.json({ ok: false, error: "label must be a string when provided" }, 400) - } - const updated = updateSourceLabelById(opts.storageRoot, sourceId, body.label) if (!updated) { - return c.json({ ok: false, error: "Source not found", sourceId }, 404) + return c.json({ ok: false, error: "Source not found" }, 404) } - invalidateProjects() return c.json({ ok: true, sourceId }) }) - api.delete("/sources/:sourceId", (c) => { + // --------------------------------------------------------------------------- + // DELETE /sources/:sourceId โ€” remove a project source + // --------------------------------------------------------------------------- + api.delete("/sources/:sourceId", async (c) => { const sourceId = c.req.param("sourceId") const deleted = deleteSourceById(opts.storageRoot, sourceId) if (!deleted) { - return c.json({ ok: false, error: "Source not found", sourceId }, 404) + return c.json({ ok: false, error: "Source not found" }, 404) } - invalidateProjects() return c.json({ ok: true, sourceId }) }) @@ -131,7 +125,7 @@ export function createApi(opts: { api.get("/projects/:sourceId", async (c) => { const sourceId = c.req.param("sourceId") const payload = await multiProjectService.getMultiProjectPayload() - const project = payload.projects.find((p) => p.sourceId === sourceId) + const project = payload.projects.find((p: { sourceId: string }) => p.sourceId === sourceId) if (!project) { return c.json({ ok: false, error: "Source not found", sourceId }, 404) } @@ -267,5 +261,15 @@ export function createApi(opts: { } }) + // --------------------------------------------------------------------------- + // GET /telegram/status โ€” Telegram notification service status + // --------------------------------------------------------------------------- + api.get("/telegram/status", (c) => { + if (!opts.telegramStatus) { + return c.json({ ok: true, telegram: { enabled: false } }) + } + return c.json({ ok: true, telegram: opts.telegramStatus() }) + }) + return api } diff --git a/src/server/dashboard.ts b/src/server/dashboard.ts index 22e5711..a916e4b 100644 --- a/src/server/dashboard.ts +++ b/src/server/dashboard.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite" import * as fs from "node:fs" import { deriveBackgroundTasks } from "../ingest/background-tasks" import * as boulderModule from "../ingest/boulder" @@ -338,184 +339,175 @@ export function buildDashboardPayload(opts: { const unintiatedPlans = scanUnintiatedPlans(opts.projectRoot, boulder?.active_plan ?? null) const planHistory = readBoulderHistorySafe(opts.projectRoot) - const active = pickActiveSessionIdSqlite({ - sqlitePath: backend.sqlitePath, - projectRoot: opts.projectRoot, - boulderSessionIds: boulder?.session_ids, - }) - if (!active.ok) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } + let db: Database + try { + db = new Database(backend.sqlitePath, { readonly: true }) + } catch { return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) } - const sessionId = active.value - let sessionMeta: SessionMetadata | null = null - if (sessionId) { - const metas = readMainSessionMetasSqlite({ sqlitePath: backend.sqlitePath, directoryFilter: opts.projectRoot }) - if (!metas.ok) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - sessionMeta = metas.rows.find((m) => m.id === sessionId) ?? null - } - - const main = sessionId - ? (() => { - const result = getMainSessionViewSqlite({ - sqlitePath: backend.sqlitePath, - sessionId, - sessionMeta, - nowMs, - }) - if (!result.ok) return null - return result.value - })() - : { agent: "unknown", currentTool: null, currentModel: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const } - if (!main) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } + const fallback = (): DashboardPayload => { + try { db.close() } catch {} return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) } - const tasksResult = sessionId - ? deriveBackgroundTasksSqlite({ - sqlitePath: backend.sqlitePath, - mainSessionId: sessionId, - nowMs, - }) - : { ok: true as const, value: [] } - if (!tasksResult.ok) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) + try { + const active = pickActiveSessionIdSqlite({ + sqlitePath: backend.sqlitePath, + projectRoot: opts.projectRoot, + boulderSessionIds: boulder?.session_ids, + db, + }) + if (!active.ok) return fallback() + + const sessionId = active.value + let sessionMeta: SessionMetadata | null = null + if (sessionId) { + const metas = readMainSessionMetasSqlite({ sqlitePath: backend.sqlitePath, directoryFilter: opts.projectRoot, db }) + if (!metas.ok) return fallback() + sessionMeta = metas.rows.find((m) => m.id === sessionId) ?? null } - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - const timeSeriesResult = deriveTimeSeriesActivitySqlite({ - sqlitePath: backend.sqlitePath, - mainSessionId: sessionId ?? null, - nowMs, - }) - if (!timeSeriesResult.ok) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - - const mainCurrentModel = main.currentModel - const mainSessionTasks = (() => { - if (!sessionId) return [] + const main = sessionId + ? (() => { + const result = getMainSessionViewSqlite({ + sqlitePath: backend.sqlitePath, + sessionId, + sessionMeta, + nowMs, + db, + }) + if (!result.ok) return null + return result.value + })() + : { agent: "unknown", currentTool: null, currentModel: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const } + if (!main) return fallback() + + const tasksResult = sessionId + ? deriveBackgroundTasksSqlite({ + sqlitePath: backend.sqlitePath, + mainSessionId: sessionId, + nowMs, + db, + }) + : { ok: true as const, value: [] } + if (!tasksResult.ok) return fallback() - const mainStatus = main.status - const status = mainStatus === "running_tool" || mainStatus === "thinking" || mainStatus === "busy" - ? "running" - : mainStatus === "idle" - ? "idle" - : "unknown" + const timeSeriesResult = deriveTimeSeriesActivitySqlite({ + sqlitePath: backend.sqlitePath, + mainSessionId: sessionId ?? null, + nowMs, + db, + }) + if (!timeSeriesResult.ok) return fallback() + + const mainCurrentModel = main.currentModel + const mainSessionTasks = (() => { + if (!sessionId) return [] + + const mainStatus = main.status + const status = mainStatus === "running_tool" || mainStatus === "thinking" || mainStatus === "busy" + ? "running" + : mainStatus === "idle" + ? "idle" + : "unknown" + + const callsResult = deriveToolCallsSqlite({ sqlitePath: backend.sqlitePath, sessionId, db }) + if (!callsResult.ok) { + return [] + } - const callsResult = deriveToolCallsSqlite({ sqlitePath: backend.sqlitePath, sessionId }) - if (!callsResult.ok) { - return [] - } + const startAt = sessionMeta?.time?.created ?? null + const endAtMs = status === "running" ? nowMs : (main.lastUpdated ?? nowMs) + + return [ + { + id: "main-session", + description: "Main session", + subline: sessionId, + agent: main.agent, + lastModel: mainCurrentModel, + status, + toolCalls: callsResult.value.toolCalls.length, + lastTool: callsResult.value.toolCalls[0]?.tool ?? "-", + timeline: formatTimeline(startAt, endAtMs), + sessionId, + }, + ] + })() + + const tokenUsageResult = deriveTokenUsageSqlite({ + sqlitePath: backend.sqlitePath, + mainSessionId: sessionId ?? null, + backgroundSessionIds: tasksResult.value.map((task) => task.sessionId ?? null), + db, + }) + if (!tokenUsageResult.ok) return fallback() - const startAt = sessionMeta?.time?.created ?? null - const endAtMs = status === "running" ? nowMs : (main.lastUpdated ?? nowMs) + const todosResult = sessionId + ? deriveTodosSqlite({ + sqlitePath: backend.sqlitePath, + sessionId, + db, + }) + : { ok: true as const, value: [] } + const todos = todosResult.ok ? todosResult.value : [] - return [ - { - id: "main-session", - description: "Main session", - subline: sessionId, + const payload: DashboardPayload = { + mainSession: { agent: main.agent, - lastModel: mainCurrentModel, - status, - toolCalls: callsResult.value.toolCalls.length, - lastTool: callsResult.value.toolCalls[0]?.tool ?? "-", - timeline: formatTimeline(startAt, endAtMs), - sessionId, + currentModel: mainCurrentModel, + currentTool: main.currentTool ?? "-", + lastUpdatedLabel: formatIso(main.lastUpdated), + session: main.sessionLabel, + sessionId: sessionId ?? null, + statusPill: plan.planComplete && main.status === "idle" + ? mainStatusPill("plan_complete") + : mainStatusPill(main.status), }, - ] - })() - - const tokenUsageResult = deriveTokenUsageSqlite({ - sqlitePath: backend.sqlitePath, - mainSessionId: sessionId ?? null, - backgroundSessionIds: tasksResult.value.map((task) => task.sessionId ?? null), - }) - if (!tokenUsageResult.ok) { - if (hasLegacyStorageRoots(opts.storage)) { - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) + planProgress: { + name: planName, + completed: plan.completed, + total: plan.total, + path: planPath, + statusPill: planStatusPill(plan), + steps: planSteps.missing ? [] : planSteps.steps, + planStale: plan.planStale, + planComplete: plan.planComplete, + boulderStatus: boulder?.status, + completedAt: boulder?.completed_at, + }, + unintiatedPlans, + planHistory, + backgroundTasks: tasksResult.value.map((t) => ({ + id: t.id, + description: t.description, + agent: t.agent, + lastModel: t.lastModel ?? null, + status: t.status, + toolCalls: t.toolCalls ?? 0, + lastTool: t.lastTool ?? "-", + timeline: typeof t.timeline === "string" ? t.timeline : "", + sessionId: t.sessionId ?? null, + })), + mainSessionTasks, + timeSeries: timeSeriesResult.value, + tokenUsage: tokenUsageResult.value, + todos, + raw: null, } - return buildDashboardPayloadFiles({ projectRoot: opts.projectRoot, storage: opts.storage, nowMs }) - } - - const todosResult = sessionId - ? deriveTodosSqlite({ - sqlitePath: backend.sqlitePath, - sessionId, - }) - : { ok: true as const, value: [] } - // Don't fail the entire payload if todos fail, just use empty array - const todos = todosResult.ok ? todosResult.value : [] - const payload: DashboardPayload = { - mainSession: { - agent: main.agent, - currentModel: mainCurrentModel, - currentTool: main.currentTool ?? "-", - lastUpdatedLabel: formatIso(main.lastUpdated), - session: main.sessionLabel, - sessionId: sessionId ?? null, - statusPill: plan.planComplete && main.status === "idle" - ? mainStatusPill("plan_complete") - : mainStatusPill(main.status), - }, - planProgress: { - name: planName, - completed: plan.completed, - total: plan.total, - path: planPath, - statusPill: planStatusPill(plan), - steps: planSteps.missing ? [] : planSteps.steps, - planStale: plan.planStale, - planComplete: plan.planComplete, - boulderStatus: boulder?.status, - completedAt: boulder?.completed_at, - }, - unintiatedPlans, - planHistory, - backgroundTasks: tasksResult.value.map((t) => ({ - id: t.id, - description: t.description, - agent: t.agent, - lastModel: t.lastModel ?? null, - status: t.status, - toolCalls: t.toolCalls ?? 0, - lastTool: t.lastTool ?? "-", - timeline: typeof t.timeline === "string" ? t.timeline : "", - sessionId: t.sessionId ?? null, - })), - mainSessionTasks, - timeSeries: timeSeriesResult.value, - tokenUsage: tokenUsageResult.value, - todos, - raw: null, - } - - payload.raw = { - mainSession: payload.mainSession, - planProgress: payload.planProgress, - backgroundTasks: payload.backgroundTasks, - mainSessionTasks: payload.mainSessionTasks, - timeSeries: payload.timeSeries, + payload.raw = { + mainSession: payload.mainSession, + planProgress: payload.planProgress, + backgroundTasks: payload.backgroundTasks, + mainSessionTasks: payload.mainSessionTasks, + timeSeries: payload.timeSeries, + } + return payload + } finally { + try { db.close() } catch {} } - return payload } // --------------------------------------------------------------------------- diff --git a/src/server/dev.ts b/src/server/dev.ts index 0a3ae5f..03a70be 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -3,6 +3,8 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { createApi } from "./api"; +import { createMultiProjectService } from "./multi-project"; +import { createTelegramService } from "./telegram"; import { selectStorageBackend, getLegacyStorageRootForBackend } from "../ingest/storage-backend"; const here = dirname(new URL(import.meta.url).pathname); @@ -13,18 +15,39 @@ const port = parseInt(process.env.OMO_PULSE_API_PORT || "51244", 10); const app = new Hono(); -// Initialize storage backend and create API router const storageBackend = selectStorageBackend(); const storageRoot = getLegacyStorageRootForBackend(storageBackend); -const apiRouter = createApi({ storageRoot, storageBackend, version: APP_VERSION }); +const multiProjectService = createMultiProjectService({ storageRoot, storageBackend }); + +const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN +const telegramChatId = process.env.TELEGRAM_CHAT_ID +const telegramService = telegramBotToken && telegramChatId + ? createTelegramService( + { botToken: telegramBotToken, chatId: telegramChatId }, + () => multiProjectService.getMultiProjectPayload(), + ) + : null + +const apiRouter = createApi({ + storageRoot, + storageBackend, + multiProjectService, + telegramStatus: telegramService ? () => telegramService.getStatus() : undefined, + version: APP_VERSION, +}); -// Mount the API router app.route("/api", apiRouter); Bun.serve({ fetch: app.fetch, hostname: "127.0.0.1", port, + idleTimeout: 60, }); +if (telegramService) { + telegramService.start() + console.log("Telegram notifications enabled") +} + console.log(`Server running at http://127.0.0.1:${port}`); diff --git a/src/server/multi-project.ts b/src/server/multi-project.ts index f233a65..3449706 100644 --- a/src/server/multi-project.ts +++ b/src/server/multi-project.ts @@ -1,12 +1,12 @@ +import { Database } from "bun:sqlite" import { getGitUncommittedCount } from "../ingest/git-status" import { getWorktreeInfo } from "../ingest/git-worktrees" import { derivePerSessionTimeSeries } from "../ingest/per-session-timeseries" -import { isSessionIncluded } from "../ingest/session-inclusion" +import { findIncludedSessionsSqlite } from "../ingest/session-inclusion" import { getSourceById, listSources } from "../ingest/sources-registry" import { getMainSessionViewSqlite } from "../ingest/sqlite-derive" import { compareSessionsBySeverity, computeAggregateStatus, selectDisplaySession } from "../ingest/status-rollup" import { getLegacyStorageRootForBackend, type StorageBackend } from "../ingest/storage-backend" -import { readMainSessionMetasSqlite } from "../ingest/storage-backend" import type { BackgroundTaskSummary, DashboardMultiProjectPayload, @@ -75,23 +75,22 @@ function buildEmptySessionTimeSeries(nowMs: number): SessionTimeSeriesPayload { export const MULTI_PROJECT_PAYLOAD_CACHE_TTL_MS = 5_000 export const SESSION_TIMESERIES_CACHE_TTL_MS = 15_000 +export const SESSION_SUMMARY_CACHE_TTL_MS = 10_000 const INCLUDED_SESSION_IDLE_WINDOW_MS = 300_000 -function buildSessionSummary(projectRoot: string, sqlitePath: string, nowMs: number): SessionSummary[] { +function buildSessionSummary(projectRoot: string, db: Database, sqlitePath: string, nowMs: number): SessionSummary[] { try { - if (typeof readMainSessionMetasSqlite !== "function" || typeof getMainSessionViewSqlite !== "function") { - return [] - } - - const metas = readMainSessionMetasSqlite({ sqlitePath, directoryFilter: projectRoot }) - if (!metas.ok) return [] + const includedMetas = findIncludedSessionsSqlite(db, projectRoot, INCLUDED_SESSION_IDLE_WINDOW_MS) + if (includedMetas.length === 0) return [] - const summaries = metas.rows.flatMap((meta) => { + // Only compute full session views for sessions that passed the pre-filter + const summaries = includedMetas.flatMap((meta) => { const result = getMainSessionViewSqlite({ sqlitePath, sessionId: meta.id, sessionMeta: meta, nowMs, + db, }) if (!result.ok) return [] @@ -105,11 +104,7 @@ function buildSessionSummary(projectRoot: string, sqlitePath: string, nowMs: num lastUpdated: result.value.lastUpdated ? new Date(result.value.lastUpdated).toISOString() : "", lastUpdatedMs: result.value.lastUpdated ?? 0, } - - const included = isSessionIncluded(meta, INCLUDED_SESSION_IDLE_WINDOW_MS, nowMs) - || summary.status === "question" - || summary.status === "error" - return included ? [summary] : [] + return [summary] }) return summaries.sort(compareSessionsBySeverity) @@ -201,6 +196,7 @@ export function createMultiProjectService(opts: { const storeBySourceId = new Map() const storeByProjectRoot = new Map() const sessionTimeSeriesByProjectRoot = new Map() + const sessionSummaryByProjectRoot = new Map() let cachedPayload: DashboardMultiProjectPayload | null = null let cachedPayloadAt = 0 @@ -233,6 +229,16 @@ export function createMultiProjectService(opts: { return empty } + function getCachedSessionSummary(projectRoot: string, db: Database, sqlitePath: string, nowMs: number): SessionSummary[] { + const cached = sessionSummaryByProjectRoot.get(projectRoot) + if (cached && nowMs - cached.fetchedAt < SESSION_SUMMARY_CACHE_TTL_MS) { + return cached.value + } + const value = buildSessionSummary(projectRoot, db, sqlitePath, nowMs) + sessionSummaryByProjectRoot.set(projectRoot, { value, fetchedAt: nowMs }) + return value + } + function getOrCreateStore(sourceId: string, projectRoot: string): DashboardStore { const existing = storeBySourceId.get(sourceId) if (existing) return existing @@ -264,28 +270,56 @@ export function createMultiProjectService(opts: { } const sources = listSources(opts.storageRoot) - const projects: ProjectSnapshot[] = [] + const snapshots: Array<{ snapshot: ProjectSnapshot; projectRoot: string }> = [] + const sqlitePath = opts.storageBackend.kind === "sqlite" ? opts.storageBackend.sqlitePath : undefined - for (const source of sources) { + let sharedDb: Database | null = null + if (sqlitePath) { try { - const entry = getSourceById(opts.storageRoot, source.id) - if (!entry) continue - - const store = getOrCreateStore(source.id, entry.projectRoot) - const payload = store.getSnapshot() - const label = source.label ?? entry.projectRoot - const sqlitePath = opts.storageBackend.kind === "sqlite" ? opts.storageBackend.sqlitePath : undefined - const sessionTimeSeries = getCachedSessionTimeSeries(entry.projectRoot, sqlitePath, nowMs) - const sessions = sqlitePath ? buildSessionSummary(entry.projectRoot, sqlitePath, nowMs) : [] - const snapshot = transformPayloadToSnapshot(source.id, label, entry.projectRoot, payload, sessions, nowMs, sessionTimeSeries) - snapshot.gitUncommittedCount = await getGitUncommittedCount(entry.projectRoot) - snapshot.worktrees = await getWorktreeInfo(entry.projectRoot) - projects.push(snapshot) + sharedDb = new Database(sqlitePath, { readonly: true }) } catch { - // Per-source error isolation: if one source fails, others still return } } + try { + for (const source of sources) { + try { + const entry = getSourceById(opts.storageRoot, source.id) + if (!entry) continue + + const store = getOrCreateStore(source.id, entry.projectRoot) + const payload = store.getSnapshot() + const label = source.label ?? entry.projectRoot + const sessionTimeSeries = getCachedSessionTimeSeries(entry.projectRoot, sqlitePath, nowMs) + const sessions = sharedDb && sqlitePath + ? getCachedSessionSummary(entry.projectRoot, sharedDb, sqlitePath, nowMs) + : [] + const snapshot = transformPayloadToSnapshot(source.id, label, entry.projectRoot, payload, sessions, nowMs, sessionTimeSeries) + snapshots.push({ snapshot, projectRoot: entry.projectRoot }) + } catch { + // Per-source error isolation: if one source fails, others still return + } + } + } finally { + try { sharedDb?.close() } catch {} + } + + // Phase 2: Parallel async git operations across all sources + await Promise.all(snapshots.map(async ({ snapshot, projectRoot }) => { + try { + const [gitCount, worktrees] = await Promise.all([ + getGitUncommittedCount(projectRoot), + getWorktreeInfo(projectRoot), + ]) + snapshot.gitUncommittedCount = gitCount + snapshot.worktrees = worktrees + } catch { + // Git failures are isolated per-source + } + })) + + const projects = snapshots.map((s) => s.snapshot) + const payload = { projects, serverNowMs: nowMs, @@ -293,7 +327,7 @@ export function createMultiProjectService(opts: { } cachedPayload = payload - cachedPayloadAt = nowMs + cachedPayloadAt = Date.now() return payload } @@ -301,6 +335,7 @@ export function createMultiProjectService(opts: { cachedPayload = null cachedPayloadAt = 0 sessionTimeSeriesByProjectRoot.clear() + sessionSummaryByProjectRoot.clear() } return { getMultiProjectPayload, invalidate } diff --git a/src/server/start.ts b/src/server/start.ts index 2adc68e..7efd89f 100644 --- a/src/server/start.ts +++ b/src/server/start.ts @@ -3,6 +3,8 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { createApi } from "./api"; +import { createMultiProjectService } from "./multi-project"; +import { createTelegramService } from "./telegram"; import { selectStorageBackend, getLegacyStorageRootForBackend } from "../ingest/storage-backend"; const here = dirname(new URL(import.meta.url).pathname); @@ -14,13 +16,27 @@ const app = new Hono(); const port = parseInt(process.env.OMO_PULSE_PORT || "4300", 10); const distRoot = join(import.meta.dir, "../../dist"); - -// Initialize storage backend and create API router const storageBackend = selectStorageBackend(); const storageRoot = getLegacyStorageRootForBackend(storageBackend); -const apiRouter = createApi({ storageRoot, storageBackend, version: APP_VERSION }); +const multiProjectService = createMultiProjectService({ storageRoot, storageBackend }); + +const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN +const telegramChatId = process.env.TELEGRAM_CHAT_ID +const telegramService = telegramBotToken && telegramChatId + ? createTelegramService( + { botToken: telegramBotToken, chatId: telegramChatId }, + () => multiProjectService.getMultiProjectPayload(), + ) + : null + +const apiRouter = createApi({ + storageRoot, + storageBackend, + multiProjectService, + telegramStatus: telegramService ? () => telegramService.getStatus() : undefined, + version: APP_VERSION, +}); -// Mount the API router BEFORE the SPA fallback middleware app.route("/api", apiRouter); // SPA fallback middleware app.use("*", async (c, next) => { @@ -78,4 +94,10 @@ Bun.serve({ fetch: app.fetch, hostname: "127.0.0.1", port, + idleTimeout: 60, }); + +if (telegramService) { + telegramService.start() + console.log("Telegram notifications enabled") +} diff --git a/src/server/telegram.ts b/src/server/telegram.ts new file mode 100644 index 0000000..e63726f --- /dev/null +++ b/src/server/telegram.ts @@ -0,0 +1,332 @@ +import type { + DashboardMultiProjectPayload, + ProjectSnapshot, + SessionStatus, + TelegramServiceConfig, + TelegramServiceStatus, +} from "../types" + +const STATUS_EMOJI: Record = { + busy: "๐ŸŸข", + running_tool: "๐ŸŸข", + thinking: "๐ŸŸก", + idle: "โšช", + question: "โ“", + plan_complete: "โœ…", + error: "๐Ÿ”ด", + unknown: "โšซ", +} + +const ALERT_STATUSES: ReadonlySet = new Set(["question", "error", "plan_complete"]) + +type TelegramApiResponse = { + ok: boolean + result?: { message_id: number } + description?: string + parameters?: { retry_after?: number } +} + +type TelegramState = { + pinnedMessageId: number | null + prevSessionStatuses: Map + alertedSessions: Set + lastEditMs: number + lastMessageText: string + alertsSent: number + lastError: string | null + isFirstPoll: boolean +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") +} + +function formatTime(ms: number): string { + const d = new Date(ms) + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}` +} + +function formatRelativeTime(diffMs: number): string { + if (diffMs < 60_000) return `${Math.round(diffMs / 1000)}s ago` + if (diffMs < 3_600_000) return `${Math.round(diffMs / 60_000)}m ago` + return `${Math.round(diffMs / 3_600_000)}h ago` +} + +export function formatStatusMessage(payload: DashboardMultiProjectPayload): string { + const { projects, serverNowMs } = payload + const totalSessions = projects.reduce((sum, p) => sum + Math.max(p.sessions.length, 1), 0) + + const lines: string[] = [] + lines.push(`omo-pulse ยท ${projects.length} project${projects.length !== 1 ? "s" : ""} ยท ${totalSessions} session${totalSessions !== 1 ? "s" : ""}`) + lines.push("") + + for (const project of projects) { + if (project.sessions.length === 0) { + const emoji = STATUS_EMOJI[project.aggregateStatus] + const label = escapeHtml(project.label) + const tool = + project.mainSession.currentTool && project.mainSession.currentTool !== "-" + ? ` (${escapeHtml(project.mainSession.currentTool)})` + : "" + lines.push(`${emoji} ${label} ${project.aggregateStatus}${tool}`) + } else { + for (const session of project.sessions) { + const emoji = STATUS_EMOJI[session.status] + const label = escapeHtml(project.label) + const sessionLabel = escapeHtml(session.sessionLabel) + const tool = + session.currentTool && session.currentTool !== "-" + ? ` (${escapeHtml(session.currentTool)})` + : "" + const diffMs = serverNowMs - session.lastUpdatedMs + const relative = + session.status === "idle" && session.lastUpdatedMs > 0 && diffMs > 60_000 + ? ` ${formatRelativeTime(diffMs)}` + : "" + lines.push(`${emoji} ${label} ${sessionLabel}: ${session.status}${tool}${relative}`) + } + } + } + + if (projects.length === 0) { + lines.push("No projects registered") + } + + lines.push("") + lines.push(`๐Ÿ• ${formatTime(serverNowMs)} ยท updated just now`) + + return lines.join("\n") +} + +function formatAlertMessage(project: ProjectSnapshot, sessionId: string, status: SessionStatus): string { + const emoji = STATUS_EMOJI[status] + const label = escapeHtml(project.label) + const session = project.sessions.find((s) => s.sessionId === sessionId) + const sessionLabel = session ? escapeHtml(session.sessionLabel) : sessionId + + if (status === "question") { + return `${emoji} ${label} ยท ${sessionLabel}\nAgent is waiting for your input` + } + if (status === "error") { + return `${emoji} ${label} ยท ${sessionLabel}\nSession encountered an error` + } + if (status === "plan_complete") { + return `${emoji} ${label} ยท ${sessionLabel}\nPlan completed` + } + return `${emoji} ${label} ยท ${sessionLabel}: ${status}` +} + +async function telegramApi( + botToken: string, + method: string, + params: Record, +): Promise { + const url = `https://api.telegram.org/bot${botToken}/${method}` + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }) + return (await response.json()) as TelegramApiResponse +} + +export type TelegramService = { + start: () => void + stop: () => void + getStatus: () => TelegramServiceStatus +} + +export function createTelegramService( + config: TelegramServiceConfig, + getPayload: () => Promise, +): TelegramService { + const pollIntervalMs = config.pollIntervalMs ?? 5000 + const debounceMs = config.debounceMs ?? 3000 + + const state: TelegramState = { + pinnedMessageId: null, + prevSessionStatuses: new Map(), + alertedSessions: new Set(), + lastEditMs: 0, + lastMessageText: "", + alertsSent: 0, + lastError: null, + isFirstPoll: true, + } + + let timer: ReturnType | null = null + + async function sendMessage(text: string): Promise { + try { + const result = await telegramApi(config.botToken, "sendMessage", { + chat_id: config.chatId, + text, + parse_mode: "HTML", + disable_notification: true, + }) + if (!result.ok) { + state.lastError = result.description ?? "sendMessage failed" + return null + } + state.lastError = null + return result.result?.message_id ?? null + } catch (err) { + state.lastError = err instanceof Error ? err.message : String(err) + return null + } + } + + async function editMessage(messageId: number, text: string): Promise { + try { + const result = await telegramApi(config.botToken, "editMessageText", { + chat_id: config.chatId, + message_id: messageId, + text, + parse_mode: "HTML", + }) + if (!result.ok) { + if (result.description?.includes("message is not modified")) { + return true + } + if (result.parameters?.retry_after) { + state.lastError = `Rate limited, retry after ${result.parameters.retry_after}s` + return false + } + state.lastError = result.description ?? "editMessageText failed" + return false + } + state.lastError = null + return true + } catch (err) { + state.lastError = err instanceof Error ? err.message : String(err) + return false + } + } + + async function pinMessage(messageId: number): Promise { + try { + const result = await telegramApi(config.botToken, "pinChatMessage", { + chat_id: config.chatId, + message_id: messageId, + disable_notification: true, + }) + if (!result.ok) { + state.lastError = result.description ?? "pinChatMessage failed" + } + } catch (err) { + state.lastError = err instanceof Error ? err.message : String(err) + } + } + + async function sendAlert(text: string): Promise { + try { + const result = await telegramApi(config.botToken, "sendMessage", { + chat_id: config.chatId, + text, + parse_mode: "HTML", + }) + if (result.ok) { + state.alertsSent++ + } + } catch { + // Alert failures are non-critical + } + } + + function detectAlerts(payload: DashboardMultiProjectPayload): void { + if (state.isFirstPoll) return + + for (const project of payload.projects) { + for (const session of project.sessions) { + const alertKey = `${session.sessionId}:${session.status}` + + if (ALERT_STATUSES.has(session.status)) { + if (!state.alertedSessions.has(alertKey)) { + state.alertedSessions.add(alertKey) + const alertText = formatAlertMessage(project, session.sessionId, session.status) + sendAlert(alertText) + } + } else { + for (const s of ALERT_STATUSES) { + state.alertedSessions.delete(`${session.sessionId}:${s}`) + } + } + } + } + } + + function updatePrevStatuses(payload: DashboardMultiProjectPayload): void { + state.prevSessionStatuses.clear() + for (const project of payload.projects) { + for (const session of project.sessions) { + state.prevSessionStatuses.set(session.sessionId, session.status) + } + } + } + + async function poll(): Promise { + try { + const payload = await getPayload() + const text = formatStatusMessage(payload) + + detectAlerts(payload) + updatePrevStatuses(payload) + + const nowMs = Date.now() + + if (state.pinnedMessageId === null) { + const messageId = await sendMessage(text) + if (messageId !== null) { + state.pinnedMessageId = messageId + state.lastMessageText = text + state.lastEditMs = nowMs + await pinMessage(messageId) + } + state.isFirstPoll = false + return + } + + if (nowMs - state.lastEditMs < debounceMs) return + + if (text === state.lastMessageText) return + + const success = await editMessage(state.pinnedMessageId, text) + if (success) { + state.lastMessageText = text + state.lastEditMs = nowMs + } + + state.isFirstPoll = false + } catch (err) { + state.lastError = err instanceof Error ? err.message : String(err) + } + } + + function start(): void { + if (timer !== null) return + poll() + timer = setInterval(poll, pollIntervalMs) + } + + function stop(): void { + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + + function getStatus(): TelegramServiceStatus { + return { + enabled: true, + pinnedMessageId: state.pinnedMessageId, + lastUpdateMs: state.lastEditMs || null, + lastError: state.lastError, + alertsSent: state.alertsSent, + } + } + + return { start, stop, getStatus } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 92b485b..ca81e68 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -65,6 +65,8 @@ --status-busy-new: #0284c7; --status-thinking-new: #0278b0; --status-tool-new: #026c9f; + --status-script: #7c6040; + --status-script-glow: rgba(124, 96, 64, 0.3); --status-idle-glow: #475569; --status-idle-glow-border: #334155; --status-idle-border-bright: #d4d4d8; @@ -115,6 +117,8 @@ --status-busy-new: #0891b2; --status-thinking-new: #0284c7; --status-tool-new: #0278b0; + --status-script: #8b7355; + --status-script-glow: rgba(139, 115, 85, 0.25); --status-idle-glow: #475569; --status-idle-glow-border: #cbd5e1; --status-idle-border-bright: #94a3b8; diff --git a/src/types.ts b/src/types.ts index 69182a3..1d59ef7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,15 +32,17 @@ export type UnintiatedPlan = { steps: PlanStep[] } +/** Boulder state representing an active or completed plan */ export type BoulderState = { active_plan: string started_at: string session_ids: string[] plan_name: string - status?: string + status?: "active" | "completed" completed_at?: string } +/** Historical entry for a completed plan */ export type BoulderHistoryEntry = { plan_name: string plan_path: string @@ -53,6 +55,14 @@ export type BoulderHistoryEntry = { agent?: string } +/** Archived plan reference */ +export type ArchivedPlan = { + name: string + path: string + archivedAt: string +} + +/** Plan completion history */ export type PlanHistory = { entries: BoulderHistoryEntry[] totalCompleted: number @@ -76,17 +86,6 @@ export type TimeSeriesPayload = { series: TimeSeriesSeries[] } -export type SessionSummary = { - sessionId: string - sessionLabel: string - agent: string - status: SessionStatus - currentModel: string - currentTool: string - lastUpdated: string - lastUpdatedMs: number -} - /** Single session's contribution to time series data */ export type SessionTimeSeriesEntry = { sessionId: string @@ -105,26 +104,6 @@ export type SessionTimeSeriesPayload = { sessions: SessionTimeSeriesEntry[] } -/** Summary of a single git worktree */ -export type WorktreeSummary = { - path: string - branch: string | null - commitHash: string - isMainWorktree: boolean - commitsAhead: number - diffStat: { filesChanged: number; insertions: number; deletions: number } | null - isLocked: boolean - isPrunable: boolean -} - -/** Aggregated git worktree information */ -export type WorktreeInfo = { - totalCount: number - activeCount: number - hotCount: number - worktrees: WorktreeSummary[] -} - /** Summary of a background task for dashboard display */ export type BackgroundTaskSummary = { taskId: string @@ -136,6 +115,18 @@ export type BackgroundTaskSummary = { lastUpdated: string } +/** Summary of a single included session */ +export type SessionSummary = { + sessionId: string + sessionLabel: string + agent: string + status: SessionStatus + currentModel: string + currentTool: string + lastUpdated: string + lastUpdatedMs: number +} + /** Token usage summary */ export type TokenUsageSummary = { inputTokens: number @@ -143,6 +134,28 @@ export type TokenUsageSummary = { totalTokens: number } +export type WorktreeSummary = { + path: string + branch: string | null + commitHash: string + isMainWorktree: boolean + isLocked: boolean + isPrunable: boolean + commitsAhead: number + diffStat: { + filesChanged: number + insertions: number + deletions: number + } | null +} + +export type WorktreeInfo = { + totalCount: number + activeCount: number + hotCount: number + worktrees: WorktreeSummary[] +} + /** Snapshot of a single project's state at a point in time */ export type ProjectSnapshot = { sourceId: string @@ -168,7 +181,7 @@ export type ProjectSnapshot = { steps: PlanStep[] planStale: boolean planComplete: boolean - boulderStatus?: string + boulderStatus?: "active" | "completed" completedAt?: string } unintiatedPlans: UnintiatedPlan[] @@ -176,11 +189,11 @@ export type ProjectSnapshot = { timeSeries: TimeSeriesPayload backgroundTasks: BackgroundTaskSummary[] sessionTimeSeries: SessionTimeSeriesPayload - tokenUsage?: TokenUsageSummary - /** Uncommitted git changes count (staged + unstaged + untracked). undefined = not available */ - gitUncommittedCount?: number - worktrees?: WorktreeInfo - lastUpdatedMs: number + tokenUsage?: TokenUsageSummary + /** Uncommitted git changes count (staged + unstaged + untracked). undefined = not available */ + gitUncommittedCount?: number + worktrees?: WorktreeInfo + lastUpdatedMs: number } /** Multi-project dashboard payload combining all project snapshots */ @@ -219,7 +232,27 @@ export type SoundConfig = { export type ProjectOrderState = { orderedIds: string[] columns: number + isManualOrder: boolean } /** Per-project visibility configuration */ export type VisibilityConfig = Record + +/** Telegram notification service configuration */ +export type TelegramServiceConfig = { + botToken: string + chatId: string + /** Polling interval in ms (default: 5000) */ + pollIntervalMs?: number + /** Debounce interval for edits in ms (default: 3000) */ + debounceMs?: number +} + +/** Telegram notification service runtime status */ +export type TelegramServiceStatus = { + enabled: boolean + pinnedMessageId: number | null + lastUpdateMs: number | null + lastError: string | null + alertsSent: number +} diff --git a/src/ui/App.css b/src/ui/App.css index 64b9089..7eb4c3f 100644 --- a/src/ui/App.css +++ b/src/ui/App.css @@ -13,7 +13,6 @@ width: calc(100% / var(--zoom)); margin: 0 auto; padding: var(--sp-4) var(--sp-4) var(--sp-8); - flex: 1 1 auto; transform: scale(var(--zoom)); transform-origin: top center; } @@ -274,15 +273,15 @@ /* Comfortable โ€” default, no overrides needed beyond base */ [data-density="comfortable"] .project-stack { - gap: var(--sp-2); + gap: var(--grid-gap, var(--sp-2)); } /* Dense */ [data-density="dense"] .project-stack { - gap: var(--sp-1); + gap: var(--grid-gap, var(--sp-1)); } /* Ultra-dense */ [data-density="ultra-dense"] .project-stack { - gap: 2px; + gap: var(--grid-gap, 2px); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 11b3bd9..458084e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,5 +1,11 @@ import { useMemo, useCallback, useRef, useEffect, useState } from "react" -import type { DashboardMultiProjectPayload, ProjectSnapshot, SessionStatus, StripConfigState } from "../types" +import type { + DashboardMultiProjectPayload, + PlanStatus, + ProjectSnapshot, + SoundConfig, + StripConfigState, +} from "../types" import { DashboardHeader } from "./components/DashboardHeader" import { ProjectStrip } from "./components/ProjectStrip" import { Sparkline } from "./components/Sparkline" @@ -18,6 +24,17 @@ import { useDensityMode } from "./hooks/useDensityMode" import { useSoundNotifications } from "./hooks/useSoundNotifications" import { useProjectOrder } from "./hooks/useProjectOrder" import { useProjectVisibility } from "./hooks/useProjectVisibility" +import { ATTENTION_FIRST_PRIORITY } from "../ingest/status-rollup" +import { + buildSessionStatusMap, + diffSessionStatuses, + shouldPlaySound, +} from "../ingest/session-diff" +import type { + SessionStatusDiff, + SessionStatusMap, + SoundPlaybackDecision, +} from "../ingest/session-diff" import { DndContext, closestCenter, @@ -36,25 +53,80 @@ import type React from "react" /* โ”€โ”€ Helpers โ”€โ”€ */ -/** Status priority for sorting: attention-first, then recency */ -const STATUS_PRIORITY: Record = { - error: 0, - question: 1, - thinking: 2, - idle: 3, - plan_complete: 4, - busy: 5, - running_tool: 5, - unknown: 6, +type ProjectSessionStatusMaps = Map +type ProjectPlanStatuses = Map + +export type ProjectSoundDecision = { + sourceId: string + diff: SessionStatusDiff + playback: SoundPlaybackDecision } -function compareProjects(a: ProjectSnapshot, b: ProjectSnapshot): number { - const pa = STATUS_PRIORITY[a.aggregateStatus ?? a.mainSession.status] ?? STATUS_PRIORITY.unknown - const pb = STATUS_PRIORITY[b.aggregateStatus ?? b.mainSession.status] ?? STATUS_PRIORITY.unknown +export function compareProjects(a: ProjectSnapshot, b: ProjectSnapshot): number { + const pa = ATTENTION_FIRST_PRIORITY[a.aggregateStatus] ?? ATTENTION_FIRST_PRIORITY.unknown + const pb = ATTENTION_FIRST_PRIORITY[b.aggregateStatus] ?? ATTENTION_FIRST_PRIORITY.unknown if (pa !== pb) return pa - pb return b.lastUpdatedMs - a.lastUpdatedMs } +export function resolveProjectOrderIds( + sortedProjects: ProjectSnapshot[], + orderedIds: string[], + isManualOrder: boolean, +): string[] { + const currentIds = sortedProjects.map((project) => project.sourceId) + if (!isManualOrder) return currentIds + + const retained = orderedIds.filter((id) => currentIds.includes(id)) + const added = currentIds.filter((id) => !orderedIds.includes(id)) + return [...retained, ...added] +} + +function buildProjectSessionMaps(projects: ProjectSnapshot[]): ProjectSessionStatusMaps { + return new Map( + projects.map((project) => [project.sourceId, buildSessionStatusMap(project.sessions)]), + ) +} + +function buildProjectPlanStatuses(projects: ProjectSnapshot[]): ProjectPlanStatuses { + return new Map(projects.map((project) => [project.sourceId, project.planProgress.status])) +} + +export function computeProjectSoundDecisions(args: { + previousSessionMaps: ProjectSessionStatusMaps + previousPlanStatuses: ProjectPlanStatuses + projects: ProjectSnapshot[] + soundConfig: SoundConfig +}): { + decisions: ProjectSoundDecision[] + nextSessionMaps: ProjectSessionStatusMaps + nextPlanStatuses: ProjectPlanStatuses +} { + const { previousSessionMaps, previousPlanStatuses, projects, soundConfig } = args + const nextSessionMaps = buildProjectSessionMaps(projects) + const nextPlanStatuses = buildProjectPlanStatuses(projects) + const decisions: ProjectSoundDecision[] = [] + + for (const project of projects) { + const diff = diffSessionStatuses( + previousSessionMaps.get(project.sourceId) ?? new Map(), + nextSessionMaps.get(project.sourceId) ?? new Map(), + { + prevPlanStatus: previousPlanStatuses.get(project.sourceId), + currPlanStatus: project.planProgress.status, + }, + ) + + decisions.push({ + sourceId: project.sourceId, + diff, + playback: shouldPlaySound(diff, soundConfig), + }) + } + + return { decisions, nextSessionMaps, nextPlanStatuses } +} + /* โ”€โ”€ Props โ”€โ”€ */ export type AppProps = { @@ -65,6 +137,8 @@ export type AppProps = { refresh: () => Promise } +export type ActiveOverlay = 'none' | 'settings' | 'projectManagement' + /* โ”€โ”€ localStorage helpers โ”€โ”€ */ function safeGetItem(key: string): string | null { @@ -83,7 +157,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { orderedIds, columns, reorder, setColumns, syncIds } = useProjectOrder() const { visibility, isVisible, toggleVisibility } = useProjectVisibility() const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode } = useStripConfig() - const [overlayState, setOverlayState] = useState<'none' | 'settings' | 'projectManagement'>('none') + const [activeOverlay, setActiveOverlay] = useState('none') /* โ”€โ”€ Zoom โ”€โ”€ */ const [zoom, setZoom] = useState(() => { @@ -192,9 +266,10 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap }, [columns], ) - const handleCloseSettings = useCallback(() => setOverlayState('none'), []) - const prevDataRef = useRef(null) + const handleCloseOverlay = useCallback(() => setActiveOverlay('none'), []) const firstLoadRef = useRef(true) + const prevSessionMapsRef = useRef(new Map()) + const prevPlanStatusesRef = useRef(new Map()) const handleExpandAll = useCallback(() => { if (!data) return @@ -205,60 +280,47 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap useEffect(() => { if (!data || !connected) return + const { + decisions, + nextSessionMaps, + nextPlanStatuses, + } = computeProjectSoundDecisions({ + previousSessionMaps: prevSessionMapsRef.current, + previousPlanStatuses: prevPlanStatusesRef.current, + projects: data.projects, + soundConfig, + }) + // Skip sound on first successful load if (firstLoadRef.current) { firstLoadRef.current = false - prevDataRef.current = data + prevSessionMapsRef.current = nextSessionMaps + prevPlanStatusesRef.current = nextPlanStatuses return } - const prev = prevDataRef.current - const soundDebug = safeGetItem('dashboard-sound-debug') === 'true' - if (prev) { - for (const project of data.projects) { - const prevProject = prev.projects.find(p => p.sourceId === project.sourceId) - if (!prevProject) continue - - const prevSessionId = prevProject.mainSession.sessionId - const currSessionId = project.mainSession.sessionId - if (prevSessionId !== currSessionId) continue - - const prevStatus = prevProject.mainSession.status - const currStatus = project.mainSession.status - const activeStates: SessionStatus[] = ['busy', 'running_tool', 'thinking'] - - // Session idle: active โ†’ idle - if (activeStates.includes(prevStatus) && currStatus === 'idle') { - playWaiting() - if (soundDebug) console.debug('[sound] idle', project.sourceId, prevStatus, 'โ†’', currStatus) - } - - // Session error: active/idle โ†’ error - if (prevStatus !== 'error' && currStatus === 'error') { - playAttention() - if (soundDebug) console.debug('[sound] error', project.sourceId, prevStatus, 'โ†’', currStatus) - } - - // Plan complete: in progress โ†’ complete - const prevPlanStatus = prevProject.planProgress.status - const currPlanStatus = project.planProgress.status - if (prevPlanStatus === 'in progress' && currPlanStatus === 'complete') { - playAllClear() - if (soundDebug) console.debug('[sound] complete', project.sourceId, prevPlanStatus, 'โ†’', currPlanStatus) - } - - // Question: any โ†’ question - if (prevStatus !== 'question' && currStatus === 'question') { - playQuestion() - if (soundDebug) console.debug('[sound] question', project.sourceId, prevStatus, 'โ†’', currStatus) - } + for (const decision of decisions) { + if (decision.playback.playWaiting) { + playWaiting() + } + + if (decision.playback.playAttention) { + playAttention() + } + + if (decision.playback.playAllClear) { + playAllClear() + } + + if (decision.playback.playQuestion) { + playQuestion() } } - prevDataRef.current = data - }, [data, connected, playWaiting, playAllClear, playAttention, playQuestion]) + prevSessionMapsRef.current = nextSessionMaps + prevPlanStatusesRef.current = nextPlanStatuses + }, [data, connected, soundConfig, playWaiting, playAllClear, playAttention, playQuestion]) - /* Sort projects: active first */ const sortedProjects = useMemo(() => { if (!data) return [] return [...data.projects].sort(compareProjects) @@ -271,13 +333,19 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap } }, [sortedProjects, syncIds]) + const currentOrderIds = useMemo( + () => resolveProjectOrderIds(sortedProjects, orderedIds, orderedIds.length > 0), + [sortedProjects, orderedIds], + ) + /* Display projects in DnD order when available, else status sort; then filter by visibility */ const displayProjects = useMemo(() => { const map = new Map(sortedProjects.map((p) => [p.sourceId, p])) - const ordered = orderedIds.length === 0 ? sortedProjects : - orderedIds.map((id) => map.get(id)).filter((p): p is ProjectSnapshot => p !== undefined) + const ordered = currentOrderIds + .map((id) => map.get(id)) + .filter((p): p is ProjectSnapshot => p !== undefined) return ordered.filter((p) => isVisible(p.sourceId)) - }, [sortedProjects, orderedIds, isVisible]) + }, [sortedProjects, currentOrderIds, isVisible]) const resizeHandleIds = useMemo( () => Array.from({ length: Math.max(columns - 1, 0) }, (_, handleIndex) => `column-resize-handle-${handleIndex + 1}`), @@ -326,14 +394,14 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { active, over } = event if (!over || active.id === over.id) return - const oldIndex = orderedIds.indexOf(String(active.id)) - const newIndex = orderedIds.indexOf(String(over.id)) + const oldIndex = currentOrderIds.indexOf(String(active.id)) + const newIndex = currentOrderIds.indexOf(String(over.id)) if (oldIndex !== -1 && newIndex !== -1) { reorder(oldIndex, newIndex) } }, - [orderedIds, reorder] + [currentOrderIds, reorder] ) return ( @@ -345,8 +413,8 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onCollapseAll={collapseAll} columns={columns} onSetColumns={setColumns} - onSettingsOpen={() => setOverlayState('settings')} - onManageProjectsOpen={() => setOverlayState('projectManagement')} + onSettingsOpen={() => setActiveOverlay('settings')} + onManageProjectsOpen={() => setActiveOverlay('projectManagement')} zoom={zoom} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} @@ -359,7 +427,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap
โŠ˜ No registered projects found -
@@ -367,7 +435,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap
โŠ˜ All projects hidden โ€” adjust visibility in Manage Projects -
@@ -435,9 +503,9 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap if (event === 'error') playAttention() if (event === 'question') playQuestion() }} - open={overlayState === 'settings'} - onClose={handleCloseSettings} - onOpenProjectManagement={() => setOverlayState('projectManagement')} + open={activeOverlay === 'settings'} + onClose={handleCloseOverlay} + onOpenProjectManagement={() => setActiveOverlay('projectManagement')} collapsedHeight={collapsedHeight} onCollapsedHeightChange={setCollapsedHeight} gridGap={gridGap} @@ -447,8 +515,8 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap /> setOverlayState('none')} + open={activeOverlay === 'projectManagement'} + onClose={handleCloseOverlay} projects={data?.projects ?? []} orderedIds={orderedIds} visibility={visibility} @@ -456,7 +524,7 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap onReorder={reorder} onProjectAdded={refresh} onRefresh={refresh} - onOpenSettings={() => setOverlayState('settings')} + onOpenSettings={() => setActiveOverlay('settings')} /> ) diff --git a/src/ui/components/DashboardHeader.tsx b/src/ui/components/DashboardHeader.tsx index 2653acc..034ef17 100644 --- a/src/ui/components/DashboardHeader.tsx +++ b/src/ui/components/DashboardHeader.tsx @@ -32,6 +32,32 @@ function formatUpdateTime(ms: number | null): string { return `${hours}h ago` } +type LastUpdatedLabelProps = { + lastUpdatedMs: number | null +} + +function LastUpdatedLabel({ lastUpdatedMs }: LastUpdatedLabelProps) { + const [nowMs, setNowMs] = useState(() => Date.now()) + + useEffect(() => { + if (lastUpdatedMs === null) return + + setNowMs(Date.now()) + + const intervalId = window.setInterval(() => { + setNowMs(Date.now()) + }, 1_000) + + return () => window.clearInterval(intervalId) + }, [lastUpdatedMs]) + + return ( + + {formatUpdateTime(lastUpdatedMs === null ? null : Math.min(lastUpdatedMs, nowMs))} + + ) +} + /* โ”€โ”€ Component โ”€โ”€ */ export function DashboardHeader({ @@ -48,19 +74,10 @@ export function DashboardHeader({ onZoomOut, onZoomReset, }: DashboardHeaderProps) { - - - /* Re-render update time every second */ - const [, setTick] = useState(0) - useEffect(() => { - const id = setInterval(() => setTick((t) => t + 1), 1_000) - return () => clearInterval(id) - }, []) - return (
-

ez-omo-dash

+

OmO Pulse

{onManageProjectsOpen && (
- - {formatUpdateTime(lastUpdatedMs)} - + * { flex: 1; + min-width: 0; } .project-management-panel__empty { diff --git a/src/ui/components/ProjectStrip.css b/src/ui/components/ProjectStrip.css index 599e45d..8c0b547 100644 --- a/src/ui/components/ProjectStrip.css +++ b/src/ui/components/ProjectStrip.css @@ -41,6 +41,7 @@ /* ::before โ€” active states: full opacity */ .project-strip[data-status="busy"]::before, .project-strip[data-status="running_tool"]::before, +.project-strip[data-status="running_script"]::before, .project-strip[data-status="thinking"]::before { opacity: 0.7; } @@ -57,11 +58,6 @@ 100% { box-shadow: 0 0 5px 0px color-mix(in srgb, var(--status-question-border) 50%, transparent), inset 0 1px 0 color-mix(in srgb, var(--status-question-border) 50%, transparent); } } -@keyframes status-dash-rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - @keyframes status-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-question-border) 0%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-question-border) 28%, transparent); } @@ -83,10 +79,6 @@ opacity: 1; } -.project-strip[data-expanded="true"][data-status="question"] { - animation: status-pulse 1.5s ease-in-out infinite; -} - @keyframes danger-border-radiation { 0% { background-position: 0% 50%; opacity: 0.8; } 50% { background-position: 100% 50%; opacity: 1; } @@ -265,6 +257,17 @@ animation: none; } +/* running_script โ€” subdued warm amber for long-running scripts */ +.strip-status-dot[data-status="running_script"] { + color: var(--status-script); + background: + radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.1) 0%, transparent 60%), + conic-gradient(from 0deg, rgba(124, 96, 64, 0.15) 0%, var(--status-script) 100%); + border-color: transparent; + box-shadow: inset 0 2px 5px var(--status-active-inset), 0 0 5px 1px var(--status-script-glow); + animation: none; +} + /* idle โ€” MOST PROMINENT: stone texture, large glow, breathing animation */ .strip-status-dot[data-status="idle"] { color: var(--status-idle-glow); @@ -280,27 +283,14 @@ .strip-status-dot[data-status="question"] { color: var(--status-question); - background: var(--status-question); - border-style: dashed; - border-width: 2px; - border-color: var(--status-question-border); - box-shadow: 0 0 6px var(--status-question); - display: flex; - align-items: center; - justify-content: center; -} - -.strip-status-dot[data-status="question"]::after { - content: '?'; - position: absolute; - font-family: var(--font-mono); - font-size: 10px; - font-weight: 800; - color: #ffffff; -} - -.project-strip[data-expanded="true"] .strip-status-dot[data-status="question"] { - animation: status-dash-rotate 2s ease-in-out infinite; + background: + radial-gradient(ellipse 3px 2px at 35% 25%, rgba(255, 255, 255, 0.6) 0%, transparent 100%), + radial-gradient(circle at 35% 35%, rgba(0, 0, 0, 0.4) 0%, var(--status-question) 80%); + border-color: color-mix(in srgb, var(--status-question) 80%, #000 20%); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.4), + inset 0 -2px 4px rgba(0, 0, 0, 0.6), + 0 0 10px 3px color-mix(in srgb, var(--status-question) 60%, transparent); } /* error โ€” red, pulse + intense glow, PROMINENT */ @@ -359,6 +349,28 @@ 0 0 3px rgba(255, 255, 255, 0.3); } +.strip-tool-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + font-size: 9px; + font-weight: 600; + font-family: var(--font-mono); + line-height: 1.2; + border-radius: 3px; + background: color-mix(in srgb, var(--status-tool-new) 15%, var(--bg-tertiary)); + color: var(--text-muted); + border: 1px solid color-mix(in srgb, var(--status-tool-new) 20%, transparent); + white-space: nowrap; + flex-shrink: 0; +} + +.strip-tool-badge[data-script="true"] { + background: color-mix(in srgb, var(--status-script) 18%, var(--bg-tertiary)); + border-color: color-mix(in srgb, var(--status-script) 25%, transparent); + color: color-mix(in srgb, var(--status-script) 70%, var(--text-secondary)); +} + /* Expanded dot size */ .project-strip[data-expanded="true"] .strip-status-dot { width: 16px; @@ -414,11 +426,6 @@ box-shadow: inset 0 1px 2px rgba(255,255,255,0.3), 0 0 8px 2px rgba(239, 68, 68, 0.7); } -.session-dot[data-family="danger"] { - background: var(--status-danger); - box-shadow: inset 0 1px 2px rgba(0,0,0,0.3), 0 0 4px 1px rgba(239, 68, 68, 0.3); -} - .session-dot[data-family="idle"] { background: var(--status-stone-base); } @@ -476,13 +483,15 @@ .strip-git-badge { display: inline-flex; align-items: center; - padding: 0 var(--sp-2); - height: 18px; - border-radius: 9px; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; background: color-mix(in srgb, var(--accent-warning) 18%, transparent); border: 1px solid color-mix(in srgb, var(--accent-warning) 40%, transparent); font-family: var(--font-mono); - font-size: var(--font-xs); + font-size: 10px; color: var(--accent-warning); white-space: nowrap; flex-shrink: 0; @@ -606,28 +615,30 @@ } /* โ”€โ”€ Uninitiated Plans UI โ”€โ”€ */ -.uninitiated-badge { +.queued-plan-badge { display: inline-flex; align-items: center; - padding: 0 var(--sp-2); - height: 18px; - border-radius: 9px; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; background: color-mix(in srgb, var(--status-idle) 15%, transparent); border: 1px solid color-mix(in srgb, var(--status-idle) 30%, transparent); font-family: var(--font-mono); - font-size: var(--font-xs); + font-size: 10px; color: var(--status-idle); white-space: nowrap; flex-shrink: 0; } -.uninitiated-plans-section { +.queued-plans-section { display: flex; flex-direction: column; gap: var(--sp-2); } -.uninitiated-plan-item { +.queued-plan-item { appearance: none; display: flex; flex-direction: column; @@ -642,18 +653,18 @@ font: inherit; } -.uninitiated-plan-item:hover { +.queued-plan-item:hover { border-color: var(--border-primary); background: var(--bg-tertiary); } -.uninitiated-plan-item--expanded { +.queued-plan-item--expanded { border-color: var(--border-primary); background: var(--bg-tertiary); cursor: default; } -.uninitiated-plan-steps { +.queued-plan-steps { display: flex; flex-direction: column; gap: var(--sp-1); @@ -776,55 +787,14 @@ animation: none; } -/* โ”€โ”€ Collapsed strips: NO animations, static only โ”€โ”€ */ - -.project-strip[data-expanded="false"] .strip-status-dot { - animation: none !important; -} -.project-strip[data-expanded="false"] { - animation: none !important; -} -.project-strip[data-expanded="false"]::after { - animation: none; - opacity: 0; -} - -.project-strip[data-expanded="false"][data-status="question"] { - animation: question-shadow-drift 4s ease-in-out infinite !important; -} -.project-strip[data-expanded="false"][data-status="error"] { - animation: danger-shadow-drift 4s ease-in-out infinite !important; -} -.project-strip[data-expanded="false"][data-status="plan_complete"] { - animation: plan-complete-shadow-drift 4s ease-in-out infinite !important; -} -.project-strip[data-expanded="false"][data-status="idle"] { - animation: idle-border-breathe 4s ease-in-out infinite !important; -} - -.project-strip[data-expanded="false"][data-status="question"]::before { - animation: question-border-radiation 5s linear infinite !important; - opacity: 1; - background-size: 220% 220%; -} -.project-strip[data-expanded="false"][data-status="error"]::before { - animation: danger-border-radiation 5s ease-in-out infinite !important; - opacity: 1; - background-size: 220% 220%; -} -.project-strip[data-expanded="false"][data-status="plan_complete"]::before { - animation: plan-complete-border-radiation 5s ease-in-out infinite !important; - opacity: 1; - background-size: 220% 220%; -} /* โ”€โ”€ Density overrides โ”€โ”€ */ /* Dense mode: 40px strips, tighter padding, smaller font */ [data-density="dense"] .strip-header { - height: 40px; - min-height: 40px; + height: var(--collapsed-pane-height, 40px); + min-height: var(--collapsed-pane-height, 40px); padding: 0 var(--sp-2); gap: var(--sp-2); font-size: var(--font-xs); @@ -841,12 +811,30 @@ } [data-density="dense"] .strip-git-badge { height: 16px; + min-width: 16px; + padding: 0 4px; font-size: 0.6rem; } -[data-density="dense"] .uninitiated-badge { +[data-density="dense"] .strip-worktree-badge, +[data-density="dense"] .strip-worktree-indicator { + min-width: 16px; + min-height: 18px; + padding: 1px 3px; + font-size: 10px; +} +[data-density="dense"] .queued-plan-badge { height: 16px; + min-width: 16px; + padding: 0 4px; font-size: 0.6rem; } +[data-density="dense"] .session-indicators { + max-width: 26px; +} +[data-density="dense"] .session-dot { + width: 6px; + height: 6px; +} [data-density="dense"] .strip-status-dot { width: 12px; height: 12px; @@ -856,6 +844,10 @@ height: 18px; font-size: 8px; } +[data-density="dense"] .strip-tool-badge { + font-size: 8px; + padding: 0 4px; +} [data-density="dense"] .strip-session-overflow { font-size: 0.6rem; @@ -863,8 +855,8 @@ /* Ultra-dense mode: 36px strips, minimal padding, abbreviated */ [data-density="ultra-dense"] .strip-header { - height: 36px; - min-height: 36px; + height: var(--collapsed-pane-height, 36px); + min-height: var(--collapsed-pane-height, 36px); padding: 0 var(--sp-1); gap: var(--sp-1); font-size: 0.6rem; @@ -884,14 +876,30 @@ } [data-density="ultra-dense"] .strip-git-badge { height: 14px; - padding: 0 var(--sp-1); + min-width: 14px; + padding: 0 3px; font-size: 0.55rem; } -[data-density="ultra-dense"] .uninitiated-badge { +[data-density="ultra-dense"] .strip-worktree-badge, +[data-density="ultra-dense"] .strip-worktree-indicator { + min-width: 14px; + min-height: 16px; + padding: 1px 3px; + font-size: 9px; +} +[data-density="ultra-dense"] .queued-plan-badge { height: 14px; - padding: 0 var(--sp-1); + min-width: 14px; + padding: 0 3px; font-size: 0.55rem; } +[data-density="ultra-dense"] .session-indicators { + max-width: 23px; +} +[data-density="ultra-dense"] .session-dot { + width: 5px; + height: 5px; +} [data-density="ultra-dense"] .strip-status-dot { width: 10px; height: 10px; @@ -901,6 +909,10 @@ height: 16px; font-size: 7px; } +[data-density="ultra-dense"] .strip-tool-badge { + font-size: 7px; + padding: 0 3px; +} [data-density="ultra-dense"] .strip-session-overflow { font-size: 0.55rem; } @@ -921,12 +933,17 @@ border-color: var(--status-busy-new); --strip-glow-color: rgba(6, 182, 212, 0.15); } +.project-strip[data-status="running_script"] { + border-color: var(--status-script); + --strip-glow-color: var(--status-script-glow); +} .project-strip[data-status="thinking"] { border-color: var(--status-busy-new); --strip-glow-color: rgba(6, 182, 212, 0.15); } .project-strip[data-status="busy"], .project-strip[data-status="running_tool"], +.project-strip[data-status="running_script"], .project-strip[data-status="thinking"] { opacity: 0.78; } @@ -938,7 +955,7 @@ } .project-strip[data-status="question"] { border-color: var(--status-question); - --strip-glow-color: var(--status-question-border); + --strip-glow-color: rgba(245, 158, 11, 0.15); animation: question-shadow-drift 4s ease-in-out infinite; } .project-strip[data-status="error"] { @@ -966,10 +983,32 @@ .project-strip[data-expanded="true"][data-status="thinking"] { box-shadow: 0 0 15px 2px rgba(6, 182, 212, 0.1), inset 0 1px 0 rgba(6, 182, 212, 0.05); } +.project-strip[data-expanded="true"][data-status="running_script"] { + box-shadow: 0 0 12px 2px var(--status-script-glow), inset 0 1px 0 rgba(124, 96, 64, 0.05); +} /* โ”€โ”€ Scanning sheen โ€” expanded active only โ”€โ”€ */ /* (Removed strip-level animation from busy/thinking/running_tool) */ +/* โ”€โ”€ Collapsed strips: NO animations, static only โ”€โ”€ */ + +.project-strip[data-expanded="false"] { + animation: none; +} + +.project-strip[data-expanded="false"]::before { + animation: none; +} + +.project-strip[data-expanded="false"]::after { + animation: none; + opacity: 0; +} + +.project-strip[data-expanded="false"] .strip-status-dot { + animation: none; +} + .project-strip[data-expanded="true"][data-status="question"]::after { left: 30%; background: linear-gradient( @@ -1219,13 +1258,45 @@ } .strip-worktree-badge { - background: color-mix(in srgb, var(--accent-primary) 18%, transparent); - border-color: color-mix(in srgb, var(--accent-primary) 40%, transparent); - color: var(--accent-primary); + display: inline-flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + min-width: 18px; + min-height: 20px; + padding: 2px 4px; + border-radius: 8px; + background: color-mix(in srgb, var(--accent-info, #60a5fa) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-info, #60a5fa) 32%, transparent); + color: color-mix(in srgb, var(--accent-info, #60a5fa) 70%, white 30%); + font-family: var(--font-mono); + font-size: 10px; + flex-shrink: 0; + gap: 2px; +} + +.strip-worktree-badge__value { + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + min-width: 100%; +} + +.strip-worktree-badge__value--hot { + font-weight: 700; +} + +.strip-worktree-badge__divider { + display: block; + width: 100%; + height: 1px; + background: currentColor; + opacity: 0.45; } .strip-worktree-badge--hot { - background: color-mix(in srgb, var(--accent-warning) 18%, transparent); - border-color: color-mix(in srgb, var(--accent-warning) 40%, transparent); - color: var(--accent-warning); + background: color-mix(in srgb, var(--status-danger) 18%, transparent); + border-color: color-mix(in srgb, var(--status-danger) 36%, transparent); + color: color-mix(in srgb, var(--status-danger) 72%, white 28%); } diff --git a/src/ui/components/ProjectStrip.tsx b/src/ui/components/ProjectStrip.tsx index 7cec62a..48aed79 100644 --- a/src/ui/components/ProjectStrip.tsx +++ b/src/ui/components/ProjectStrip.tsx @@ -8,7 +8,6 @@ import "./ProjectStrip.css" /* โ”€โ”€ Helpers โ”€โ”€ */ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes -const MAX_SESSION_DOTS = 5 /** Format millisecond timestamp to relative time string ("2s ago", "1m ago", "3h ago") */ export function formatRelativeTime(ms: number): string { @@ -25,27 +24,6 @@ export function formatRelativeTime(ms: number): string { return `${days}d ago` } -export function computeDisplayStatus( - aggregateStatus: string, - lastUpdatedTime: number, - idleTimeoutMs = 300_000, - nowMs = Date.now(), -): string { - if (aggregateStatus === "plan_complete") return "idle" - - const demotableStatuses = ["running_tool", "thinking", "busy"] - if (!demotableStatuses.includes(aggregateStatus)) return aggregateStatus - - return nowMs - lastUpdatedTime > idleTimeoutMs ? "idle" : aggregateStatus -} - -export function getSessionFamily(status: string): "active" | "attention" | "danger" | "idle" { - if (["busy", "thinking", "running_tool"].includes(status)) return "active" - if (status === "question") return "attention" - if (status === "error") return "danger" - return "idle" -} - /** Format a token count to a compact string (e.g., 1234 โ†’ "1.2k") */ function formatTokenCount(n: number): string { if (n < 1_000) return String(n) @@ -53,6 +31,31 @@ function formatTokenCount(n: number): string { return `${(n / 1_000_000).toFixed(2)}M` } +/** Tool names that indicate a long-running script/command rather than AI tool activity */ +const SCRIPT_TOOL_NAMES = new Set(["bash", "interactive_bash", "shell", "terminal", "execute_command"]) + +export function computeDisplayStatus( + aggregateStatus: string, + lastUpdatedTime: number, + idleTimeoutMs: number = 300_000, + nowMs: number = Date.now(), + currentTool?: string, +): string { + if (aggregateStatus === 'plan_complete') return 'idle' + + // Only active execution states demote to idle when stale + const DEMOTABLE_STATUSES = ['running_tool', 'thinking', 'busy'] + if (!DEMOTABLE_STATUSES.includes(aggregateStatus)) return aggregateStatus + + const isClientStale = nowMs - lastUpdatedTime > idleTimeoutMs + if (isClientStale) return "idle" + + if (aggregateStatus === 'running_tool' && currentTool && SCRIPT_TOOL_NAMES.has(currentTool)) { + return 'running_script' + } + + return aggregateStatus +} function formatDuration(startedAt: string, completedAt: string): string { try { @@ -108,12 +111,21 @@ export type ProjectStripProps = { /* โ”€โ”€ Component โ”€โ”€ */ +const MAX_SESSION_DOTS = 5; + +export function getSessionFamily(status: string): 'active' | 'attention' | 'danger' | 'idle' { + if (['busy', 'thinking', 'running_tool', 'running_script'].includes(status)) return 'active' + if (status === 'question') return 'attention' + if (status === 'error') return 'danger' + return 'idle' +} + function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idleTimeoutMs, children }: ProjectStripProps) { const { mainSession, planProgress, backgroundTasks, tokenUsage, lastUpdatedMs, gitUncommittedCount, unintiatedPlans } = project const sourceId = project.sourceId const aggregateStatus = project.aggregateStatus ?? mainSession.status const isStale = (() => { - const activeStates = ['busy', 'thinking', 'running_tool', 'question', 'error'] + const activeStates = ['busy', 'thinking', 'running_tool', 'running_script', 'question', 'error'] if (activeStates.includes(aggregateStatus)) return false if (planProgress?.planStale) return true if (!mainSession?.lastUpdated) return true @@ -124,7 +136,9 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl const displayStatus = computeDisplayStatus( aggregateStatus, mainSession.lastUpdated ? new Date(mainSession.lastUpdated).getTime() : 0, - idleTimeoutMs ?? 300_000, + idleTimeoutMs, + undefined, + mainSession.currentTool || undefined, ) const finalDisplayStatus = sourceId.startsWith('preview-') && mainSession.status === 'plan_complete' @@ -233,6 +247,11 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl {stripConfig?.showAvatar !== false ? getInitials(project.label) : null} )} + {(finalDisplayStatus === 'running_tool' || finalDisplayStatus === 'running_script') && mainSession.currentTool && ( + + {mainSession.currentTool} + + )} {stripConfig?.showProjectName !== false && ( {project.label} )} @@ -261,13 +280,14 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl )} {stripConfig?.showGitWorktrees !== false && project.worktrees && project.worktrees.activeCount > 0 && ( 0 ? "strip-worktree-badge--hot" : ""}`} - title={project.worktrees.hotCount > 0 - ? `${project.worktrees.hotCount} hot worktree${project.worktrees.hotCount === 1 ? "" : "s"}` - : `${project.worktrees.activeCount} active worktree${project.worktrees.activeCount === 1 ? "" : "s"}`} + className={`strip-worktree-badge${project.worktrees.hotCount > 0 ? " strip-worktree-badge--hot" : ""}`} + title={`${project.worktrees.activeCount} active worktree${project.worktrees.activeCount === 1 ? "" : "s"}${project.worktrees.hotCount > 0 ? ` โ€ข ${project.worktrees.hotCount} hot` : ""}`} > - {project.worktrees.hotCount > 0 && )} + {(finalDisplayStatus === 'running_tool' || finalDisplayStatus === 'running_script') && mainSession.currentTool && ( + + {mainSession.currentTool} + + )} {stripConfig?.showProjectName !== false && ( {project.label} )} @@ -318,13 +343,14 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl )} {stripConfig?.showGitWorktrees !== false && project.worktrees && project.worktrees.activeCount > 0 && ( 0 ? "strip-worktree-badge--hot" : ""}`} - title={project.worktrees.hotCount > 0 - ? `${project.worktrees.hotCount} hot worktree${project.worktrees.hotCount === 1 ? "" : "s"}` - : `${project.worktrees.activeCount} active worktree${project.worktrees.activeCount === 1 ? "" : "s"}`} + className={`strip-worktree-badge${project.worktrees.hotCount > 0 ? " strip-worktree-badge--hot" : ""}`} + title={`${project.worktrees.activeCount} active worktree${project.worktrees.activeCount === 1 ? "" : "s"}${project.worktrees.hotCount > 0 ? ` โ€ข ${project.worktrees.hotCount} hot` : ""}`} > - {project.worktrees.hotCount > 0 &&