opencode-usage-report 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,12 @@ Or add the plugin manually to each file and restart opencode:
71
71
  > `opencode plugin opencode-usage-report` prints `Detected server + tui targets`
72
72
  > when the resolution works.
73
73
 
74
+ > **OpenCode 2.x (0.5.0).** The server entrypoint default-exports the V2
75
+ > `{ id, setup }` definition and still carries the V1 `server` hook, so
76
+ > OpenCode 1.18.29+ and 2.x both load it. Before 0.5.0 the module exported a
77
+ > bare V1 function, which OpenCode 2 rejects. The TUI entrypoint is unchanged
78
+ > in this release.
79
+
74
80
  Options can be passed in the tuple form, independently per entrypoint:
75
81
 
76
82
  ```jsonc
@@ -283,7 +289,7 @@ Releases are **staged**, not published directly. Pushing a `v*` tag triggers
283
289
  the staged release with 2FA:
284
290
 
285
291
  ```sh
286
- git tag v0.4.0 && git push origin main v0.4.0
292
+ git tag v0.4.1 && git push origin main v0.4.1
287
293
  npm stage list # find the stage id
288
294
  npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
289
295
  ```
@@ -293,6 +299,9 @@ so a failing check blocks the stage.
293
299
 
294
300
  ## Changelog
295
301
 
302
+ - **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
303
+ replaces the copy-pasted fetch/retry/error code; context estimates simplified;
304
+ reset times render as `Wed, Sep 16`.
296
305
  - **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
297
306
  compaction headroom and cost); `/usage` became a local TUI command that costs
298
307
  no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -49,7 +49,6 @@
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.0.0",
51
51
  "@opentui/core": "^0.5.11",
52
- "@opentui/keymap": "^0.5.11",
53
52
  "@opentui/solid": "^0.5.11",
54
53
  "@types/node": "^26.6.2",
55
54
  "oxfmt": "^0.70.0",
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
2
2
  import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
3
3
  import { estimateJson, estimateTokens } from "./estimate";
4
+ import { finiteOrNull, isRecord, safeString } from "../normalize";
4
5
  import { buildGrid } from "./grid";
5
6
  import { computeHeadroom } from "./headroom";
6
7
 
@@ -15,24 +16,6 @@ const ROW_LABELS: Record<RowKey, string> = {
15
16
 
16
17
  const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
17
18
 
18
- function isRecord(value: unknown): value is Record<string, unknown> {
19
- return typeof value === "object" && value !== null;
20
- }
21
-
22
- function finiteOrNull(value: unknown): number | null {
23
- return typeof value === "number" && Number.isFinite(value) ? value : null;
24
- }
25
-
26
- function safeString(value: unknown): string {
27
- if (typeof value === "string") return value;
28
- if (value === null || value === undefined) return "";
29
- try {
30
- return String(value);
31
- } catch {
32
- return "";
33
- }
34
- }
35
-
36
19
  function isAssistant(value: unknown): value is AssistantMessage {
37
20
  return isRecord(value) && value.role === "assistant";
38
21
  }
@@ -72,49 +55,25 @@ function isUsableCapture(capture: SystemCapture | null | undefined): capture is
72
55
  }
73
56
 
74
57
  /**
75
- * Scales `values` so they sum to `target` by the largest-remainder method; the
76
- * result never exceeds `target`. Used when raw `chars/4` estimates overshoot.
58
+ * Scales `values` proportionally so they sum to at most `target`; each row is an
59
+ * estimate, so flooring may leave the five rows a few tokens short of the total.
77
60
  */
78
- function largestRemainderScale(
79
- values: readonly number[],
80
- target: number,
81
- divisor: number,
82
- ): number[] {
61
+ function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
83
62
  if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
84
63
  const total = Math.max(0, Math.round(target));
85
- const scaled = values.map((value) => (value > 0 ? (value * total) / divisor : 0));
86
- const rounded = scaled.map((value) => Math.floor(value));
87
- let deficit = total - rounded.reduce((sum, value) => sum + value, 0);
88
- if (deficit <= 0) return rounded;
89
-
90
- const order = scaled
91
- .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
92
- .sort((a, b) => b.fraction - a.fraction);
93
- for (const { index } of order) {
94
- if (deficit <= 0) break;
95
- rounded[index] += 1;
96
- deficit -= 1;
97
- }
98
- return rounded;
64
+ return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
99
65
  }
100
66
 
101
- function makeRow(
102
- key: RowKey,
103
- tokens: number | null,
104
- exact: boolean,
105
- limit: number | null,
106
- ): ContextRow {
67
+ function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
107
68
  const percent =
108
69
  tokens !== null && limit !== null && limit > 0
109
70
  ? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
110
71
  : null;
111
- return { key, label: ROW_LABELS[key], tokens, percent, exact };
72
+ return { key, label: ROW_LABELS[key], tokens, percent };
112
73
  }
113
74
 
114
75
  function emptyRows(limit: number | null): ContextRow[] {
115
- return [...MEASURED_KEYS, "free" as RowKey].map((key) =>
116
- makeRow(key, null, key === "free", limit),
117
- );
76
+ return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
118
77
  }
119
78
 
120
79
  /**
@@ -212,9 +171,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
212
171
  let systemTokens = Math.max(0, total - estimatedSum);
213
172
 
214
173
  // chars/4 is an estimate in both directions; when it overshoots the exact
215
- // total, scale all five measured rows down so they sum to `total` (6.5).
174
+ // total, scale all five measured rows down so they sum to at most `total` (6.5).
216
175
  if (estimatedSum > total) {
217
- const scaled = largestRemainderScale(
176
+ const scaled = scaleToTotal(
218
177
  [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
219
178
  total,
220
179
  estimatedSum,
@@ -227,9 +186,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
227
186
  }
228
187
 
229
188
  const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
230
- const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], false, limit));
189
+ const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
231
190
  const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
232
- rows.push(makeRow("free", freeTokens, true, limit));
191
+ rows.push(makeRow("free", freeTokens, limit));
233
192
 
234
193
  return {
235
194
  ready: true,
@@ -5,32 +5,7 @@ export function estimateTokens(text: string): number {
5
5
  return Math.max(0, Math.round(value.length / 4));
6
6
  }
7
7
 
8
- const MAX_JSON_CHARS = 2_000_000;
9
-
10
- /**
11
- * Estimates tokens for the JSON serialization of an arbitrary value. Circular
12
- * references and unserializable values are handled without throwing, and the
13
- * serialized prefix is capped so allocation stays bounded.
14
- */
8
+ /** Estimates tokens for the JSON serialization of a value that came from JSON. */
15
9
  export function estimateJson(value: unknown): number {
16
- let json: string;
17
- try {
18
- const seen = new WeakSet<object>();
19
- json =
20
- JSON.stringify(value, (_key, val) => {
21
- if (typeof val === "bigint") return val.toString();
22
- if (typeof val === "object" && val !== null) {
23
- if (seen.has(val)) return "[Circular]";
24
- seen.add(val);
25
- }
26
- return val;
27
- }) ?? "";
28
- } catch {
29
- try {
30
- json = String(value);
31
- } catch {
32
- json = "";
33
- }
34
- }
35
- return estimateTokens(json.length > MAX_JSON_CHARS ? json.slice(0, MAX_JSON_CHARS) : json);
10
+ return estimateTokens(JSON.stringify(value) ?? "");
36
11
  }
@@ -4,6 +4,8 @@
4
4
  * same shape `buildLines` uses in `src/quota-lines.ts`.
5
5
  */
6
6
  import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
7
+ import { finiteOrNull, nonNegative } from "../normalize";
8
+ import { padLabel } from "../tui-format";
7
9
 
8
10
  /** Theme color names used by the context block. */
9
11
  export type ContextTone =
@@ -40,15 +42,6 @@ const LEGEND_PERCENT_WIDTH = 6;
40
42
  const LEGEND_GAP = " ";
41
43
  const COLUMNS_PER_LEGEND_ROW = 3;
42
44
 
43
- const ROW_LABELS: Record<RowKey, string> = {
44
- user: "User messages",
45
- agent: "Agent responses",
46
- reasoning: "Reasoning",
47
- tools: "Tool calls",
48
- system: "System & tools",
49
- free: "Free space",
50
- };
51
-
52
45
  const CATEGORY_TONES: Record<RowKey, ContextTone> = {
53
46
  user: "info",
54
47
  agent: "success",
@@ -79,15 +72,6 @@ export function formatMoney(n: number): string {
79
72
  return MONEY.format(value);
80
73
  }
81
74
 
82
- function finite(value: unknown): number | null {
83
- return typeof value === "number" && Number.isFinite(value) ? value : null;
84
- }
85
-
86
- function nonNegative(value: unknown): number | null {
87
- const n = finite(value);
88
- return n !== null && n >= 0 ? n : null;
89
- }
90
-
91
75
  function glyphFor(fill: number): string {
92
76
  if (fill <= 0) return "░░";
93
77
  if (fill <= 0.25) return "▒▒";
@@ -100,19 +84,6 @@ function toneForRowKey(key: unknown): ContextTone {
100
84
  return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
101
85
  }
102
86
 
103
- function truncateLabel(label: string, width: number): string {
104
- if (label.length <= width) return label;
105
- return label.slice(0, Math.max(0, width - 1)) + "…";
106
- }
107
-
108
- function padLabel(label: string, width: number): string {
109
- return truncateLabel(label, width).padEnd(width);
110
- }
111
-
112
- function rightAlign(value: string, width: number): string {
113
- return value.length >= width ? value : " ".repeat(width - value.length) + value;
114
- }
115
-
116
87
  /**
117
88
  * One-decimal percentage. A non-zero share that would round to `0.0%` reads as
118
89
  * "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
@@ -144,10 +115,7 @@ function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
144
115
  }
145
116
 
146
117
  function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: boolean): Segment[] {
147
- const baseLabel =
148
- row !== undefined && typeof row.label === "string" && row.label !== ""
149
- ? row.label
150
- : ROW_LABELS[key];
118
+ const baseLabel = row?.label ?? key;
151
119
  const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
152
120
  const tokens = nonNegative(row?.tokens);
153
121
  const percent = nonNegative(row?.percent);
@@ -156,11 +124,11 @@ function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: bo
156
124
  { text: "● ", fg: CATEGORY_TONES[key] },
157
125
  { text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
158
126
  {
159
- text: rightAlign(tokens === null ? "—" : formatTokens(tokens), LEGEND_COUNT_WIDTH),
127
+ text: (tokens === null ? "—" : formatTokens(tokens)).padStart(LEGEND_COUNT_WIDTH, " "),
160
128
  fg: "text",
161
129
  },
162
130
  { text: " ", fg: "text" },
163
- { text: rightAlign(percentText, LEGEND_PERCENT_WIDTH), fg: "textMuted" },
131
+ { text: percentText.padStart(LEGEND_PERCENT_WIDTH, " "), fg: "textMuted" },
164
132
  ];
165
133
  }
166
134
 
@@ -178,7 +146,7 @@ function gridLines(breakdown: ContextBreakdown): Line[] {
178
146
  const segments: Segment[] = [];
179
147
  for (let c = 0; c < renderCols; c++) {
180
148
  const cell = cells[r * cols + c] as GridCell | undefined;
181
- const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finite(cell.fill) ?? 0));
149
+ const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finiteOrNull(cell.fill) ?? 0));
182
150
  segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
183
151
  }
184
152
  lines.push({ segments });
@@ -1,8 +1,8 @@
1
1
  import type { ContextRow, GridCell, RowKey } from "./types";
2
+ import { finiteOrNull } from "../normalize";
2
3
 
3
4
  const GRID_ROWS_DEFAULT = 6;
4
- const MIN_COLS = 8;
5
- const MAX_COLS = 60;
5
+ const GRID_COLS_DEFAULT = 8;
6
6
  const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
7
7
 
8
8
  export interface BuildGridInput {
@@ -13,20 +13,6 @@ export interface BuildGridInput {
13
13
  gridRows?: number | null;
14
14
  }
15
15
 
16
- function finiteOrNull(value: unknown): number | null {
17
- return typeof value === "number" && Number.isFinite(value) ? value : null;
18
- }
19
-
20
- function clampCols(value: number | null | undefined): number {
21
- const n = finiteOrNull(value);
22
- return Math.min(MAX_COLS, Math.max(MIN_COLS, n === null ? MIN_COLS : Math.floor(n)));
23
- }
24
-
25
- function clampGridRows(value: number | null | undefined): number {
26
- const n = finiteOrNull(value);
27
- return n !== null && Math.floor(n) >= 1 ? Math.floor(n) : GRID_ROWS_DEFAULT;
28
- }
29
-
30
16
  function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
31
17
  if (!Array.isArray(rows)) return 0;
32
18
  for (const row of rows) {
@@ -58,8 +44,8 @@ export function buildGrid(input: BuildGridInput): {
58
44
  rows: number;
59
45
  cells: GridCell[];
60
46
  } {
61
- const cols = clampCols(input.cols);
62
- const rows = clampGridRows(input.gridRows);
47
+ const cols = finiteOrNull(input.cols) ?? GRID_COLS_DEFAULT;
48
+ const rows = finiteOrNull(input.gridRows) ?? GRID_ROWS_DEFAULT;
63
49
  const limit = finiteOrNull(input.limit);
64
50
  if (limit === null || limit <= 0) return { cols, rows, cells: [] };
65
51
 
@@ -1,4 +1,5 @@
1
1
  import type { Model } from "@opencode-ai/sdk/v2";
2
+ import { finiteOrNull } from "../normalize";
2
3
 
3
4
  export interface Headroom {
4
5
  usable: number | null;
@@ -12,10 +13,6 @@ export interface HeadroomInput {
12
13
  compaction?: { reserved?: number } | null;
13
14
  }
14
15
 
15
- function finiteOrNull(value: unknown): number | null {
16
- return typeof value === "number" && Number.isFinite(value) ? value : null;
17
- }
18
-
19
16
  /** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
20
17
  export function computeHeadroom(input: HeadroomInput): Headroom {
21
18
  const limit = input.limit;
@@ -7,23 +7,12 @@
7
7
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import type { SystemCapture } from "./types";
10
+ import { isRecord, nonNegative } from "../normalize";
10
11
  import { pluginStateDir } from "../paths";
11
12
 
12
13
  const CAPTURE_VERSION = 1;
13
- const WRITE_THROTTLE_MS = 60_000;
14
14
  const MAX_SESSION_ID_LENGTH = 200;
15
15
 
16
- /** sessionID -> epoch ms of the last successful write, for throttling. */
17
- const writeThrottle = new Map<string, number>();
18
-
19
- function isRecord(value: unknown): value is Record<string, unknown> {
20
- return typeof value === "object" && value !== null && !Array.isArray(value);
21
- }
22
-
23
- function nonNegative(value: unknown): number | null {
24
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
25
- }
26
-
27
16
  /** <pluginStateDir>/context/<sanitized-sessionID>.json */
28
17
  export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
29
18
  const raw = typeof sessionID === "string" ? sessionID : "";
@@ -35,10 +24,6 @@ export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv):
35
24
  * Reads and validates a capture. Returns null for a missing/unreadable file,
36
25
  * invalid JSON, an unsupported version, a session mismatch, non-string
37
26
  * provider/model, or non-finite/negative sizes and timestamps.
38
- *
39
- * Deliberately independent of the write-throttle map: the server plugin writes
40
- * and the TUI plugin reads in separate processes, so a reader can never have a
41
- * throttle entry of its own.
42
27
  */
43
28
  export async function readCapture(
44
29
  sessionID: string,
@@ -74,10 +59,7 @@ export async function readCapture(
74
59
  }
75
60
  }
76
61
 
77
- /**
78
- * Atomically persists a capture (temp file + rename), throttled to at most one
79
- * write per session per 60 s unless the provider/model changed. Never throws.
80
- */
62
+ /** Atomically persists a capture (temp file + rename). Never throws. */
81
63
  export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
82
64
  try {
83
65
  if (!isRecord(capture)) return;
@@ -92,14 +74,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
92
74
  const capturedAt = nonNegative(capture.capturedAt);
93
75
  if (systemChars === null || systemTokens === null || capturedAt === null) return;
94
76
 
95
- const previous = await readCapture(sessionID, env);
96
- const modelChanged =
97
- previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
98
-
99
- const now = Date.now();
100
- const last = writeThrottle.get(sessionID);
101
- if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
102
-
103
77
  const directory = join(pluginStateDir(env), "context");
104
78
  await mkdir(directory, { recursive: true });
105
79
  const file = contextCapturePath(sessionID, env);
@@ -115,7 +89,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
115
89
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
116
90
  await writeFile(tmp, JSON.stringify(normalized), "utf8");
117
91
  await rename(tmp, file);
118
- writeThrottle.set(sessionID, now);
119
92
  } catch {
120
93
  // Capture writes are best-effort; a failure must never break a request.
121
94
  }
@@ -140,8 +113,3 @@ export function captureIsFresh(
140
113
  if (capturedAt === null || since === null) return false;
141
114
  return capturedAt >= since;
142
115
  }
143
-
144
- /** Clears the write-throttle map so tests can reset between cases. */
145
- export function resetCaptureThrottle(): void {
146
- writeThrottle.clear();
147
- }
@@ -7,7 +7,6 @@ export interface ContextRow {
7
7
  label: string;
8
8
  tokens: number | null;
9
9
  percent: number | null; // 0-100, null when the limit is unknown
10
- exact: boolean;
11
10
  }
12
11
 
13
12
  export interface GridCell {
package/src/fallback.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { AdapterResult, UsageWindow } from "./types";
3
3
  import { dbPath } from "./paths";
4
+ import { asRecord } from "./providers/http";
4
5
 
5
6
  const HOUR_MS = 60 * 60 * 1000;
6
7
  const DAY_MS = 24 * HOUR_MS;
@@ -15,12 +16,6 @@ interface SqliteDatabase {
15
16
  }
16
17
  type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
17
18
 
18
- function asRecord(value: unknown): Record<string, unknown> | null {
19
- return value !== null && typeof value === "object" && !Array.isArray(value)
20
- ? (value as Record<string, unknown>)
21
- : null;
22
- }
23
-
24
19
  function toNum(value: unknown): number | null {
25
20
  return typeof value === "number" && Number.isFinite(value) ? value : null;
26
21
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
2
  import { tool } from "@opencode-ai/plugin";
3
3
  import { DEFAULT_OPTIONS, collectReports } from "./report";
4
4
  import { renderJson, renderText } from "./render";
@@ -39,12 +39,16 @@ export function coerceOptions(raw: Record<string, unknown> | undefined): PluginO
39
39
  return opts;
40
40
  }
41
41
 
42
- const plugin: Plugin = async ({ client }, options) => {
43
- const opts = coerceOptions(options);
42
+ /**
43
+ * Throttled warning pass shared by the V1 and V2 plugin APIs (spec §3.6/§3.7).
44
+ * Every failure is swallowed (spec §4): it must never throw into the event bus.
45
+ */
46
+ function makeWarnRunner(
47
+ opts: PluginOptions,
48
+ notify: (message: string) => Promise<unknown> | unknown,
49
+ ): () => Promise<void> {
44
50
  let lastWarnCheck = 0;
45
-
46
- /** Throttled warning pass (spec §3.7). Every failure is swallowed (spec §4). */
47
- const runWarnCheck = async (): Promise<void> => {
51
+ return async (): Promise<void> => {
48
52
  try {
49
53
  if (Date.now() - lastWarnCheck < WARN_THROTTLE_MS) return;
50
54
  lastWarnCheck = Date.now();
@@ -53,9 +57,7 @@ const plugin: Plugin = async ({ client }, options) => {
53
57
  const hits = await checkWarnings(reports, opts.thresholdPercent);
54
58
  for (const hit of hits) {
55
59
  try {
56
- await client.tui.showToast({
57
- body: { title: "Usage warning", message: hit.message, variant: "warning" },
58
- });
60
+ await notify(hit.message);
59
61
  } catch {
60
62
  // Toast delivery is best-effort; it is unavailable in headless/server mode.
61
63
  }
@@ -64,6 +66,159 @@ const plugin: Plugin = async ({ client }, options) => {
64
66
  // Never throw into the event bus.
65
67
  }
66
68
  };
69
+ }
70
+
71
+ async function runUsageReport(
72
+ args: Record<string, unknown> | undefined,
73
+ opts: PluginOptions,
74
+ ): Promise<{ content: string }> {
75
+ const reports = await collectReports({
76
+ providers: args?.provider ? [String(args.provider)] : undefined,
77
+ refresh: args?.refresh === true,
78
+ options: opts,
79
+ });
80
+ return { content: args?.json === true ? renderJson(reports) : renderText(reports) };
81
+ }
82
+
83
+ const TOOL_INPUT_SCHEMA: Record<string, unknown> = {
84
+ type: "object",
85
+ properties: {
86
+ provider: { type: "string" },
87
+ json: { type: "boolean" },
88
+ refresh: { type: "boolean" },
89
+ },
90
+ additionalProperties: false,
91
+ };
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // OpenCode 2.x (V2) plugin API.
95
+ //
96
+ // The harness decodes the module's default export against `{ id, setup }` or
97
+ // `{ id, effect }` and otherwise fails with "Plugin must export a default
98
+ // definition with an id and an effect or setup function" (err_b890e30e). The
99
+ // context types are declared locally so the package needs no runtime dependency
100
+ // on the V2 SDK; opencode erases `import type` anyway.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ interface V2ToolDefinition {
104
+ name: string;
105
+ description: string;
106
+ input: Record<string, unknown>;
107
+ execute: (args: Record<string, unknown> | undefined) => Promise<{ content: string }>;
108
+ }
109
+
110
+ interface V2ToolEditor {
111
+ add(definition: V2ToolDefinition): void;
112
+ }
113
+
114
+ interface V2ContextEvent {
115
+ sessionID?: string;
116
+ model?: { providerID?: string; id?: string };
117
+ system?: Array<{ type?: string; text?: string } | string>;
118
+ }
119
+
120
+ interface V2PluginContext {
121
+ options?: Record<string, unknown>;
122
+ tool: { transform: (callback: (editor: V2ToolEditor) => void) => Promise<unknown> };
123
+ session: {
124
+ hook: (name: "context", handler: (event: V2ContextEvent) => unknown) => Promise<unknown>;
125
+ };
126
+ event: { subscribe: (options: { signal: AbortSignal }) => AsyncIterable<{ type?: string }> };
127
+ /** Not part of the V2 server context; present only if the runtime exposes it. */
128
+ tui?: { showToast?: (input: unknown) => Promise<unknown> };
129
+ }
130
+
131
+ /** Joins V2 system parts (text parts plus plain strings) into one prompt string. */
132
+ function joinSystem(event: V2ContextEvent | undefined): string {
133
+ if (!Array.isArray(event?.system)) return "";
134
+ return event.system
135
+ .map((part) => (typeof part === "string" ? part : (part?.text ?? "")))
136
+ .join("\n");
137
+ }
138
+
139
+ /** Best-effort capture of the assembled system prompt size (spec §9.1). */
140
+ async function captureSystemFromContext(event: V2ContextEvent | undefined): Promise<void> {
141
+ try {
142
+ const sessionID = event?.sessionID;
143
+ const model = event?.model;
144
+ if (typeof sessionID !== "string" || sessionID === "") return;
145
+ if (
146
+ model === undefined ||
147
+ typeof model.providerID !== "string" ||
148
+ typeof model.id !== "string"
149
+ ) {
150
+ return;
151
+ }
152
+
153
+ const system = joinSystem(event);
154
+ if (system === "") return;
155
+
156
+ await writeCapture({
157
+ version: 1,
158
+ sessionID,
159
+ providerID: model.providerID,
160
+ modelID: model.id,
161
+ systemChars: system.length,
162
+ systemTokens: estimateTokens(system),
163
+ capturedAt: Date.now(),
164
+ });
165
+ } catch {
166
+ // Best-effort: an experimental hook must never be able to break a request.
167
+ }
168
+ }
169
+
170
+ async function setup(ctx: V2PluginContext): Promise<() => void> {
171
+ const opts = coerceOptions(ctx?.options);
172
+
173
+ await ctx.tool.transform((editor) => {
174
+ editor.add({
175
+ name: "usage_report",
176
+ description: TOOL_DESCRIPTION,
177
+ input: TOOL_INPUT_SCHEMA,
178
+ execute: (args) => runUsageReport(args, opts),
179
+ });
180
+ });
181
+
182
+ await ctx.session.hook("context", (event) => captureSystemFromContext(event));
183
+
184
+ const runWarnCheck = makeWarnRunner(opts, (message) => {
185
+ const showToast = ctx.tui?.showToast;
186
+ if (typeof showToast !== "function") return;
187
+ return showToast({ body: { title: "Usage warning", message, variant: "warning" } });
188
+ });
189
+
190
+ // Spec §3.6: one throttled warning check at startup, fire-and-forget.
191
+ setTimeout(() => {
192
+ void runWarnCheck();
193
+ }, 0);
194
+
195
+ const controller = new AbortController();
196
+ void (async () => {
197
+ try {
198
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
199
+ if (event?.type === "session.idle") await runWarnCheck();
200
+ }
201
+ } catch {
202
+ // Subscription aborted on unload, or the event transport failed.
203
+ }
204
+ })();
205
+
206
+ return () => controller.abort();
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // OpenCode 1.x (V1) plugin API. Kept from the previous release so packages can
211
+ // serve both APIs during the V2 migration; V2 calls `setup`, V1 calls `server`.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async function server({ client }: PluginInput, options?: Record<string, unknown>): Promise<Hooks> {
215
+ const opts = coerceOptions(options);
216
+
217
+ const runWarnCheck = makeWarnRunner(opts, (message) =>
218
+ client.tui.showToast({
219
+ body: { title: "Usage warning", message, variant: "warning" },
220
+ }),
221
+ );
67
222
 
68
223
  // Spec §3.6: one throttled warning check at startup, fire-and-forget.
69
224
  setTimeout(() => {
@@ -80,12 +235,7 @@ const plugin: Plugin = async ({ client }, options) => {
80
235
  refresh: tool.schema.boolean().optional(),
81
236
  },
82
237
  async execute(args) {
83
- const reports = await collectReports({
84
- providers: args.provider ? [args.provider] : undefined,
85
- refresh: args.refresh === true,
86
- options: opts,
87
- });
88
- return args.json ? renderJson(reports) : renderText(reports);
238
+ return (await runUsageReport(args, opts)).content;
89
239
  },
90
240
  }),
91
241
  },
@@ -126,6 +276,12 @@ const plugin: Plugin = async ({ client }, options) => {
126
276
  }
127
277
  },
128
278
  };
279
+ }
280
+
281
+ const definition = {
282
+ id: "opencode-usage-report",
283
+ setup,
284
+ server,
129
285
  };
130
286
 
131
- export default plugin;
287
+ export default definition;