@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 +21 -0
- package/README.md +101 -2
- package/package.json +44 -4
- package/src/core/usage.test.ts +167 -0
- package/src/core/usage.ts +193 -0
- package/src/index.ts +104 -0
- package/src/rpc.ts +41 -0
- package/src/tui.tsx +174 -0
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
|
-
#
|
|
1
|
+
# @danielbdev/opencode-go-usage
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
})
|