diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md
index 3cd934dd2..cc8be20d2 100644
--- a/docs/providers/opencode-go.md
+++ b/docs/providers/opencode-go.md
@@ -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.
@@ -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
diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js
index 6a1ce28d2..99bec4931 100644
--- a/plugins/opencode-go/plugin.js
+++ b/plugins/opencode-go/plugin.js
@@ -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) {
@@ -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) {
@@ -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;
diff --git a/plugins/opencode-go/plugin.json b/plugins/opencode-go/plugin.json
index 1b010135d..8664a5f4b 100644
--- a/plugins/opencode-go/plugin.json
+++ b/plugins/opencode-go/plugin.json
@@ -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",
diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js
index d7cdf6f18..d601becc0 100644
--- a/plugins/opencode-go/plugin.test.js
+++ b/plugins/opencode-go/plugin.test.js
@@ -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);
@@ -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);
});
@@ -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);
diff --git a/src/App.test.tsx b/src/App.test.tsx
index f0a6fe411..f7fb22b42 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -468,6 +468,18 @@ describe("App", () => {
await screen.findByText("Now")
})
+ it("triggers refresh on visibilitychange when panel becomes visible", async () => {
+ render()
+ 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") {
diff --git a/src/App.tsx b/src/App.tsx
index 918e74dcd..6fd4a13fe 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
void
onBatchComplete: () => void
@@ -38,7 +40,7 @@ export function useProbeEvents({ onResult, onBatchComplete }: UseProbeEventsOpti
const setup = async () => {
const resultUnlisten = await listen("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)
}
})