@danielbdev/opencode-go-usage 0.1.3 → 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
@@ -34,7 +34,7 @@ Two entrypoints in one package:
34
34
 
35
35
  | Entrypoint | Role |
36
36
  | ---------- | ---- |
37
- | `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. |
38
38
  | `src/tui.tsx` (`@opencode/plugin/tui`) | TUI plugin. Renders the sidebar section and the `/usage` command. |
39
39
 
40
40
  The credential never leaves the server process. The TUI plugin **cannot** read a credential — the
@@ -63,7 +63,9 @@ This endpoint is **live but undocumented**. Two consequences the code respects:
63
63
  - Because the endpoint can change without notice, every failure degrades to an explicit state
64
64
  (`Usage unavailable: ...`) instead of breaking the TUI.
65
65
 
66
- 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.
67
69
 
68
70
  ## Security
69
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danielbdev/opencode-go-usage",
3
- "version": "0.1.3",
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
+ }
package/src/core/usage.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * (bump this line alongside the manifest) instead of adding a JSON import to
27
27
  * the server plugin's load path.
28
28
  */
29
- export const USER_AGENT = "opencode-go-usage/0.1.3"
29
+ export const USER_AGENT = "opencode-go-usage/0.1.4"
30
30
 
31
31
  export const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const
32
32
 
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
2
 
3
+ import { createResyncGate } from "./core/resync.ts"
3
4
  import { errorSnapshot, parseUsage, USER_AGENT, type UsageSnapshot } from "./core/usage.ts"
4
5
  import { UsageRpc } from "./rpc.ts"
5
6
 
@@ -10,6 +11,9 @@ const USAGE_URL = "https://opencode.ai/inference/go/v1/usage"
10
11
  const REFRESH_MS = 5 * 60_000
11
12
  const REQUEST_TIMEOUT_MS = 10_000
12
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
+
13
17
  /**
14
18
  * Server plugin.
15
19
  *
@@ -104,7 +108,25 @@ export default Plugin.define({
104
108
  void refresh()
105
109
  }, REFRESH_MS)
106
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
+
107
128
  return () => {
129
+ controller.abort()
108
130
  clearInterval(timer)
109
131
  }
110
132
  },