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 — 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(
+