diff --git a/AGENTS.md b/AGENTS.md index d4e4fa7..6c3e2ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,7 @@ All routes are under `/api` prefix (via Vite proxy in dev, direct in production) - `POST /api/sources` — Register new project source - `GET /api/projects` — All projects with full snapshots (main polling endpoint) - `GET /api/projects/:sourceId` — Single project detail +- `GET /api/quotas` — Provider subscription quota usage (5 providers, server-cached ~3 min) - `GET /api/tool-calls/:sessionId` — Tool call history for a session - `GET /api/service/status` — Systemd service status - `POST /api/service/enable` — Enable systemd auto-start diff --git a/README.md b/README.md index 623501b..9d39f94 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,7 @@ Run it as a persistent service alongside your development workflow and always kn - **Session swimlane** — per-session activity timeline across all projects - **Activity sparklines** — time-series charts for spotting patterns faster than your agent can hallucinate - **Sound notifications** — audio alerts for idle, plan complete, errors, and questions (now you can pretend to work while your AI does) -- **Uncommitted changes tracker** — per-project git badge shows unsaved work at a glance, so you notice before an agent wipes it -- **Zero instrumentation** — reads oh-my-opencode's native SQLite database directly +- **Provider quota strip** — live subscription limits (5-hour / weekly / monthly) for Z.AI, ChatGPT, Kimi, Ollama Cloud, and OpenCode Go, with reset countdowns and smart collapsing when a longer quota is exhausted - **Systemd service** *(optional)* — persistent background service with auto-start on login ### Interface — How You Monitor It @@ -49,6 +48,12 @@ Run it as a persistent service alongside your development workflow and always kn

Refined status language — distinct glass and lamp-style indicators for danger, questions, active work, completion, idle, and unknown states.

+

+ Provider quota strip: thin usage lines with reset countdowns for Z.AI, ChatGPT, OpenCode Go, Ollama Cloud, and Kimi +

+ +

Provider quota strip — one thin line per subscription window with live reset countdowns. Shorter windows collapse when a longer quota is exhausted, and windows always sort shortest to longest.

+ diff --git a/docs/screenshots/provider-quotas.png b/docs/screenshots/provider-quotas.png new file mode 100644 index 0000000..baef328 Binary files /dev/null and b/docs/screenshots/provider-quotas.png differ diff --git a/src/__tests__/activity-status.test.ts b/src/__tests__/activity-status.test.ts index 7f159ae..1f47336 100644 --- a/src/__tests__/activity-status.test.ts +++ b/src/__tests__/activity-status.test.ts @@ -4,6 +4,7 @@ import { ACTIVE_STALE_MS, BACKGROUND_QUEUE_STALE_MS, hasFreshMainSessionActivity, + isStaleQuestionTool, resolveLastUpdatedTime, shouldSuppressStaleToolActivity, shouldKeepQueuedBackgroundTaskActive, @@ -66,4 +67,16 @@ describe("activity-status helpers", () => { it("suppresses stale running question tools", () => { expect(shouldSuppressStaleToolActivity("question", "running", false)).toBe(true) }) + + it("detects stale running question tools by tool start age", () => { + expect(isStaleQuestionTool("question", "running", 1_000, 1_000 + ACTIVE_STALE_MS + 1)).toBe(true) + }) + + it("keeps fresh running question tools active", () => { + expect(isStaleQuestionTool("question", "running", 1_000, 1_000 + ACTIVE_STALE_MS - 1)).toBe(false) + }) + + it("never treats pending question tools as stale", () => { + expect(isStaleQuestionTool("mcp_question", "pending", 1_000, 1_000 + ACTIVE_STALE_MS + 1)).toBe(false) + }) }) diff --git a/src/__tests__/background-tasks-derive.test.ts b/src/__tests__/background-tasks-derive.test.ts index 30a23d6..f52f9fa 100644 --- a/src/__tests__/background-tasks-derive.test.ts +++ b/src/__tests__/background-tasks-derive.test.ts @@ -267,6 +267,100 @@ describe("deriveBackgroundTasksSqlite", () => { expect(result.value[0].timeline).not.toBe(""); }); + it("marks linked background task as question when child session has a running canonical question tool", () => { + const nowMs = 1_000_000; + const db = createMockDb({ + sessionRows: [ + { + id: "ses-main", + project_id: "proj-1", + directory: "/tmp/project", + time_created: nowMs - 10_000, + time_updated: nowMs - 1_000, + }, + { + id: "ses-child", + project_id: "proj-1", + parent_id: "ses-main", + directory: "/tmp/project", + title: "Background: Ask user", + time_created: nowMs - 800, + time_updated: nowMs - 500, + }, + ], + messagesBySession: { + "ses-main": [ + makeMessageRow({ + id: "msg-main", + sessionId: "ses-main", + createdAt: nowMs - 1_000, + agent: "sisyphus", + }), + ], + "ses-child": [ + makeMessageRow({ + id: "msg-child", + sessionId: "ses-child", + createdAt: nowMs - 500, + agent: "atlas", + }), + ], + }, + partsByMessage: { + "msg-main": [ + makePartRow({ + id: "part-main", + messageId: "msg-main", + sessionId: "ses-main", + createdAt: nowMs - 1_000, + callId: "call-bg-question", + tool: "background_task", + status: "completed", + input: { + description: "Ask user", + run_in_background: true, + subagent_type: "atlas", + }, + metadata: { sessionId: "ses-child" }, + startAt: nowMs - 2_000, + }), + ], + "msg-child": [ + makePartRow({ + id: "part-child-question", + messageId: "msg-child", + sessionId: "ses-child", + createdAt: nowMs - 500, + callId: "child-question", + tool: "question", + status: "running", + }), + ], + }, + }); + + const result = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs, + db: db as unknown as Database, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toHaveLength(1); + expect(result.value[0]).toMatchObject({ + id: "call-bg-question", + description: "Ask user", + agent: "atlas", + status: "question", + toolCalls: 1, + lastTool: "question", + sessionId: "ses-child", + }); + }); + it("marks stale unlinked background task as unknown with null toolCalls", () => { const nowMs = 2_000_000; const startedAt = nowMs - 16 * 60_000; @@ -511,6 +605,78 @@ describe("deriveBackgroundTasks (file-based)", () => { }); }); + it("does not mark file-backed background tasks as question for stale running question tools", () => { + const storage = makeTempStorage(); + const nowMs = 1_000_000; + + writeJson(path.join(storage.message, "ses-main", "msg-main.json"), { + id: "msg-main", + sessionID: "ses-main", + role: "assistant", + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + agent: "sisyphus", + } satisfies StoredMessageMeta); + + writeJson(path.join(storage.part, "msg-main", "0001.json"), { + id: "part-main", + sessionID: "ses-main", + messageID: "msg-main", + type: "tool", + callID: "call-bg", + tool: "background_task", + state: { + status: "completed", + input: { + description: "Ask stale question", + run_in_background: true, + subagent_type: "atlas", + }, + metadata: { sessionId: "ses-child" }, + time: { start: nowMs - 2_000 }, + }, + } satisfies PersistedToolPart); + + writeJson(path.join(storage.message, "ses-child", "msg-child.json"), { + id: "msg-child", + sessionID: "ses-child", + role: "assistant", + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + agent: "atlas", + } satisfies StoredMessageMeta); + + writeJson(path.join(storage.part, "msg-child", "0001.json"), { + id: "part-child", + sessionID: "ses-child", + messageID: "msg-child", + type: "tool", + callID: "call-question", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: nowMs - 700_000 }, + }, + } satisfies PersistedToolPart); + + writeSessionMeta(storage, "child", { + id: "ses-child", + projectID: "proj-1", + directory: "/tmp/project", + parentID: "ses-main", + title: "Task: Ask stale question", + time: { created: nowMs - 2_000, updated: nowMs - 1_000 }, + }); + + const rows = deriveBackgroundTasks({ + storage, + mainSessionId: "ses-main", + nowMs, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.status).not.toBe("question"); + }); + it("keeps stale unlinked background task as unknown", () => { const storage = makeTempStorage(); const nowMs = 2_000_000; diff --git a/src/__tests__/dashboard-payload.test.ts b/src/__tests__/dashboard-payload.test.ts index 63e01cf..d1c1f3e 100644 --- a/src/__tests__/dashboard-payload.test.ts +++ b/src/__tests__/dashboard-payload.test.ts @@ -68,7 +68,9 @@ const { return { createMockDb, - MockDatabase: vi.fn(() => createMockDb()), + MockDatabase: vi.fn(function (this: unknown) { + return createMockDb() + }), mockReadBoulderState: vi.fn(), mockReadBoulderHistory: vi.fn(), mockReadPlanProgress: vi.fn(), @@ -180,7 +182,9 @@ describe("buildDashboardPayload characterization", () => { beforeEach(() => { vi.clearAllMocks() - MockDatabase.mockImplementation(() => createMockDb()) + MockDatabase.mockImplementation(function (this: unknown) { + return createMockDb() + }) mockReadBoulderState.mockReturnValue(null) mockReadBoulderHistory.mockReturnValue([]) diff --git a/src/__tests__/git-status.test.ts b/src/__tests__/git-status.test.ts index 3188faf..bd5ac28 100644 --- a/src/__tests__/git-status.test.ts +++ b/src/__tests__/git-status.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" const spawnMock = vi.fn() vi.stubGlobal("Bun", { spawn: spawnMock }) -import { getGitUncommittedCount, GIT_STATUS_CACHE_TTL_MS } from "../ingest/git-status" +import { getGitUncommittedCount, GIT_STATUS_CACHE_TTL_MS, NEGATIVE_CACHE_BASE_MS } from "../ingest/git-status" function mockSpawnResult( stdout: string, @@ -120,14 +120,20 @@ describe("getGitUncommittedCount", () => { expect(result).toBeUndefined() }) - it("does not cache undefined results from failed git calls", async () => { + it("backs off failed git calls and retries after the negative-cache TTL", async () => { spawnMock.mockReturnValue(mockSpawnResult("", 1)) const first = await getGitUncommittedCount("/test/no-cache-fail") expect(first).toBeUndefined() + /* Immediate retry is suppressed by the failure backoff (no respawn) */ spawnMock.mockReturnValue(mockSpawnResult("M file.ts\n", 0)) + const blocked = await getGitUncommittedCount("/test/no-cache-fail") + expect(blocked).toBeUndefined() + expect(spawnMock).toHaveBeenCalledTimes(1) + /* After the backoff window elapses, the retry succeeds */ + vi.advanceTimersByTime(NEGATIVE_CACHE_BASE_MS + 1) const second = await getGitUncommittedCount("/test/no-cache-fail") expect(second).toBe(1) expect(spawnMock).toHaveBeenCalledTimes(2) diff --git a/src/__tests__/project-strip-history.test.tsx b/src/__tests__/project-strip-history.test.tsx index 3828368..df1f10e 100644 --- a/src/__tests__/project-strip-history.test.tsx +++ b/src/__tests__/project-strip-history.test.tsx @@ -35,7 +35,7 @@ describe("ProjectStrip plan history rendering", () => { } const baseConfig: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, diff --git a/src/__tests__/project-strip-status.test.tsx b/src/__tests__/project-strip-status.test.tsx index 9cd8877..da0f53a 100644 --- a/src/__tests__/project-strip-status.test.tsx +++ b/src/__tests__/project-strip-status.test.tsx @@ -36,7 +36,7 @@ const baseProject: ProjectSnapshot = { } const baseConfig: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, @@ -47,6 +47,8 @@ const baseConfig: StripConfigState = { showAvatar: true, showProjectName: true, stripDisplayMode: "project", + showQuotas: true, + quotaIconMode: "icons", } const children = { @@ -149,6 +151,30 @@ describe("ProjectStrip rendered status", () => { expect(html).toContain('data-status="idle"') expect(html).toContain('data-stale="true"') }) + + it("does not mark bg_agent strips as stale even when main session is idle", () => { + // bg_agent = project has running background task; main session is idle/stale. + // Panel must show active styling, not the dimmed stale overlay. + const staleTime = new Date(Date.now() - 6 * 60_000).toISOString() + const project = { + ...baseProject, + mainSession: { + ...baseProject.mainSession, + lastUpdated: staleTime, + status: "idle" as const, + }, + aggregateStatus: "bg_agent" as const, + } + + const html = renderToStaticMarkup( + {}} stripConfig={baseConfig}> + {children} + + ) + + expect(html).toContain('data-status="bg_agent"') + expect(html).toContain('data-stale="false"') + }) }) describe("computeDisplayStatus", () => { diff --git a/src/__tests__/question-bridge-sqlite.test.ts b/src/__tests__/question-bridge-sqlite.test.ts index ace6f54..a879c5f 100644 --- a/src/__tests__/question-bridge-sqlite.test.ts +++ b/src/__tests__/question-bridge-sqlite.test.ts @@ -73,12 +73,12 @@ vi.mock("../ingest/storage-backend", () => ({ 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(function ({ sessionId }: { sessionId: string }) { + 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(function ({ messageIds }: { messageIds: string[] }) { + 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) @@ -118,4 +118,191 @@ describe("background question bridge (SQLite)", () => { expect(viewResult.value.status).toBe("question") expect(viewResult.value.currentTool).toBe("mcp_question") }) + + it("surfaces question for SQLite background tasks when child question tool is running", async () => { + vi.doMock("../ingest/storage-backend", () => { + const runningChildQuestionPart: StoredToolPart = { + ...childQuestionPart, + tool: "question", + state: { + ...childQuestionPart.state, + status: "running", + }, + } + + 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(runningChildQuestionPart) + return { ok: true as const, rows } + }), + } + }) + + vi.resetModules() + 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("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("question") + }) + + it("does not surface question for stale orphaned SQLite running question tools", async () => { + const nowMs = 2_000_000 + const freshMainMeta: StoredMessageMeta = { + ...mainMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + const freshChildMeta: StoredMessageMeta = { + ...childMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + const staleRunningQuestionPart: StoredToolPart = { + ...childQuestionPart, + tool: "question", + state: { + ...childQuestionPart.state, + status: "running", + time: { start: nowMs - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + } + + vi.doMock("../ingest/storage-backend", () => ({ + 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: [freshMainMeta] } + if (sessionId === "ses-child") return { ok: true as const, rows: [freshChildMeta] } + 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(staleRunningQuestionPart) + return { ok: true as const, rows } + }), + })) + + vi.resetModules() + const { deriveBackgroundTasksSqlite, getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const tasksResult = deriveBackgroundTasksSqlite({ + sqlitePath: "/tmp/opencode.db", + mainSessionId: "ses-main", + nowMs, + }) + + expect(tasksResult.ok).toBe(true) + if (!tasksResult.ok) throw new Error("expected sqlite background tasks") + expect(tasksResult.value[0]?.status).not.toBe("question") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: nowMs - 10_000, updated: nowMs - 1_000 }, + }, + nowMs, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).not.toBe("question") + }) + + it("does not surface question for stale orphaned SQLite main-session running question tools", async () => { + const nowMs = 2_000_000 + const staleMainQuestionPart: StoredToolPart = { + id: "part-main-stale-question", + sessionID: "ses-main", + messageID: "msg-main", + type: "tool", + callID: "call-main-stale-question", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: nowMs - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + } + const freshMainMeta: StoredMessageMeta = { + ...mainMeta, + time: { created: nowMs - 1_000, completed: nowMs - 900 }, + } + + vi.doMock("../ingest/storage-backend", () => ({ + readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })), + readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })), + readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => { + if (sessionId === "ses-main") return { ok: true as const, rows: [freshMainMeta] } + return { ok: true as const, rows: [] } + }), + readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => { + const rows: StoredToolPart[] = [] + if (messageIds.includes("msg-main")) rows.push(staleMainQuestionPart) + return { ok: true as const, rows } + }), + })) + + vi.resetModules() + const { getMainSessionViewSqlite } = await import("../ingest/sqlite-derive") + + const viewResult = getMainSessionViewSqlite({ + sqlitePath: "/tmp/opencode.db", + sessionId: "ses-main", + sessionMeta: { + id: "ses-main", + projectID: "proj-1", + directory: "/tmp/project", + time: { created: nowMs - 10_000, updated: nowMs - 1_000 }, + }, + nowMs, + }) + + expect(viewResult.ok).toBe(true) + if (!viewResult.ok) throw new Error("expected sqlite main session view") + expect(viewResult.value.status).not.toBe("question") + expect(viewResult.value.currentTool).toBeNull() + }) }) diff --git a/src/__tests__/quota-strip.test.ts b/src/__tests__/quota-strip.test.ts new file mode 100644 index 0000000..ce232cf --- /dev/null +++ b/src/__tests__/quota-strip.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" +import { visibleWindows } from "../ui/components/QuotaStrip" +import type { QuotaWindow } from "../types" + +function win(id: string, usedPercent: number): QuotaWindow { + return { id, shortLabel: id.toUpperCase(), label: id, usedPercent, resetsAtMs: null } +} + +describe("visibleWindows", () => { + it("hides shorter windows when a longer one is exhausted", () => { + const windows = [win("5h", 22), win("weekly", 40), win("monthly", 100)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["monthly"]) + }) + + it("hides only the 5h window when weekly is exhausted", () => { + const windows = [win("5h", 22), win("weekly", 100), win("monthly", 40)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["weekly", "monthly"]) + }) + + it("keeps everything when nothing is exhausted", () => { + const windows = [win("5h", 99), win("weekly", 70), win("monthly", 20)] + expect(visibleWindows(windows)).toEqual(windows) + }) + + it("keeps everything when only the shortest window is exhausted", () => { + const windows = [win("5h", 100), win("weekly", 40), win("monthly", 10)] + expect(visibleWindows(windows)).toEqual(windows) + }) + + it("sorts windows shortest-to-longer regardless of payload order", () => { + const windows = [win("monthly", 100), win("5h", 22), win("weekly", 40)] + expect(visibleWindows(windows).map((w) => w.id)).toEqual(["monthly"]) + const all = [win("monthly", 10), win("5h", 22), win("weekly", 40)] + expect(visibleWindows(all).map((w) => w.id)).toEqual(["5h", "weekly", "monthly"]) + }) + + it("handles single and empty lists", () => { + expect(visibleWindows([])).toEqual([]) + expect(visibleWindows([win("monthly", 100)])).toEqual([win("monthly", 100)]) + }) +}) diff --git a/src/__tests__/quotas.test.ts b/src/__tests__/quotas.test.ts new file mode 100644 index 0000000..f591f52 --- /dev/null +++ b/src/__tests__/quotas.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "vitest" +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createQuotaService, + parseAuthFile, + parseGoUsage, + parseKimiUsage, + parseOllamaUsage, + parseOpenAiUsage, + parseZaiUsage, + type FetchLike, +} from "../server/quotas" +import type { ProviderQuotasPayload } from "../types" + +/* ── Test helpers ── */ + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +const NOW_MS = Date.UTC(2026, 8, 12, 12, 0, 0) + +/* ── Parsers ── */ + +describe("parseGoUsage", () => { + it("maps rolling/weekly/monthly windows", () => { + const windows = parseGoUsage({ + usage: { + rolling: { status: "ok", percent: 12.4, resetsAt: "2026-09-12T16:00:00Z" }, + weekly: { status: "ok", percent: 40, resetsAt: "2026-09-14T00:00:00Z" }, + monthly: { status: "rate-limited", percent: 99.9, resetsAt: null }, + }, + }) + expect(windows).toHaveLength(3) + expect(windows[0]).toMatchObject({ id: "5h", shortLabel: "5H", usedPercent: 12.4 }) + expect(windows[0].resetsAtMs).toBe(Date.parse("2026-09-12T16:00:00Z")) + expect(windows[1]).toMatchObject({ id: "weekly", shortLabel: "WK" }) + expect(windows[2]).toMatchObject({ id: "monthly", shortLabel: "MO", usedPercent: 99.9 }) + expect(windows[2].resetsAtMs).toBeNull() + }) + + it("returns empty for malformed bodies", () => { + expect(parseGoUsage(null)).toEqual([]) + expect(parseGoUsage({})).toEqual([]) + expect(parseGoUsage({ usage: { rolling: { percent: "NaN" } } })).toEqual([]) + }) +}) + +describe("parseZaiUsage", () => { + it("decodes limit rows by unit/number", () => { + const windows = parseZaiUsage({ + code: 200, + success: true, + data: { + level: "max", + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 15, nextResetTime: NOW_MS + 3_600_000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 20, nextResetTime: NOW_MS + 86_400_000 }, + { type: "TIME_LIMIT", unit: 5, number: 1, percentage: 45 }, + { type: "TOKENS_LIMIT", unit: 9, number: 9, percentage: 50 }, // unknown → skipped + ], + }, + }) + expect(windows.map((w) => w.id)).toEqual(["5h", "weekly", "monthly"]) + expect(windows[0]).toMatchObject({ shortLabel: "5H", usedPercent: 15, resetsAtMs: NOW_MS + 3_600_000 }) + expect(windows[2]).toMatchObject({ shortLabel: "MO", resetsAtMs: null }) + }) + + it("derives percentage from currentValue/usage when percentage is missing", () => { + const windows = parseZaiUsage({ + data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, usage: 800, currentValue: 200 }] }, + }) + expect(windows).toHaveLength(1) + expect(windows[0].usedPercent).toBeCloseTo(25) + }) + + it("falls back to legacy flat fields", () => { + const windows = parseZaiUsage({ data: { fiveHourPercent: 10, weeklyPercent: 30 } }) + expect(windows.map((w) => w.id)).toEqual(["5h", "weekly"]) + expect(windows[0].usedPercent).toBe(10) + }) +}) + +describe("parseKimiUsage", () => { + const nowMs = Date.UTC(2026, 8, 12, 12, 0, 0) + + it("coerces string numbers and maps weekly/5h/monthly", () => { + const windows = parseKimiUsage( + { + usage: { limit: "100", remaining: "70", resetTime: "2026-09-14T09:59:07Z" }, + limits: [ + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "40", remaining: "10", resetTime: "2026-09-12T15:00:00Z" }, + }, + ], + totalQuota: { limit: "1000", used: "250" }, + }, + nowMs, + ) + expect(windows).toHaveLength(3) + expect(windows.find((w) => w.id === "weekly")?.usedPercent).toBeCloseTo(30) + expect(windows.find((w) => w.id === "5h")?.usedPercent).toBeCloseTo(75) + const monthly = windows.find((w) => w.id === "monthly") + expect(monthly?.usedPercent).toBeCloseTo(25) + expect(monthly?.resetsAtMs).toBe(Date.UTC(2026, 9, 1, 0, 0, 0)) // next month boundary + }) + + it("derives usedPercent from used when remaining is missing", () => { + const windows = parseKimiUsage({ usage: { limit: "100", used: "25", resetTime: "" } }, nowMs) + expect(windows).toHaveLength(1) + expect(windows[0].usedPercent).toBeCloseTo(25) + }) + + it("returns empty for malformed bodies", () => { + expect(parseKimiUsage(null, nowMs)).toEqual([]) + expect(parseKimiUsage("nope", nowMs)).toEqual([]) + }) +}) + +describe("parseOpenAiUsage", () => { + it("maps primary/secondary windows with epoch-second resets", () => { + const windows = parseOpenAiUsage({ + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 12, + limit_window_seconds: 18000, + reset_after_seconds: 3600, + reset_at: 1_789_000_000, + }, + secondary_window: { used_percent: 4, limit_window_seconds: 604800, reset_after_seconds: 86400 }, + }, + }) + expect(windows).toHaveLength(2) + expect(windows[0]).toMatchObject({ id: "5h", shortLabel: "5H", usedPercent: 12 }) + expect(windows[0].resetsAtMs).toBe(1_789_000_000 * 1000) + expect(windows[1]).toMatchObject({ id: "weekly", shortLabel: "WK", usedPercent: 4 }) + expect(windows[1].resetsAtMs).toBeGreaterThan(Date.now()) + }) + + it("tolerates missing windows", () => { + expect(parseOpenAiUsage({ rate_limit: { primary_window: null } })).toEqual([]) + expect(parseOpenAiUsage({})).toEqual([]) + }) +}) + +describe("parseOllamaUsage", () => { + const nowMs = Date.UTC(2026, 8, 12, 12, 0, 0) + + it("converts 0..1 fractions to percent with epoch-aligned resets", () => { + const windows = parseOllamaUsage( + { limits: { session: { usage: 0.046 }, weekly: { usage: 0.051 } } }, + nowMs, + ) + expect(windows).toHaveLength(2) + expect(windows[0]).toMatchObject({ id: "5h", usedPercent: 4.6 }) + const fiveHourPeriodMs = 5 * 3_600_000 + expect(windows[0].resetsAtMs).toBe((Math.floor(nowMs / fiveHourPeriodMs) + 1) * fiveHourPeriodMs) + expect(windows[1]).toMatchObject({ id: "weekly", usedPercent: 5.1 }) + }) + + it("returns empty when limits absent", () => { + expect(parseOllamaUsage({ limits: {} }, nowMs)).toEqual([]) + expect(parseOllamaUsage(null, nowMs)).toEqual([]) + }) +}) + +describe("parseAuthFile", () => { + it("parses api and oauth entries", () => { + const auth = parseAuthFile( + JSON.stringify({ + "opencode-go": { type: "api", key: "sk-go" }, + openai: { + type: "oauth", + access: "at", + refresh: "rt", + expires: NOW_MS, + accountId: "acc-1", + }, + broken: { type: "api" }, + }), + ) + expect(auth["opencode-go"]).toEqual({ type: "api", key: "sk-go" }) + expect(auth["openai"]).toMatchObject({ type: "oauth", access: "at", accountId: "acc-1" }) + expect(auth["broken"]).toBeUndefined() + }) + + it("returns empty for garbage", () => { + expect(parseAuthFile("not json")).toEqual({}) + expect(parseAuthFile("[1,2]")).toEqual({}) + }) + + it("normalizes second-based expires to ms", () => { + const auth = parseAuthFile( + JSON.stringify({ openai: { type: "oauth", access: "a", refresh: "r", expires: 1_789_000_000 } }), + ) + expect(auth["openai"]?.type === "oauth" && auth["openai"].expiresMs).toBe(1_789_000_000_000) + }) +}) + +/* ── Service ── */ + +async function withTempAuth(contents: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "omo-quotas-")) + const authPath = join(dir, "auth.json") + await writeFile(authPath, contents, "utf8") + return authPath +} + +describe("createQuotaService", () => { + it("reports unconfigured providers when auth entries are missing", async () => { + const authPath = await withTempAuth(JSON.stringify({})) + const service = createQuotaService({ authPath, fetchImpl: async () => jsonResponse({}) }) + const payload = await service.getQuotas() + expect(payload.providers).toHaveLength(5) + expect(payload.providers.every((p) => p.status === "unconfigured")).toBe(true) + }) + + it("isolates per-provider errors and serves from cache within TTL", async () => { + const authPath = await withTempAuth( + JSON.stringify({ + "opencode-go": { type: "api", key: "sk-go" }, + "zai-coding-plan": { type: "api", key: "sk-zai" }, + }), + ) + let callCount = 0 + const fetchImpl: FetchLike = async (input) => { + callCount += 1 + const url = String(input) + if (new URL(url).host === "opencode.ai") return jsonResponse({ message: "denied" }, 403) + if (new URL(url).host === "api.z.ai") { + return jsonResponse({ + data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 22 }] }, + }) + } + return jsonResponse({}) + } + let nowMs = NOW_MS + const service = createQuotaService({ + authPath, + fetchImpl, + now: () => nowMs, + cacheTtlMs: 60_000, + }) + + const first = await service.getQuotas() + const go = first.providers.find((p) => p.providerId === "opencode-go") + const zai = first.providers.find((p) => p.providerId === "zai-coding-plan") + expect(go).toMatchObject({ status: "error" }) + expect(go?.error).toContain("403") + expect(zai).toMatchObject({ status: "ok" }) + expect(zai?.windows[0]).toMatchObject({ id: "5h", usedPercent: 22 }) + const callsAfterFirst = callCount + + nowMs += 30_000 // still within TTL + const second = await service.getQuotas() + expect(callCount).toBe(callsAfterFirst) + expect(second.serverNowMs).toBe(NOW_MS) // cached payload keeps first-fetch timestamp + + nowMs += 31_000 // TTL elapsed → refetch + await service.getQuotas() + expect(callCount).toBeGreaterThan(callsAfterFirst) + }) + + it("refreshes expired OpenAI OAuth tokens in memory and uses the new token", async () => { + const authPath = await withTempAuth( + JSON.stringify({ + openai: { type: "oauth", access: "stale", refresh: "rt", expires: Date.now() - 60_000, accountId: "acc" }, + }), + ) + const seenAuth: string[] = [] + const fetchImpl: FetchLike = async (input, init) => { + const url = String(input) + const headers = (init?.headers ?? {}) as Record + if (new URL(url).host === "auth.openai.com") { + expect(init?.method).toBe("POST") + return jsonResponse({ access_token: "fresh", refresh_token: "rt2", expires_in: 3600 }) + } + if (new URL(url).pathname === "/backend-api/wham/usage") { + seenAuth.push(headers.Authorization ?? "") + seenAuth.push(headers["ChatGPT-Account-Id"] ?? "") + return jsonResponse({ + rate_limit: { primary_window: { used_percent: 33, limit_window_seconds: 18000 } }, + }) + } + return jsonResponse({}) + } + const service = createQuotaService({ authPath, fetchImpl }) + const payload = await service.getQuotas() + const openai = payload.providers.find((p) => p.providerId === "openai") + expect(openai).toMatchObject({ status: "ok" }) + expect(openai?.windows[0]).toMatchObject({ id: "5h", usedPercent: 33 }) + expect(seenAuth[0]).toBe("Bearer fresh") + expect(seenAuth[1]).toBe("acc") + }) + + it("reports an error payload shape matching ProviderQuotasPayload", async () => { + const authPath = await withTempAuth( + JSON.stringify({ "ollama-cloud": { type: "api", key: "sk-ol" } }), + ) + const fetchImpl: FetchLike = async () => jsonResponse({ limits: { session: { usage: 0.5 } } }) + const service = createQuotaService({ authPath, fetchImpl }) + const payload: ProviderQuotasPayload = await service.getQuotas() + expect(payload.serverNowMs).toBeGreaterThan(0) + const ollama = payload.providers.find((p) => p.providerId === "ollama-cloud") + expect(ollama?.windows.map((w) => w.id)).toContain("5h") + expect(typeof ollama?.symbol).toBe("string") + }) + + it("fetches favicons as data URIs, sniffs missing content-types, and degrades to null", async () => { + const authPath = await withTempAuth( + JSON.stringify({ "ollama-cloud": { type: "api", key: "sk-ol" } }), + ) + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const icoBytes = Buffer.from([0x00, 0x00, 0x01, 0x00, 0x03, 0x00]) + const fetchImpl: FetchLike = async (input) => { + const url = String(input) + if (new URL(url).host === "ollama.com") { + return new Response(pngBytes, { status: 200, headers: { "Content-Type": "image/png" } }) + } + if (new URL(url).host === "www.kimi.com") { + // Kimi serves a real ICO without a content-type header. + return new Response(icoBytes, { status: 200 }) + } + return jsonResponse({}) + } + const service = createQuotaService({ authPath, fetchImpl }) + const payload = await service.getQuotas() + const ollama = payload.providers.find((p) => p.providerId === "ollama-cloud") + expect(ollama?.icon?.startsWith("data:image/png;base64,")).toBe(true) + const kimi = payload.providers.find((p) => p.providerId === "kimi") + expect(kimi?.icon?.startsWith("data:image/x-icon;base64,")).toBe(true) + const zai = payload.providers.find((p) => p.providerId === "zai-coding-plan") + expect(zai?.icon).toBeNull() + expect(zai?.status).toBe("unconfigured") + }) +}) diff --git a/src/__tests__/session-diff.test.ts b/src/__tests__/session-diff.test.ts new file mode 100644 index 0000000..080e26a --- /dev/null +++ b/src/__tests__/session-diff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { buildSessionStatusMap, diffSessionStatuses, shouldPlaySound } from "../ingest/session-diff" +import type { SessionSummary, SoundConfig } from "../types" + +const SOUND_CONFIG: SoundConfig = { + enabled: true, + volume: 0.8, + onSessionIdle: true, + onPlanComplete: true, + onSessionError: true, + onQuestion: true, +} + +function makeSessionSummary(sessionId: string, status: SessionSummary["status"]): SessionSummary { + return { + sessionId, + sessionLabel: sessionId, + agent: "build", + status, + currentModel: "gpt-5.4", + currentTool: status === "question" ? "question" : "bash", + lastUpdated: "2026-05-10T00:00:00.000Z", + lastUpdatedMs: 1_000_000, + } +} + +describe("session-diff question sound", () => { + it("plays the question sound when a session transitions into question status", () => { + const previous = buildSessionStatusMap([ + makeSessionSummary("ses-main", "running_tool"), + ]) + const current = buildSessionStatusMap([ + makeSessionSummary("ses-main", "question"), + ]) + + const diff = diffSessionStatuses(previous, current) + const playback = shouldPlaySound(diff, SOUND_CONFIG) + + expect(playback.playQuestion).toBe(true) + expect(playback.playAttention).toBe(false) + expect(playback.playWaiting).toBe(false) + expect(playback.playAllClear).toBe(false) + }) +}) diff --git a/src/__tests__/session-inclusion.test.ts b/src/__tests__/session-inclusion.test.ts index 82815dc..2653da0 100644 --- a/src/__tests__/session-inclusion.test.ts +++ b/src/__tests__/session-inclusion.test.ts @@ -1,13 +1,12 @@ import type { Database } from "bun:sqlite" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { SessionMetadata } from "../ingest/session" +import { findIncludedSessionsSqlite, isSessionIncluded } from "../ingest/session-inclusion" vi.mock("../ingest/paths", () => ({ realpathSafe: vi.fn((p: string) => p), })) -import { isSessionIncluded, findIncludedSessionsSqlite } from "../ingest/session-inclusion" -import type { SessionMetadata } from "../ingest/session" - type SessionRow = { id: string title?: string @@ -20,6 +19,8 @@ type SessionRow = { type ActivePartRow = { tool: string status?: string + started_at?: number | null + time_created?: number | null } type TerminalPartRow = { @@ -412,7 +413,14 @@ describe("findIncludedSessionsSqlite", () => { }, ], activePartsBySession: { - "stale-question": [{ tool: "question", status: "running" }], + "stale-question": [ + { + tool: "question", + status: "running", + started_at: now - 15 * 60_000, + time_created: now - 15 * 60_000, + }, + ], }, }), "/home/user/project", @@ -422,6 +430,44 @@ describe("findIncludedSessionsSqlite", () => { expect(result).toEqual([]) }) + it("orders fresh running canonical question tools ahead of busy sessions", () => { + const now = Date.now() + const result = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "question-session", + title: "Question", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 1_000, + }, + { + id: "busy-session", + title: "Busy", + directory: "/home/user/project", + time_created: now - 20_000, + time_updated: now - 500, + }, + ], + activePartsBySession: { + "question-session": [ + { + tool: "question", + status: "running", + started_at: now - 1_000, + time_created: now - 1_000, + }, + ], + }, + }), + "/home/user/project", + 60000, + ) + + expect(result.map((session) => session.id)).toEqual(["question-session", "busy-session"]) + }) + it("handles mixed sessions: active top-level, stale excluded, child/background excluded", () => { const now = Date.now() const result = runFindIncludedSessionsSqlite( diff --git a/src/__tests__/session-status-derivation.test.ts b/src/__tests__/session-status-derivation.test.ts index 75d46e1..3fc301e 100644 --- a/src/__tests__/session-status-derivation.test.ts +++ b/src/__tests__/session-status-derivation.test.ts @@ -33,6 +33,8 @@ type SessionRow = { type ActivePartRow = { tool: string; status?: string; + started_at?: number | null; + time_created?: number | null; }; type TerminalPartRow = { @@ -286,8 +288,8 @@ describe("status derivation characterization: SQLite session inclusion path", () "ses-running", "ses-thinking", "ses-busy-fresh", - "ses-busy", "ses-unknown", + "ses-busy", "ses-idle", ]); }); @@ -321,6 +323,85 @@ describe("status derivation characterization: SQLite session inclusion path", () "ses-stale", ]); }); + + it("does not include a fresh session solely for a stale running question tool", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-stale-question", + directory: PROJECT_ROOT, + time_created: age(30_000), + time_updated: age(30_000), + }, + ], + activePartsBySession: { + "ses-stale-question": [ + { tool: "question", status: "running", started_at: age(700_000), time_created: age(700_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual([]); + }); + + it("keeps fresh running question tools included as attention", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-fresh-question", + directory: PROJECT_ROOT, + time_created: age(700_000), + time_updated: age(700_000), + }, + ], + activePartsBySession: { + "ses-fresh-question": [ + { tool: "question", status: "running", started_at: age(1_000), time_created: age(1_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual(["ses-fresh-question"]); + }); + + it("keeps scanning SQLite active parts after a stale running question candidate", () => { + vi.spyOn(Date, "now").mockReturnValue(NOW_MS); + const age = (ms: number): number => NOW_MS - ms; + + const sessions = runFindIncludedSessionsSqlite( + createMockDb({ + sessionRows: [ + { + id: "ses-masked-question", + directory: PROJECT_ROOT, + time_created: age(700_000), + time_updated: age(700_000), + }, + ], + activePartsBySession: { + "ses-masked-question": [ + { tool: "question", status: "running", started_at: age(700_000), time_created: age(1_000) }, + { tool: "mcp_question", status: "pending", started_at: age(700_000), time_created: age(2_000) }, + ], + }, + }), + 10_000, + ); + + expect(sessions.map((session) => session.id)).toEqual(["ses-masked-question"]); + }); }); describe("status derivation characterization: file-based getMainSessionView path", () => { @@ -355,6 +436,119 @@ describe("status derivation characterization: file-based getMainSessionView path expect(view.status).toBe("question"); }); + it("returns question when the latest active tool is a running canonical question tool", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-question-running", + "ses-question-running", + NOW_MS - 5_000, + NOW_MS - 4_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-question-running", + tool: "question", + state: { status: "running", input: {} }, + }); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 5_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("question"); + }); + + it("ignores stale orphaned running question tools in file-backed main sessions", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-stale-question-running", + "ses-stale-question-running", + NOW_MS - 30_000, + NOW_MS - 29_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-stale-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-stale-question-running", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: NOW_MS - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + }); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 30_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("busy"); + expect(view.currentTool).toBeNull(); + }); + + it("keeps scanning file-backed tool parts after a stale running question candidate", () => { + const storage = makeTempStorage(); + const message = makeAssistantMessage( + "msg-masked-question", + "ses-masked-question", + NOW_MS - 30_000, + NOW_MS - 29_900, + ); + writeMessage(storage, message); + + writeToolPart(storage, message.id, { + id: "part-pending-question", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-pending-question", + tool: "mcp_question", + state: { status: "pending", input: {} }, + }, "0001.json"); + + writeToolPart(storage, message.id, { + id: "part-stale-question-running", + sessionID: message.sessionID, + messageID: message.id, + type: "tool", + callID: "call-stale-question-running", + tool: "question", + state: { + status: "running", + input: {}, + time: { start: NOW_MS - 700_000 }, + } as StoredToolPart["state"] & { time: { start: number } }, + }, "0002.json"); + + const view = getMainSessionView({ + projectRoot: PROJECT_ROOT, + sessionId: message.sessionID, + storage, + sessionMeta: makeSessionMeta(message.sessionID, NOW_MS - 30_000), + nowMs: NOW_MS, + }); + + expect(view.status).toBe("question"); + expect(view.currentTool).toBe("mcp_question"); + }); + it("returns running_tool when the latest active tool is non-question", () => { const storage = makeTempStorage(); const message = makeAssistantMessage( diff --git a/src/ingest/activity-status.ts b/src/ingest/activity-status.ts index 2996596..26db5af 100644 --- a/src/ingest/activity-status.ts +++ b/src/ingest/activity-status.ts @@ -14,6 +14,33 @@ export function shouldKeepQueuedBackgroundTaskActive(startedAt: number, nowMs: n return nowMs - startedAt <= BACKGROUND_QUEUE_STALE_MS } +/** + * Returns true if a question tool with 'running' status started too long ago + * to be considered active. Pending questions are never stale (they wait in queue). + * Fresh running questions (within ACTIVE_STALE_MS) are preserved. + */ +export function isStaleQuestionTool( + toolName: string, + status: string, + toolStartedAt: number | null, + nowMs: number, +): boolean { + if (status !== "running") return false + if (!QUESTION_TOOL_NAMES.has(toolName)) return false + if (typeof toolStartedAt !== "number" || !Number.isFinite(toolStartedAt)) return false + return nowMs - toolStartedAt > ACTIVE_STALE_MS +} + +export function readToolStartTime(toolPart: unknown): number | null { + if (!toolPart || typeof toolPart !== "object") return null + const state = (toolPart as Record).state + if (!state || typeof state !== "object") return null + const time = (state as Record).time + if (!time || typeof time !== "object") return null + const start = (time as Record).start + return typeof start === "number" && Number.isFinite(start) ? start : null +} + export function resolveLastUpdatedTime(primary: number | null, fallback: number | null): number | null { if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary if (typeof fallback === "number" && Number.isFinite(fallback) && fallback > 0) return fallback diff --git a/src/ingest/background-tasks.ts b/src/ingest/background-tasks.ts index 327e943..3b453cc 100644 --- a/src/ingest/background-tasks.ts +++ b/src/ingest/background-tasks.ts @@ -1,14 +1,14 @@ import * as fs from "node:fs" import * as path from "node:path" -import { BACKGROUND_RUNNING_WINDOW_MS, shouldKeepQueuedBackgroundTaskActive } from "./activity-status" -import { formatElapsed, formatIsoNoMs, formatTimeline } from "./format-utils" -import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" -import { getMessageDir } from "./paths" +import { BACKGROUND_RUNNING_WINDOW_MS, isStaleQuestionTool, shouldKeepQueuedBackgroundTaskActive } from "./activity-status" +import { formatTimeline } from "./format-utils" import { pickLatestModelString } from "./model" -import { isPendingQuestionTool } from "./tool-names" +import { getMessageDir } from "./paths" +import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" +import { isActiveQuestionTool } from "./tool-names" -type FsLike = Pick +type FsLike = Pick const RECENT_MESSAGES_LIMIT = 200 @@ -170,8 +170,9 @@ export function readAllSessionMetas(sessionStorage: string, fsLike: FsLike = fs) function deriveBackgroundSessionStats( storage: OpenCodeStorageRoots, metas: StoredMessageMeta[], - fsLike: FsLike -): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null; pendingQuestionTool: string | null } { + fsLike: FsLike, + nowMs: number, +): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null; activeQuestionTool: string | null } { let toolCalls = 0 let lastTool: string | null = null let lastUpdateAt: number | null = null @@ -183,17 +184,20 @@ function deriveBackgroundSessionStats( return String(b.id).localeCompare(String(a.id)) }) - let pendingQuestionTool: string | null = null + let activeQuestionTool: string | null = null for (const meta of newestFirst) { const parts = readToolPartsForMessage(storage, meta.id, fsLike) for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] - if (isPendingQuestionTool(part.tool, part.state.status)) { - pendingQuestionTool = part.tool + if (isActiveQuestionTool(part.tool, part.state.status)) { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } + activeQuestionTool = part.tool break } } - if (pendingQuestionTool) break + if (activeQuestionTool) break } // Deterministic ordering by time.created then id. @@ -214,7 +218,7 @@ function deriveBackgroundSessionStats( } } - return { toolCalls, lastTool, lastUpdateAt, pendingQuestionTool } + return { toolCalls, lastTool, lastUpdateAt, activeQuestionTool } } const TASK_TOOL_NAMES = new Set(["delegate_task", "task", "call_omo_agent", "background_task"]) @@ -285,7 +289,7 @@ function deriveBackgroundTaskStatusFiles(opts: { backgroundSessionId: string | null toolCalls: number lastUpdateAt: number | null - pendingQuestionTool: string | null + activeQuestionTool: string | null startedAt: number nowMs: number }): BackgroundTaskRow["status"] { @@ -295,7 +299,7 @@ function deriveBackgroundTaskStatusFiles(opts: { if (opts.toolCalls === 0 && opts.lastUpdateAt === null) { return shouldKeepQueuedBackgroundTaskActive(opts.startedAt, opts.nowMs) ? "queued" : "unknown" } - if (opts.pendingQuestionTool) return "question" + if (opts.activeQuestionTool) return "question" if (opts.lastUpdateAt && opts.nowMs - opts.lastUpdateAt <= BACKGROUND_RUNNING_WINDOW_MS) return "running" if (opts.toolCalls > 0) return "completed" return "unknown" @@ -314,7 +318,7 @@ export function deriveBackgroundTasks(opts: { const allSessionMetas = readAllSessionMetas(opts.storage.session, fsLike) const sessionMetaById = new Map(allSessionMetas.map((m) => [m.id, m] as const)) const backgroundMessageCache = new Map() - const backgroundStatsCache = new Map() + const backgroundStatsCache = new Map() const backgroundModelCache = new Map() const readBackgroundMetas = (sessionId: string): StoredMessageMeta[] => { @@ -330,7 +334,7 @@ export function deriveBackgroundTasks(opts: { const cached = backgroundStatsCache.get(sessionId) if (cached) return cached const recent = readBackgroundMetas(sessionId) - const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike) + const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike, nowMs) backgroundStatsCache.set(sessionId, stats) return stats } @@ -405,14 +409,14 @@ export function deriveBackgroundTasks(opts: { const stats = backgroundSessionId ? readBackgroundStats(backgroundSessionId) - : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt, pendingQuestionTool: null } + : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt, activeQuestionTool: null } const lastModel = backgroundSessionId ? readBackgroundModel(backgroundSessionId) : null const status = deriveBackgroundTaskStatusFiles({ backgroundSessionId, toolCalls: stats.toolCalls, lastUpdateAt: stats.lastUpdateAt, - pendingQuestionTool: stats.pendingQuestionTool, + activeQuestionTool: stats.activeQuestionTool, startedAt, nowMs, }) @@ -425,7 +429,7 @@ export function deriveBackgroundTasks(opts: { agent, status, toolCalls: backgroundSessionId ? stats.toolCalls : null, - lastTool: stats.pendingQuestionTool ?? stats.lastTool, + lastTool: stats.activeQuestionTool ?? stats.lastTool, lastModel, timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs), sessionId: backgroundSessionId, diff --git a/src/ingest/git-status.ts b/src/ingest/git-status.ts index c255ca6..5a9daff 100644 --- a/src/ingest/git-status.ts +++ b/src/ingest/git-status.ts @@ -4,9 +4,15 @@ */ export const GIT_STATUS_CACHE_TTL_MS = 30_000 +export const NEGATIVE_CACHE_BASE_MS = 2_000 const MAX_CACHE_SIZE = 100 +const GIT_STATUS_TIMEOUT_MS = 5_000 +const GIT_SIGKILL_GRACE_MS = 500 + +const NEGATIVE_CACHE_MAX_MS = GIT_STATUS_CACHE_TTL_MS const cache = new Map() +const negativeCache = new Map() function evictOldestIfFull(): void { if (cache.size < MAX_CACHE_SIZE) return @@ -22,26 +28,52 @@ function evictOldestIfFull(): void { } export async function getGitUncommittedCount(projectRoot: string): Promise { - try { - const cached = cache.get(projectRoot) - if (cached && Date.now() - cached.fetchedAt < GIT_STATUS_CACHE_TTL_MS) { - return cached.count - } + const cached = cache.get(projectRoot) + if (cached && Date.now() - cached.fetchedAt < GIT_STATUS_CACHE_TTL_MS) { + return cached.count + } + + const negative = negativeCache.get(projectRoot) + if (negative && Date.now() < negative.nextRetryAt) { + return undefined + } + + const result = await runGitStatusPorcelain(projectRoot) + + if (result === undefined) { + recordFailure(projectRoot) + return undefined + } + + evictOldestIfFull() + cache.set(projectRoot, { count: result, fetchedAt: Date.now() }) + negativeCache.delete(projectRoot) + return result +} +function recordFailure(projectRoot: string): void { + const prev = negativeCache.get(projectRoot) + const failureCount = (prev?.failureCount ?? 0) + 1 + negativeCache.set(projectRoot, { + nextRetryAt: Date.now() + backoffDelayMs(failureCount), + failureCount, + }) +} + +function backoffDelayMs(failureCount: number): number { + return Math.min(NEGATIVE_CACHE_BASE_MS * 2 ** (failureCount - 1), NEGATIVE_CACHE_MAX_MS) +} + +async function runGitStatusPorcelain(projectRoot: string): Promise { + let outerTimer: ReturnType | undefined + let timedOut = false + try { const proc = Bun.spawn(["git", "status", "--porcelain"], { cwd: projectRoot, stdout: "pipe", stderr: "pipe", }) - const timeoutMs = 5_000 - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - proc.kill() - resolve(undefined) - }, timeoutMs) - }) - const workPromise = (async (): Promise => { const stdout = await new Response(proc.stdout).text() const exitCode = await proc.exited @@ -52,13 +84,27 @@ export async function getGitUncommittedCount(projectRoot: string): Promise((resolve) => { + outerTimer = setTimeout(() => { + timedOut = true + try { + proc.kill() + } catch {} + setTimeout(() => { + try { + proc.kill("SIGKILL") + } catch {} + }, GIT_SIGKILL_GRACE_MS) + resolve(undefined) + }, GIT_STATUS_TIMEOUT_MS) + }) - evictOldestIfFull() - cache.set(projectRoot, { count: result, fetchedAt: Date.now() }) - return result + return await Promise.race([workPromise, timeoutPromise]) } catch { return undefined + } finally { + if (!timedOut && outerTimer !== undefined) { + clearTimeout(outerTimer) + } } } diff --git a/src/ingest/git-worktrees.ts b/src/ingest/git-worktrees.ts index a7414b7..b819bcb 100644 --- a/src/ingest/git-worktrees.ts +++ b/src/ingest/git-worktrees.ts @@ -4,7 +4,12 @@ export const GIT_WORKTREE_CACHE_TTL_MS = 30_000 const cache = new Map() +const negativeCache = new Map() + const GIT_COMMAND_TIMEOUT_MS = 5_000 +const GIT_SIGKILL_GRACE_MS = 500 +const NEGATIVE_CACHE_BASE_MS = 2_000 +const NEGATIVE_CACHE_MAX_MS = GIT_WORKTREE_CACHE_TTL_MS type ParsedWorktree = { path: string @@ -16,12 +21,43 @@ type ParsedWorktree = { } export async function getWorktreeInfo(projectRoot: string): Promise { - try { - const cached = cache.get(projectRoot) - if (cached && Date.now() - cached.fetchedAt < GIT_WORKTREE_CACHE_TTL_MS) { - return cached.data - } + const cached = cache.get(projectRoot) + if (cached && Date.now() - cached.fetchedAt < GIT_WORKTREE_CACHE_TTL_MS) { + return cached.data + } + + const negative = negativeCache.get(projectRoot) + if (negative && Date.now() < negative.nextRetryAt) { + return undefined + } + + const data = await computeWorktreeInfo(projectRoot) + + if (data === undefined) { + recordFailure(projectRoot) + return undefined + } + + cache.set(projectRoot, { data, fetchedAt: Date.now() }) + negativeCache.delete(projectRoot) + return data +} + +function recordFailure(projectRoot: string): void { + const prev = negativeCache.get(projectRoot) + const failureCount = (prev?.failureCount ?? 0) + 1 + negativeCache.set(projectRoot, { + nextRetryAt: Date.now() + backoffDelayMs(failureCount), + failureCount, + }) +} +function backoffDelayMs(failureCount: number): number { + return Math.min(NEGATIVE_CACHE_BASE_MS * 2 ** (failureCount - 1), NEGATIVE_CACHE_MAX_MS) +} + +async function computeWorktreeInfo(projectRoot: string): Promise { + try { const porcelain = await runGitCommand(projectRoot, ["worktree", "list", "--porcelain"]) if (porcelain === undefined) return undefined @@ -42,7 +78,7 @@ export async function getWorktreeInfo(projectRoot: string): Promise !worktree.isMainWorktree && !worktree.isPrunable).length, hotCount: worktrees.filter(isHotWorktree).length, worktrees, } - - cache.set(projectRoot, { data, fetchedAt: Date.now() }) - return data } catch { return undefined } @@ -92,6 +125,8 @@ async function detectMainBranch(projectRoot: string): Promise { + let outerTimer: ReturnType | undefined + let timedOut = false try { const proc = Bun.spawn(["git", ...args], { cwd, @@ -99,13 +134,6 @@ async function runGitCommand(cwd: string, args: string[]): Promise((resolve) => { - setTimeout(() => { - proc.kill() - resolve(undefined) - }, GIT_COMMAND_TIMEOUT_MS) - }) - const workPromise = (async (): Promise => { const stdout = await new Response(proc.stdout).text() const exitCode = await proc.exited @@ -115,9 +143,28 @@ async function runGitCommand(cwd: string, args: string[]): Promise((resolve) => { + outerTimer = setTimeout(() => { + timedOut = true + try { + proc.kill() + } catch {} + setTimeout(() => { + try { + proc.kill("SIGKILL") + } catch {} + }, GIT_SIGKILL_GRACE_MS) + resolve(undefined) + }, GIT_COMMAND_TIMEOUT_MS) + }) + return await Promise.race([workPromise, timeoutPromise]) } catch { return undefined + } finally { + if (!timedOut && outerTimer !== undefined) { + clearTimeout(outerTimer) + } } } diff --git a/src/ingest/paths.ts b/src/ingest/paths.ts index e3e078c..1d96608 100644 --- a/src/ingest/paths.ts +++ b/src/ingest/paths.ts @@ -4,6 +4,15 @@ import * as path from "node:path" export type Env = Record +export function expandTilde(p: string, hd?: string): string { + const home = hd ?? os.homedir() + if (p === "~") return home + if (p.startsWith("~/") || p.startsWith("~\\")) { + return path.join(home, p.slice(2).replace(/[\\/]+/g, path.sep)) + } + return p +} + export function getDataDir(env: Env = process.env, homedir: string = os.homedir()): string { // Match oh-my-opencode behavior exactly: // XDG_DATA_HOME or ~/.local/share on all platforms. diff --git a/src/ingest/session-inclusion.ts b/src/ingest/session-inclusion.ts index b16da83..12db0d5 100644 --- a/src/ingest/session-inclusion.ts +++ b/src/ingest/session-inclusion.ts @@ -1,9 +1,9 @@ -import * as path from "node:path" import type { Database } from "bun:sqlite" +import * as path from "node:path" +import { ACTIVE_BUSY_WINDOW_MS, hasFreshMainSessionActivity, isStaleQuestionTool, shouldSuppressStaleToolActivity } from "./activity-status" import { realpathSafe } from "./paths" -import { ACTIVE_BUSY_WINDOW_MS, hasFreshMainSessionActivity, shouldSuppressStaleToolActivity } from "./activity-status" import type { SessionMetadata } from "./session" -import { isPendingQuestionTool } from "./tool-names" +import { isActiveQuestionTool } from "./tool-names" // Severity levels for attention-first ordering const STATUS_SEVERITY: Record = { @@ -30,23 +30,20 @@ function deriveSessionStatusFromMaps( sessionId: string, lastUpdated: number, nowMs: number, - activePartsMap: Map>, - terminalPartsMap: Map>, + activePartsMap: Map>, assistantMsgsMap: Map>, ): string { const activeParts = activePartsMap.get(sessionId) ?? [] const ageMs = nowMs - lastUpdated const hasFreshActivity = hasFreshMainSessionActivity(lastUpdated, nowMs) - if (activeParts.length > 0) { - const activePart = activeParts[0] - if (!shouldSuppressStaleToolActivity(activePart.tool, activePart.status, hasFreshActivity)) { - return isPendingQuestionTool(activePart.tool, activePart.status) ? "question" : "running_tool" + for (const activePart of activeParts) { + if (!isStaleQuestionTool(activePart.tool, activePart.status, activePart.startedAt, nowMs)) { + if (isActiveQuestionTool(activePart.tool, activePart.status)) return "question" + if (!shouldSuppressStaleToolActivity(activePart.tool, activePart.status, hasFreshActivity)) return "running_tool" } } - const isStaleActivity = ageMs > ACTIVE_BUSY_WINDOW_MS - const recentMessages = assistantMsgsMap.get(sessionId) ?? [] if (recentMessages.length > 0 && recentMessages[0].time_completed === null) { @@ -116,46 +113,30 @@ export function findIncludedSessionsSqlite( const candidateIds = candidates.map(s => s.id) // Batch query 1: active parts (pending/running) - const activePartsMap = new Map>() + const activePartsMap = new Map>() const placeholders = candidateIds.map(() => "?").join(",") const activeRows = db .query( `SELECT session_id, json_extract(data, '$.tool') as tool, - json_extract(data, '$.state.status') as status + json_extract(data, '$.state.status') as status, + json_extract(data, '$.state.time.start') as started_at, + time_created FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.state.status') IN ('pending', 'running') ORDER BY time_created DESC` ) - .all(...candidateIds) as Array<{ session_id: string; tool: string; status: string }> + .all(...candidateIds) as Array<{ session_id: string; tool: string; status: string; started_at: number | null; time_created: number | null }> for (const row of activeRows) { - if (!activePartsMap.has(row.session_id)) { - activePartsMap.set(row.session_id, []) - } - if ((activePartsMap.get(row.session_id)?.length ?? 0) < 1) { - activePartsMap.get(row.session_id)!.push({ tool: row.tool, status: row.status }) + const startedAt = row.started_at ?? row.time_created ?? null + const sessionActiveParts = activePartsMap.get(row.session_id) + if (!sessionActiveParts) { + activePartsMap.set(row.session_id, [{ tool: row.tool, status: row.status, startedAt }]) + } else { + sessionActiveParts.push({ tool: row.tool, status: row.status, startedAt }) } } - // Batch query 2: terminal parts (error/completed) - const terminalPartsMap = new Map>() - const terminalRows = db - .query( - `SELECT session_id, time_created, json_extract(data, '$.state.status') as status - FROM part - WHERE session_id IN (${placeholders}) AND json_extract(data, '$.state.status') IN ('error', 'completed') - ORDER BY time_created DESC` - ) - .all(...candidateIds) as Array<{ session_id: string; time_created: number; status: string }> - for (const row of terminalRows) { - if (!terminalPartsMap.has(row.session_id)) { - terminalPartsMap.set(row.session_id, []) - } - if ((terminalPartsMap.get(row.session_id)?.length ?? 0) < 1) { - terminalPartsMap.get(row.session_id)!.push({ time_created: row.time_created, status: row.status }) - } - } - - // Batch query 3: recent assistant messages + // Batch query 2: recent assistant messages const assistantMsgsMap = new Map>() const assistantRows = db .query( @@ -166,11 +147,11 @@ export function findIncludedSessionsSqlite( ) .all(...candidateIds) as Array<{ session_id: string; time_completed: number | null }> for (const row of assistantRows) { - if (!assistantMsgsMap.has(row.session_id)) { - assistantMsgsMap.set(row.session_id, []) - } - if ((assistantMsgsMap.get(row.session_id)?.length ?? 0) < 1) { - assistantMsgsMap.get(row.session_id)!.push({ time_completed: row.time_completed }) + const sessionAssistantMsgs = assistantMsgsMap.get(row.session_id) + if (!sessionAssistantMsgs) { + assistantMsgsMap.set(row.session_id, [{ time_completed: row.time_completed }]) + } else if (sessionAssistantMsgs.length < 1) { + sessionAssistantMsgs.push({ time_completed: row.time_completed }) } } @@ -181,7 +162,7 @@ export function findIncludedSessionsSqlite( const lastUpdated = meta.time.updated ?? meta.time.created ?? 0 const status = deriveSessionStatusFromMaps( meta.id, lastUpdated, nowMs, - activePartsMap, terminalPartsMap, assistantMsgsMap, + activePartsMap, assistantMsgsMap, ) if ( isSessionIncluded(meta, idleWindowMs, nowMs) || diff --git a/src/ingest/session-status.ts b/src/ingest/session-status.ts index 1c074ac..72eec7f 100644 --- a/src/ingest/session-status.ts +++ b/src/ingest/session-status.ts @@ -1,6 +1,6 @@ import { ACTIVE_BUSY_WINDOW_MS, shouldSuppressStaleToolActivity } from "./activity-status" -import { isPendingQuestionTool } from "./tool-names" import type { MainSessionView } from "./session" +import { isActiveQuestionTool } from "./tool-names" export type ActiveToolRef = { tool: string; status: string } | null @@ -32,8 +32,6 @@ export function deriveMainSessionStatus(opts: DeriveMainSessionStatusOpts): Deri const { hasFreshActivity, isStaleActivity, - latestTerminalStatus, - latestTerminalAt, recentRole, recentTimeCreated, recentTimeCompleted, @@ -47,7 +45,7 @@ export function deriveMainSessionStatus(opts: DeriveMainSessionStatusOpts): Deri if (shouldSuppressStaleToolActivity(activeTool.tool, activeTool.status, hasFreshActivity)) { activeTool = null } else { - status = isPendingQuestionTool(activeTool.tool, activeTool.status) ? "question" : "running_tool" + status = isActiveQuestionTool(activeTool.tool, activeTool.status) ? "question" : "running_tool" } } diff --git a/src/ingest/session.ts b/src/ingest/session.ts index 52b8431..7f4dc45 100644 --- a/src/ingest/session.ts +++ b/src/ingest/session.ts @@ -2,6 +2,8 @@ import * as fs from "node:fs" import * as path from "node:path" import { hasFreshMainSessionActivity, + isStaleQuestionTool, + readToolStartTime, resolveLastUpdatedTime, } from "./activity-status" import { pickLatestModelString } from "./model" @@ -254,11 +256,12 @@ function readRecentMessageMetas(messageDir: string, maxMessages: number): Stored .map(item => item.meta) } -function readLastToolPart(partStorage: string, messageID: string): { tool: string; status: string } | null { +function readToolPartsForMessage(partStorage: string, messageID: string): Array<{ tool: string; status: string; startedAt: number | null }> { const partDir = path.join(partStorage, messageID) - if (!fs.existsSync(partDir)) return null + if (!fs.existsSync(partDir)) return [] const files = fs.readdirSync(partDir).filter((f) => f.endsWith(".json")).sort() + const parts: Array<{ tool: string; status: string; startedAt: number | null }> = [] for (let i = files.length - 1; i >= 0; i--) { const file = files[i] try { @@ -266,14 +269,14 @@ function readLastToolPart(partStorage: string, messageID: string): { tool: strin const part = JSON.parse(content) as Partial if (part.type === "tool" && typeof part.tool === "string") { const status = (part as StoredToolPart).state?.status - return { tool: part.tool, status: typeof status === "string" ? status : "unknown" } + parts.push({ tool: part.tool, status: typeof status === "string" ? status : "unknown", startedAt: readToolStartTime(part) }) } } catch { // Expected: file may not exist or be malformed continue } } - return null + return parts } function messageTerminalToolStatus(partStorage: string, messageID: string): "error" | "completed" | null { @@ -325,11 +328,15 @@ export function getMainSessionView(opts: { // Iterate newest -> oldest, early-exit on first tool part with pending/running status for (const meta of recentMetas) { - const toolPart = readLastToolPart(opts.storage.part, meta.id) - if (toolPart && (toolPart.status === "pending" || toolPart.status === "running")) { + for (const toolPart of readToolPartsForMessage(opts.storage.part, meta.id)) { + if (toolPart.status !== "pending" && toolPart.status !== "running") continue + if (isStaleQuestionTool(toolPart.tool, toolPart.status, toolPart.startedAt ?? meta.time?.created ?? null, nowMs)) { + continue + } activeTool = toolPart break } + if (activeTool) break } let latestTerminalStatus: "error" | "completed" | null = null diff --git a/src/ingest/sqlite-derive.ts b/src/ingest/sqlite-derive.ts index 4ad890b..51bdd0f 100644 --- a/src/ingest/sqlite-derive.ts +++ b/src/ingest/sqlite-derive.ts @@ -2,13 +2,16 @@ import type { Database } from "bun:sqlite" import { BACKGROUND_RUNNING_WINDOW_MS, hasFreshMainSessionActivity, + isStaleQuestionTool, resolveLastUpdatedTime, shouldKeepQueuedBackgroundTaskActive, } from "./activity-status" import type { BackgroundTaskRow } from "./background-tasks" -import { canonicalizeAgent, formatElapsed, formatIsoNoMs, formatTimeline, normalizeSessionIds } from "./format-utils" +import { canonicalizeAgent, formatTimeline, normalizeSessionIds } from "./format-utils" import { pickLatestModelString } from "./model" import type { MainSessionView, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session" +import { deriveMainSessionStatus } from "./session-status" +import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" import { readAllSessionMetasSqlite, readMainSessionMetasSqlite, @@ -20,12 +23,10 @@ import { type SqliteReadFailureReason, type TodoItem, } from "./storage-backend" -import { findBackgroundSessionId, findTaskSessionId } from "./sqlite-utils" +import type { TimeSeriesPayload, TimeSeriesSeries } from "./timeseries" import { aggregateTokenUsage } from "./token-usage-core" import { MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS, type ToolCallSummaryResult } from "./tool-calls" -import { isPendingQuestionTool, TASK_TOOL_NAMES } from "./tool-names" -import { deriveMainSessionStatus } from "./session-status" -import type { TimeSeriesPayload, TimeSeriesSeries } from "./timeseries" +import { isActiveQuestionTool, TASK_TOOL_NAMES } from "./tool-names" type SqliteDeriveResult = | { ok: true; value: T } @@ -157,15 +158,19 @@ function mapToolPartsByMessage(parts: StoredToolPart[]): Map, + nowMs: number, ): string | null { for (const meta of metas) { const parts = partsByMessage.get(meta.id) ?? [] for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] - if (isPendingQuestionTool(part.tool, part.state.status)) { + if (isActiveQuestionTool(part.tool, part.state.status)) { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } return part.tool } } @@ -307,6 +312,9 @@ export function getMainSessionViewSqlite(opts: { for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i] if (part.state.status === "pending" || part.state.status === "running") { + if (isStaleQuestionTool(part.tool, part.state.status, readStartTimeFromToolPart(part) ?? meta.time?.created ?? null, nowMs)) { + continue + } activeTool = { tool: part.tool, status: part.state.status } break } @@ -467,7 +475,7 @@ function deriveBackgroundTaskStatus(opts: { backgroundSessionId: string | null toolCalls: number lastUpdateAt: number | null - pendingQuestionTool: string | null + activeQuestionTool: string | null startedAt: number nowMs: number }): BackgroundTaskRow["status"] { @@ -477,7 +485,7 @@ function deriveBackgroundTaskStatus(opts: { if (opts.toolCalls === 0 && opts.lastUpdateAt === null) { return shouldKeepQueuedBackgroundTaskActive(opts.startedAt, opts.nowMs) ? "queued" : "unknown" } - if (opts.pendingQuestionTool) return "question" + if (opts.activeQuestionTool) return "question" if (opts.lastUpdateAt && opts.nowMs - opts.lastUpdateAt <= BACKGROUND_RUNNING_WINDOW_MS) return "running" if (opts.toolCalls > 0) return "completed" return "unknown" @@ -580,14 +588,14 @@ export function deriveBackgroundTasksSqlite(opts: { const background = backgroundSessionId ? readBackgroundSession(backgroundSessionId) : null if (background && !background.ok) return background - const backgroundMetas = background && background.ok ? background.value.metas : [] - const backgroundPartsByMessage = background && background.ok ? background.value.partsByMessage : new Map() - const pendingQuestionTool = findPendingQuestionTool(backgroundMetas, backgroundPartsByMessage) + const backgroundMetas = background?.ok ? background.value.metas : [] + const backgroundPartsByMessage = background?.ok ? background.value.partsByMessage : new Map() + const activeQuestionTool = findActiveQuestionTool(backgroundMetas, backgroundPartsByMessage, nowMs) const { toolCalls, lastTool, lastUpdateAt } = computeBackgroundTaskStatsSqlite(backgroundMetas, backgroundPartsByMessage) const lastModel = backgroundMetas.length > 0 ? pickLatestModelString(backgroundMetas) : null - const status = deriveBackgroundTaskStatus({ backgroundSessionId, toolCalls, lastUpdateAt, pendingQuestionTool, startedAt, nowMs }) + const status = deriveBackgroundTaskStatus({ backgroundSessionId, toolCalls, lastUpdateAt, activeQuestionTool, startedAt, nowMs }) const timelineEndMs = status === "completed" ? (lastUpdateAt ?? nowMs) : nowMs rows.push({ @@ -596,7 +604,7 @@ export function deriveBackgroundTasksSqlite(opts: { agent, status, toolCalls: backgroundSessionId ? toolCalls : null, - lastTool: pendingQuestionTool ?? lastTool, + lastTool: activeQuestionTool ?? lastTool, lastModel, timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs), sessionId: backgroundSessionId, @@ -944,7 +952,6 @@ export function deriveTodosSqliteForSessions(opts: { }) if (!result.ok) return result - const { sessionId: _, ...todoRest } = result.rows[0] ?? {} return { ok: true, value: result.rows.map(({ sessionId: _sid, ...item }) => item), diff --git a/src/ingest/tool-names.ts b/src/ingest/tool-names.ts index 458430f..231228a 100644 --- a/src/ingest/tool-names.ts +++ b/src/ingest/tool-names.ts @@ -6,6 +6,14 @@ export const QUESTION_TOOL_NAMES = new Set([ "mcp_question", ]) +export function isActiveQuestionTool(toolName: string | null | undefined, status: string | null | undefined): boolean { + return ( + typeof toolName === "string" && + (status === "pending" || status === "running") && + QUESTION_TOOL_NAMES.has(toolName) + ) +} + export function isPendingQuestionTool(toolName: string | null | undefined, status: string | null | undefined): boolean { return typeof toolName === "string" && status === "pending" && QUESTION_TOOL_NAMES.has(toolName) } diff --git a/src/server/AGENTS.md b/src/server/AGENTS.md index edf633d..659d64c 100644 --- a/src/server/AGENTS.md +++ b/src/server/AGENTS.md @@ -13,6 +13,7 @@ Hono HTTP server + multi-project dashboard assembly. | `api.ts` | MED | `createApi()` factory — all REST routes, middleware, error handler | | `dashboard.ts` | HIGH | `buildDashboardPayload()` (SQLite) + `buildDashboardPayloadFiles()` (files) + `createDashboardStore()` | | `multi-project.ts` | MED | `createMultiProjectService()` — one `DashboardStore` per registered source | +| `quotas.ts` | MED | `createQuotaService()` — provider quota fetchers (auth.json read-only, TTL cache) | | `dev.ts` | LOW | Dev entry: Bun.serve on `OMO_PULSE_API_PORT` (4301) | | `start.ts` | LOW | Prod entry: serves Vite-built SPA + API on `OMO_PULSE_PORT` (4300) | diff --git a/src/server/api.ts b/src/server/api.ts index 3eed73e..b4e128a 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -4,10 +4,11 @@ import * as fs from "node:fs" import { homedir } from "node:os" import { listSources, getDefaultSourceId, addOrUpdateSource, updateSourceLabelById, deleteSourceById } from "../ingest/sources-registry" import { getStorageRoots, getMessageDir } from "../ingest/session" -import { assertAllowedPath } from "../ingest/paths" +import { assertAllowedPath, expandTilde } 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 { createQuotaService } from "./quotas" import type { DashboardMultiProjectPayload, TelegramServiceStatus } from "../types" const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ @@ -27,6 +28,7 @@ export function createApi(opts: { const api = new Hono() const version = opts.version ?? "0.0.0" const multiProjectService = opts.multiProjectService + const quotaService = createQuotaService() const invalidateProjects = (): void => { multiProjectService.invalidate() } @@ -75,11 +77,13 @@ export function createApi(opts: { return c.json({ ok: false, error: "projectRoot is required and must be a non-empty string" }, 400) } - if (!fs.existsSync(projectRoot)) { + const resolvedRoot = expandTilde(projectRoot.trim()) + + if (!fs.existsSync(resolvedRoot)) { return c.json({ ok: false, error: "projectRoot directory does not exist" }, 400) } - const sourceId = addOrUpdateSource(opts.storageRoot, projectRoot, label) + const sourceId = addOrUpdateSource(opts.storageRoot, resolvedRoot, label) invalidateProjects() return c.json({ ok: true, sourceId }) }) @@ -132,9 +136,17 @@ export function createApi(opts: { return c.json(project) }) - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // GET /quotas — provider subscription quota usage (cached ~3 min server-side) + // ------------------------------------------------------------------------- + api.get("/quotas", async (c) => { + const payload = await quotaService.getQuotas() + return c.json({ ok: true, ...payload }) + }) + + // ------------------------------------------------------------------------- // GET /tool-calls/:sessionId — tool call details per session - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- api.get("/tool-calls/:sessionId", (c) => { const sessionId = c.req.param("sessionId") if (!SESSION_ID_PATTERN.test(sessionId)) { diff --git a/src/server/quotas.ts b/src/server/quotas.ts new file mode 100644 index 0000000..c01195a --- /dev/null +++ b/src/server/quotas.ts @@ -0,0 +1,725 @@ +/** + * Provider quota service — queries subscription usage limits for providers + * configured in OpenCode's auth.json. + * + * Read-only observer: auth.json is never written. OAuth token refreshes are + * held in memory only (OpenCode rotates the persisted tokens itself). + * + * Endpoints (community/codex-sourced, undocumented where noted): + * - OpenCode Go: GET https://opencode.ai/zen/go/v1/usage + * - Z.AI: GET https://api.z.ai/api/monitor/usage/quota/limit (undocumented) + * - Kimi: GET https://api.kimi.com/coding/v1/usages (undocumented) + * - ChatGPT: GET https://chatgpt.com/backend-api/wham/usage (codex-rs contract) + * - Ollama Cloud: GET https://ollama.com/api/usage (undocumented) + */ + +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { getDataDir } from "../ingest/paths" +import type { + ProviderQuota, + ProviderQuotasPayload, + QuotaWindow, +} from "../types" + +/* ── Constants ── */ + +const CACHE_TTL_MS = 3 * 60_000 +const FETCH_TIMEOUT_MS = 10_000 + +const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token" +const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token" +const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" + +const GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage" +const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit" +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages" +const OPENAI_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" +const OLLAMA_USAGE_URL = "https://ollama.com/api/usage" + +const HOUR_SECONDS = 3_600 +const FIVE_HOURS_SECONDS = 5 * HOUR_SECONDS +const WEEK_SECONDS = 7 * 24 * HOUR_SECONDS + +/** Minimal structural fetch signature — injectable for tests. */ +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise + +const GO_MONTH_SECONDS = 30 * 24 * HOUR_SECONDS + +/* ── auth.json shape (read-only) ── */ + +type AuthEntryApi = { type: "api"; key: string } +type AuthEntryOauth = { + type: "oauth" + access: string + refresh: string + expiresMs: number + accountId?: string +} +type AuthEntry = AuthEntryApi | AuthEntryOauth +type AuthFile = Record + +function normalizeExpiresMs(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0 + // OpenCode stores ms epoch; tolerate seconds. + return value < 1e12 ? value * 1_000 : value +} + +export function parseAuthFile(raw: string): AuthFile { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return {} + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {} + const out: AuthFile = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "object" || value === null) continue + const entry = value as Record + if (entry.type === "api" && typeof entry.key === "string" && entry.key.length > 0) { + out[key] = { type: "api", key: entry.key } + } else if ( + entry.type === "oauth" && + typeof entry.access === "string" && entry.access.length > 0 && + typeof entry.refresh === "string" && entry.refresh.length > 0 + ) { + out[key] = { + type: "oauth", + access: entry.access, + refresh: entry.refresh, + expiresMs: normalizeExpiresMs(entry.expires), + accountId: typeof entry.accountId === "string" ? entry.accountId : undefined, + } + } + } + return out +} + +/* ── Helpers ── */ + +function toNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim() !== "") { + const n = Number(value) + if (Number.isFinite(n)) return n + } + return null +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) +} + +function toIsoMs(value: unknown): number | null { + if (typeof value !== "string" || value === "") return null + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null +} + +/** Next epoch-aligned boundary for fixed-period windows (Ollama Cloud resets). */ +function nextBoundaryMs(nowMs: number, periodSeconds: number): number { + const periodMs = periodSeconds * 1_000 + return (Math.floor(nowMs / periodMs) + 1) * periodMs +} + +/** First day of the next UTC calendar month (Kimi monthly cap has no reset field). */ +function nextMonthBoundaryMs(nowMs: number): number { + const d = new Date(nowMs) + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) +} + +function windowLabel(seconds: number): { id: string; shortLabel: string; label: string } { + if (seconds === FIVE_HOURS_SECONDS) return { id: "5h", shortLabel: "5H", label: "5-hour rolling" } + if (seconds === WEEK_SECONDS) return { id: "weekly", shortLabel: "WK", label: "Weekly" } + if (seconds === GO_MONTH_SECONDS) return { id: "monthly", shortLabel: "MO", label: "Monthly" } + const hours = Math.round(seconds / HOUR_SECONDS) + if (hours < 48) return { id: `${hours}h`, shortLabel: `${hours}H`, label: `${hours}-hour window` } + return { id: "weekly", shortLabel: "WK", label: "Weekly" } +} + +async function fetchJson( + fetchImpl: FetchLike, + url: string, + headers: Record, +): Promise { + const res = await fetchImpl(url, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status} from ${new URL(url).host}`) + } + return res.json() as unknown +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +/* ── Pure response parsers (exported for tests) ── */ + +/** OpenCode Go: { usage: { rolling|weekly|monthly: { percent, resetsAt } } } */ +export function parseGoUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const usage = root ? asRecord(root.usage) : null + if (!usage) return [] + const windows: QuotaWindow[] = [] + const defs: Array<{ key: string; id: string; shortLabel: string; label: string }> = [ + { key: "rolling", id: "5h", shortLabel: "5H", label: "5-hour rolling" }, + { key: "weekly", id: "weekly", shortLabel: "WK", label: "Weekly" }, + { key: "monthly", id: "monthly", shortLabel: "MO", label: "Monthly" }, + ] + for (const def of defs) { + const win = asRecord(usage[def.key]) + if (!win) continue + const percent = toNumber(win.percent) + if (percent === null) continue + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: toIsoMs(win.resetsAt), + }) + } + return windows +} + +/** + * Z.AI: { data: { limits: [{ type, unit, number, percentage?, currentValue?, usage?, nextResetTime? }] } } + * (unit=3,number=5) → 5h; (unit=6,number=1) → weekly; TIME_LIMIT → monthly tools budget. + */ +export function parseZaiUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const data = root ? asRecord(root.data) : null + const limits = data && Array.isArray(data.limits) ? data.limits : null + if (!limits) { + // Legacy flat shape: fiveHourPercent / weeklyPercent at data level. + if (!data) return [] + const windows: QuotaWindow[] = [] + const fiveHour = toNumber(data.fiveHourPercent) + if (fiveHour !== null) { + windows.push({ id: "5h", shortLabel: "5H", label: "5-hour rolling", usedPercent: clampPercent(fiveHour), resetsAtMs: null }) + } + const weekly = toNumber(data.weeklyPercent) + if (weekly !== null) { + windows.push({ id: "weekly", shortLabel: "WK", label: "Weekly", usedPercent: clampPercent(weekly), resetsAtMs: null }) + } + return windows + } + const windows: QuotaWindow[] = [] + for (const raw of limits) { + const row = asRecord(raw) + if (!row) continue + let def: { id: string; shortLabel: string; label: string } | null = null + if (row.type === "TIME_LIMIT") { + def = { id: "monthly", shortLabel: "MO", label: "Monthly tools" } + } else if (row.unit === 3 && row.number === 5) { + def = { id: "5h", shortLabel: "5H", label: "5-hour rolling" } + } else if (row.unit === 6 && row.number === 1) { + def = { id: "weekly", shortLabel: "WK", label: "Weekly" } + } + if (!def) continue + let percent = toNumber(row.percentage) + if (percent === null) { + const used = toNumber(row.currentValue) + const total = toNumber(row.usage) + if (used !== null && total !== null && total > 0) percent = (used / total) * 100 + } + if (percent === null) continue + const resetMs = toNumber(row.nextResetTime) + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: resetMs !== null && resetMs > 0 ? resetMs : null, + }) + } + return windows +} + +/** + * Kimi: strings for all numbers. usage → weekly, limits[0] → 5h session, + * totalQuota → monthly membership cap (no reset field → next month boundary). + */ +export function parseKimiUsage(body: unknown, nowMs: number): QuotaWindow[] { + const root = asRecord(body) + if (!root) return [] + const windows: QuotaWindow[] = [] + + const readUsage = (record: Record): { usedPercent: number; resetsAtMs: number | null } | null => { + const limit = toNumber(record.limit) + if (limit === null || limit <= 0) return null + const remaining = toNumber(record.remaining) + const used = toNumber(record.used) + let usedPercent: number + if (remaining !== null) usedPercent = ((limit - remaining) / limit) * 100 + else if (used !== null) usedPercent = (used / limit) * 100 + else return null + const reset = toIsoMs(record.resetTime) ?? toIsoMs(record.reset_at) + return { usedPercent: clampPercent(usedPercent), resetsAtMs: reset } + } + + const usage = asRecord(root.usage) + if (usage) { + const parsed = readUsage(usage) + if (parsed) windows.push({ id: "weekly", shortLabel: "WK", label: "Weekly", ...parsed }) + } + + if (Array.isArray(root.limits)) { + for (const raw of root.limits) { + const entry = asRecord(raw) + if (!entry) continue + const win = asRecord(entry.window) + const duration = win ? toNumber(win.duration) : null + const detail = asRecord(entry.detail) + if (!detail) continue + const parsed = readUsage(detail) + if (!parsed) continue + if (duration === 300) { + windows.push({ id: "5h", shortLabel: "5H", label: "5-hour rolling", ...parsed }) + } else { + windows.push({ id: "window", shortLabel: "WIN", label: "Rate window", ...parsed }) + } + } + } + + const totalQuota = asRecord(root.totalQuota) + if (totalQuota) { + const limit = toNumber(totalQuota.limit) + const used = toNumber(totalQuota.used) ?? (limit !== null && typeof totalQuota.remaining !== "undefined" + ? limit - (toNumber(totalQuota.remaining) ?? 0) + : null) + if (limit !== null && limit > 0 && used !== null) { + windows.push({ + id: "monthly", + shortLabel: "MO", + label: "Monthly cap", + usedPercent: clampPercent((used / limit) * 100), + resetsAtMs: nextMonthBoundaryMs(nowMs), + }) + } + } + return windows +} + +/** ChatGPT (codex): rate_limit.primary_window / secondary_window with used_percent. */ +export function parseOpenAiUsage(body: unknown): QuotaWindow[] { + const root = asRecord(body) + const rateLimit = root ? asRecord(root.rate_limit) : null + if (!rateLimit) return [] + const windows: QuotaWindow[] = [] + for (const key of ["primary_window", "secondary_window"] as const) { + const win = asRecord(rateLimit[key]) + if (!win) continue + const percent = toNumber(win.used_percent) + if (percent === null) continue + const windowSeconds = toNumber(win.limit_window_seconds) ?? FIVE_HOURS_SECONDS + const def = windowLabel(windowSeconds) + const resetAtSec = toNumber(win.reset_at) + const resetAfterSec = toNumber(win.reset_after_seconds) + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(percent), + resetsAtMs: + resetAtSec !== null && resetAtSec > 0 + ? resetAtSec * 1_000 + : resetAfterSec !== null + ? Date.now() + resetAfterSec * 1_000 + : null, + }) + } + return windows +} + +/** Ollama Cloud: limits.{session,weekly}.usage as 0..1 fractions, no reset timestamps. */ +export function parseOllamaUsage(body: unknown, nowMs: number): QuotaWindow[] { + const root = asRecord(body) + const limits = root ? asRecord(root.limits) : null + if (!limits) return [] + const windows: QuotaWindow[] = [] + const defs: Array<{ key: string; id: string; shortLabel: string; label: string; periodSeconds: number }> = [ + { key: "session", id: "5h", shortLabel: "5H", label: "5-hour rolling", periodSeconds: FIVE_HOURS_SECONDS }, + { key: "weekly", id: "weekly", shortLabel: "WK", label: "Weekly", periodSeconds: WEEK_SECONDS }, + ] + for (const def of defs) { + const win = asRecord(limits[def.key]) + if (!win) continue + const fraction = toNumber(win.usage) + if (fraction === null) continue + windows.push({ + id: def.id, + shortLabel: def.shortLabel, + label: def.label, + usedPercent: clampPercent(fraction * 100), + resetsAtMs: nextBoundaryMs(nowMs, def.periodSeconds), + }) + } + return windows +} + +/* ── OAuth refresh (in-memory only) ── */ + +type RefreshedToken = { access: string; expiresMs: number } +const refreshedTokens = new Map() + +function isFresh(token: RefreshedToken, nowMs: number): boolean { + return token.expiresMs - 60_000 > nowMs +} + +async function refreshOAuthToken(args: { + providerKey: string + tokenUrl: string + clientId: string + refreshToken: string + fetchImpl: FetchLike +}): Promise { + const cached = refreshedTokens.get(args.providerKey) + const nowMs = Date.now() + if (cached && isFresh(cached, nowMs)) return cached + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: args.refreshToken, + client_id: args.clientId, + }) + const res = await args.fetchImpl(args.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`token refresh failed: HTTP ${res.status}`) + } + const json = asRecord(await res.json() as unknown) + const access = json && typeof json.access_token === "string" ? json.access_token : null + if (!access) throw new Error("token refresh response missing access_token") + const expiresIn = (json !== null ? toNumber(json.expires_in) : null) ?? 3_600 + const token: RefreshedToken = { + access, + expiresMs: Date.now() + expiresIn * 1_000, + } + refreshedTokens.set(args.providerKey, token) + return token +} + +/** Resolve a usable bearer token for an oauth entry, refreshing when expired. */ +async function resolveOauthAccessToken(args: { + providerKey: string + entry: AuthEntryOauth + tokenUrl: string + clientId: string + fetchImpl: FetchLike +}): Promise { + const nowMs = Date.now() + const cached = refreshedTokens.get(args.providerKey) + if (cached && isFresh(cached, nowMs)) return cached.access + if (args.entry.expiresMs - 60_000 > nowMs) return args.entry.access + const token = await refreshOAuthToken({ + providerKey: args.providerKey, + tokenUrl: args.tokenUrl, + clientId: args.clientId, + refreshToken: args.entry.refresh, + fetchImpl: args.fetchImpl, + }) + return token.access +} + +/* ── Provider registry ── */ + +type ProviderFetchArgs = { + auth: AuthFile + fetchImpl: FetchLike + nowMs: number +} + +/* ── Provider icons (server-fetched favicons, cached in memory) ── */ + +const ICON_TTL_MS = 7 * 24 * 3_600_000 +const ICON_FAILURE_TTL_MS = 3_600_000 +const ICON_MAX_BYTES = 100_000 + +type CachedIcon = { dataUri: string | null; atMs: number } + +async function fetchProviderIcon(args: { + providerId: string + iconUrl: string + fetchImpl: FetchLike + cache: Map +}): Promise { + const cached = args.cache.get(args.providerId) + const nowMs = Date.now() + if (cached) { + const ttl = cached.dataUri === null ? ICON_FAILURE_TTL_MS : ICON_TTL_MS + if (nowMs - cached.atMs < ttl) return cached.dataUri + } + try { + const res = await args.fetchImpl(args.iconUrl, { + signal: AbortSignal.timeout(5_000), + }) + const rawMime = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() + const buf = await res.arrayBuffer() + const mime = sniffImageMime(buf, rawMime) + if (!res.ok || mime === null) { + throw new Error(`favicon is not an image (${rawMime || "no content type"})`) + } + if (buf.byteLength === 0 || buf.byteLength > ICON_MAX_BYTES) { + throw new Error(`favicon size out of range (${buf.byteLength} bytes)`) + } + const dataUri = `data:${mime};base64,${Buffer.from(buf).toString("base64")}` + args.cache.set(args.providerId, { dataUri, atMs: nowMs }) + return dataUri + } catch { + // Icon stays unavailable; letter codes remain the fallback in the UI. + args.cache.set(args.providerId, { dataUri: null, atMs: nowMs }) + return null + } +} + +/** Identify an image from magic bytes when the server omits/misstates content-type. */ +function sniffImageMime(buf: ArrayBuffer, declaredMime: string): string | null { + if (declaredMime.startsWith("image/")) return declaredMime + const b = new Uint8Array(buf) + if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png" + if (b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00) return "image/x-icon" + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg" + if ( + b.length >= 12 && + String.fromCharCode(...b.slice(0, 4)) === "RIFF" && + String.fromCharCode(...b.slice(8, 12)) === "WEBP" + ) { + return "image/webp" + } + const head = new TextDecoder().decode(b.slice(0, 200)).trim().toLowerCase() + if (head.startsWith(" Promise +} + +const providerDefs: ProviderDef[] = [ + { + providerId: "zai-coding-plan", + name: "Z.AI", + symbol: "Z", + iconUrl: "https://z.ai/favicon.png", + authKeys: ["zai-coding-plan"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "api") throw new Error("zai-coding-plan auth entry is not an API key") + const body = await fetchJson(fetchImpl, ZAI_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseZaiUsage(body) + }, + }, + { + providerId: "openai", + name: "ChatGPT", + symbol: "GP", + iconUrl: "https://chatgpt.com/favicon.ico", + authKeys: ["openai"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "oauth") throw new Error("openai auth entry is not OAuth") + const token = await resolveOauthAccessToken({ + providerKey: "openai", + entry, + tokenUrl: OPENAI_TOKEN_URL, + clientId: OPENAI_CLIENT_ID, + fetchImpl, + }) + const headers: Record = { + Authorization: `Bearer ${token}`, + "User-Agent": "omo-pulse", + } + if (entry.accountId) headers["ChatGPT-Account-Id"] = entry.accountId + const body = await fetchJson(fetchImpl, OPENAI_USAGE_URL, headers) + return parseOpenAiUsage(body) + }, + }, + { + providerId: "ollama-cloud", + name: "Ollama Cloud", + symbol: "OL", + iconUrl: "https://ollama.com/public/icon-32x32.png", + authKeys: ["ollama-cloud"], + fetchWindows: async ({ entry, fetchImpl, nowMs }) => { + if (entry.type !== "api") throw new Error("ollama-cloud auth entry is not an API key") + const body = await fetchJson(fetchImpl, OLLAMA_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseOllamaUsage(body, nowMs) + }, + }, + { + providerId: "kimi", + name: "Kimi", + symbol: "KI", + iconUrl: "https://www.kimi.com/favicon.ico", + authKeys: ["kimi-code", "kimi-for-coding-oauth", "moonshot"], + fetchWindows: async ({ entry, fetchImpl, nowMs }) => { + let token: string + if (entry.type === "oauth") { + token = await resolveOauthAccessToken({ + providerKey: "kimi-for-coding-oauth", + entry, + tokenUrl: KIMI_TOKEN_URL, + clientId: KIMI_CLIENT_ID, + fetchImpl, + }) + } else { + token = entry.key + } + const body = await fetchJson(fetchImpl, KIMI_USAGE_URL, { + Authorization: `Bearer ${token}`, + }) + return parseKimiUsage(body, nowMs) + }, + }, + { + providerId: "opencode-go", + name: "OpenCode Go", + symbol: "GO", + iconUrl: "https://opencode.ai/favicon.ico", + authKeys: ["opencode-go"], + fetchWindows: async ({ entry, fetchImpl }) => { + if (entry.type !== "api") throw new Error("opencode-go auth entry is not an API key") + const body = await fetchJson(fetchImpl, GO_USAGE_URL, { + Authorization: `Bearer ${entry.key}`, + }) + return parseGoUsage(body) + }, + }, +] + +/* ── Service ── */ + +export type QuotaService = { + getQuotas: () => Promise + invalidate: () => void +} + +export type QuotaServiceOptions = { + authPath?: string + cacheTtlMs?: number + fetchImpl?: FetchLike + now?: () => number +} + +export function defaultAuthPath(): string { + return join(getDataDir(), "opencode", "auth.json") +} + +export function createQuotaService(opts: QuotaServiceOptions = {}): QuotaService { + const authPath = opts.authPath ?? defaultAuthPath() + const cacheTtlMs = opts.cacheTtlMs ?? CACHE_TTL_MS + const fetchImpl = opts.fetchImpl ?? fetch + const now = opts.now ?? (() => Date.now()) + const iconCache = new Map() + let cache: { atMs: number; payload: ProviderQuotasPayload } | null = null + let inFlight: Promise | null = null + + const fetchOne = async (def: ProviderDef, auth: AuthFile): Promise => { + const fetchedAtMs = now() + const iconPromise = fetchProviderIcon({ + providerId: def.providerId, + iconUrl: def.iconUrl, + fetchImpl, + cache: iconCache, + }) + let entry: AuthEntry | undefined + for (const key of def.authKeys) { + const candidate = auth[key] + if (candidate) { + entry = candidate + break + } + } + if (!entry) { + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + icon: await iconPromise, + windows: [], + status: "unconfigured", + fetchedAtMs, + } + } + try { + const windows = await def.fetchWindows({ auth, entry, fetchImpl, nowMs: fetchedAtMs }) + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + icon: await iconPromise, + windows, + status: "ok", + fetchedAtMs, + } + } catch (err) { + return { + providerId: def.providerId, + name: def.name, + symbol: def.symbol, + icon: await iconPromise, + windows: [], + status: "error", + error: err instanceof Error ? err.message : String(err), + fetchedAtMs, + } + } + } + + const refresh = async (): Promise => { + let auth: AuthFile = {} + try { + auth = parseAuthFile(await readFile(authPath, "utf8")) + } catch { + // Missing/unreadable auth.json → every provider reports unconfigured. + } + const providers = await Promise.all(providerDefs.map((def) => fetchOne(def, auth))) + return { providers, serverNowMs: now() } + } + + const getQuotas = async (): Promise => { + const currentMs = now() + if (cache && currentMs - cache.atMs < cacheTtlMs) return cache.payload + if (inFlight) return inFlight + inFlight = refresh() + .then((payload) => { + cache = { atMs: now(), payload } + return payload + }) + .finally(() => { + inFlight = null + }) + return inFlight + } + + return { + getQuotas, + invalidate: () => { + cache = null + }, + } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 9eed0fa..8caa611 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -138,6 +138,15 @@ --sparkline-fill-sand: rgba(217, 70, 239, 0.8); --sparkline-fill-muted: rgba(148, 163, 184, 0.5); --sparkline-drop-shadow-muted: rgba(148, 163, 184, 0.4); + + /* Background activity bars (ambient sparkline behind header) */ + --bg-activity-teal: rgba(13, 148, 136, 0.32); + --bg-activity-red: rgba(225, 29, 72, 0.32); + --bg-activity-green: rgba(22, 163, 74, 0.32); + --bg-activity-sand: rgba(217, 119, 6, 0.32); + --bg-activity-muted: rgba(255, 255, 255, 0.09); + --bg-activity-flatline: rgba(255, 255, 255, 0.55); + --bg-activity-blend: normal; --swimlane-dot-teal: rgba(0, 212, 170, 0.7); --swimlane-dot-red: rgba(255, 107, 107, 0.7); --swimlane-dot-green: rgba(78, 205, 196, 0.7); @@ -246,6 +255,14 @@ --sparkline-fill-sand: rgba(180, 60, 200, 0.8); --sparkline-fill-muted: rgba(148, 163, 184, 0.4); --sparkline-drop-shadow-muted: rgba(148, 163, 184, 0.3); + + --bg-activity-teal: rgba(15, 118, 110, 0.30); + --bg-activity-red: rgba(190, 18, 60, 0.30); + --bg-activity-green: rgba(21, 128, 61, 0.30); + --bg-activity-sand: rgba(180, 83, 9, 0.30); + --bg-activity-muted: rgba(0, 0, 0, 0.08); + --bg-activity-flatline: rgba(0, 0, 0, 0.48); + --bg-activity-blend: normal; --swimlane-dot-teal: rgba(0, 155, 122, 0.7); --swimlane-dot-red: rgba(212, 64, 64, 0.7); --swimlane-dot-green: rgba(42, 157, 143, 0.7); diff --git a/src/types.ts b/src/types.ts index 297d89a..2678f44 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,8 +206,11 @@ export type DashboardMultiProjectPayload = { } /** Configuration state for strip visibility options */ +/** Sparkline rendering mode in the collapsed strip header */ +export type MiniSparklineMode = "ambient" | "inline" | "off" + export type StripConfigState = { - showMiniSparkline: boolean + miniSparklineMode: MiniSparklineMode showPlanProgress: boolean showAgentBadge: boolean showLastUpdated: boolean @@ -217,6 +220,10 @@ export type StripConfigState = { showGitWorktrees: boolean showAvatar: boolean showProjectName: boolean + /** Provider quota strip visibility (dashboard header area) */ + showQuotas: boolean + /** Provider quota strip identifier style: fetched favicons or letter codes */ + quotaIconMode: "icons" | "codes" stripDisplayMode: "project" | "session" } @@ -258,3 +265,37 @@ export type TelegramServiceStatus = { lastError: string | null alertsSent: number } + +/** Single quota window for a provider (e.g. 5-hour rolling, weekly, monthly) */ +export type QuotaWindow = { + /** Stable window id: "5h" | "weekly" | "monthly" */ + id: string + /** Micro label shown next to the usage line: "5H", "WK", "MO" */ + shortLabel: string + /** Human label for tooltips: "5-hour rolling" */ + label: string + /** 0..100 percent used */ + usedPercent: number + /** Next reset as epoch ms, null when the provider does not report one */ + resetsAtMs: number | null +} + +/** Quota state for a single provider */ +export type ProviderQuota = { + providerId: string + name: string + /** 1-2 char monogram shown in the strip (fallback when no icon) */ + symbol: string + /** Provider favicon as a data URI, null when unavailable */ + icon: string | null + windows: QuotaWindow[] + status: "ok" | "unconfigured" | "error" + error?: string + fetchedAtMs: number +} + +/** Payload for GET /api/quotas */ +export type ProviderQuotasPayload = { + providers: ProviderQuota[] + serverNowMs: number +} diff --git a/src/ui/App.css b/src/ui/App.css index 7eb4c3f..b4d405f 100644 --- a/src/ui/App.css +++ b/src/ui/App.css @@ -12,11 +12,40 @@ max-width: 1400px; width: calc(100% / var(--zoom)); margin: 0 auto; - padding: var(--sp-4) var(--sp-4) var(--sp-8); + padding: 2px var(--sp-4) 6px; transform: scale(var(--zoom)); transform-origin: top center; } + +/* Bookmark-style tab shown when the header is collapsed: hangs from the top + edge, horizontally centered, so it never collides with the quota strip. + Compact footprint, but one step brighter than ambient chrome so it stays findable. */ +.header-restore { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: 20; + width: 10px; + font-size: 0.6rem; + line-height: 1; + text-align: center; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + padding: 3px 0 2px; + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.header-restore:hover { + color: var(--text-primary); + border-color: var(--accent-primary); +} + /* ── Header ── */ .dashboard-header { display: flex; @@ -45,7 +74,6 @@ .dashboard-header__count { font-family: var(--font-mono); - font-size: var(--font-xs); color: var(--text-muted); } @@ -71,7 +99,6 @@ background: var(--bg-tertiary); color: var(--text-secondary); font-family: var(--font-mono); - font-size: var(--font-xs); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); line-height: 1; @@ -107,7 +134,6 @@ .header-cols__label { font-family: var(--font-mono); - font-size: var(--font-xs); color: var(--text-muted); user-select: none; line-height: 1; @@ -128,7 +154,6 @@ background: var(--bg-tertiary); color: var(--text-secondary); font-family: var(--font-mono); - font-size: var(--font-xs); cursor: pointer; border: none; border-right: 1px solid var(--border-primary); @@ -163,7 +188,6 @@ } .header-zoom__pct { - font-size: var(--font-xs); color: var(--text-secondary); line-height: 1; user-select: none; @@ -194,7 +218,6 @@ /* Last updated text */ .dashboard-header__updated { - font-size: var(--font-xs); color: var(--text-muted); white-space: nowrap; } @@ -205,12 +228,16 @@ grid-template-columns: repeat(var(--grid-cols, 1), 1fr); gap: var(--grid-gap, 10px); overflow-y: auto; - max-height: calc(100vh - 96px); /* viewport minus header (~44px) + container padding (52px) */ + max-height: calc(100vh - 56px); /* viewport minus header (~44px) + container vertical padding (~8px) */ scrollbar-gutter: stable; align-items: start; position: relative; } + .page[data-header-collapsed="true"] .project-stack { + max-height: calc(100vh - 14px); +} + .project-stack > * { min-width: 0; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 09431cf..a5a9d50 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -14,6 +14,7 @@ import { SessionSwimlane } from "./components/SessionSwimlane" import { SettingsPanel } from "./components/SettingsPanel" import { ColumnResizeHandle } from "./components/ColumnResizeHandle" import { ProjectManagementPanel } from "./components/ProjectManagementPanel" +import { QuotaStrip } from "./components/QuotaStrip" import { useStripConfig } from "./hooks/useStripConfig" import { PreviewNav } from "./components/PreviewNav" import type { PreviewMode } from "./types" @@ -22,6 +23,7 @@ import "./App.css" import { useExpandState } from "./hooks/useExpandState" import { useDensityMode } from "./hooks/useDensityMode" import { useSoundNotifications } from "./hooks/useSoundNotifications" +import { useQuotas } from "./hooks/useQuotas" import { useProjectOrder } from "./hooks/useProjectOrder" import { useProjectVisibility } from "./hooks/useProjectVisibility" import { ATTENTION_FIRST_PRIORITY } from "../ingest/status-rollup" @@ -156,9 +158,19 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap const { config: soundConfig, setConfig: setSoundConfig, playWaiting, playAllClear, playAttention, playQuestion } = useSoundNotifications() const { orderedIds, columns, reorder, setColumns, syncIds } = useProjectOrder() const { visibility, isVisible, toggleVisibility } = useProjectVisibility() - const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode } = useStripConfig() + const { config: stripConfig, toggle: toggleStripConfig, setMode: setStripMode, setMiniSparklineMode, setQuotaIconMode } = useStripConfig() + const { quotas } = useQuotas() const [activeOverlay, setActiveOverlay] = useState('none') + /* ── Collapsible header ── */ + const [headerCollapsed, setHeaderCollapsed] = useState(() => safeGetItem('dashboard-header-collapsed') === 'true') + + useEffect(() => { + safeSetItem('dashboard-header-collapsed', String(headerCollapsed)) + }, [headerCollapsed]) + + const handleToggleHeader = useCallback(() => setHeaderCollapsed((c) => !c), []) + /* ── Zoom ── */ const [zoom, setZoom] = useState(() => { const saved = safeGetItem('dashboard-zoom') @@ -185,7 +197,9 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap /* ── Collapsed pane height & grid gap ── */ const [collapsedHeight, setCollapsedHeight] = useState(() => { const saved = safeGetItem('dashboard-collapsed-height') - return saved ? parseInt(saved, 10) : 40 + const parsed = saved ? parseInt(saved, 10) : 40 + /* Clamp persisted values to the 30–100px slider range */ + return Number.isFinite(parsed) ? Math.min(100, Math.max(30, parsed)) : 40 }) const [gridGap, setGridGap] = useState(() => { @@ -415,21 +429,35 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap }, [playWaiting, playAllClear, playAttention, playQuestion]) return ( -
- +
+ {headerCollapsed ? ( + + ) : ( + + )} + {stripConfig.showQuotas && }
{data === null ? (
Loading…
@@ -505,6 +533,8 @@ export function App({ data, connected, lastUpdatedMs, previewMode, refresh }: Ap stripConfig={stripConfig} onToggleStrip={toggleStripConfig} onSetStripMode={setStripMode} + onSetMiniSparklineMode={setMiniSparklineMode} + onSetQuotaIconMode={setQuotaIconMode} soundConfig={soundConfig} onSoundConfigChange={setSoundConfig} onTestSound={handleTestSound} @@ -589,7 +619,7 @@ function ProjectStripWithChildren({ project, expanded, onToggleExpand, stripConf {{ miniSparkline: ( ), diff --git a/src/ui/components/AddProjectForm.tsx b/src/ui/components/AddProjectForm.tsx index 6ef281e..664f61c 100644 --- a/src/ui/components/AddProjectForm.tsx +++ b/src/ui/components/AddProjectForm.tsx @@ -89,7 +89,7 @@ export function AddProjectForm({ onProjectAdded }: AddProjectFormProps) { className={`add-project-input add-project-input--path${statusType === "error" && !projectRoot.trim() ? " add-project-input--error" : ""}`} type="text" name="projectRoot" - placeholder="/path/to/project" + placeholder="~/projects/my-project" value={projectRoot} onChange={(e) => setProjectRoot(e.target.value)} disabled={loading} diff --git a/src/ui/components/DashboardHeader.tsx b/src/ui/components/DashboardHeader.tsx index fb9c658..5bf636b 100644 --- a/src/ui/components/DashboardHeader.tsx +++ b/src/ui/components/DashboardHeader.tsx @@ -15,6 +15,7 @@ export type DashboardHeaderProps = { onZoomIn?: () => void onZoomOut?: () => void onZoomReset?: () => void + onCollapse?: () => void } /* ── Helpers ── */ @@ -73,6 +74,7 @@ export const DashboardHeader = memo(function DashboardHeader({ onZoomIn, onZoomOut, onZoomReset, + onCollapse, }: DashboardHeaderProps) { return (
@@ -154,6 +156,12 @@ export const DashboardHeader = memo(function DashboardHeader({ ⚙ )} + + {onCollapse && ( + + )}
) diff --git a/src/ui/components/ProjectStrip.css b/src/ui/components/ProjectStrip.css index 5d21bc5..f895261 100644 --- a/src/ui/components/ProjectStrip.css +++ b/src/ui/components/ProjectStrip.css @@ -180,6 +180,8 @@ align-items: center; gap: var(--sp-3); height: var(--collapsed-pane-height, 40px); + position: relative; + z-index: 3; padding: 0 var(--sp-3); cursor: pointer; user-select: none; @@ -188,6 +190,8 @@ .strip-header-row { display: flex; + position: relative; + z-index: 3; align-items: center; gap: var(--sp-2); } @@ -204,7 +208,7 @@ } .strip-header:hover { - background: var(--bg-tertiary); + background: color-mix(in srgb, var(--bg-tertiary) 78%, transparent); } .strip-header:focus-visible { outline: 2px solid var(--accent-primary); @@ -476,6 +480,35 @@ border: 1px solid var(--bg-tertiary); } +/* ── Background activity layer (ambient bars behind header) ── */ +.strip-bg-sparkline { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--collapsed-pane-height, 40px); + z-index: 0; + pointer-events: none; + mix-blend-mode: var(--bg-activity-blend, screen); + -webkit-mask-image: linear-gradient(to top, #000 0%, transparent 85%); + mask-image: linear-gradient(to top, #000 0%, transparent 85%); +} + +[data-density="ultra-dense"] .strip-bg-sparkline { + height: var(--collapsed-pane-height, 36px); +} + +/* Dim ambient bars on inactive / unknown states */ +.project-strip[data-status="idle"] .strip-bg-sparkline, +.project-strip[data-status="error"] .strip-bg-sparkline, +.project-strip[data-status="unknown"] .strip-bg-sparkline { + opacity: 0.3; +} + +.project-strip[data-stale="true"] .strip-bg-sparkline { + opacity: 0.2; +} + /* Agent badge */ .strip-agent-badge { display: inline-flex; @@ -546,6 +579,8 @@ /* ── Expanded body ── */ .strip-body { max-height: 0; + position: relative; + z-index: 3; overflow: hidden; transition: max-height var(--transition-normal); } @@ -621,6 +656,11 @@ min-width: 0; } +.strip-top-row-activity > .strip-section { + width: 100%; + min-width: 0; +} + .strip-section--token-usage { flex: 0 0 136px; min-width: 120px; @@ -767,6 +807,7 @@ flex-direction: column; gap: var(--sp-2); align-items: flex-start; + width: 100%; font-family: var(--font-mono); font-size: var(--font-xs); } @@ -1095,11 +1136,18 @@ @media (max-width: 900px) { .strip-top-row { flex-direction: column; + flex-wrap: nowrap; } - .strip-section--token-usage { + .strip-top-row-activity { flex: 1 1 auto; + width: 100%; + } + + .strip-section--token-usage { + flex: 0 0 auto; min-width: 0; + width: 100%; } } diff --git a/src/ui/components/ProjectStrip.tsx b/src/ui/components/ProjectStrip.tsx index b902eb1..7e3baa1 100644 --- a/src/ui/components/ProjectStrip.tsx +++ b/src/ui/components/ProjectStrip.tsx @@ -144,6 +144,10 @@ type StripHeaderContentProps = { const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisplayStatus, isStale, stripConfig, mainSession, gitUncommittedCount, unintiatedPlans, children, slots }: StripHeaderContentProps) { const slotContent = slots ?? children + const uninitiatedPlanTooltip = unintiatedPlans && unintiatedPlans.length > 0 + ? `${unintiatedPlans.length} uninitiated plan${unintiatedPlans.length === 1 ? "" : "s"}:\n${unintiatedPlans.map((plan) => `- ${plan.name}`).join("\n")}` + : undefined + return ( <> {stripConfig?.showStatusDot !== false && ( @@ -175,7 +179,7 @@ const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisp )}
)} - {stripConfig?.showMiniSparkline !== false &&
{slotContent?.miniSparkline}
} + {stripConfig?.miniSparklineMode === "inline" &&
{slotContent?.miniSparkline}
} {stripConfig?.showAgentBadge !== false && {mainSession.agent}} {gitUncommittedCount != null && gitUncommittedCount > 0 && ( @@ -196,7 +200,12 @@ const StripHeaderContent = memo(function StripHeaderContent({ project, finalDisp )} {stripConfig?.showPlanProgress !== false &&
{slotContent?.compactPlan}
} {unintiatedPlans && unintiatedPlans.length > 0 && ( - {unintiatedPlans.length} + + {unintiatedPlans.length} + )} {stripConfig?.showLastUpdated !== false && {mainSession.lastUpdated ? formatRelativeTime(new Date(mainSession.lastUpdated).getTime()) : "—"}} @@ -445,7 +454,9 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl : displayStatus const isStale = (() => { - const activeStates = ['busy', 'thinking', 'running_tool', 'running_script', 'question', 'error'] + // bg_agent: project with running background task — main session may be idle/stale, + // but the project IS active (aggregateStatus promoted in multi-project.ts). + const activeStates = ['busy', 'thinking', 'running_tool', 'running_script', 'bg_agent', 'question', 'error'] if (activeStates.includes(finalDisplayStatus)) return false if (planProgress?.planStale) return true if (!mainSession?.lastUpdated) return true @@ -542,6 +553,11 @@ function ProjectStripInner({ project, expanded, onToggleExpand, stripConfig, idl return (
+ {stripConfig?.miniSparklineMode === "ambient" && children?.miniSparkline && ( + + )} {previewPublicName ? ( 0 ? `${hours}h${restMinutes}m` : `${hours}h` + const days = Math.floor(hours / 24) + const restHours = hours % 24 + return restHours > 0 ? `${days}d${restHours}h` : `${days}d` +} + +function usageLevel(percent: number): "ok" | "warn" | "danger" { + if (percent >= 90) return "danger" + if (percent >= 70) return "warn" + return "ok" +} + +/** + * Display priority per window duration. When a longer window is exhausted + * (100% used), every shorter window collapses: a 5h reset is irrelevant while + * the provider is locked out for another week by the monthly quota. + */ +const WINDOW_RANK: Record = { window: 0, "5h": 0, weekly: 1, monthly: 2 } + +export function visibleWindows(windows: QuotaWindow[]): QuotaWindow[] { + if (windows.length <= 1) return windows + const rankOf = (w: QuotaWindow): number => WINDOW_RANK[w.id] ?? 0 + /* Always display shortest → longest left-to-right, regardless of payload order */ + const sorted = [...windows].sort((a, b) => rankOf(a) - rankOf(b)) + const firstExhausted = sorted.find((w) => w.usedPercent >= 100) + if (!firstExhausted) return sorted + const cutoff = rankOf(firstExhausted) + return sorted.filter((w) => rankOf(w) >= cutoff) +} + +function tooltipFor(provider: ProviderQuota, windows: QuotaWindow[]): string { + if (provider.status === "unconfigured") return `${provider.name} — not configured` + if (provider.status === "error") return `${provider.name} — ${provider.error ?? "unavailable"}` + const parts = windows.map( + (w: QuotaWindow) => { + const remaining = formatRemaining(w.resetsAtMs) + const resetText = + w.resetsAtMs === null ? "" : remaining === "now" ? "resetting" : `, resets in ${remaining}` + return `${w.label}: ${Math.round(w.usedPercent)}% used${resetText}` + }, + ) + return parts.length > 0 ? `${provider.name} · ${parts.join(" · ")}` : provider.name +} + +/* ── Component ── */ + +export type QuotaStripProps = { + quotas: ProviderQuotasPayload | null + iconMode: "icons" | "codes" +} + +export const QuotaStrip = memo(function QuotaStrip({ quotas, iconMode }: QuotaStripProps) { + if (quotas === null || quotas.providers.length === 0) return null + + const useIcons = iconMode === "icons" + + return ( +
+ {quotas.providers.map((provider: ProviderQuota) => { + const windows = visibleWindows(provider.windows) + return ( +
+ {useIcons && provider.icon ? ( + + ) : ( + + )} +
+ {provider.status !== "ok" || windows.length === 0 ? ( + + ) + })} +
+ ) +}) diff --git a/src/ui/components/SettingsPanel.tsx b/src/ui/components/SettingsPanel.tsx index 7e7e86e..af1ecd6 100644 --- a/src/ui/components/SettingsPanel.tsx +++ b/src/ui/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useState } from "react" -import type { StripConfigState, SoundConfig } from "../../types" +import type { StripConfigState, SoundConfig, MiniSparklineMode } from "../../types" import { OverlayShell } from "./OverlayShell" import "./SettingsPanel.css" @@ -39,6 +39,8 @@ export type SettingsPanelProps = { stripConfig: StripConfigState onToggleStrip: (key: keyof StripConfigState) => void onSetStripMode: (mode: "project" | "session") => void + onSetMiniSparklineMode: (mode: MiniSparklineMode) => void + onSetQuotaIconMode: (mode: "icons" | "codes") => void soundConfig: SoundConfig onSoundConfigChange: (config: SoundConfig) => void onTestSound: (event: "idle" | "complete" | "error" | "question") => void @@ -55,10 +57,9 @@ export type SettingsPanelProps = { /* ── Display toggle metadata ── */ -const COLLAPSED_TOGGLES: { key: Extract; label: string }[] = [ +const COLLAPSED_TOGGLES: { key: Extract; label: string }[] = [ { key: "showProjectName", label: "Project Name" }, { key: "showStatusDot", label: "Status Dot" }, - { key: "showMiniSparkline", label: "Mini Sparkline" }, { key: "showPlanProgress", label: "Plan Progress" }, { key: "showAgentBadge", label: "Agent Badge" }, { key: "showLastUpdated", label: "Last Updated" }, @@ -90,6 +91,8 @@ export function SettingsPanel({ stripConfig, onToggleStrip, onSetStripMode, + onSetMiniSparklineMode, + onSetQuotaIconMode, soundConfig, onSoundConfigChange, onTestSound, @@ -165,6 +168,12 @@ export function SettingsPanel({ const handleSetStripModeProject = useCallback(() => onSetStripMode("project"), [onSetStripMode]) const handleSetStripModeSession = useCallback(() => onSetStripMode("session"), [onSetStripMode]) + + const handleSetMiniSparklineModeAmbient = () => onSetMiniSparklineMode("ambient") + const handleSetMiniSparklineModeInline = () => onSetMiniSparklineMode("inline") + const handleSetMiniSparklineModeOff = () => onSetMiniSparklineMode("off") + const handleSetQuotaIconModeIcons = () => onSetQuotaIconMode("icons") + const handleSetQuotaIconModeCodes = () => onSetQuotaIconMode("codes") const handleCollapsedHeightChange = useCallback( (e: React.ChangeEvent) => onCollapsedHeightChange(Number(e.target.value)), [onCollapsedHeightChange], @@ -216,6 +225,42 @@ export function SettingsPanel({
+
+ Activity Chart +
+ + + +
+
+

Collapsed View

{COLLAPSED_TOGGLES.map(({ key, label }) => (
@@ -247,14 +292,54 @@ export function SettingsPanel({
))} +

Dashboard

+
+ Provider Quotas +
+ +
+ Quota Identifiers +
+ + +
+
+ {/* Collapsed Pane Height */}
Collapsed Height ) } + +/* ── Background mode (ambient layer behind header) ── */ + +function renderBg( + totalBuckets: number, + lookup: Map, + barW: number, + barInset: number, + widthProp: number | undefined, + heightProp: number | undefined, + className: string | undefined, +) { + const h = heightProp ?? 40 + const windowSize = Math.min(totalBuckets, 300) + const chartH = h + + const sisV = lookup.get("agent:sisyphus")?.values ?? [] + const proV = lookup.get("agent:prometheus")?.values ?? [] + const atlV = lookup.get("agent:atlas")?.values ?? [] + const bgV = lookup.get("background-total")?.values ?? [] + + const startIdx = 0 + const count = windowSize + + /* Sum main agents per bucket + track dominant tone */ + const sums: number[] = [] + const dominantTones: AgentTone[] = [] + let maxVal = 0 + let hasAny = false + for (let i = 0; i < count; i++) { + const idx = startIdx + i + const sisVal = toSafe(sisV[idx]) + const proVal = toSafe(proV[idx]) + const atlVal = toSafe(atlV[idx]) + const sum = sisVal + proVal + atlVal + sums.push(sum) + if (sum > 0) hasAny = true + if (sum > maxVal) maxVal = sum + + /* Determine dominant agent tone for this bucket */ + let dominantTone: AgentTone = "teal" /* default */ + const maxValBucket = Math.max(sisVal, proVal, atlVal) + if (maxValBucket > 0) { + if (sisVal === maxValBucket) { + dominantTone = "teal" + } else if (proVal === maxValBucket) { + dominantTone = "red" + } else if (atlVal === maxValBucket) { + dominantTone = "green" + } + } + dominantTones.push(dominantTone) + } + + /* Account for background-agent bars in scale */ + for (let i = 0; i < count; i++) { + const idx = startIdx + i + const bgVal = toSafe(bgV[idx]) + if (bgVal > 0) hasAny = true + const candidate = Math.max(sums[i], bgVal) + if (candidate > maxVal) maxVal = candidate + } + const scaleMax = Math.max(1, maxVal) + + const viewBox = `0 0 ${count} ${h}` + const cls = ["sparkline", "sparkline--bg", className].filter(Boolean).join(" ") + + return ( + + ) +} diff --git a/src/ui/hooks/useQuotas.ts b/src/ui/hooks/useQuotas.ts new file mode 100644 index 0000000..2330c95 --- /dev/null +++ b/src/ui/hooks/useQuotas.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react" +import type { ProviderQuotasPayload } from "../../types" + +/** UI poll cadence; the server caches provider responses for ~3 min. */ +const POLL_MS = 60_000 + +export type UseQuotasReturn = { + quotas: ProviderQuotasPayload | null + connected: boolean +} + +/** Polls GET /api/quotas once a minute for provider quota usage. */ +export function useQuotas(): UseQuotasReturn { + const [quotas, setQuotas] = useState(null) + const [connected, setConnected] = useState(false) + const connectedRef = useRef(false) + + useEffect(() => { + const ac = new AbortController() + let stopped = false + let timer: number | undefined + + const poll = async (): Promise => { + try { + const res = await fetch("/api/quotas", { signal: ac.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as Partial & { ok?: boolean } + if (stopped) return + if (!json.ok || !Array.isArray(json.providers)) throw new Error("malformed payload") + setQuotas({ providers: json.providers, serverNowMs: json.serverNowMs ?? Date.now() }) + if (!connectedRef.current) { + connectedRef.current = true + setConnected(true) + } + } catch { + if (stopped || ac.signal.aborted) return + if (connectedRef.current) { + connectedRef.current = false + setConnected(false) + } + } finally { + if (!stopped) { + timer = window.setTimeout(() => { void poll() }, POLL_MS) + } + } + } + + void poll() + + return () => { + stopped = true + ac.abort() + if (timer !== undefined) window.clearTimeout(timer) + } + }, []) + + return { quotas, connected } +} diff --git a/src/ui/hooks/useStripConfig.ts b/src/ui/hooks/useStripConfig.ts index 3b716c8..a914a84 100644 --- a/src/ui/hooks/useStripConfig.ts +++ b/src/ui/hooks/useStripConfig.ts @@ -1,11 +1,11 @@ import { useState, useCallback, useEffect } from "react" -import type { StripConfigState } from "../../types" +import type { StripConfigState, MiniSparklineMode } from "../../types" const STORAGE_KEY = "dashboard-strip-config" /** Default strip configuration with all toggles enabled */ const DEFAULT_CONFIG: StripConfigState = { - showMiniSparkline: true, + miniSparklineMode: "ambient", showPlanProgress: true, showAgentBadge: true, showLastUpdated: true, @@ -15,6 +15,8 @@ const DEFAULT_CONFIG: StripConfigState = { showGitWorktrees: true, showAvatar: true, showProjectName: true, + showQuotas: true, + quotaIconMode: "icons", stripDisplayMode: "project", } @@ -25,7 +27,14 @@ function readPersistedConfig(): StripConfigState { if (!raw) return DEFAULT_CONFIG const parsed: unknown = JSON.parse(raw) if (typeof parsed !== "object" || parsed === null) return DEFAULT_CONFIG - return { ...DEFAULT_CONFIG, ...parsed } + const merged = { ...DEFAULT_CONFIG, ...(parsed as object) } as StripConfigState & { showMiniSparkline?: unknown } + /* Migrate legacy boolean toggle → 3-way mode */ + const parsedObj = parsed as Record + if (!("miniSparklineMode" in parsedObj) && "showMiniSparkline" in parsedObj) { + merged.miniSparklineMode = parsedObj.showMiniSparkline === false ? "off" : "ambient" + } + delete (merged as { showMiniSparkline?: unknown }).showMiniSparkline + return merged } catch { return DEFAULT_CONFIG } @@ -48,6 +57,8 @@ export function useStripConfig(): { config: StripConfigState toggle: (key: keyof StripConfigState) => void setMode: (mode: "project" | "session") => void + setMiniSparklineMode: (mode: MiniSparklineMode) => void + setQuotaIconMode: (mode: "icons" | "codes") => void reset: () => void } { const [config, setConfig] = useState(() => readPersistedConfig()) @@ -71,9 +82,23 @@ export function useStripConfig(): { })) }, []) + const setMiniSparklineMode = useCallback((mode: MiniSparklineMode) => { + setConfig((prev) => ({ + ...prev, + miniSparklineMode: mode, + })) + }, []) + + const setQuotaIconMode = useCallback((mode: "icons" | "codes") => { + setConfig((prev) => ({ + ...prev, + quotaIconMode: mode, + })) + }, []) + const reset = useCallback(() => { setConfig(DEFAULT_CONFIG) }, []) - return { config, toggle, setMode, reset } + return { config, toggle, setMode, setMiniSparklineMode, setQuotaIconMode, reset } }
Full detail view with expanded panes