pi-opencode-gs 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 Jonathan Soto
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,148 @@
1
- # Temporary Holding Version
1
+ # pi-opencode-gs
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
+ OpenCode Go subscription usage in [Gentle Shell](https://github.com/Gentleman-Programming/gentle-shell): the
4
+ `/gentle:usage` panel behind `alt+u`, the usage segment in the status bar, and the DeepSeek peak/off-peak
5
+ pricing window OpenCode Go bills at double rate.
6
+
7
+ Gentle Shell ships built-in usage readers for Codex, Claude and NaN Cloud. This package registers OpenCode Go
8
+ through the `gentle-pi:usage-source/v1` event, so the shell treats it exactly like a built-in provider and owns
9
+ the refresh schedule.
10
+
11
+ ## Requirements
12
+
13
+ - Gentle Shell with a `usage-source` capable version
14
+ - Pi `>= 0.85`, Node `>= 22.19`
15
+ - An `opencode-go` provider configured in Pi, with its API key stored as usual (`auth.json` or `OPENCODE_API_KEY`)
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ # Standalone Gentle Shell (the usual case)
21
+ gentle-shell install npm:pi-opencode-gs
22
+
23
+ # Gentle Shell linked to an existing pi home
24
+ gentle-shell --link install npm:pi-opencode-gs
25
+
26
+ # Plain Pi
27
+ pi install npm:pi-opencode-gs
28
+
29
+ # From a checkout
30
+ gentle-shell install ./pi-opencode-gs
31
+ ```
32
+
33
+ Plain Pi has no listener for the event, so the extension is a no-op there. Reload (`/reload`) or restart, then
34
+ send one message with an `opencode-go/*` model and open `alt+u`.
35
+
36
+ ## What you see
37
+
38
+ The panel, one row per window, with the schedule on the provider line:
39
+
40
+ ```text
41
+ ✿ Subscriptions
42
+ opencode-go · peak 20m · updated just now
43
+ go 5h ▰▱▱▱▱▱▱▱ 12% · resets in 1h 42m
44
+ go week ▰▰▰▱▱▱▱▱ 37% · resets in 1d 20h
45
+ go month ▰▰▰▰▰▰▰▰ 96% · resets in 4d 0h
46
+ ```
47
+
48
+ Labels are lowercase, the way the panel writes everything else. The header text is plain — `peak 20m` while
49
+ peak, `off-peak 1hr 30m` otherwise — so red stays reserved for a window that is nearly spent. The percentages are
50
+ what the endpoint answered at the last refresh, never an average or an estimate, and `r` refreshes them.
51
+
52
+ While a peak window runs, one line sits under the editor:
53
+
54
+ ```text
55
+ peak · 2× deepseek · until 04:00
56
+ ```
57
+
58
+ `peak` is amber, the rest is dim, and `until` is the clock time the window ends in the machine's own timezone —
59
+ `until Mon 01:00` when that instant falls on another local date. The line appears and disappears on its own at the
60
+ boundary: the package arms one timer to the exact transition and never polls. Nothing is drawn in off-peak, and
61
+ nothing is drawn until a refresh proves the `opencode-go` provider is logged in — the session model is irrelevant.
62
+
63
+ ## The peak schedule
64
+
65
+ OpenCode Go publishes two peak windows for DeepSeek V4.1 Flash and V4 Pro, billed at twice the off-peak rate on
66
+ input, output and cached reads:
67
+
68
+ | Window | UTC | Days |
69
+ | --- | --- | --- |
70
+ | Peak 1 | 01:00-04:00 | Monday through Friday |
71
+ | Peak 2 | 06:00-10:00 | Monday through Friday |
72
+
73
+ Everything else is off-peak, including both weekend days in full. The published contract names weekends only;
74
+ no holiday calendar is assumed, so a national holiday is billed as a normal weekday.
75
+
76
+ ## Configuration
77
+
78
+ | Variable | Effect |
79
+ | --- | --- |
80
+ | `PI_OPENCODE_GO_USAGE_DEBUG` | Set to any value to log why a refresh produced no snapshot, by fixed code only |
81
+
82
+ ## How it works
83
+
84
+ One authenticated, read-only `GET` to a fixed route:
85
+
86
+ ```
87
+ https://opencode.ai/zen/go/v1/usage
88
+ ```
89
+
90
+ It answers with three windows, each a used percentage and a reset instant:
91
+
92
+ ```json
93
+ { "usage": {
94
+ "rolling": { "status": "ok", "percent": 12, "resetsAt": "2026-11-07T19:45:51.000Z" },
95
+ "weekly": { "status": "ok", "percent": 37, "resetsAt": "2026-11-09T00:00:00.000Z" },
96
+ "monthly": { "status": "ok", "percent": 96, "resetsAt": "2026-11-24T21:47:15.000Z" }
97
+ } }
98
+ ```
99
+
100
+ The read is all or nothing: a payload missing a window, or carrying a status other than `ok` or
101
+ `rate-limited`, yields no snapshot at all, so a depleted subscription never looks less used than it is. Gentle
102
+ Shell keeps the last valid snapshot through a failed refresh and shows its generic "fetch failed" note beside it.
103
+
104
+ The endpoint does not report how long its windows are, so `5h`, `week` and `month` are display durations (the
105
+ month is nominally 30 days). They only drive the label, the meter and the ordering; the percentage and the reset
106
+ time always come from the server.
107
+
108
+ ## Security
109
+
110
+ This package handles a credential, so its behaviour is deliberately narrow:
111
+
112
+ - **One destination, hardcoded.** The only URL literal in the source is the usage route. No configuration,
113
+ argument or environment variable can point the bearer anywhere else.
114
+ - **No redirects.** `redirect: "error"` plus a post-response check, so a `3xx` cannot replay the token.
115
+ - **Read-only.** Only `GET`. No disk writes, no settings or auth file changes, no shell, no `eval`.
116
+ - **The key never leaves the header.** It is not part of the returned snapshot, so it cannot reach the panel,
117
+ the sidebar, a log or a session file. Diagnostics report fixed codes only.
118
+ - **No runtime dependencies, no install scripts.** Installing the package executes nothing.
119
+ - **Defensive parsing.** The payload is foreign JSON: every field is checked, validated and rebuilt, never
120
+ merged into an existing object. Labels are rejected if they carry control characters.
121
+
122
+ ## Known limits
123
+
124
+ - **The subscription tier is not shown, because it cannot be read.** The usage route publishes percentages only,
125
+ and `go`, `go plus` and `go teams` live behind the console's session cookie. No tier is displayed rather than a
126
+ guessed one.
127
+ - **The peak line is not in the status bar.** That line belongs to the shell's own chrome, which exposes no slot
128
+ for a third party; the line therefore lives in the widget row under the editor, and Integrations is left alone.
129
+ - **The schedule rides the provider line, not a note under the rows.** The panel's per-provider note slot is
130
+ reserved for NaN's quota disclaimer and the fetch-failed note, so the schedule goes into `plan`, between the
131
+ provider name and `updated …`.
132
+ - **The panel schedule is as fresh as the last refresh**, exactly like the quota percentages. The widget line is
133
+ not: it appears and disappears from its own timer at the boundary.
134
+ - **The endpoint is outside OpenCode's published OpenAPI**, so its shape may change; an unknown shape is
135
+ rejected rather than displayed.
136
+ - **It reflects the subscription, not this session.** Usage you spend in the OpenCode CLI counts too.
137
+
138
+ ## Development
139
+
140
+ ```sh
141
+ npm run verify # tsc --noEmit, then node --test
142
+ ```
143
+
144
+ ## License
145
+
146
+ MIT — see [LICENSE](LICENSE).
147
+
148
+ This is a community package: it is not an official Gentle Shell or OpenCode release.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "pi-opencode-gs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenCode Go subscription usage for Gentle Shell, with DeepSeek peak/off-peak pricing awareness",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jonathan Soto",
8
+ "keywords": [
9
+ "pi-package",
10
+ "gentle-shell",
11
+ "opencode",
12
+ "opencode-go",
13
+ "usage",
14
+ "quota",
15
+ "deepseek",
16
+ "peak"
17
+ ],
18
+ "files": [
19
+ "src/**/*.ts",
20
+ "README.md",
21
+ "LICENSE",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "test": "node --experimental-strip-types --test test/*.test.ts",
31
+ "check": "tsc --noEmit",
32
+ "verify": "npm run check && npm test",
33
+ "prepublishOnly": "npm run verify"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.19.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "1.5.2",
43
+ "@types/node": "^22.19.0",
44
+ "typescript": "^5.9.3"
45
+ }
46
+ }
@@ -0,0 +1,139 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { PEAK_WIDGET_KEY, panelSchedule, peakWidgetLine } from "./labels.ts";
4
+ import { peakStateAt } from "./peak.ts";
5
+ import { PROVIDER, fetchOpenCodeGoUsage, type FailureReason, type ProviderUsage } from "./usage.ts";
6
+
7
+ /**
8
+ * Registers OpenCode Go with Gentle Shell and keeps the DeepSeek peak schedule
9
+ * in view.
10
+ *
11
+ * Two surfaces, two different contracts:
12
+ *
13
+ * - The usage source feeds the status bar's usage segment and the
14
+ * `/gentle:usage` panel. The panel prints `plan` between the provider name and
15
+ * its `updated …` note, so the peak schedule always rides there, peak or not.
16
+ * - The widget line above the editor is this extension's own row. It appears only
17
+ * while the opencode-go provider is usable AND the schedule is peak, whatever
18
+ * the session model is; every other combination clears it.
19
+ *
20
+ * Nothing here polls: Gentle Shell owns the refresh schedule for the source, and
21
+ * the only timer is one that re-arms itself at the next schedule boundary so the
22
+ * widget appears and disappears with the window.
23
+ */
24
+
25
+ const TRANSITION_MARGIN_MS = 1_000;
26
+ const PENDING_NOTE = "opencode-go · press r to fetch";
27
+
28
+ interface PeakContext {
29
+ ui?: { setWidget?(key: string, content: string[] | undefined): void };
30
+ }
31
+
32
+ export default function (pi: ExtensionAPI): void {
33
+ let timer: ReturnType<typeof setTimeout> | undefined;
34
+ let lastContext: PeakContext | undefined;
35
+ // Unknown until a fetch proves the provider is logged in, so nothing is drawn
36
+ // on a session whose key is missing.
37
+ let providerReady = false;
38
+
39
+ /**
40
+ * The source's plan field: the schedule, always, whatever the session model is
41
+ * and whether the window is peak or off-peak. The window labels are left exactly
42
+ * as read, so the panel's rows keep their plain `go 5h`, `go week`, `go month`
43
+ * names. The instant the shell passes is the fetch's own, so a refresh from the
44
+ * panel and the widget cannot disagree about which window is running.
45
+ */
46
+ const withPlan = (usage: ProviderUsage, now: number): ProviderUsage => ({
47
+ ...usage,
48
+ plan: panelSchedule(peakStateAt(now), now),
49
+ });
50
+
51
+ const clearTimer = (): void => {
52
+ if (timer === undefined) return;
53
+ clearTimeout(timer);
54
+ timer = undefined;
55
+ };
56
+
57
+ /**
58
+ * Writes the single peak widget line and clears it for every other combination,
59
+ * then re-arms the boundary timer. The session model is irrelevant: provider
60
+ * readiness and the peak window are the only gates.
61
+ */
62
+ const sync = (ctx: PeakContext | undefined, now = Date.now()): void => {
63
+ lastContext = ctx ?? lastContext;
64
+ const setWidget = ctx?.ui?.setWidget;
65
+ clearTimer();
66
+ if (!providerReady) {
67
+ setWidget?.(PEAK_WIDGET_KEY, undefined);
68
+ return;
69
+ }
70
+ const state = peakStateAt(now);
71
+ if (state.kind === "peak") {
72
+ setWidget?.(PEAK_WIDGET_KEY, [peakWidgetLine(state, now)]);
73
+ } else {
74
+ setWidget?.(PEAK_WIDGET_KEY, undefined);
75
+ }
76
+ // One timer to the exact boundary: the line appears and disappears when the
77
+ // schedule does, not on the next turn, and nothing here polls.
78
+ const delay = Math.max(TRANSITION_MARGIN_MS, state.until - now + TRANSITION_MARGIN_MS);
79
+ timer = setTimeout(() => sync(lastContext), delay);
80
+ // Never hold the process open for a schedule that changes in hours.
81
+ (timer as { unref?: () => void }).unref?.();
82
+ };
83
+
84
+ /**
85
+ * The readiness gate: only `missing-key` proves the provider is unusable.
86
+ * Timeouts, network errors and malformed responses say nothing about whether
87
+ * the key is present, so they leave readiness exactly as it was.
88
+ */
89
+ const markReady = (ready: boolean): void => {
90
+ if (ready === providerReady) return;
91
+ providerReady = ready;
92
+ // A readiness change must appear without waiting for the next turn.
93
+ sync(lastContext);
94
+ };
95
+
96
+ const sourceFetch = async (
97
+ apiKey: string | undefined,
98
+ fetchFn: typeof fetch,
99
+ now: number,
100
+ ): Promise<ProviderUsage | undefined> => {
101
+ const usage = await fetchOpenCodeGoUsage(apiKey, fetchFn, now, {
102
+ report: (reason: FailureReason) => {
103
+ if (reason === "missing-key") markReady(false);
104
+ },
105
+ });
106
+ if (usage) markReady(true);
107
+ return usage ? withPlan(usage, now) : undefined;
108
+ };
109
+
110
+ const register = (): void => {
111
+ // Re-registering replaces the source, so this is safe on every session start
112
+ // and keeps the extension's load order irrelevant.
113
+ pi.events.emit("gentle-pi:usage-source/v1", {
114
+ schema: "gentle-pi.usage-source/v1",
115
+ provider: PROVIDER,
116
+ pendingNote: PENDING_NOTE,
117
+ fetch: sourceFetch,
118
+ });
119
+ };
120
+
121
+ // Register at load and again on every session start: either load order works,
122
+ // and a session switch re-targets the same provider.
123
+ register();
124
+
125
+ pi.on("session_start", (_event, ctx) => {
126
+ register();
127
+ sync(ctx as PeakContext | undefined);
128
+ });
129
+
130
+ pi.on("model_select", (_event, ctx) => sync(ctx as PeakContext | undefined));
131
+
132
+ // A turn can cross a boundary; the next one re-reads the clock.
133
+ pi.on("turn_end", (_event, ctx) => sync(ctx as PeakContext | undefined));
134
+
135
+ pi.on("session_shutdown", (_event, ctx) => {
136
+ clearTimer();
137
+ (ctx as PeakContext | undefined)?.ui?.setWidget?.(PEAK_WIDGET_KEY, undefined);
138
+ });
139
+ }
package/src/labels.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The strings Gentle Shell paints for the OpenCode Go peak schedule.
3
+ *
4
+ * Two surfaces, two different freedoms:
5
+ *
6
+ * - The usage panel prints `ProviderUsage.plan` inside its own muted role and
7
+ * never sanitizes it, so the header is plain text: the schedule rides there
8
+ * unconditionally, and red stays reserved for near-limit quota inside the
9
+ * panel. The panel already prints ` · ` before that field, so the field itself
10
+ * carries no leading separator.
11
+ * - The widget line is this extension's own row above the editor, so it paints
12
+ * itself: `peak` is amber while DeepSeek is peak-priced, and every escape is
13
+ * closed with `\x1b[0m` so nothing leaks into neighbouring rows. Off-peak has
14
+ * no widget line at all.
15
+ *
16
+ * The panel's window labels stay untouched: the rows read `go 5h`, `go week`,
17
+ * `go month`, with the schedule only on the provider line.
18
+ *
19
+ * Nothing here reads a clock; every function takes the instant it must describe.
20
+ */
21
+
22
+ import { formatBoundaryClock, formatPeakState, type PeakState } from "./peak.ts";
23
+
24
+ /** The key this extension owns in pi's widget map. */
25
+ export const PEAK_WIDGET_KEY = "opencode-go-peak-widget";
26
+
27
+ const AMBER = "\x1b[33m";
28
+ const DIM = "\x1b[2m";
29
+ const RESET = "\x1b[0m";
30
+
31
+ /**
32
+ * The schedule the panel prints on its provider line, between the provider name
33
+ * and the `updated …` note: `off-peak 3hr 20m`.
34
+ *
35
+ * The panel inserts its own ` · ` before this field, so the field never starts
36
+ * with one: a leading separator here would render as a doubled ` · · `.
37
+ */
38
+ export function panelSchedule(state: PeakState, now: number): string {
39
+ return formatPeakState(state, now);
40
+ }
41
+
42
+ /**
43
+ * The single widget line, shown only while DeepSeek is peak-priced:
44
+ * `peak · 2× deepseek · until 04:00`. There is no off-peak variant; the caller
45
+ * clears the widget instead. The boundary is rendered in the machine's own
46
+ * timezone: `timeZone` is not passed here, only by unit tests that call
47
+ * `formatBoundaryClock` directly.
48
+ */
49
+ export function peakWidgetLine(state: PeakState, now: number): string {
50
+ const clock = formatBoundaryClock(state.until, now);
51
+ return `${AMBER}peak${DIM} · 2× deepseek · until ${clock}${RESET}`;
52
+ }
package/src/peak.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * OpenCode Go's DeepSeek pricing schedule.
3
+ *
4
+ * OpenCode Go publishes two peak windows for its two DeepSeek models, and bills
5
+ * them at double the off-peak rate. The windows are stated in UTC and only on
6
+ * weekdays; every other hour, and both weekend days in full, are off-peak.
7
+ *
8
+ * ```
9
+ * Peak 1 01:00-04:00 UTC Monday through Friday
10
+ * Peak 2 06:00-10:00 UTC Monday through Friday
11
+ * ```
12
+ *
13
+ * Nothing here reads a clock: every function takes the instant it must reason
14
+ * about, so the panel and the bar never disagree and the schedule is testable
15
+ * without freezing time.
16
+ */
17
+
18
+ /** The two published peak windows, in UTC hours, half-open (`start <= t < end`). */
19
+ const PEAK_WINDOWS = [
20
+ { startHour: 1, endHour: 4 },
21
+ { startHour: 6, endHour: 10 },
22
+ ] as const;
23
+
24
+ /**
25
+ * How far ahead to look for the next transition. A Friday evening can put the
26
+ * next window three days out; the extra margin keeps the search correct without
27
+ * ever approximating the answer.
28
+ */
29
+ const DAYS_AHEAD = 9;
30
+
31
+ const MS_PER_DAY = 86_400_000;
32
+ const MINUTE_MS = 60_000;
33
+
34
+ export type PeakKind = "peak" | "off-peak";
35
+
36
+ export interface PeakState {
37
+ kind: PeakKind;
38
+ /** The instant this state ends, in epoch milliseconds. */
39
+ until: number;
40
+ }
41
+
42
+ /**
43
+ * The peak-priced model ids, matched leniently on purpose: the published ids
44
+ * drift in punctuation (`v4.1-flash`, `v4-flash`, a `-high` reasoning suffix),
45
+ * but the pricing contract is the family, not the spelling.
46
+ */
47
+ const DEEPSEEK_PEAK_PRICING = /^deepseek-v4(?:\.1)?[-._]?(?:flash|pro)/i;
48
+
49
+ /**
50
+ * True when this model id belongs to the peak-priced DeepSeek family. The test is
51
+ * provider-independent on purpose: the schedule follows the model, so the same id
52
+ * served by any provider earns the same peak reading.
53
+ */
54
+ export function deepseekPeakPricing(modelId: string | undefined): boolean {
55
+ if (typeof modelId !== "string") return false;
56
+ return DEEPSEEK_PEAK_PRICING.test(modelId);
57
+ }
58
+
59
+ function isUtcWeekday(ts: number): boolean {
60
+ const day = new Date(ts).getUTCDay();
61
+ return day !== 0 && day !== 6;
62
+ }
63
+
64
+ /** The schedule predicate itself, evaluated at one instant. */
65
+ function isPeakInstant(ts: number): boolean {
66
+ if (!isUtcWeekday(ts)) return false;
67
+ const date = new Date(ts);
68
+ const minute = date.getUTCHours() * 60 + date.getUTCMinutes();
69
+ return PEAK_WINDOWS.some(({ startHour, endHour }) => minute >= startHour * 60 && minute < endHour * 60);
70
+ }
71
+
72
+ /**
73
+ * Every instant that could be a transition, before filtering. Weekends produce
74
+ * candidates too; they are dropped by the transition test in `nextTransition`,
75
+ * which is what keeps a Saturday 01:00 from being reported as a change.
76
+ */
77
+ function candidateInstants(now: number): number[] {
78
+ const date = new Date(now);
79
+ const year = date.getUTCFullYear();
80
+ const month = date.getUTCMonth();
81
+ const day = date.getUTCDate();
82
+ const instants: number[] = [];
83
+ for (let offset = 0; offset <= DAYS_AHEAD; offset++) {
84
+ for (const { startHour, endHour } of PEAK_WINDOWS) {
85
+ instants.push(Date.UTC(year, month, day + offset, startHour));
86
+ instants.push(Date.UTC(year, month, day + offset, endHour));
87
+ }
88
+ }
89
+ return instants;
90
+ }
91
+
92
+ /**
93
+ * The next instant at which the schedule actually flips. A candidate counts only
94
+ * when the predicate disagrees across it by one millisecond, so the answer is the
95
+ * end of the current state whether that state is a peak window or an off-peak gap
96
+ * that spans a weekend.
97
+ */
98
+ export function nextTransition(now: number): number {
99
+ let next = now + DAYS_AHEAD * MS_PER_DAY;
100
+ for (const instant of candidateInstants(now)) {
101
+ if (instant <= now || instant >= next) continue;
102
+ if (isPeakInstant(instant - 1) !== isPeakInstant(instant)) next = instant;
103
+ }
104
+ return next;
105
+ }
106
+
107
+ /** Whether `now` falls in a peak window, and when that answer changes. */
108
+ export function peakStateAt(now: number): PeakState {
109
+ if (!Number.isFinite(now)) return { kind: "off-peak", until: 0 };
110
+ return { kind: isPeakInstant(now) ? "peak" : "off-peak", until: nextTransition(now) };
111
+ }
112
+
113
+ /**
114
+ * A compact remaining time, in the same shape the shell uses for quota resets.
115
+ * Rounds up, so a live window never reads as already over.
116
+ */
117
+ export function formatCountdown(milliseconds: number): string {
118
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) return "now";
119
+ const minutes = Math.ceil(milliseconds / MINUTE_MS);
120
+ if (minutes < 60) return `${minutes}m`;
121
+ const hours = Math.floor(minutes / 60);
122
+ const restMinutes = minutes % 60;
123
+ if (hours < 24) return restMinutes === 0 ? `${hours}hr` : `${hours}hr ${restMinutes}m`;
124
+ const days = Math.floor(hours / 24);
125
+ const restHours = hours % 24;
126
+ return restHours === 0 ? `${days}d` : `${days}d ${restHours}hr`;
127
+ }
128
+
129
+ /**
130
+ * The next transition as a local clock: `04:00` on the same local date, or
131
+ * `Mon 01:00` when the boundary falls on another one. The instant the schedule
132
+ * actually ends is printed, never a countdown. `timeZone` is optional so a test
133
+ * can pin the locale without freezing the machine's clock.
134
+ */
135
+ export function formatBoundaryClock(until: number, now: number, timeZone?: string): string {
136
+ if (!Number.isFinite(until) || !Number.isFinite(now)) return "now";
137
+ const boundary = new Date(until);
138
+ const clock = new Intl.DateTimeFormat("en-US", {
139
+ hour: "2-digit",
140
+ minute: "2-digit",
141
+ hour12: false,
142
+ timeZone,
143
+ }).format(boundary);
144
+ const localDate = (instant: Date): string =>
145
+ new Intl.DateTimeFormat("en-US", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(
146
+ instant,
147
+ );
148
+ if (localDate(boundary) === localDate(new Date(now))) return clock;
149
+ const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone }).format(boundary);
150
+ return `${weekday} ${clock}`;
151
+ }
152
+
153
+ /**
154
+ * `peak 20m` or `off-peak 3hr 20m`: the state, then the time left in it, with no
155
+ * word between and no colour.
156
+ */
157
+ export function formatPeakState(state: PeakState, now: number): string {
158
+ return `${state.kind} ${formatCountdown(state.until - now)}`;
159
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * OpenCode Go subscription usage, read from the read-only endpoint the OpenCode
3
+ * console itself uses: `GET /zen/go/v1/usage`, authenticated with the same API
4
+ * key Pi already holds for the `opencode-go` provider.
5
+ *
6
+ * The endpoint answers three windows and reports each as a used percentage plus
7
+ * a reset instant. It does not publish how long a window is, so the spans below
8
+ * are display durations: they drive the label, the meter width and the ordering
9
+ * the shell draws, never the percentage or the reset time.
10
+ */
11
+
12
+ export const PROVIDER = "opencode-go";
13
+
14
+ /** The only URL this package can reach. There is no configuration for it. */
15
+ export const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
16
+
17
+ /** Matches Gentle Shell's own default provider window. */
18
+ export const REQUEST_TIMEOUT_MS = 10_000;
19
+
20
+ /** Opt-in diagnostics: fixed reason codes only, never a key or an error text. */
21
+ export const DEBUG_ENV = "PI_OPENCODE_GO_USAGE_DEBUG";
22
+
23
+ /** The row name: the panel prints `<limit name> <window label>` per line, lowercased
24
+ * to match the panel's own lowercase voice. */
25
+ const LIMIT_NAME = "go";
26
+
27
+ const HOUR = 3600;
28
+ const DAY = 24 * HOUR;
29
+
30
+ /**
31
+ * The endpoint's three windows. The month is approximated at 30 days because no
32
+ * length is published for it; a calendar month is not always 30 days.
33
+ */
34
+ const WINDOWS = [
35
+ { key: "rolling", label: "5h", seconds: 5 * HOUR },
36
+ { key: "weekly", label: "week", seconds: 7 * DAY },
37
+ { key: "monthly", label: "month", seconds: 30 * DAY },
38
+ ] as const;
39
+
40
+ /**
41
+ * The two statuses the endpoint is known to answer with. Anything else is
42
+ * treated as a malformed payload rather than guessed at, because the shell
43
+ * keeps its last good snapshot and a wrong number is worse than none.
44
+ */
45
+ const KNOWN_STATUSES: ReadonlySet<string> = new Set(["ok", "rate-limited"]);
46
+
47
+ /** A full ISO 8601 instant, with an explicit offset or `Z`. */
48
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
49
+
50
+ /**
51
+ * Mirrors the `UsageWindow` Gentle Shell validates a registered source against.
52
+ * Declared here because that type lives in a private module of gentle-pi, so a
53
+ * contract drift shows up as the shell discarding this source rather than as a
54
+ * failed import in this package.
55
+ */
56
+ export interface UsageWindow {
57
+ label: string;
58
+ usedPercent: number;
59
+ windowSeconds: number;
60
+ resetAt: number | null;
61
+ }
62
+
63
+ /** Mirrors `UsageLimit`. */
64
+ export interface UsageLimit {
65
+ name: string;
66
+ windows: UsageWindow[];
67
+ limitReached: boolean;
68
+ scope?: "provider";
69
+ }
70
+
71
+ /**
72
+ * Mirrors the `ProviderUsage` shape the same contract validates. The `plan` field
73
+ * stays because the shell's contract has it; this source never fills it, since the
74
+ * usage route publishes percentages only.
75
+ */
76
+ export interface ProviderUsage {
77
+ provider: string;
78
+ plan: string | undefined;
79
+ limits: UsageLimit[];
80
+ fetchedAt: number;
81
+ }
82
+
83
+ /** Why a refresh produced nothing. A fixed code, never a message. */
84
+ export type FailureReason =
85
+ | "missing-key"
86
+ | "timeout"
87
+ | "network"
88
+ | "redirect"
89
+ | `http-${number}`
90
+ | "invalid-json"
91
+ | "unexpected-shape"
92
+ | `unexpected-shape:${string}`;
93
+
94
+ export interface FetchOptions {
95
+ timeoutMs?: number;
96
+ /** Diagnostics sink; receives a fixed reason code only. */
97
+ report?: (reason: FailureReason) => void;
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return value !== null && typeof value === "object" && !Array.isArray(value);
102
+ }
103
+
104
+ /**
105
+ * One window, or `undefined` when anything about it is untrustworthy. Fields are
106
+ * read and rebuilt one by one, never merged, so a hostile payload cannot reach
107
+ * this object.
108
+ */
109
+ function parseWindow(value: unknown, label: string, windowSeconds: number): UsageWindow | undefined {
110
+ if (!isRecord(value)) return undefined;
111
+ if (typeof value.status !== "string" || !KNOWN_STATUSES.has(value.status)) return undefined;
112
+ const percent = value.percent;
113
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) return undefined;
114
+ if (typeof value.resetsAt !== "string" || !ISO_INSTANT.test(value.resetsAt)) return undefined;
115
+ const resetAt = Date.parse(value.resetsAt);
116
+ if (!Number.isFinite(resetAt)) return undefined;
117
+ return { label, usedPercent: percent, windowSeconds, resetAt };
118
+ }
119
+
120
+ function readUsage(payload: unknown): Record<string, unknown> | undefined {
121
+ if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
122
+ return payload.usage;
123
+ }
124
+
125
+ /**
126
+ * Converts one `/zen/go/v1/usage` payload into the windows Gentle Shell draws,
127
+ * or `undefined` when the payload is not exactly the expected shape.
128
+ *
129
+ * All or nothing on purpose: a snapshot missing one window would make a depleted
130
+ * subscription look less used than it is, and the shell already keeps the last
131
+ * valid snapshot through a failed read.
132
+ */
133
+ export function parseOpenCodeGoUsage(payload: unknown, now: number): ProviderUsage | undefined {
134
+ if (!Number.isFinite(now)) return undefined;
135
+ const usage = readUsage(payload);
136
+ if (!usage) return undefined;
137
+ const windows: UsageWindow[] = [];
138
+ for (const { key, label, seconds } of WINDOWS) {
139
+ const window = parseWindow(usage[key], label, seconds);
140
+ if (!window) return undefined;
141
+ windows.push(window);
142
+ }
143
+ const limitReached =
144
+ windows.some((window) => window.usedPercent >= 100) ||
145
+ WINDOWS.some(({ key }) => (usage[key] as { status?: unknown }).status === "rate-limited");
146
+ return {
147
+ provider: PROVIDER,
148
+ plan: undefined,
149
+ limits: [{ name: LIMIT_NAME, windows, limitReached, scope: "provider" }],
150
+ fetchedAt: now,
151
+ };
152
+ }
153
+
154
+ function defaultReport(reason: FailureReason): void {
155
+ if (process.env[DEBUG_ENV]) console.error(`[pi-opencode-gs] ${reason}`);
156
+ }
157
+
158
+ interface RequestResult {
159
+ usage?: ProviderUsage;
160
+ reason?: FailureReason;
161
+ }
162
+
163
+ async function requestUsage(
164
+ apiKey: string | undefined,
165
+ fetchFn: typeof fetch,
166
+ now: number,
167
+ timeoutMs: number,
168
+ ): Promise<RequestResult> {
169
+ if (typeof apiKey !== "string" || apiKey.length === 0) return { reason: "missing-key" };
170
+ const signal = AbortSignal.timeout(timeoutMs);
171
+ let response: Response;
172
+ try {
173
+ response = await fetchFn(USAGE_URL, {
174
+ method: "GET",
175
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
176
+ cache: "no-store",
177
+ // A redirect could replay the bearer to another origin.
178
+ redirect: "error",
179
+ signal,
180
+ });
181
+ } catch {
182
+ return { reason: signal.aborted ? "timeout" : "network" };
183
+ }
184
+ // Defense in depth: a fetch implementation that follows redirects anyway must
185
+ // not deliver a body read from anywhere but this route.
186
+ if (response.redirected || (response.url && response.url !== USAGE_URL)) return { reason: "redirect" };
187
+ if (!response.ok) return { reason: `http-${response.status}` };
188
+ let payload: unknown;
189
+ try {
190
+ payload = await response.json();
191
+ } catch {
192
+ return { reason: signal.aborted ? "timeout" : "invalid-json" };
193
+ }
194
+ if (!isRecord(payload) || !isRecord(payload.usage)) return { reason: "unexpected-shape" };
195
+ const usage = parseOpenCodeGoUsage(payload, now);
196
+ if (!usage) {
197
+ const missing = WINDOWS.find(({ key }) => !isRecord((payload.usage as Record<string, unknown>)[key]));
198
+ return { reason: missing ? `unexpected-shape:${missing.key}` : "unexpected-shape" };
199
+ }
200
+ return { usage };
201
+ }
202
+
203
+ /**
204
+ * Fetches one usage snapshot, or `undefined` when there is nothing trustworthy to
205
+ * show. The key is used in the `Authorization` header and returned nowhere else;
206
+ * failures resolve to `undefined` and report only a fixed reason code.
207
+ */
208
+ export async function fetchOpenCodeGoUsage(
209
+ apiKey: string | undefined,
210
+ fetchFn: typeof fetch,
211
+ now: number,
212
+ options: FetchOptions = {},
213
+ ): Promise<ProviderUsage | undefined> {
214
+ const { timeoutMs = REQUEST_TIMEOUT_MS, report = defaultReport } = options;
215
+ const { usage, reason } = await requestUsage(apiKey, fetchFn, now, timeoutMs);
216
+ if (reason) {
217
+ try {
218
+ report(reason);
219
+ } catch {
220
+ // Diagnostics never break a refresh.
221
+ }
222
+ }
223
+ return usage;
224
+ }