Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a2980dc
fix(api): expand ~/ in project root path when adding sources
EZotoff May 6, 2026
9b3ec09
fix(ingest): detect active question tools awaiting input
EZotoff May 10, 2026
7ef4da6
fix(ingest): add stale question age guard
EZotoff May 16, 2026
e8f28ab
fix(ingest): skip stale main question tools
EZotoff May 16, 2026
07745e7
fix(ingest): suppress stale background questions
EZotoff May 16, 2026
91ca88e
fix(ingest): scan past stale included questions
EZotoff May 16, 2026
32918fe
fix(ingest): stop runaway git diff storm (service deployment of fix/r…
EZotoff Jul 15, 2026
e28adf8
feat(ui): ambient background activity chart with 3-way mode setting
EZotoff Jul 16, 2026
3ac7967
feat(ui): provider quota strip with live usage from 5 providers
EZotoff Sep 12, 2026
96bb6a1
feat(ui): inline quota reset countdowns + settings toggle
EZotoff Sep 12, 2026
811810f
feat(ui): provider favicon identifiers with icons/codes toggle, densi…
EZotoff Sep 12, 2026
2ea58d2
feat(ui): collapsible header with corner chevron; compact vertical pa…
EZotoff Sep 12, 2026
d33425c
fix(ui): restore quota strip wrapping lost in padding edit
EZotoff Sep 12, 2026
2ec014a
fix(ui): decouple quota strip sizing from density mode to stop post-l…
EZotoff Sep 12, 2026
20ea9ce
feat(ui): quota hierarchy collapse and window sort; provider display …
EZotoff Sep 13, 2026
c9ca79d
feat(ui): two-per-row quota grid, fixed reset label width, bookmark-s…
EZotoff Sep 13, 2026
75d5d61
fix(ui): restore provider flex layout clobbered by grid edit
EZotoff Sep 13, 2026
b829201
style(ui): slimmer bookmark tab with higher-contrast resting state
EZotoff Sep 13, 2026
ef3ca14
style(ui): 10px bookmark tab; collapsed-height slider range 30-100px
EZotoff Sep 13, 2026
866da0e
docs(readme): provider quota strip demo visual and feature bullet
EZotoff Sep 13, 2026
7ebdf73
test(git): align no-cache test with negative-cache backoff behavior
EZotoff Sep 13, 2026
25b5d3d
test(dashboard): constructable MockDatabase for vitest 4 new-call sem…
EZotoff Sep 13, 2026
910a903
test(quotas): exact host matching in fetch mocks (CodeQL js/incomplet…
EZotoff Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +48,12 @@ Run it as a persistent service alongside your development workflow and always kn

<p align="center"><em>Refined status language — distinct glass and lamp-style indicators for danger, questions, active work, completion, idle, and unknown states.</em></p>

<p align="center">
<img src="docs/screenshots/provider-quotas.png" alt="Provider quota strip: thin usage lines with reset countdowns for Z.AI, ChatGPT, OpenCode Go, Ollama Cloud, and Kimi" width="820"/>
</p>

<p align="center"><em>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.</em></p>

<table>
<tr>
<td colspan="3" align="center"><img src="docs/screenshots/details-expanded.png" alt="Full detail view with expanded panes" width="560"/></td>
Expand Down
Binary file added docs/screenshots/provider-quotas.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/__tests__/activity-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ACTIVE_STALE_MS,
BACKGROUND_QUEUE_STALE_MS,
hasFreshMainSessionActivity,
isStaleQuestionTool,
resolveLastUpdatedTime,
shouldSuppressStaleToolActivity,
shouldKeepQueuedBackgroundTaskActive,
Expand Down Expand Up @@ -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)
})
})
166 changes: 166 additions & 0 deletions src/__tests__/background-tasks-derive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/__tests__/dashboard-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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([])
Expand Down
10 changes: 8 additions & 2 deletions src/__tests__/git-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/project-strip-history.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("ProjectStrip plan history rendering", () => {
}

const baseConfig: StripConfigState = {
showMiniSparkline: true,
miniSparklineMode: "ambient",
showPlanProgress: true,
showAgentBadge: true,
showLastUpdated: true,
Expand Down
28 changes: 27 additions & 1 deletion src/__tests__/project-strip-status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const baseProject: ProjectSnapshot = {
}

const baseConfig: StripConfigState = {
showMiniSparkline: true,
miniSparklineMode: "ambient",
showPlanProgress: true,
showAgentBadge: true,
showLastUpdated: true,
Expand All @@ -47,6 +47,8 @@ const baseConfig: StripConfigState = {
showAvatar: true,
showProjectName: true,
stripDisplayMode: "project",
showQuotas: true,
quotaIconMode: "icons",
}

const children = {
Expand Down Expand Up @@ -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(
<ProjectStrip project={project} expanded={false} onToggleExpand={() => {}} stripConfig={baseConfig}>
{children}
</ProjectStrip>
)

expect(html).toContain('data-status="bg_agent"')
expect(html).toContain('data-stale="false"')
})
})

describe("computeDisplayStatus", () => {
Expand Down
Loading
Loading