@oscarfalero/pi-opencode-go 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 oscarfalero
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 ADDED
@@ -0,0 +1,131 @@
1
+ # pi-opencode-go
2
+
3
+ OpenCode Go usage in pi status bar — rolling 5h / weekly / monthly + `/go-usage` + `/go-status` toggle.
4
+
5
+ Ultra-compact status bar indicator for your [OpenCode Go](https://opencode.ai) subscription, designed for [pi](https://github.com/earendil-works/pi-coding-agent) (the pi coding agent / TUI).
6
+
7
+ ## Install
8
+
9
+ ### From npm via pi
10
+
11
+ ```bash
12
+ pi install npm:@oscarfalero/pi-opencode-go
13
+ pi install npm:@oscarfalero/pi-opencode-go@0.1.0 # pinned
14
+ ```
15
+
16
+ ### From git via pi
17
+
18
+ ```bash
19
+ pi install git:github.com/oscarfalero/pi-opencode-go
20
+ pi install git:github.com/oscarfalero/pi-opencode-go@v0.1.0 # pinned tag
21
+ pi install https://github.com/oscarfalero/pi-opencode-go
22
+ # local path for development
23
+ pi install ./path/to/pi-opencode-go
24
+ ```
25
+
26
+ Restart pi after installation (or reload extensions) to activate.
27
+
28
+ ## Usage
29
+
30
+ ### `/go-usage`
31
+
32
+ Show detailed OpenCode Go usage (Rolling / Weekly / Monthly) as a notification:
33
+
34
+ ```
35
+ OpenCode Go usage:
36
+ Rolling: 42% — resets in 2h 15m
37
+ Weekly: 18% — resets in 3d 4h
38
+ Monthly: 7% — resets in 12d 6h
39
+
40
+ Toggle compact status with /go-status
41
+ ```
42
+
43
+ If the key is not configured you will see a warning. If your current model is not an `opencode-go` model, a hint is appended.
44
+
45
+ ### `/go-status`
46
+
47
+ Toggle the compact status bar indicator on/off.
48
+
49
+ - `Go compact: on — fetching…` (when a Go model is active)
50
+ - `Go compact: on — will appear when a Go model is selected` (when enabled but non-Go model active)
51
+ - `Go compact: off — hidden from status bar` (when disabled)
52
+
53
+ ### Status bar behavior
54
+
55
+ - **Only when provider configured and Go model active:** the indicator appears only if `opencode-go` is configured (valid `OPENCODE_API_KEY`) **and** the current model provider is `opencode-go` **and** compact is enabled.
56
+ - **Pastel yellow:** fixed color `mdHeading` (`#f0c674`) — no conditional warning/error colors.
57
+ - **Post-turn refresh:** no polling interval. Refreshes on `turn_end` / `agent_settled` (debounced 800ms) plus `session_start` and `model_select`. Shows `Go …` while fetching, `Go --` on failure with no cache, or `Go 12/34/56%` (rolling/weekly/monthly percents) on success.
58
+
59
+ Example status bar text: `Go 12/34/56%`
60
+
61
+ ## Requirements
62
+
63
+ - `pi` coding agent (`@earendil-works/pi-coding-agent`) and `@earendil-works/pi-tui`
64
+ - OpenCode Go subscription and API key
65
+
66
+ Set your API key via `~/.pi/agent/auth.json`:
67
+
68
+ ```json
69
+ {
70
+ "opencode-go": {
71
+ "type": "api_key",
72
+ "key": "sk-..."
73
+ }
74
+ }
75
+ ```
76
+
77
+ Or via environment variable (pi resolves `OPENCODE_API_KEY` for the provider):
78
+
79
+ ```bash
80
+ export OPENCODE_API_KEY="..."
81
+ ```
82
+
83
+ The extension authenticates with `GET https://opencode.ai/zen/go/v1/usage` using `Authorization: Bearer <OPENCODE_API_KEY>`. It also respects a custom `baseUrl` if your provider is configured with a non-localhost override containing `/zen/go/v1`.
84
+
85
+ ## Configuration
86
+
87
+ The `/go-status` toggle persists to:
88
+
89
+ ```
90
+ ~/.pi/agent/opencode-go-usage.json
91
+ ```
92
+
93
+ Format:
94
+
95
+ ```json
96
+ {
97
+ "visible": true
98
+ }
99
+ ```
100
+
101
+ - `true` (default) — compact status visible when conditions are met
102
+ - `false` — hidden from status bar (still allows `/go-usage` on demand)
103
+
104
+ You can edit this file manually; it is reloaded on each `session_start`. Delete it to restore defaults.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ # run pi with this extension loaded from the working directory
110
+ pi -e .
111
+
112
+ # install local build into pi (from inside the repo)
113
+ pi install ./.
114
+
115
+ # after changes, reinstall
116
+ pi install ./.
117
+ ```
118
+
119
+ No build step required — the extension is plain TypeScript loaded directly. No runtime dependencies; `typebox`, `pi-coding-agent`, and `pi-tui` are peer dependencies.
120
+
121
+ ## License
122
+
123
+ MIT — see [LICENSE](./LICENSE).
124
+
125
+ ## Security
126
+
127
+ - Your OpenCode API key is sent **only** to the OpenCode usage endpoint: `https://opencode.ai/zen/go/v1/usage` by default, or a custom provider `baseUrl` that includes `/zen/go/v1`. No other host ever receives it.
128
+ - The key is resolved through pi's own auth layer (`modelRegistry`) and is never logged or stored by this extension.
129
+ - No telemetry, analytics, or data collection — the only network traffic is the usage query described above.
130
+ - The only file this extension writes is its toggle state at `<agent-dir>/opencode-go-usage.json` (e.g. `~/.pi/agent/opencode-go-usage.json`).
131
+ - Review the source before installing: [extensions/opencode-go-usage.ts](./extensions/opencode-go-usage.ts) is a single small file.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * opencode-go usage — status bar for OpenCode Go subscription
3
+ *
4
+ * Shows ultra-compact usage in the footer only when:
5
+ * 1. opencode-go provider is configured (auth.json / env)
6
+ * 2. current model provider is "opencode-go"
7
+ * 3. compact is enabled (toggle via /go-status)
8
+ *
9
+ * Refresh: post-turn (turn_end / agent_settled) + session_start + model_select.
10
+ * No time-based interval.
11
+ *
12
+ * Endpoint: GET https://opencode.ai/zen/go/v1/usage
13
+ * Header: authorization: Bearer <OPENCODE_API_KEY>
14
+ * 200: { usage: { rolling:{status, percent, resetsAt}, weekly:{...}, monthly:{...} } }
15
+ *
16
+ * Status key: "opencode-go-usage"
17
+ * Color: fixed pastel yellow (mdHeading #f0c674) — no conditional warning/error.
18
+ *
19
+ * Toggle: /go-status — persists to <agent-dir>/opencode-go-usage.json (rebrand-safe via getAgentDir()).
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ const STATUS_KEY = "opencode-go-usage";
28
+ const PROD_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
29
+ const CONFIG_PATH = join(getAgentDir(), "opencode-go-usage.json");
30
+
31
+ type UsageBucket = {
32
+ status: "ok" | "rate-limited";
33
+ percent: number;
34
+ resetsAt: string;
35
+ };
36
+
37
+ type UsageResponse = {
38
+ usage: {
39
+ rolling: UsageBucket;
40
+ weekly: UsageBucket;
41
+ monthly: UsageBucket;
42
+ };
43
+ };
44
+
45
+ function loadCompactVisible(): boolean {
46
+ try {
47
+ if (!existsSync(CONFIG_PATH)) return true;
48
+ const raw = readFileSync(CONFIG_PATH, "utf8");
49
+ const data = JSON.parse(raw) as { visible?: boolean };
50
+ // explicit false disables, anything else enables (default true)
51
+ return data.visible !== false;
52
+ } catch {
53
+ return true;
54
+ }
55
+ }
56
+
57
+ function saveCompactVisible(visible: boolean): void {
58
+ try {
59
+ writeFileSync(CONFIG_PATH, JSON.stringify({ visible }, null, 2) + "\n", "utf8");
60
+ } catch {}
61
+ }
62
+
63
+ export default function (pi: ExtensionAPI) {
64
+ let cached: UsageResponse["usage"] | null = null;
65
+ let isFetching = false;
66
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
67
+ let compactVisible = loadCompactVisible();
68
+
69
+ function isGoActive(ctx: ExtensionContext): boolean {
70
+ if (!compactVisible) return false;
71
+ const auth = ctx.modelRegistry.getProviderAuthStatus("opencode-go");
72
+ if (!auth.configured) return false;
73
+ return ctx.model?.provider === "opencode-go";
74
+ }
75
+
76
+ function isProviderConfigured(ctx: ExtensionContext): boolean {
77
+ return ctx.modelRegistry.getProviderAuthStatus("opencode-go").configured;
78
+ }
79
+
80
+ function getUsageUrl(ctx: ExtensionContext): string {
81
+ const provider = ctx.modelRegistry.getProvider("opencode-go") as unknown as
82
+ | { baseUrl?: string; api?: string }
83
+ | undefined;
84
+
85
+ const candidates: (string | undefined)[] = [
86
+ (provider as any)?.baseUrl,
87
+ (provider as any)?.api,
88
+ (ctx.model as any)?.baseUrl,
89
+ ];
90
+
91
+ for (const base of candidates) {
92
+ if (!base || base.includes("localhost") || base.includes("127.0.0.1")) continue;
93
+ if (base.includes("/zen/go/v1")) {
94
+ return base.replace(/\/$/, "") + "/usage";
95
+ }
96
+ }
97
+ return PROD_USAGE_URL;
98
+ }
99
+
100
+ function formatResetsIn(iso: string): string {
101
+ const diff = new Date(iso).getTime() - Date.now();
102
+ if (diff <= 0) return "now";
103
+ const mins = Math.round(diff / 60000);
104
+ if (mins < 60) return `${mins}m`;
105
+ if (mins < 1440) {
106
+ const h = Math.floor(mins / 60);
107
+ const m = mins % 60;
108
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
109
+ }
110
+ const days = Math.floor(mins / 1440);
111
+ const rem = mins % 1440;
112
+ const h = Math.floor(rem / 60);
113
+ const m = rem % 60;
114
+ const parts: string[] = [`${days}d`];
115
+ if (h > 0) parts.push(`${h}h`);
116
+ if (m > 0) parts.push(`${m}m`);
117
+ return parts.join(" ");
118
+ }
119
+
120
+ // Fixed pastel yellow — no conditional colors. Uses mdHeading (#f0c674) which is the
121
+ // built-in pastel yellow in both dark/light themes.
122
+ function pastel(theme: ExtensionContext["ui"]["theme"], text: string): string {
123
+ try {
124
+ return theme.fg("mdHeading", text);
125
+ } catch {
126
+ // fallback to warning (yellow) if theme lacks mdHeading for some reason
127
+ return theme.fg("warning", text);
128
+ }
129
+ }
130
+
131
+ function renderCompact(usage: UsageResponse["usage"], theme: ExtensionContext["ui"]["theme"]): string {
132
+ const text = `Go ${usage.rolling.percent}/${usage.weekly.percent}/${usage.monthly.percent}%`;
133
+ return pastel(theme, text);
134
+ }
135
+
136
+ async function fetchUsage(ctx: ExtensionContext): Promise<UsageResponse["usage"]> {
137
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider("opencode-go");
138
+ if (!apiKey) throw new Error("Missing OPENCODE_API_KEY");
139
+
140
+ const url = getUsageUrl(ctx);
141
+ const controller = new AbortController();
142
+ const t = setTimeout(() => controller.abort(), 6000);
143
+
144
+ // Merge the agent's turn signal (so Esc aborts) with our own timeout, and
145
+ // always detach the listener afterwards so we never leak handlers on a
146
+ // long-lived signal.
147
+ let onAbort: (() => void) | undefined;
148
+ if (ctx.signal) {
149
+ if (ctx.signal.aborted) controller.abort();
150
+ else {
151
+ onAbort = () => controller.abort();
152
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
153
+ }
154
+ }
155
+
156
+ try {
157
+ const res = await fetch(url, {
158
+ headers: { authorization: `Bearer ${apiKey}` },
159
+ signal: controller.signal,
160
+ });
161
+
162
+ if (res.status === 401) throw new Error("401 Unauthorized — check OPENCODE_API_KEY");
163
+ if (res.status === 403) throw new Error("403 No OpenCode Go subscription for this key");
164
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
165
+
166
+ const json = (await res.json()) as UsageResponse;
167
+ if (!json.usage?.rolling || !json.usage?.weekly || !json.usage?.monthly) {
168
+ throw new Error("Unexpected usage response shape");
169
+ }
170
+ return json.usage;
171
+ } finally {
172
+ clearTimeout(t);
173
+ if (onAbort && ctx.signal) ctx.signal.removeEventListener("abort", onAbort);
174
+ }
175
+ }
176
+
177
+ /** UI setters can throw when pi invalidates ctx after session replacement/reload. Never let that escape as an unhandled rejection. */
178
+ function safeSetStatus(ctx: ExtensionContext, value: string | undefined) {
179
+ try {
180
+ ctx.ui.setStatus(STATUS_KEY, value);
181
+ } catch {}
182
+ }
183
+
184
+ async function refresh(ctx: ExtensionContext) {
185
+ if (!isGoActive(ctx)) {
186
+ safeSetStatus(ctx, undefined);
187
+ return;
188
+ }
189
+ if (isFetching) return;
190
+ isFetching = true;
191
+ try {
192
+ const usage = await fetchUsage(ctx);
193
+ cached = usage;
194
+ safeSetStatus(ctx, renderCompact(usage, ctx.ui.theme));
195
+ } catch {
196
+ if (cached) {
197
+ safeSetStatus(ctx, pastel(ctx.ui.theme, `Go ${cached.rolling.percent}/${cached.weekly.percent}/${cached.monthly.percent}%`));
198
+ } else {
199
+ safeSetStatus(ctx, pastel(ctx.ui.theme, "Go --"));
200
+ }
201
+ } finally {
202
+ isFetching = false;
203
+ }
204
+ }
205
+
206
+ function scheduleRefresh(ctx: ExtensionContext) {
207
+ if (debounceTimer) clearTimeout(debounceTimer);
208
+ debounceTimer = setTimeout(() => {
209
+ debounceTimer = null;
210
+ void refresh(ctx).catch(() => {});
211
+ }, 800);
212
+ }
213
+
214
+ // --- lifecycle ---
215
+
216
+ pi.on("session_start", async (_evt, ctx) => {
217
+ // Reload persisted preference on each session start (covers external edits)
218
+ compactVisible = loadCompactVisible();
219
+ if (!isGoActive(ctx)) {
220
+ ctx.ui.setStatus(STATUS_KEY, undefined);
221
+ return;
222
+ }
223
+ ctx.ui.setStatus(STATUS_KEY, pastel(ctx.ui.theme, "Go …"));
224
+ await refresh(ctx);
225
+ });
226
+
227
+ pi.on("model_select", async (_evt, ctx) => {
228
+ if (!isGoActive(ctx)) {
229
+ if (debounceTimer) {
230
+ clearTimeout(debounceTimer);
231
+ debounceTimer = null;
232
+ }
233
+ ctx.ui.setStatus(STATUS_KEY, undefined);
234
+ return;
235
+ }
236
+ ctx.ui.setStatus(STATUS_KEY, pastel(ctx.ui.theme, "Go …"));
237
+ await refresh(ctx);
238
+ });
239
+
240
+ pi.on("turn_end", async (_evt, ctx) => {
241
+ if (!isGoActive(ctx)) return;
242
+ scheduleRefresh(ctx);
243
+ });
244
+
245
+ pi.on("agent_settled", async (_evt, ctx) => {
246
+ if (!isGoActive(ctx)) return;
247
+ scheduleRefresh(ctx);
248
+ });
249
+
250
+ pi.on("session_shutdown", async (_evt, ctx) => {
251
+ if (debounceTimer) {
252
+ clearTimeout(debounceTimer);
253
+ debounceTimer = null;
254
+ }
255
+ ctx.ui.setStatus(STATUS_KEY, undefined);
256
+ });
257
+
258
+ // --- command: /go-usage (details) ---
259
+ pi.registerCommand("go-usage", {
260
+ description: "Show OpenCode Go usage (Rolling / Weekly / Monthly)",
261
+ handler: async (_args, ctx) => {
262
+ if (!isProviderConfigured(ctx)) {
263
+ ctx.ui.notify("opencode-go not configured — set OPENCODE_API_KEY in auth.json or env", "warning");
264
+ return;
265
+ }
266
+
267
+ const hint = ctx.model?.provider !== "opencode-go"
268
+ ? " (current model is not opencode-go — status bar hidden until you select a Go model)"
269
+ : "";
270
+
271
+ try {
272
+ const usage = await fetchUsage(ctx);
273
+ cached = usage;
274
+ if (isGoActive(ctx)) {
275
+ ctx.ui.setStatus(STATUS_KEY, renderCompact(usage, ctx.ui.theme));
276
+ }
277
+
278
+ const fmtPct = (n: number) => `${String(n).padStart(3)}%`;
279
+ const fmtStatus = (s: UsageBucket["status"]) => (s === "rate-limited" ? "rate-limited ⚠ — " : "");
280
+ const fmtLine = (label: string, pct: number, status: UsageBucket["status"], resets: string) => {
281
+ const spaces = " ".repeat(8 - label.length); // align % vertically (max label 7 + colon = 8)
282
+ return ` ${label}:${spaces}${fmtPct(pct)} — ${fmtStatus(status)}resets in ${resets}`;
283
+ };
284
+ const lines = [
285
+ `OpenCode Go usage${hint}:`,
286
+ fmtLine("Rolling", usage.rolling.percent, usage.rolling.status, formatResetsIn(usage.rolling.resetsAt)),
287
+ fmtLine("Weekly", usage.weekly.percent, usage.weekly.status, formatResetsIn(usage.weekly.resetsAt)),
288
+ fmtLine("Monthly", usage.monthly.percent, usage.monthly.status, formatResetsIn(usage.monthly.resetsAt)),
289
+ ``,
290
+ ` Toggle compact status with /go-status`,
291
+ ];
292
+ ctx.ui.notify(lines.join("\n"), "info");
293
+ } catch (e) {
294
+ const msg = e instanceof Error ? e.message : String(e);
295
+ ctx.ui.notify(`Go usage fetch failed: ${msg}`, "error");
296
+ }
297
+ },
298
+ });
299
+
300
+ // --- command: /go-status (toggle compact bar) ---
301
+ pi.registerCommand("go-status", {
302
+ description: "Toggle OpenCode Go compact status bar",
303
+ handler: async (_args, ctx) => {
304
+ compactVisible = !compactVisible;
305
+ saveCompactVisible(compactVisible);
306
+
307
+ if (!compactVisible) {
308
+ if (debounceTimer) {
309
+ clearTimeout(debounceTimer);
310
+ debounceTimer = null;
311
+ }
312
+ safeSetStatus(ctx, undefined);
313
+ ctx.ui.notify("Go compact: off — hidden from status bar", "info");
314
+ } else {
315
+ if (isProviderConfigured(ctx) && ctx.model?.provider === "opencode-go") {
316
+ ctx.ui.setStatus(STATUS_KEY, pastel(ctx.ui.theme, "Go …"));
317
+ ctx.ui.notify("Go compact: on — fetching…", "info");
318
+ await refresh(ctx);
319
+ } else if (!isProviderConfigured(ctx)) {
320
+ ctx.ui.notify("Go compact: on — will appear when opencode-go is configured and a Go model is selected", "info");
321
+ } else {
322
+ ctx.ui.notify("Go compact: on — will appear when a Go model is selected", "info");
323
+ safeSetStatus(ctx, undefined);
324
+ }
325
+ }
326
+ },
327
+ });
328
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@oscarfalero/pi-opencode-go",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode Go usage in pi status bar — rolling 5h / weekly / monthly + /go-usage + /go-status toggle",
5
+ "keywords": ["pi-package", "opencode", "pi-extension", "go"],
6
+ "license": "MIT",
7
+ "author": "oscarfalero",
8
+ "type": "module",
9
+ "files": ["extensions/", "README.md", "LICENSE"],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/oscarfalero/pi-opencode-go.git"
13
+ },
14
+ "pi": {
15
+ "extensions": ["./extensions"]
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": "*"
19
+ }
20
+ }