@danielbdev/opencode-go-usage 0.1.2 → 0.1.4

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
@@ -18,11 +18,14 @@ A `Go usage` section at the top of the sidebar content region, above the context
18
18
 
19
19
  ```
20
20
  Go usage
21
- 5h 0% · resets in 4h 52m
22
- wk 2% · resets in 3d 3h
23
- mo 1% · resets in 24d 20h
21
+ 5h 2% · resets in 50m
22
+ wk 0% · resets in 4d 3h
23
+ mo 0% · resets in 25d 20h
24
24
  ```
25
25
 
26
+ A window whose provider status is anything other than `ok` renders that status after the countdown, so
27
+ an exhausted or over-limit window is visible instead of showing a bare percentage.
28
+
26
29
  `/usage` (or the command palette) opens the same data as a detail dialog and forces a refresh.
27
30
 
28
31
  ## How it works
@@ -31,7 +34,7 @@ Two entrypoints in one package:
31
34
 
32
35
  | Entrypoint | Role |
33
36
  | ---------- | ---- |
34
- | `src/index.ts` (`@opencode/plugin`) | Server plugin. Resolves the `opencode-go` credential, reads the usage endpoint with a 5-minute cache, and publishes the snapshot over RPC. |
37
+ | `src/index.ts` (`@opencode/plugin`) | Server plugin. Resolves the `opencode-go` credential, reads the usage endpoint with a 5-minute cache, re-reads once a settled turn has moved usage, and publishes the snapshot over RPC. |
35
38
  | `src/tui.tsx` (`@opencode/plugin/tui`) | TUI plugin. Renders the sidebar section and the `/usage` command. |
36
39
 
37
40
  The credential never leaves the server process. The TUI plugin **cannot** read a credential — the
@@ -60,7 +63,9 @@ This endpoint is **live but undocumented**. Two consequences the code respects:
60
63
  - Because the endpoint can change without notice, every failure degrades to an explicit state
61
64
  (`Usage unavailable: ...`) instead of breaking the TUI.
62
65
 
63
- The plugin mirrors the endpoint exactly; its own 5-minute cache is the only staleness it can add.
66
+ The plugin mirrors the endpoint exactly. The 5-minute cache bounds how stale a background reading can
67
+ be; a settled turn re-reads sooner, throttled to at most one read per minute so rapid turns cannot hammer
68
+ the endpoint.
64
69
 
65
70
  ## Security
66
71
 
@@ -127,6 +132,10 @@ The workflow checks that the tag matches the version in the manifest, so a misty
127
132
  instead of publishing the wrong thing. It runs the tests and the typecheck before publishing, and npm
128
133
  attaches a provenance attestation automatically because the repository and the package are public.
129
134
 
135
+ After a successful publish the workflow opens a **release page for the tag**, with notes generated from the
136
+ commits since the previous release. That is where "what changed in this version" gets answered; edit the
137
+ notes afterwards when the generated list is broader than the package being released.
138
+
130
139
  Versions are **independent per package**: touching one plugin never forces a release of another.
131
140
 
132
141
  The repository root stays at `0.0.0` and is never published; it only holds the workspace configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danielbdev/opencode-go-usage",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin that shows the OpenCode Go subscription usage in the session sidebar",
6
6
  "license": "MIT",
@@ -0,0 +1,42 @@
1
+ import { test } from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import { createResyncGate } from "./resync.ts"
5
+
6
+ const MINUTE = 60_000
7
+
8
+ test("the gate opens on the first call and then holds for the whole window", () => {
9
+ let clock = 1_000_000
10
+ const allow = createResyncGate(MINUTE, () => clock)
11
+
12
+ assert.equal(allow(), true)
13
+
14
+ clock += MINUTE - 1
15
+ assert.equal(allow(), false)
16
+ })
17
+
18
+ test("the gate reopens once the window has elapsed", () => {
19
+ let clock = 1_000_000
20
+ const allow = createResyncGate(MINUTE, () => clock)
21
+
22
+ assert.equal(allow(), true)
23
+
24
+ clock += MINUTE
25
+ assert.equal(allow(), true)
26
+
27
+ // ...and closes again immediately after opening
28
+ assert.equal(allow(), false)
29
+ })
30
+
31
+ test("a burst of settled turns opens the gate exactly once", () => {
32
+ let clock = 1_000_000
33
+ const allow = createResyncGate(MINUTE, () => clock)
34
+
35
+ const openedAt: number[] = []
36
+ for (let turn = 0; turn < 10; turn++) {
37
+ if (allow()) openedAt.push(turn)
38
+ clock += 100 // turns settling back to back
39
+ }
40
+
41
+ assert.deepEqual(openedAt, [0])
42
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Gate for the settled-turn resync.
3
+ *
4
+ * A turn that settled is when usage actually moves, so it is the signal worth
5
+ * re-reading on. The endpoint is undocumented and reports only three windows, so
6
+ * the read is gated rather than free: `allow()` returns true at most once per
7
+ * `minMs` and false for every turn in between.
8
+ *
9
+ * It lives here, next to the model, so the gate is testable without a host. The
10
+ * clock is injectable for the same reason.
11
+ */
12
+ export function createResyncGate(minMs: number, now: () => number = Date.now): () => boolean {
13
+ let lastAllowed = Number.NEGATIVE_INFINITY
14
+
15
+ return () => {
16
+ const at = now()
17
+ if (at - lastAllowed < minMs) return false
18
+ lastAllowed = at
19
+ return true
20
+ }
21
+ }
@@ -1,14 +1,19 @@
1
1
  import { test } from "node:test"
2
2
  import assert from "node:assert/strict"
3
3
 
4
+ import pkg from "../../package.json" with { type: "json" }
5
+
4
6
  import {
5
7
  errorSnapshot,
6
8
  formatPercent,
7
9
  formatReset,
8
- formatSummary,
10
+ formatStatus,
9
11
  formatWindow,
12
+ formatWindowDetail,
10
13
  parseSnapshot,
11
14
  parseUsage,
15
+ USER_AGENT,
16
+ type UsageWindow,
12
17
  } from "./usage.ts"
13
18
 
14
19
  const NOW = Date.parse("2026-10-14T20:35:38.000Z")
@@ -85,6 +90,15 @@ test("parseUsage reports an error snapshot for a non-object payload", () => {
85
90
  }
86
91
  })
87
92
 
93
+ test("parseUsage carries the provider status through untouched", () => {
94
+ const snapshot = parseUsage(
95
+ { usage: { rolling: { status: "overage", percent: 100, resetsAt: "2026-10-14T21:25:45.000Z" } } },
96
+ NOW,
97
+ )
98
+
99
+ assert.equal(snapshot.windows[0]?.status, "overage")
100
+ })
101
+
88
102
  test("formatPercent renders an integer percent or an em dash", () => {
89
103
  assert.equal(formatPercent(2), "2%")
90
104
  assert.equal(formatPercent(0), "0%")
@@ -109,19 +123,44 @@ test("formatReset handles the last minutes, the expired case and unknown input",
109
123
  assert.equal(formatReset("not-a-date", now), "unknown")
110
124
  })
111
125
 
112
- test("formatWindow and formatSummary compose the rendered line", () => {
126
+ test("formatWindow and formatWindowDetail compose the rendered line", () => {
113
127
  const snapshot = parseUsage(LIVE_PAYLOAD, NOW)
114
128
 
115
129
  assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h 2% · resets in 50m")
116
130
  assert.equal(formatWindow(snapshot.windows[1]!, NOW), "wk 0% · resets in 4d 3h")
117
- assert.equal(formatSummary(snapshot, NOW), "GO 5h 2% · wk 0% · mo 0%")
131
+ assert.equal(formatWindowDetail(snapshot.windows[0]!, NOW), "5h window 2% resets in 50m")
118
132
  })
119
133
 
120
- test("formatSummary surfaces the failure state instead of percentages", () => {
134
+ test("formatStatus stays silent in the steady state and surfaces anything else", () => {
135
+ // `ok` is the steady state, so it adds nothing to the line.
136
+ assert.equal(formatStatus("ok"), "")
137
+ assert.equal(formatStatus(null), "")
138
+ assert.equal(formatStatus(""), " ()")
139
+
140
+ // Any other status is unknown territory on an undocumented endpoint: show it
141
+ // verbatim rather than guessing a friendlier wording.
142
+ assert.equal(formatStatus("overage"), " (overage)")
143
+ assert.equal(formatStatus("exceeded"), " (exceeded)")
144
+ })
145
+
146
+ test("formatWindow and formatWindowDetail surface a non-ok provider status", () => {
147
+ const window: UsageWindow = {
148
+ key: "rolling",
149
+ short: "5h",
150
+ long: "5h window",
151
+ percent: 100,
152
+ resetsAt: "2026-10-14T21:25:45.000Z",
153
+ status: "overage",
154
+ }
155
+
156
+ assert.equal(formatWindow(window, NOW), "5h 100% · resets in 50m (overage)")
157
+ assert.equal(formatWindowDetail(window, NOW), "5h window 100% resets in 50m (overage)")
158
+ })
159
+
160
+ test("an error snapshot renders as an unknown window, not fabricated percentages", () => {
121
161
  const snapshot = errorSnapshot("Go integration is not connected", NOW)
122
162
 
123
163
  assert.equal(snapshot.ok, false)
124
- assert.equal(formatSummary(snapshot, NOW), "GO usage unavailable")
125
164
  assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h — · unknown")
126
165
  })
127
166
 
@@ -138,7 +177,6 @@ test("parseSnapshot accepts a snapshot that crossed the RPC boundary", () => {
138
177
  )
139
178
  assert.equal(received!.windows[0]!.short, "5h")
140
179
  assert.equal(received!.windows[0]!.long, "5h window")
141
- assert.equal(formatSummary(received!, NOW), "GO 5h 2% · wk 0% · mo 0%")
142
180
  })
143
181
 
144
182
  test("parseSnapshot rejects anything that is not a snapshot object", () => {
@@ -165,3 +203,8 @@ test("parseSnapshot degrades a malformed window entry instead of throwing", () =
165
203
  assert.equal(received!.windows[2]!.key, "monthly")
166
204
  })
167
205
 
206
+ test("USER_AGENT tracks the published package version", () => {
207
+ // Guards the drift a hardcoded literal invites: bump the manifest without
208
+ // touching the constant and this fails before the package is published.
209
+ assert.equal(USER_AGENT, `opencode-go-usage/${pkg.version}`)
210
+ })
package/src/core/usage.ts CHANGED
@@ -17,6 +17,17 @@
17
17
  * plain `node --test`.
18
18
  */
19
19
 
20
+ /**
21
+ * User agent sent to the usage endpoint.
22
+ *
23
+ * The version travels with the request so a provider-side failure is
24
+ * attributable to a build. It stays a literal deliberately: `usage.test.ts`
25
+ * pins it to `package.json`, so a release bump fails the suite before publish
26
+ * (bump this line alongside the manifest) instead of adding a JSON import to
27
+ * the server plugin's load path.
28
+ */
29
+ export const USER_AGENT = "opencode-go-usage/0.1.4"
30
+
20
31
  export const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const
21
32
 
22
33
  export type WindowKey = (typeof WINDOW_KEYS)[number]
@@ -173,9 +184,24 @@ export function formatReset(resetsAt: string | null, now: number): string {
173
184
  return `resets in ${minutes}m`
174
185
  }
175
186
 
187
+ /**
188
+ * Provider status that qualifies the percentage, e.g. ` (overage)`.
189
+ *
190
+ * `ok` is the steady state and renders as nothing, so the common line is
191
+ * unchanged. Any other non-null status is surfaced verbatim: the endpoint is
192
+ * undocumented, so a status is the only signal that a window needs attention and
193
+ * it is not ours to reinterpret.
194
+ */
195
+ export function formatStatus(status: string | null): string {
196
+ return status === null || status === "ok" ? "" : ` (${status})`
197
+ }
198
+
176
199
  /** One window as a single line, e.g. `5h 2% · resets in 50m`. */
177
200
  export function formatWindow(window: UsageWindow, now: number): string {
178
- return `${window.short} ${formatPercent(window.percent)} · ${formatReset(window.resetsAt, now)}`
201
+ return `${window.short} ${formatPercent(window.percent)} · ${formatReset(
202
+ window.resetsAt,
203
+ now,
204
+ )}${formatStatus(window.status)}`
179
205
  }
180
206
 
181
207
  /** One window as a detail row, e.g. `5h window 2% resets in 50m`. */
@@ -183,11 +209,5 @@ export function formatWindowDetail(window: UsageWindow, now: number): string {
183
209
  return `${window.long.padEnd(10)} ${formatPercent(window.percent).padStart(4)} ${formatReset(
184
210
  window.resetsAt,
185
211
  now,
186
- )}`
187
- }
188
-
189
- /** Compact status line for the TUI slot, e.g. `GO 5h 2% · wk 0% · mo 0%`. */
190
- export function formatSummary(snapshot: UsageSnapshot, _now: number): string {
191
- if (!snapshot.ok) return "GO usage unavailable"
192
- return `GO ${snapshot.windows.map((w) => `${w.short} ${formatPercent(w.percent)}`).join(" · ")}`
212
+ )}${formatStatus(window.status)}`
193
213
  }
package/src/index.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
2
 
3
- import { errorSnapshot, parseUsage, type UsageSnapshot } from "./core/usage.ts"
3
+ import { createResyncGate } from "./core/resync.ts"
4
+ import { errorSnapshot, parseUsage, USER_AGENT, type UsageSnapshot } from "./core/usage.ts"
4
5
  import { UsageRpc } from "./rpc.ts"
5
6
 
6
7
  const INTEGRATION_ID = "opencode-go"
7
8
  const USAGE_URL = "https://opencode.ai/inference/go/v1/usage"
8
- const USER_AGENT = "opencode-go-usage/0.1.0"
9
9
 
10
10
  /** Usage moves slowly; the endpoint is undocumented, so we stay conservative. */
11
11
  const REFRESH_MS = 5 * 60_000
12
12
  const REQUEST_TIMEOUT_MS = 10_000
13
13
 
14
+ /** A settled turn moves usage; re-read on that signal, at most once per minute. */
15
+ const RESYNC_MIN_MS = 60_000
16
+
14
17
  /**
15
18
  * Server plugin.
16
19
  *
@@ -75,12 +78,20 @@ export default Plugin.define({
75
78
  inFlight = null
76
79
  }
77
80
 
78
- await registration.events.emit("updated", {
79
- fetchedAt: snapshot.fetchedAt,
80
- ok: snapshot.ok,
81
- error: snapshot.error,
82
- windows: snapshot.windows.map((window) => ({ ...window })),
83
- })
81
+ // Publishing must not fail the refresh: the snapshot is already read, and
82
+ // `refresh()` is awaited by setup and fired-and-forgotten by the interval,
83
+ // where a rejection would surface as an unhandled rejection.
84
+ try {
85
+ await registration.events.emit("updated", {
86
+ fetchedAt: snapshot.fetchedAt,
87
+ ok: snapshot.ok,
88
+ error: snapshot.error,
89
+ windows: snapshot.windows.map((window) => ({ ...window })),
90
+ })
91
+ } catch {
92
+ // Subscribers are optional; the caller still gets the fresh snapshot.
93
+ }
94
+
84
95
  return snapshot
85
96
  }
86
97
 
@@ -97,7 +108,25 @@ export default Plugin.define({
97
108
  void refresh()
98
109
  }, REFRESH_MS)
99
110
 
111
+ // A settled turn is when usage actually moves, and the cache lives in this
112
+ // half, so the trigger belongs here: the refreshed snapshot goes out over
113
+ // `updated` and the TUI keeps working unchanged. The gate keeps quick
114
+ // successive turns from hammering an undocumented endpoint.
115
+ const controller = new AbortController()
116
+ const allowed = createResyncGate(RESYNC_MIN_MS)
117
+
118
+ void (async () => {
119
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
120
+ if (event.type !== "session.execution.succeeded") continue
121
+ if (!allowed()) continue
122
+ void refresh()
123
+ }
124
+ })().catch(() => {
125
+ // A closed subscription is not a reason to fail the plugin.
126
+ })
127
+
100
128
  return () => {
129
+ controller.abort()
101
130
  clearInterval(timer)
102
131
  }
103
132
  },