@danielbdev/opencode-go-usage 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # @danielbdev/opencode-go-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [OpenCode](https://opencode.ai) V2 plugin that shows your **OpenCode Go** subscription usage in the
4
+ session sidebar: the rolling 5-hour window, the weekly window and the monthly window, each with the time
5
+ left until it resets.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ opencode plugin add @danielbdev/opencode-go-usage
11
+ ```
12
+
13
+ Requires OpenCode V2. The Go integration must be connected (`/connect` → OpenCode Go).
14
+
15
+ ## What it shows
16
+
17
+ A `Go usage` section at the top of the sidebar content region, above the context block:
18
+
19
+ ```
20
+ Go usage
21
+ 5h 0% · resets in 4h 52m
22
+ wk 2% · resets in 3d 3h
23
+ mo 1% · resets in 24d 20h
24
+ ```
25
+
26
+ `/usage` (or the command palette) opens the same data as a detail dialog and forces a refresh.
27
+
28
+ ## How it works
29
+
30
+ Two entrypoints in one package:
31
+
32
+ | Entrypoint | Role |
33
+ | ---------- | ---- |
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. |
35
+ | `src/tui.tsx` (`@opencode/plugin/tui`) | TUI plugin. Renders the sidebar section and the `/usage` command. |
36
+
37
+ The credential never leaves the server process. The TUI plugin **cannot** read a credential — the
38
+ OpenCode client exposes only `credential.update/activate/remove` — so the split is not a style choice,
39
+ it is the only design that keeps the secret out of the terminal plugin. Only percentages, reset
40
+ instants and status strings cross the RPC boundary.
41
+
42
+ ### Data source
43
+
44
+ ```
45
+ GET https://opencode.ai/inference/go/v1/usage
46
+ Authorization: Bearer <opencode-go key>
47
+ ```
48
+
49
+ ```json
50
+ { "usage": {
51
+ "rolling": { "status": "ok", "percent": 2, "resetsAt": "2026-10-14T21:25:45.000Z" },
52
+ "weekly": { "status": "ok", "percent": 0, "resetsAt": "2026-10-19T00:00:00.000Z" },
53
+ "monthly": { "status": "ok", "percent": 0, "resetsAt": "2026-11-09T17:09:16.000Z" } } }
54
+ ```
55
+
56
+ This endpoint is **live but undocumented**. Two consequences the code respects:
57
+
58
+ - The three percentages are **not** monotonic (observed: rolling 2% while weekly and monthly were 0%),
59
+ so each value is rendered exactly as received and never derived from another window.
60
+ - Because the endpoint can change without notice, every failure degrades to an explicit state
61
+ (`Usage unavailable: ...`) instead of breaking the TUI.
62
+
63
+ The plugin mirrors the endpoint exactly; its own 5-minute cache is the only staleness it can add.
64
+
65
+ ## Development
66
+
67
+ ```sh
68
+ npm install
69
+ npm test # node --test, no build step (Node 24 type stripping)
70
+ npm run typecheck
71
+ ```
72
+
73
+ `src/core/usage.ts` is the only module with logic worth testing and it has no host dependency, so the
74
+ whole model, the parser and the formatters run under plain `node --test`.
75
+
76
+ This package lives in a monorepo of atomic plugins: each package does one thing and installs alone.
77
+
78
+ ## Releasing
79
+
80
+ Versions are **independent per package**: touching one plugin never forces a release of another.
81
+
82
+ ```sh
83
+ npm version patch -w @danielbdev/opencode-go-usage # or minor / major
84
+ npm publish -w @danielbdev/opencode-go-usage --access public
85
+ ```
86
+
87
+ `--access public` is required on every publish: scoped packages default to private.
88
+
89
+ `npm publish --workspaces` publishes every package at once, but npm refuses to republish a version that
90
+ already exists, so it only fits when every package is bumped together.
91
+
92
+ The repository root stays at `0.0.0` and is never published; it only holds the workspace configuration.
93
+ `npm version` also needs a version there, or npm crashes while reading the workspace manifest.
94
+
95
+ ## Roadmap
96
+
97
+ Agent status and a to-do list are planned as **separate packages** in the same repository, not as modes
98
+ of this one — each capability gets its own install.
99
+
100
+ ## License
101
+
102
+ MIT
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@danielbdev/opencode-go-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenCode V2 plugin that shows the OpenCode Go subscription usage in the session sidebar",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "opencode-go",
11
+ "usage",
12
+ "quota",
13
+ "sidebar"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./tui": "./src/tui.tsx",
18
+ "./rpc": "./src/rpc.ts"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test \"src/**/*.test.ts\"",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "dependencies": {
30
+ "@opencode/plugin": "^2.0.4"
31
+ },
32
+ "peerDependencies": {
33
+ "@opentui/core": ">=0.5.8",
34
+ "@opentui/solid": ">=0.5.8",
35
+ "solid-js": ">=1.9.0"
36
+ },
37
+ "devDependencies": {
38
+ "@opencode/client": "^2.0.4",
39
+ "@opencode/schema": "^2.0.4",
40
+ "@opencode/theme": "^2.0.4",
41
+ "@opentui/core": ">=0.5.8",
42
+ "@opentui/solid": ">=0.5.8",
43
+ "solid-js": ">=1.9.0",
44
+ "typescript": "^5.9.0"
45
+ }
46
+ }
@@ -0,0 +1,167 @@
1
+ import { test } from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import {
5
+ errorSnapshot,
6
+ formatPercent,
7
+ formatReset,
8
+ formatSummary,
9
+ formatWindow,
10
+ parseSnapshot,
11
+ parseUsage,
12
+ } from "./usage.ts"
13
+
14
+ const NOW = Date.parse("2026-10-14T20:35:38.000Z")
15
+
16
+ const LIVE_PAYLOAD = {
17
+ usage: {
18
+ rolling: { status: "ok", percent: 2, resetsAt: "2026-10-14T21:25:45.000Z" },
19
+ weekly: { status: "ok", percent: 0, resetsAt: "2026-10-19T00:00:00.000Z" },
20
+ monthly: { status: "ok", percent: 0, resetsAt: "2026-11-09T17:09:16.000Z" },
21
+ },
22
+ }
23
+
24
+ test("parseUsage reads the three windows from the live payload shape", () => {
25
+ const snapshot = parseUsage(LIVE_PAYLOAD, NOW)
26
+
27
+ assert.equal(snapshot.ok, true)
28
+ assert.equal(snapshot.error, null)
29
+ assert.deepEqual(
30
+ snapshot.windows.map((w) => [w.key, w.percent]),
31
+ [
32
+ ["rolling", 2],
33
+ ["weekly", 0],
34
+ ["monthly", 0],
35
+ ],
36
+ )
37
+ assert.equal(snapshot.windows[0]?.resetsAt, "2026-10-14T21:25:45.000Z")
38
+ })
39
+
40
+ test("parseUsage keeps each percent as received and never derives one window from another", () => {
41
+ const snapshot = parseUsage(LIVE_PAYLOAD, NOW)
42
+
43
+ // Observed live: rolling 2% while weekly and monthly were 0%. Not monotonic.
44
+ assert.equal(snapshot.windows[0]?.percent, 2)
45
+ assert.equal(snapshot.windows[1]?.percent, 0)
46
+ assert.equal(snapshot.windows[2]?.percent, 0)
47
+ })
48
+
49
+ test("parseUsage degrades a missing window to an unknown window instead of throwing", () => {
50
+ const snapshot = parseUsage({ usage: { rolling: LIVE_PAYLOAD.usage.rolling } }, NOW)
51
+
52
+ assert.equal(snapshot.windows.length, 3)
53
+ assert.equal(snapshot.windows[0]?.percent, 2)
54
+ assert.equal(snapshot.windows[1]?.percent, null)
55
+ assert.equal(snapshot.windows[1]?.resetsAt, null)
56
+ assert.equal(snapshot.ok, true)
57
+ })
58
+
59
+ test("parseUsage degrades a malformed window to unknown", () => {
60
+ const snapshot = parseUsage(
61
+ {
62
+ usage: {
63
+ rolling: { status: "ok", percent: "2", resetsAt: 12345 },
64
+ weekly: null,
65
+ monthly: { status: "ok", percent: 50, resetsAt: "not-a-date" },
66
+ },
67
+ },
68
+ NOW,
69
+ )
70
+
71
+ assert.equal(snapshot.windows[0]?.percent, null)
72
+ assert.equal(snapshot.windows[0]?.resetsAt, null)
73
+ assert.equal(snapshot.windows[1]?.percent, null)
74
+ assert.equal(snapshot.windows[2]?.percent, 50)
75
+ assert.equal(snapshot.windows[2]?.resetsAt, null)
76
+ })
77
+
78
+ test("parseUsage reports an error snapshot for a non-object payload", () => {
79
+ for (const payload of [null, undefined, "boom", 42, []]) {
80
+ const snapshot = parseUsage(payload, NOW)
81
+ assert.equal(snapshot.ok, false)
82
+ assert.ok(snapshot.error)
83
+ assert.equal(snapshot.windows.length, 3)
84
+ assert.equal(snapshot.windows[0]?.percent, null)
85
+ }
86
+ })
87
+
88
+ test("formatPercent renders an integer percent or an em dash", () => {
89
+ assert.equal(formatPercent(2), "2%")
90
+ assert.equal(formatPercent(0), "0%")
91
+ assert.equal(formatPercent(100), "100%")
92
+ assert.equal(formatPercent(null), "—")
93
+ })
94
+
95
+ test("formatReset renders days, hours and minutes until the reset", () => {
96
+ const now = Date.parse("2026-10-14T20:35:38.000Z")
97
+
98
+ assert.equal(formatReset("2026-10-14T21:25:45.000Z", now), "resets in 50m")
99
+ assert.equal(formatReset("2026-10-19T00:00:00.000Z", now), "resets in 4d 3h")
100
+ assert.equal(formatReset("2026-11-09T17:09:16.000Z", now), "resets in 25d 20h")
101
+ })
102
+
103
+ test("formatReset handles the last minutes, the expired case and unknown input", () => {
104
+ const now = Date.parse("2026-10-14T20:35:38.000Z")
105
+
106
+ assert.equal(formatReset("2026-10-14T20:40:38.000Z", now), "resets in 5m")
107
+ assert.equal(formatReset("2026-10-14T20:35:00.000Z", now), "resetting")
108
+ assert.equal(formatReset(null, now), "unknown")
109
+ assert.equal(formatReset("not-a-date", now), "unknown")
110
+ })
111
+
112
+ test("formatWindow and formatSummary compose the rendered line", () => {
113
+ const snapshot = parseUsage(LIVE_PAYLOAD, NOW)
114
+
115
+ assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h 2% · resets in 50m")
116
+ 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%")
118
+ })
119
+
120
+ test("formatSummary surfaces the failure state instead of percentages", () => {
121
+ const snapshot = errorSnapshot("Go integration is not connected", NOW)
122
+
123
+ assert.equal(snapshot.ok, false)
124
+ assert.equal(formatSummary(snapshot, NOW), "GO usage unavailable")
125
+ assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h — · unknown")
126
+ })
127
+
128
+ test("parseSnapshot accepts a snapshot that crossed the RPC boundary", () => {
129
+ const sent = parseUsage(LIVE_PAYLOAD, NOW)
130
+ const received = parseSnapshot(JSON.parse(JSON.stringify(sent)))
131
+
132
+ assert.notEqual(received, null)
133
+ assert.equal(received!.ok, true)
134
+ assert.equal(received!.fetchedAt, NOW)
135
+ assert.deepEqual(
136
+ received!.windows.map((w) => w.percent),
137
+ [2, 0, 0],
138
+ )
139
+ assert.equal(received!.windows[0]!.short, "5h")
140
+ assert.equal(received!.windows[0]!.long, "5h window")
141
+ assert.equal(formatSummary(received!, NOW), "GO 5h 2% · wk 0% · mo 0%")
142
+ })
143
+
144
+ test("parseSnapshot rejects anything that is not a snapshot object", () => {
145
+ for (const value of [null, undefined, "boom", 42, [], {}, { windows: "nope" }]) {
146
+ assert.equal(parseSnapshot(value), null)
147
+ }
148
+ })
149
+
150
+ test("parseSnapshot degrades a malformed window entry instead of throwing", () => {
151
+ const received = parseSnapshot({
152
+ fetchedAt: "nope",
153
+ ok: "yes",
154
+ error: 7,
155
+ windows: [{ key: "rolling", short: "5h", long: "5h window", percent: 9 }, null, "x"],
156
+ })
157
+
158
+ assert.notEqual(received, null)
159
+ assert.equal(received!.ok, false)
160
+ assert.equal(received!.error, null)
161
+ assert.equal(received!.fetchedAt, 0)
162
+ assert.equal(received!.windows.length, 3)
163
+ assert.equal(received!.windows[0]!.percent, 9)
164
+ assert.equal(received!.windows[1]!.percent, null)
165
+ assert.equal(received!.windows[2]!.key, "monthly")
166
+ })
167
+
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Pure model and formatting for the OpenCode Go usage windows.
3
+ *
4
+ * The payload shape was verified live against
5
+ * `GET https://opencode.ai/inference/go/v1/usage`:
6
+ *
7
+ * { "usage": { "rolling": { "status": "ok", "percent": 2,
8
+ * "resetsAt": "2026-10-14T21:25:45.000Z" },
9
+ * "weekly": { ... }, "monthly": { ... } } }
10
+ *
11
+ * The 5-hour window is named `rolling`. The three percentages are NOT
12
+ * monotonic (observed: rolling 2% while weekly and monthly were 0%), so every
13
+ * value is carried through exactly as received and never derived from another
14
+ * window.
15
+ *
16
+ * This module is deliberately free of any host API so it stays testable with
17
+ * plain `node --test`.
18
+ */
19
+
20
+ export const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const
21
+
22
+ export type WindowKey = (typeof WINDOW_KEYS)[number]
23
+
24
+ /** Short label used in the compact status line. */
25
+ const SHORT_LABEL: Record<WindowKey, string> = {
26
+ rolling: "5h",
27
+ weekly: "wk",
28
+ monthly: "mo",
29
+ }
30
+
31
+ /** Longer label used in the detail view. */
32
+ const LONG_LABEL: Record<WindowKey, string> = {
33
+ rolling: "5h window",
34
+ weekly: "Weekly",
35
+ monthly: "Monthly",
36
+ }
37
+
38
+ export interface UsageWindow {
39
+ readonly key: WindowKey
40
+ readonly short: string
41
+ readonly long: string
42
+ /** Integer 0-100 as reported by the endpoint, or null when unavailable. */
43
+ readonly percent: number | null
44
+ /** ISO-8601 UTC reset instant, or null when unavailable. */
45
+ readonly resetsAt: string | null
46
+ /** Provider-reported status string, or null when unavailable. */
47
+ readonly status: string | null
48
+ }
49
+
50
+ export interface UsageSnapshot {
51
+ readonly fetchedAt: number
52
+ readonly ok: boolean
53
+ readonly error: string | null
54
+ readonly windows: readonly UsageWindow[]
55
+ }
56
+
57
+ function unknownWindow(key: WindowKey): UsageWindow {
58
+ return {
59
+ key,
60
+ short: SHORT_LABEL[key],
61
+ long: LONG_LABEL[key],
62
+ percent: null,
63
+ resetsAt: null,
64
+ status: null,
65
+ }
66
+ }
67
+
68
+ function isRecord(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value)
70
+ }
71
+
72
+ function readPercent(value: unknown): number | null {
73
+ return typeof value === "number" && Number.isFinite(value) ? value : null
74
+ }
75
+
76
+ function readInstant(value: unknown): string | null {
77
+ if (typeof value !== "string") return null
78
+ return Number.isNaN(Date.parse(value)) ? null : value
79
+ }
80
+
81
+ function readStatus(value: unknown): string | null {
82
+ return typeof value === "string" && value.length > 0 ? value : null
83
+ }
84
+
85
+ function parseWindow(key: WindowKey, raw: unknown): UsageWindow {
86
+ if (!isRecord(raw)) return unknownWindow(key)
87
+ return {
88
+ key,
89
+ short: SHORT_LABEL[key],
90
+ long: LONG_LABEL[key],
91
+ percent: readPercent(raw.percent),
92
+ resetsAt: readInstant(raw.resetsAt),
93
+ status: readStatus(raw.status),
94
+ }
95
+ }
96
+
97
+ /** Snapshot for any failure: no windows, an explicit message. */
98
+ export function errorSnapshot(message: string, now: number): UsageSnapshot {
99
+ return {
100
+ fetchedAt: now,
101
+ ok: false,
102
+ error: message,
103
+ windows: WINDOW_KEYS.map(unknownWindow),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Parse an endpoint payload into a snapshot.
109
+ *
110
+ * A malformed or missing window degrades to an unknown window. Only a payload
111
+ * that is not an object at all fails the whole snapshot.
112
+ */
113
+ export function parseUsage(payload: unknown, now: number): UsageSnapshot {
114
+ if (!isRecord(payload)) return errorSnapshot("Malformed usage response", now)
115
+
116
+ const usage = payload.usage
117
+ if (!isRecord(usage)) return errorSnapshot("Usage response has no usage object", now)
118
+
119
+ return {
120
+ fetchedAt: now,
121
+ ok: true,
122
+ error: null,
123
+ windows: WINDOW_KEYS.map((key) => parseWindow(key, usage[key])),
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Defensive parse of a snapshot that crossed the RPC boundary.
129
+ *
130
+ * Windows are matched positionally to WINDOW_KEYS, so a reordered or missing
131
+ * entry degrades to an unknown window. Returns null only when the value is not
132
+ * a snapshot at all.
133
+ */
134
+ export function parseSnapshot(value: unknown): UsageSnapshot | null {
135
+ if (!isRecord(value)) return null
136
+
137
+ const rawWindows = value.windows
138
+ if (!Array.isArray(rawWindows)) return null
139
+
140
+ return {
141
+ fetchedAt:
142
+ typeof value.fetchedAt === "number" && Number.isFinite(value.fetchedAt) ? value.fetchedAt : 0,
143
+ ok: value.ok === true,
144
+ error: typeof value.error === "string" && value.error.length > 0 ? value.error : null,
145
+ windows: WINDOW_KEYS.map((key, index) => parseWindow(key, rawWindows[index])),
146
+ }
147
+ }
148
+
149
+ export function formatPercent(percent: number | null): string {
150
+ return percent === null ? "—" : `${percent}%`
151
+ }
152
+
153
+ const MINUTE = 60_000
154
+ const HOUR = 60 * MINUTE
155
+ const DAY = 24 * HOUR
156
+
157
+ /** Human countdown to a reset instant. */
158
+ export function formatReset(resetsAt: string | null, now: number): string {
159
+ if (resetsAt === null) return "unknown"
160
+
161
+ const target = Date.parse(resetsAt)
162
+ if (Number.isNaN(target)) return "unknown"
163
+
164
+ const remaining = target - now
165
+ if (remaining <= 0) return "resetting"
166
+
167
+ const days = Math.floor(remaining / DAY)
168
+ const hours = Math.floor((remaining % DAY) / HOUR)
169
+ const minutes = Math.floor((remaining % HOUR) / MINUTE)
170
+
171
+ if (days > 0) return `resets in ${days}d ${hours}h`
172
+ if (hours > 0) return minutes > 0 ? `resets in ${hours}h ${minutes}m` : `resets in ${hours}h`
173
+ return `resets in ${minutes}m`
174
+ }
175
+
176
+ /** One window as a single line, e.g. `5h 2% · resets in 50m`. */
177
+ export function formatWindow(window: UsageWindow, now: number): string {
178
+ return `${window.short} ${formatPercent(window.percent)} · ${formatReset(window.resetsAt, now)}`
179
+ }
180
+
181
+ /** One window as a detail row, e.g. `5h window 2% resets in 50m`. */
182
+ export function formatWindowDetail(window: UsageWindow, now: number): string {
183
+ return `${window.long.padEnd(10)} ${formatPercent(window.percent).padStart(4)} ${formatReset(
184
+ window.resetsAt,
185
+ 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(" · ")}`
193
+ }
package/src/index.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ import { errorSnapshot, parseUsage, type UsageSnapshot } from "./core/usage.ts"
4
+ import { UsageRpc } from "./rpc.ts"
5
+
6
+ const INTEGRATION_ID = "opencode-go"
7
+ const USAGE_URL = "https://opencode.ai/inference/go/v1/usage"
8
+ const USER_AGENT = "opencode-go-usage/0.1.0"
9
+
10
+ /** Usage moves slowly; the endpoint is undocumented, so we stay conservative. */
11
+ const REFRESH_MS = 5 * 60_000
12
+ const REQUEST_TIMEOUT_MS = 10_000
13
+
14
+ /**
15
+ * Server plugin.
16
+ *
17
+ * It owns the whole credential path: the TUI cannot read a credential (the
18
+ * client exposes only update/activate/remove), so the fetch happens here and
19
+ * only the resulting snapshot crosses the RPC boundary.
20
+ */
21
+ export default Plugin.define({
22
+ id: "opencode-go-usage.server",
23
+ async setup(ctx) {
24
+ let snapshot: UsageSnapshot = errorSnapshot("Usage not loaded yet", Date.now())
25
+ let inFlight: Promise<UsageSnapshot> | null = null
26
+
27
+ async function readApiKey(): Promise<string | null> {
28
+ const connection = await ctx.integration.connection.active(INTEGRATION_ID)
29
+ if (!connection) return null
30
+
31
+ const credential = await ctx.integration.connection.resolve(connection)
32
+ if (!credential || credential.type !== "key") return null
33
+
34
+ // Never log, print, persist or forward this value.
35
+ return credential.key
36
+ }
37
+
38
+ async function fetchSnapshot(): Promise<UsageSnapshot> {
39
+ const now = Date.now()
40
+
41
+ try {
42
+ const apiKey = await readApiKey()
43
+ if (apiKey === null) {
44
+ return errorSnapshot(`The ${INTEGRATION_ID} integration is not connected`, now)
45
+ }
46
+
47
+ const response = await fetch(USAGE_URL, {
48
+ headers: {
49
+ authorization: `Bearer ${apiKey}`,
50
+ "user-agent": USER_AGENT,
51
+ accept: "application/json",
52
+ },
53
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
54
+ })
55
+
56
+ if (!response.ok) {
57
+ return errorSnapshot(`Usage request failed with HTTP ${response.status}`, now)
58
+ }
59
+
60
+ return parseUsage(await response.json(), now)
61
+ } catch (cause) {
62
+ const detail = cause instanceof Error ? cause.message : "unknown error"
63
+ return errorSnapshot(`Usage request failed: ${detail}`, now)
64
+ }
65
+ }
66
+
67
+ /** Coalesces concurrent refreshes into one request. */
68
+ async function refresh(): Promise<UsageSnapshot> {
69
+ if (inFlight) return inFlight
70
+
71
+ inFlight = fetchSnapshot()
72
+ try {
73
+ snapshot = await inFlight
74
+ } finally {
75
+ inFlight = null
76
+ }
77
+
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
+ })
84
+ return snapshot
85
+ }
86
+
87
+ const registration = await ctx.rpc.register(UsageRpc, {
88
+ get: async (input) => {
89
+ const wantsRefresh = (input as { refresh?: boolean } | undefined)?.refresh === true
90
+ return wantsRefresh ? await refresh() : snapshot
91
+ },
92
+ })
93
+
94
+ await refresh()
95
+
96
+ const timer = setInterval(() => {
97
+ void refresh()
98
+ }, REFRESH_MS)
99
+
100
+ return () => {
101
+ clearInterval(timer)
102
+ }
103
+ },
104
+ })
package/src/rpc.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ /**
4
+ * RPC contract shared by the server plugin (producer) and the TUI plugin
5
+ * (consumer).
6
+ *
7
+ * The schemas are intentionally permissive. The snapshot crosses a process and
8
+ * validation boundary, so the TUI re-parses it defensively with
9
+ * `parseSnapshot` from `core/usage.ts` instead of trusting a generated type.
10
+ * No credential ever appears in this contract: the snapshot carries only
11
+ * percentages, reset instants and status strings.
12
+ */
13
+ export const UsageRpc = Rpc.define({
14
+ id: "go-usage",
15
+ methods: {
16
+ get: {
17
+ input: {
18
+ type: "object",
19
+ properties: {
20
+ /** Bypass the refresh interval and re-read the endpoint now. */
21
+ refresh: { type: "boolean" },
22
+ },
23
+ additionalProperties: true,
24
+ },
25
+ output: { type: "object", additionalProperties: true },
26
+ },
27
+ },
28
+ events: {
29
+ updated: {
30
+ /**
31
+ * The full snapshot travels in the event payload, so the TUI applies it
32
+ * directly instead of issuing a second round trip. No properties are
33
+ * declared: the schema stays permissive on purpose and the consumer
34
+ * re-parses defensively.
35
+ */
36
+ schema: { type: "object", additionalProperties: true },
37
+ },
38
+ },
39
+ })
40
+
41
+ export type UsageRpcDefinition = typeof UsageRpc
package/src/tui.tsx ADDED
@@ -0,0 +1,174 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { createMemo, createSignal, onCleanup } from "solid-js"
4
+
5
+ import {
6
+ errorSnapshot,
7
+ formatWindow,
8
+ formatWindowDetail,
9
+ parseSnapshot,
10
+ type UsageSnapshot,
11
+ } from "./core/usage.ts"
12
+ import { UsageRpc } from "./rpc.ts"
13
+
14
+ /** The reset countdown is the only thing that changes between refreshes. */
15
+ const COUNTDOWN_TICK_MS = 30_000
16
+
17
+ /**
18
+ * The host publishes a coarse sidebar tree: `sidebar.content` (the sidebar's
19
+ * content region, which holds the context and MCP blocks) and `sidebar.footer`.
20
+ * The finer strings in the 2.0.28 bundle (`sidebar.context`, `sidebar.mcp`) are
21
+ * internal identifiers, not slot paths — anchoring to one of those made the
22
+ * section disappear.
23
+ *
24
+ * There is no sub-slot between the context and MCP blocks, so the only two
25
+ * positions inside the content region are its start and its end. Usage owns the
26
+ * top: `prepend` puts it above the context block. The planned agent and to-do
27
+ * sections take the bottom (`append`, below MCP), so they do not compete with
28
+ * this one for the same edge.
29
+ */
30
+ const SIDEBAR_SLOT = "sidebar.content"
31
+
32
+ interface HudState {
33
+ snapshot: UsageSnapshot
34
+ /** Set when the last interaction failed, e.g. the server plugin is absent. */
35
+ fault: string | null
36
+ }
37
+
38
+ function initialSnapshot(): UsageSnapshot {
39
+ return errorSnapshot("Usage not loaded yet", Date.now())
40
+ }
41
+
42
+ function themeColor(context: Plugin.Context, key: "base" | "muted" | "accent"): string {
43
+ const text = (context.theme as unknown as { text?: Record<string, string | undefined> } | undefined)
44
+ ?.text
45
+ return text?.[key] ?? (key === "muted" ? "gray" : "white")
46
+ }
47
+
48
+ function detailMessage(snapshot: UsageSnapshot, now: number, fault: string | null): string {
49
+ if (fault !== null) return fault
50
+ if (!snapshot.ok && snapshot.error !== null) return snapshot.error
51
+
52
+ const rows = snapshot.windows.map((window) => formatWindowDetail(window, now))
53
+ const fetched = new Date(snapshot.fetchedAt).toISOString().slice(11, 19)
54
+ return `${rows.join("\n")}\n\nupdated ${fetched} UTC · each window resets on its own schedule`
55
+ }
56
+
57
+ /**
58
+ * TUI plugin.
59
+ *
60
+ * It never touches the credential: the server plugin resolves it, reads the
61
+ * endpoint and pushes the resulting snapshot over RPC.
62
+ */
63
+ export default Plugin.define({
64
+ id: "opencode-go-usage.tui",
65
+ async setup(context) {
66
+ // The RPC lives in the server half. If that half is not loaded the subclient
67
+ // cannot be built, and the HUD must degrade instead of failing setup.
68
+ let usage: ReturnType<typeof context.client.rpc<typeof UsageRpc>> | undefined
69
+ try {
70
+ usage = context.client.rpc(UsageRpc)
71
+ } catch {
72
+ usage = undefined
73
+ }
74
+
75
+ const [state, setState] = context.storage.memory<HudState>("usage", {
76
+ initial: { snapshot: initialSnapshot(), fault: null },
77
+ })
78
+
79
+ const apply = (value: unknown) => {
80
+ const parsed = parseSnapshot(value)
81
+ if (parsed === null) return
82
+ setState((draft) => {
83
+ draft.snapshot = parsed
84
+ draft.fault = null
85
+ })
86
+ }
87
+
88
+ const pull = async (refresh: boolean) => {
89
+ if (usage === undefined) {
90
+ setState((draft) => {
91
+ draft.fault = "Usage unavailable: the usage plugin is not loaded"
92
+ })
93
+ return
94
+ }
95
+ try {
96
+ apply(await usage.get({ refresh }))
97
+ } catch (cause) {
98
+ const detail = cause instanceof Error ? cause.message : "unknown error"
99
+ setState((draft) => {
100
+ draft.fault = `Usage unavailable: ${detail}`
101
+ })
102
+ }
103
+ }
104
+
105
+ void pull(false)
106
+
107
+ const stopEvents = (() => {
108
+ try {
109
+ return usage?.events.on("updated", (event) => {
110
+ apply((event as { data?: unknown }).data)
111
+ })
112
+ } catch {
113
+ return undefined
114
+ }
115
+ })()
116
+
117
+ const UsageSection = () => {
118
+ const [now, setNow] = createSignal(Date.now())
119
+ const timer = setInterval(() => setNow(Date.now()), COUNTDOWN_TICK_MS)
120
+ onCleanup(() => clearInterval(timer))
121
+
122
+ const body = createMemo(() => {
123
+ if (state.fault !== null) return state.fault
124
+ if (!state.snapshot.ok && state.snapshot.error !== null) return state.snapshot.error
125
+ return null
126
+ })
127
+
128
+ return (
129
+ <box flexDirection="column">
130
+ <text fg={themeColor(context, "base")}>Go usage</text>
131
+ {body() !== null ? (
132
+ <text fg={themeColor(context, "muted")}>{body()}</text>
133
+ ) : (
134
+ state.snapshot.windows.map((window) => (
135
+ <text fg={themeColor(context, "muted")}>{formatWindow(window, now())}</text>
136
+ ))
137
+ )}
138
+ </box>
139
+ )
140
+ }
141
+
142
+ const stopSlot = context.ui.slot({
143
+ prepend: SIDEBAR_SLOT,
144
+ render: () => <UsageSection />,
145
+ })
146
+
147
+ context.keymap.layer(() => ({
148
+ mode: "global",
149
+ priority: 10,
150
+ commands: [
151
+ {
152
+ id: "opencode-go-usage.usage",
153
+ title: "OpenCode Go usage",
154
+ description: "Show the 5h, weekly and monthly Go usage windows",
155
+ group: "Go usage",
156
+ palette: true,
157
+ slash: { name: "usage", aliases: ["go-usage"] },
158
+ run: async () => {
159
+ await pull(true)
160
+ await context.ui.dialog.alert({
161
+ title: "OpenCode Go usage",
162
+ message: detailMessage(state.snapshot, Date.now(), state.fault),
163
+ })
164
+ },
165
+ },
166
+ ],
167
+ }))
168
+
169
+ return () => {
170
+ stopEvents?.()
171
+ stopSlot()
172
+ }
173
+ },
174
+ })