Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 9 additions & 10 deletions docs/providers/opencode-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
- **Source of truth:** `~/.local/share/opencode/opencode.db`
- **Auth discovery:** `~/.local/share/opencode/auth.json`
- **Provider ID:** `opencode-go`
- **Usage scope:** local observed assistant spend only
- **Usage scope:** local observed session spend only

## Detection

The plugin enables when either condition is true:

- `~/.local/share/opencode/auth.json` contains an `opencode-go` entry with a non-empty `key`
- local OpenCode history already contains `opencode-go` assistant messages with numeric `cost`
- local OpenCode history already contains `opencode-go` sessions with cost data

If neither signal exists, the plugin stays hidden.

Expand All @@ -24,16 +24,15 @@ OpenUsage reads the local OpenCode SQLite database directly:

```sql
SELECT
CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
CAST(json_extract(data, '$.cost') AS REAL) AS cost
FROM message
WHERE json_valid(data)
AND json_extract(data, '$.providerID') = 'opencode-go'
AND json_extract(data, '$.role') = 'assistant'
AND json_type(data, '$.cost') IN ('integer', 'real')
time_updated AS createdMs,
CAST(cost AS TEXT) AS cost
FROM session
WHERE json_valid(model)
AND json_extract(model, '$.providerID') = 'opencode-go'
AND cost > 0
```

Only assistant messages with numeric `cost` count. Missing remote or other-device usage is not estimated.
Only sessions with a positive cost count. `time_updated` is stored in milliseconds (same unit as `Date.now()`), confirmed from OpenCode's [`packages/core/src/session/sql.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/sql.ts) where `time_created` and `time_updated` use `Date.now()` as their default value. Missing remote or other-device usage is not estimated.

## Limits

Expand Down
25 changes: 12 additions & 13 deletions plugins/opencode-go/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,21 @@

const HISTORY_EXISTS_SQL = `
SELECT 1 AS present
FROM message
WHERE json_valid(data)
AND json_extract(data, '$.providerID') = 'opencode-go'
AND json_extract(data, '$.role') = 'assistant'
AND json_type(data, '$.cost') IN ('integer', 'real')
FROM session
WHERE json_valid(model)
AND json_extract(model, '$.providerID') = 'opencode-go'
AND cost > 0
LIMIT 1
`;

const HISTORY_ROWS_SQL = `
SELECT
CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
CAST(json_extract(data, '$.cost') AS REAL) AS cost
FROM message
WHERE json_valid(data)
AND json_extract(data, '$.providerID') = 'opencode-go'
AND json_extract(data, '$.role') = 'assistant'
AND json_type(data, '$.cost') IN ('integer', 'real')
time_updated AS createdMs,
CAST(cost AS TEXT) AS cost
FROM session
WHERE json_valid(model)
AND json_extract(model, '$.providerID') = 'opencode-go'
AND cost > 0
`;

function readNumber(value) {
Expand All @@ -45,7 +43,7 @@
return 0;
const percent = (used / limit) * 100;
if (!Number.isFinite(percent)) return 0;
return Math.round(Math.max(0, Math.min(100, percent)) * 10) / 10;
return Math.floor(Math.max(0, Math.min(100, percent)));
}

function toIso(ms) {
Expand Down Expand Up @@ -186,6 +184,7 @@
}

function loadHistory(ctx) {
// time_updated is in milliseconds (same unit as Date.now()), confirmed from OpenCode source
const result = queryRows(ctx, HISTORY_ROWS_SQL);
if (!result.ok) return result;

Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode-go/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "opencode-go",
"name": "OpenCode Go",
"version": "0.0.1",
"version": "0.0.2",
"entry": "plugin.js",
"icon": "icon.svg",
"brandColor": "#000000",
Expand Down
42 changes: 24 additions & 18 deletions plugins/opencode-go/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,19 @@ function setHistoryQuery(ctx, rows, options = {}) {
if (String(sql).includes("SELECT 1 AS present")) {
if (options.assertFilters !== false) {
expect(String(sql)).toContain(
"json_extract(data, '$.providerID') = 'opencode-go'",
);
expect(String(sql)).toContain(
"json_extract(data, '$.role') = 'assistant'",
);
expect(String(sql)).toContain(
"json_type(data, '$.cost') IN ('integer', 'real')",
"json_extract(model, '$.providerID') = 'opencode-go'",
);
expect(String(sql)).toContain("cost > 0");
}
return JSON.stringify(list.length > 0 ? [{ present: 1 }] : []);
}

if (options.assertFilters !== false) {
expect(String(sql)).toContain(
"json_extract(data, '$.providerID') = 'opencode-go'",
);
expect(String(sql)).toContain(
"json_extract(data, '$.role') = 'assistant'",
);
expect(String(sql)).toContain(
"json_type(data, '$.cost') IN ('integer', 'real')",
);
expect(String(sql)).toContain(
"COALESCE(json_extract(data, '$.time.created'), time_created)",
"json_extract(model, '$.providerID') = 'opencode-go'",
);
expect(String(sql)).toContain("time_updated");
expect(String(sql)).toContain("cost > 0");
}

return JSON.stringify(list);
Expand Down Expand Up @@ -204,7 +192,7 @@ describe("opencode-go plugin", () => {
const result = plugin.probe(ctx);
const monthlyLine = result.lines.find((line) => line.label === "Monthly");

expect(monthlyLine.used).toBe(4.5);
expect(monthlyLine.used).toBe(4);
expect(monthlyLine.resetsAt).toBe("2026-03-25T07:53:16.000Z");
expect(monthlyLine.periodDurationMs).toBe(28 * 24 * 60 * 60 * 1000);
});
Expand Down Expand Up @@ -245,6 +233,24 @@ describe("opencode-go plugin", () => {
});
});

it("parses fractional costs from CAST(cost AS TEXT) without integer truncation", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z"));

const ctx = makeCtx();
// cost as a small decimal that would floor to 0 if truncated to integer
setHistoryQuery(ctx, [
{ createdMs: Date.parse("2026-03-06T11:00:00.000Z"), cost: 0.7 },
]);

const plugin = await loadPlugin();
const result = plugin.probe(ctx);

// 0.7 / 12 * 100 = 5.83 → Math.floor = 5
// If QuickJS truncated 0.7 to 0, this would be 0
expect(result.lines[0].used).toBe(5);
});

it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => {
const ctx = makeCtx();
setAuth(ctx);
Expand Down
12 changes: 12 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,18 @@ describe("App", () => {
await screen.findByText("Now")
})

it("triggers refresh on visibilitychange when panel becomes visible", async () => {
render(<App />)
await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled())
state.startBatchMock.mockClear()

// Simulate panel becoming visible (document.hidden = false)
Object.defineProperty(document, "hidden", { configurable: true, value: false })
fireEvent(document, new Event("visibilitychange"))

await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled())
})

it("updates tray icon on probe results when plugin has a primary progress", async () => {
state.invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === "list_plugins") {
Expand Down
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,17 @@ function App() {
[pluginStates]
)

useEffect(() => {
const refreshOnShow = () => {
if (!document.hidden) {
startBatch()
}
}

document.addEventListener("visibilitychange", refreshOnShow)
return () => document.removeEventListener("visibilitychange", refreshOnShow)
}, [startBatch])

return (
<AppShell
onRefreshAll={handleRefreshAll}
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/use-probe-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type ProbeBatchStarted = {
pluginIds: string[]
}

export const BACKGROUND_BATCH_ID = "background"

type UseProbeEventsOptions = {
onResult: (output: PluginOutput) => void
onBatchComplete: () => void
Expand All @@ -38,7 +40,7 @@ export function useProbeEvents({ onResult, onBatchComplete }: UseProbeEventsOpti

const setup = async () => {
const resultUnlisten = await listen<ProbeResult>("probe:result", (event) => {
if (activeBatchIds.current.has(event.payload.batchId)) {
if (activeBatchIds.current.has(event.payload.batchId) || event.payload.batchId === BACKGROUND_BATCH_ID) {
onResult(event.payload.output)
}
})
Expand Down
Loading