oc-go-usage-display 1.0.2 → 2.0.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.
Files changed (41) hide show
  1. package/README.md +249 -68
  2. package/bin/lib.js +433 -29
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +38 -0
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +33 -42
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +116 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +568 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -9
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +258 -278
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
  18. package/dist/plugins/oc-go-usage-display.ts +678 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1018 -0
  20. package/dist/shared.d.ts +96 -0
  21. package/dist/shared.d.ts.map +1 -0
  22. package/dist/shared.js +575 -0
  23. package/dist/shared.js.map +1 -0
  24. package/dist/tui-shared.d.ts +109 -0
  25. package/dist/tui-shared.d.ts.map +1 -0
  26. package/dist/tui-shared.js +372 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts.map +1 -1
  29. package/dist/tui.js +226 -377
  30. package/dist/tui.js.map +1 -1
  31. package/dist/tui.kilo.d.ts +8 -0
  32. package/dist/tui.kilo.d.ts.map +1 -0
  33. package/dist/tui.kilo.js +502 -0
  34. package/dist/tui.kilo.js.map +1 -0
  35. package/package.json +47 -13
  36. package/src/helpers.ts +733 -0
  37. package/src/index.ts +331 -304
  38. package/src/shared.ts +696 -0
  39. package/src/tui-shared.tsx +705 -0
  40. package/src/tui.kilo.tsx +816 -0
  41. package/src/tui.tsx +356 -465
package/src/index.ts CHANGED
@@ -12,28 +12,57 @@
12
12
  // only (one tool). No transcript injection, no TUI
13
13
  // footer slot (unstable API).
14
14
  //
15
- // Auth (first match wins, secrets are never logged):
15
+ // Auth (first match wins, secrets are never logged), host-specific stores:
16
16
  // 1. OPENCODE_GO_MOCK=1 -> deterministic mock snapshot (for testing)
17
17
  // 2. OPENCODE_GO_API_KEY -> GET https://opencode.ai/zen/go/v1/usage
18
18
  // (Authorization: Bearer <key>)
19
- // 3. Provider auth.json key -> same Bearer path as (2), no paste needed:
20
- // $XDG_DATA_HOME/opencode/auth.json (or
21
- // ~/.local/share/opencode/auth.json), fallback
22
- // ~/.config/opencode/auth.json (legacy).
23
- // Uses `opencode-go` key, else `opencode` key.
19
+ // 3. Provider auth.json key -> same Bearer path as (2), no paste needed.
20
+ // opencode bundle: $XDG_DATA_HOME/opencode/
21
+ // auth.json (~/.local/share/opencode/auth.json),
22
+ // fallback $XDG_CONFIG_HOME/opencode/auth.json
23
+ // (~/.config/opencode/auth.json); uses the
24
+ // `opencode-go` key, else `opencode`.
25
+ // Kilo bundle (oc-go-usage-display.kilo.ts):
26
+ // the same files under the `kilo` roots, so
27
+ // a Kilo-only login works.
24
28
  // 4. OPENCODE_GO_WORKSPACE_ID + OPENCODE_GO_AUTH_COOKIE (env), or
25
- // ~/.config/opencode/oc-go-usage-display.json
29
+ // <host config dir>/oc-go-usage-display.json
26
30
  // ({ "workspaceId": "...", "authCookie": "..." })
27
31
  // -> GET https://opencode.ai/workspace/{id}/go
28
32
  // (scraped rolling/weekly/monthly)
29
33
  // 5. none -> unavailable snapshot (literal-only error)
30
- // Snapshots are cached 60s in memory + on disk.
34
+ // Snapshots are cached 60s in memory + on disk (host config dir).
31
35
 
32
- import type { Plugin } from "@opencode-ai/plugin";
36
+ import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin";
33
37
  import { tool } from "@opencode-ai/plugin";
34
38
  import * as fs from "node:fs";
35
- import * as os from "node:os";
36
39
  import * as path from "node:path";
40
+ import {
41
+ formatServerLine,
42
+ hasMalformedAuthCookie,
43
+ isMalformedAuthCookie,
44
+ readFileConfig,
45
+ resolveAllowedRedirect,
46
+ } from "./helpers.js";
47
+ import type { FileConfig } from "./helpers.js";
48
+ import {
49
+ errorMessage,
50
+ extractSnapshotFromApiPayload,
51
+ extractWindow,
52
+ hostEnv,
53
+ hostEnvName,
54
+ isLimitedStatus,
55
+ isRecord,
56
+ mockSnapshot,
57
+ readAuthJsonApiKey,
58
+ resolveHostRoots,
59
+ resolveUsageHost,
60
+ safeJoinPath,
61
+ toFiniteNumber,
62
+ toNonEmptyString,
63
+ unavailableSnapshot,
64
+ } from "./shared.js";
65
+ import type { UsageHost, UsageSnapshot, UsageWindow } from "./shared.js";
37
66
 
38
67
  // ---------------------------------------------------------------------------
39
68
  // Constants
@@ -43,159 +72,55 @@ const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
43
72
  const CACHE_TTL_MS = 60_000;
44
73
  const FETCH_TIMEOUT_MS = 10_000;
45
74
 
46
- const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
47
- const FILE_CONFIG_PATH = path.join(CONFIG_DIR, "oc-go-usage-display.json");
48
- const DISK_CACHE_PATH = path.join(CONFIG_DIR, "oc-go-usage-display-cache.json");
75
+ // The three usage windows every snapshot carries, in display order.
76
+ type WindowKey = "rolling" | "weekly" | "monthly";
49
77
 
50
- function dataShareAuthPath(): string {
51
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
52
- if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
53
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
54
- }
78
+ const WINDOW_KEYS: readonly WindowKey[] = ["rolling", "weekly", "monthly"];
55
79
 
56
- function authJsonPaths(): string[] {
57
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
58
- }
80
+ // Fetch redirect statuses (the cookie path handles them manually).
81
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
82
+
83
+ // Host this bundle serves: "kilo" only when baked in by the build (see
84
+ // scripts/build-plugins.mjs); every other case is opencode. Credentials, file
85
+ // config and the disk cache always resolve from this host's own roots.
86
+ const HOST: UsageHost = resolveUsageHost();
87
+
88
+ const DISK_CACHE_PATH = safeJoinPath(
89
+ resolveHostRoots(HOST).configDir,
90
+ "oc-go-usage-display-cache.json",
91
+ );
59
92
 
60
93
  // ---------------------------------------------------------------------------
61
94
  // Trusted types (parsed at the boundary, trusted internally)
62
95
  // ---------------------------------------------------------------------------
63
96
 
64
- type UsageWindow = {
65
- percent: number;
66
- resetInSec: number | null;
67
- status: string | null;
68
- resetText: string | null;
69
- };
70
-
71
- type UsageSnapshot = {
72
- rolling: UsageWindow | null;
73
- weekly: UsageWindow | null;
74
- monthly: UsageWindow | null;
75
- source: "api" | "scrape" | "mock" | "unavailable";
76
- fetchedAt: number;
77
- apiUnavailable?: boolean;
78
- apiError?: string;
79
- };
80
-
81
97
  type Credentials =
82
98
  | { kind: "apiKey"; apiKey: string }
83
99
  | { kind: "cookie"; workspaceId: string; authCookie: string }
84
100
  | { kind: "mock" }
85
101
  | { kind: "none" };
86
102
 
87
- // ---------------------------------------------------------------------------
88
- // Small pure helpers
89
- // ---------------------------------------------------------------------------
90
-
91
- function isRecord(value: unknown): value is Record<string, unknown> {
92
- return typeof value === "object" && value !== null && !Array.isArray(value);
93
- }
94
-
95
- function toFiniteNumber(value: unknown): number | null {
96
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
97
- return value;
98
- }
99
-
100
- function toNonEmptyString(value: unknown): string | null {
101
- if (typeof value !== "string") return null;
102
- const trimmed = value.trim();
103
- return trimmed.length > 0 ? trimmed : null;
104
- }
105
-
106
- function formatResetDuration(totalSec: number | null): string | null {
107
- if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
108
- const sec = Math.floor(totalSec);
109
- const hours = Math.floor(sec / 3600);
110
- const minutes = Math.floor((sec % 3600) / 60);
111
- if (hours > 0) return `${hours}h${minutes}m`;
112
- if (minutes > 0) return `${minutes}m`;
113
- return `${sec}s`;
114
- }
115
-
116
- function formatWindow(window: UsageWindow | null): string {
117
- if (!window) return "n/a";
118
- return `${window.percent}%`;
119
- }
120
-
121
- function formatCompactLine(snapshot: UsageSnapshot): string {
122
- if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
123
- const reason = snapshot.apiError ?? "unknown error";
124
- return `Go n/a (${reason})`;
125
- }
126
- const rollingReset =
127
- formatResetDuration(snapshot.rolling?.resetInSec ?? null) ??
128
- snapshot.rolling?.resetText ??
129
- null;
130
- const rollingText =
131
- snapshot.rolling === null
132
- ? "5h n/a"
133
- : `5h ${snapshot.rolling.percent}%${rollingReset ? ` (reset ${rollingReset})` : ""}`;
134
- return `Go ${rollingText} | 7d ${formatWindow(snapshot.weekly)} | 30d ${formatWindow(snapshot.monthly)}`;
135
- }
136
-
137
103
  // ---------------------------------------------------------------------------
138
104
  // Credentials (boundary: env + optional JSON file; never logged)
139
105
  // ---------------------------------------------------------------------------
140
106
 
141
- function readFileConfig(): { workspaceId: string | null; authCookie: string | null } {
142
- let raw: string;
143
- try {
144
- raw = fs.readFileSync(FILE_CONFIG_PATH, "utf8");
145
- } catch {
146
- return { workspaceId: null, authCookie: null };
147
- }
148
- let parsed: unknown;
149
- try {
150
- parsed = JSON.parse(raw);
151
- } catch {
152
- return { workspaceId: null, authCookie: null };
153
- }
154
- if (!isRecord(parsed)) return { workspaceId: null, authCookie: null };
155
- return {
156
- workspaceId: toNonEmptyString(parsed.workspaceId),
157
- authCookie: toNonEmptyString(parsed.authCookie),
158
- };
159
- }
160
-
161
- function readAuthJsonApiKey(): string | null {
162
- for (const authPath of authJsonPaths()) {
163
- let raw: string;
164
- try {
165
- raw = fs.readFileSync(authPath, "utf8");
166
- } catch {
167
- continue;
168
- }
169
- let parsed: unknown;
170
- try {
171
- parsed = JSON.parse(raw);
172
- } catch {
173
- continue;
174
- }
175
- if (!isRecord(parsed)) continue;
176
- const goEntry = parsed["opencode-go"];
177
- const fallbackEntry = parsed["opencode"];
178
- const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
179
- if (goKey) return goKey;
180
- const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
181
- if (fallbackKey) return fallbackKey;
182
- }
183
- return null;
184
- }
107
+ function resolveCredentials(fileConfig: FileConfig = readFileConfig(HOST)): Credentials {
108
+ if (hostEnv(HOST, "MOCK") === "1") return { kind: "mock" };
185
109
 
186
- function resolveCredentials(): Credentials {
187
- if (process.env.OPENCODE_GO_MOCK === "1") return { kind: "mock" };
188
-
189
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY);
110
+ const apiKey = toNonEmptyString(hostEnv(HOST, "API_KEY"));
190
111
  if (apiKey) return { kind: "apiKey", apiKey };
191
112
 
192
- const authJsonKey = readAuthJsonApiKey();
113
+ const authJsonKey = readAuthJsonApiKey(HOST);
193
114
  if (authJsonKey) return { kind: "apiKey", apiKey: authJsonKey };
194
115
 
195
- const fileConfig = readFileConfig();
196
- const workspaceId = toNonEmptyString(process.env.OPENCODE_GO_WORKSPACE_ID) ?? fileConfig.workspaceId;
197
- const authCookie = toNonEmptyString(process.env.OPENCODE_GO_AUTH_COOKIE) ?? fileConfig.authCookie;
198
- if (workspaceId && authCookie) return { kind: "cookie", workspaceId, authCookie };
116
+ const workspaceId = toNonEmptyString(hostEnv(HOST, "WORKSPACE_ID")) ?? fileConfig.workspaceId;
117
+ const authCookie = toNonEmptyString(hostEnv(HOST, "AUTH_COOKIE")) ?? fileConfig.authCookie;
118
+ if (workspaceId && authCookie) {
119
+ // Reject header-injection / cookie-jar confusion payloads. The cookie
120
+ // value is never logged; malformed values fall through to "none".
121
+ if (isMalformedAuthCookie(authCookie)) return { kind: "none" };
122
+ return { kind: "cookie", workspaceId, authCookie };
123
+ }
199
124
 
200
125
  return { kind: "none" };
201
126
  }
@@ -207,7 +132,7 @@ function resolveCredentials(): Credentials {
207
132
  let memoryCache: { at: number; snapshot: UsageSnapshot } | null = null;
208
133
 
209
134
  function isFresh(at: number, now: number): boolean {
210
- return now - at < CACHE_TTL_MS;
135
+ return at <= now && now - at < CACHE_TTL_MS;
211
136
  }
212
137
 
213
138
  function readDiskCache(now: number): UsageSnapshot | null {
@@ -229,7 +154,23 @@ function readDiskCache(now: number): UsageSnapshot | null {
229
154
  if (!isRecord(parsed.snapshot)) return null;
230
155
  const snapshot = parsed.snapshot;
231
156
  if (!("rolling" in snapshot && "weekly" in snapshot && "monthly" in snapshot)) return null;
232
- return snapshot as UsageSnapshot;
157
+ // Re-parse the stored windows instead of casting the raw entry: a cache file
158
+ // written by an older build predates newer `UsageWindow` fields, so the entry
159
+ // can be missing them. A window that no longer parses drops the whole entry
160
+ // so the next fetch repopulates the cache.
161
+ const windows: Record<WindowKey, UsageWindow | null> = {
162
+ rolling: null,
163
+ weekly: null,
164
+ monthly: null,
165
+ };
166
+ for (const key of WINDOW_KEYS) {
167
+ const cachedWindow = snapshot[key];
168
+ if (cachedWindow === null) continue;
169
+ const window = extractWindow(cachedWindow);
170
+ if (window === null) return null;
171
+ windows[key] = window;
172
+ }
173
+ return { ...snapshot, ...windows } as UsageSnapshot;
233
174
  }
234
175
 
235
176
  function writeDiskCache(snapshot: UsageSnapshot): void {
@@ -249,105 +190,76 @@ function writeDiskCache(snapshot: UsageSnapshot): void {
249
190
  // Fetch helpers
250
191
  // ---------------------------------------------------------------------------
251
192
 
252
- function unavailableSnapshot(error: string, source: UsageSnapshot["source"] = "unavailable"): UsageSnapshot {
253
- return {
254
- rolling: null,
255
- weekly: null,
256
- monthly: null,
257
- source,
258
- fetchedAt: Date.now(),
259
- apiUnavailable: true,
260
- apiError: error,
261
- };
262
- }
263
-
264
- async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
193
+ // Run the whole exchange (headers and body) under one abort timer: clearing
194
+ // it as soon as the response headers arrive would leave a server that stalls
195
+ // mid-body hanging past FETCH_TIMEOUT_MS. `read` consumes the body while the
196
+ // timer is armed; the signal lets callers tell an aborted read from a payload
197
+ // problem. The timer is cleared on every path (body read, redirect handling,
198
+ // fetch failure, abort).
199
+ async function fetchWithTimeout<T>(
200
+ url: string,
201
+ init: RequestInit,
202
+ read: (response: Response, signal: AbortSignal) => Promise<T>,
203
+ ): Promise<T> {
265
204
  const controller = new AbortController();
266
205
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
267
206
  try {
268
- return await fetch(url, { ...init, signal: controller.signal });
207
+ const response = await fetch(url, { ...init, signal: controller.signal });
208
+ return await read(response, controller.signal);
269
209
  } finally {
270
210
  clearTimeout(timer);
271
211
  }
272
212
  }
273
213
 
274
- // --- API-key path: tolerant JSON parsing (shape may evolve) ---
275
-
276
- function extractWindow(candidate: unknown): UsageWindow | null {
277
- if (!isRecord(candidate)) return null;
278
- const percent = toFiniteNumber(
279
- candidate.percent ??
280
- candidate.usagePercent ??
281
- candidate.usedPercent ??
282
- candidate.value ??
283
- candidate.usage,
284
- );
285
- if (percent === null) return null;
286
- const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
287
- const status = toNonEmptyString(candidate.status);
288
- const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
289
- return { percent: Math.round(percent), resetInSec, status, resetText };
290
- }
291
-
292
- function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
293
- if (!isRecord(payload)) return null;
294
- const containers: unknown[] = [payload];
295
- for (const key of ["usage", "data", "go"]) {
296
- if (isRecord(payload[key])) containers.push(payload[key]);
297
- }
298
- for (const container of containers) {
299
- if (!isRecord(container)) continue;
300
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
301
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
302
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
303
- if (rolling || weekly || monthly) {
304
- return {
305
- rolling,
306
- weekly,
307
- monthly,
308
- source: "api",
309
- fetchedAt: Date.now(),
310
- };
311
- }
312
- }
313
- return null;
314
- }
214
+ // --- API-key path: tolerant JSON parsing lives in `./shared.js` (shape may
215
+ // evolve); scrape helpers below stay server-local. ---
315
216
 
316
217
  async function fetchViaApiKey(apiKey: string): Promise<UsageSnapshot> {
317
- let response: Response;
318
218
  try {
319
- response = await fetchWithTimeout(API_USAGE_URL, {
320
- headers: {
321
- Authorization: `Bearer ${apiKey}`,
322
- Accept: "application/json",
219
+ return await fetchWithTimeout(
220
+ API_USAGE_URL,
221
+ {
222
+ headers: {
223
+ Authorization: `Bearer ${apiKey}`,
224
+ Accept: "application/json",
225
+ },
323
226
  },
324
- });
227
+ async (response, signal) => {
228
+ if (response.status === 401 || response.status === 403) {
229
+ return unavailableSnapshot("API key rejected (401/403)");
230
+ }
231
+ if (!response.ok) {
232
+ return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
233
+ }
234
+ let payload: unknown;
235
+ try {
236
+ payload = await response.json();
237
+ } catch {
238
+ // A body read cut off by the timeout is a request failure; anything
239
+ // else is a payload the API-shape parser cannot use.
240
+ return unavailableSnapshot(
241
+ signal.aborted ? "request failed" : "unexpected API response shape",
242
+ );
243
+ }
244
+ return extractSnapshotFromApiPayload(payload) ?? unavailableSnapshot("unexpected API response shape");
245
+ },
246
+ );
325
247
  } catch {
326
248
  return unavailableSnapshot("request failed");
327
249
  }
328
- if (response.status === 401 || response.status === 403) {
329
- return unavailableSnapshot("API key rejected (401/403)");
330
- }
331
- if (!response.ok) {
332
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
333
- }
334
- let payload: unknown;
335
- try {
336
- payload = await response.json();
337
- } catch {
338
- return unavailableSnapshot("unexpected API response shape");
339
- }
340
- return extractSnapshotFromApiPayload(payload) ?? unavailableSnapshot("unexpected API response shape");
341
250
  }
342
251
 
343
252
  // --- Cookie path: workspace page scrape (ported from opencode-go-hud) ---
344
253
 
345
- const USAGE_KEYS: Record<"rolling" | "weekly" | "monthly", string> = {
254
+ const USAGE_KEYS: Record<WindowKey, string> = {
346
255
  rolling: "rollingUsage",
347
256
  weekly: "weeklyUsage",
348
257
  monthly: "monthlyUsage",
349
258
  };
350
- const DOM_ORDER: Array<"rolling" | "weekly" | "monthly"> = ["rolling", "weekly", "monthly"];
259
+ // Markup order of the `usage-item` slots. Spelled out rather than derived from
260
+ // `WINDOW_KEYS`: it is a property of the scraped page, not of the display, and
261
+ // must not follow a display-order change.
262
+ const DOM_ORDER: WindowKey[] = ["rolling", "weekly", "monthly"];
351
263
  const LOGIN_TITLE_MARKER = "<title>OpenAuth</title>";
352
264
 
353
265
  function isLoginPage(finalUrl: string, html: string): boolean {
@@ -392,26 +304,29 @@ function extractUsageBlock(html: string, key: string): string | null {
392
304
 
393
305
  function parseIntField(block: string, field: string): number | null {
394
306
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*(-?\\d+)`).exec(block);
395
- return match ? Number.parseInt(match[1], 10) : null;
307
+ const digits = match?.[1];
308
+ return digits === undefined ? null : Number.parseInt(digits, 10);
396
309
  }
397
310
 
398
311
  function parseStrField(block: string, field: string): string | null {
399
312
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*"([^"]*)"`).exec(block);
400
- return match ? match[1] : null;
313
+ return match?.[1] ?? null;
401
314
  }
402
315
 
403
- function parseInlineUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
404
- const result: Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> = {};
316
+ function parseInlineUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
317
+ const result: Partial<Record<WindowKey, UsageWindow>> = {};
405
318
  for (const [name, key] of Object.entries(USAGE_KEYS)) {
406
- const window = name as "rolling" | "weekly" | "monthly";
319
+ const window = name as WindowKey;
407
320
  const block = extractUsageBlock(html, key);
408
321
  if (!block) continue;
409
322
  const percent = parseIntField(block, "usagePercent");
410
323
  if (percent === null) continue;
324
+ const status = parseStrField(block, "status");
411
325
  result[window] = {
412
326
  percent,
327
+ status,
328
+ limited: isLimitedStatus(status),
413
329
  resetInSec: parseIntField(block, "resetInSec"),
414
- status: parseStrField(block, "status"),
415
330
  resetText: null,
416
331
  };
417
332
  }
@@ -426,85 +341,112 @@ function cleanResetText(raw: string): string | null {
426
341
  return cleaned || null;
427
342
  }
428
343
 
429
- function parseDomUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
430
- const result: Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> = {};
344
+ function parseDomUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
345
+ const result: Partial<Record<WindowKey, UsageWindow>> = {};
431
346
  const itemStarts: number[] = [];
432
347
  for (const match of html.matchAll(/data-slot="usage-item"/g)) {
433
348
  if (match.index !== undefined) itemStarts.push(match.index);
434
349
  }
435
350
  for (let idx = 0; idx < DOM_ORDER.length; idx++) {
436
- if (idx >= itemStarts.length) break;
437
- const segment = html.slice(itemStarts[idx], idx + 1 < itemStarts.length ? itemStarts[idx + 1] : itemStarts[idx] + 800);
351
+ const window = DOM_ORDER[idx];
352
+ const start = itemStarts[idx];
353
+ if (window === undefined || start === undefined) break;
354
+ const nextStart = itemStarts[idx + 1];
355
+ const segment = html.slice(start, nextStart !== undefined ? nextStart : start + 800);
438
356
  const valueMatch =
439
357
  /data-slot="usage-value">\s*(?:<!--[\s\S]*?-->)?\s*(\d+)/s.exec(segment) ??
440
358
  /width:\s*(\d+)%/.exec(segment);
441
- if (!valueMatch) continue;
359
+ const percent = valueMatch?.[1];
360
+ if (percent === undefined) continue;
442
361
  const resetMatch = /data-slot="reset-time">\s*([\s\S]*?)<\/span>/.exec(segment);
443
- result[DOM_ORDER[idx]] = {
444
- percent: Number.parseInt(valueMatch[1], 10),
445
- resetInSec: null,
362
+ const resetText = resetMatch?.[1];
363
+ result[window] = {
364
+ percent: Number.parseInt(percent, 10),
446
365
  status: null,
447
- resetText: resetMatch ? cleanResetText(resetMatch[1]) : null,
366
+ limited: false,
367
+ resetInSec: null,
368
+ resetText: resetText !== undefined ? cleanResetText(resetText) : null,
448
369
  };
449
370
  }
450
371
  return result;
451
372
  }
452
373
 
453
- function parseScrapedUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
374
+ function parseScrapedUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
454
375
  const inline = parseInlineUsage(html);
455
376
  if (Object.keys(inline).length === 3) return inline;
456
377
  return { ...parseDomUsage(html), ...inline };
457
378
  }
458
379
 
380
+ // One redirect-following step of the workspace scrape: a redirect hop carries
381
+ // only the Location decision, an ordinary response carries its status + body.
382
+ type CookieHop =
383
+ | { kind: "redirect"; location: string | null }
384
+ | { kind: "body"; status: number; html: string };
385
+
459
386
  async function fetchViaCookie(workspaceId: string, authCookie: string): Promise<UsageSnapshot> {
460
387
  const workspaceUrl = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
461
- let response: Response;
462
- try {
463
- response = await fetchWithTimeout(workspaceUrl, {
464
- redirect: "follow",
465
- headers: {
466
- Cookie: `auth=${authCookie}`,
467
- "User-Agent": "oc-go-usage-display-plugin/1.0",
468
- Accept: "text/html,application/xhtml+xml",
469
- },
470
- });
471
- } catch {
472
- return unavailableSnapshot("request failed");
473
- }
474
- let html = "";
475
- try {
476
- html = await response.text();
477
- } catch {
478
- return unavailableSnapshot("request failed");
479
- }
480
- if (isLoginPage(response.url, html)) {
481
- return unavailableSnapshot("login expired (refresh auth cookie)");
482
- }
483
- if (response.status !== 200) {
484
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
485
- }
486
- const usages = parseScrapedUsage(html);
487
- if (Object.keys(usages).length === 0) {
488
- if (isNoSubscription(html)) return unavailableSnapshot("no OpenCode Go subscription");
489
- return unavailableSnapshot("usage markup not recognized");
490
- }
491
- return {
492
- rolling: usages.rolling ?? null,
493
- weekly: usages.weekly ?? null,
494
- monthly: usages.monthly ?? null,
495
- source: "scrape",
496
- fetchedAt: Date.now(),
497
- };
498
- }
388
+ let currentUrl = workspaceUrl;
389
+ let hopsFollowed = 0;
390
+
391
+ // `redirect: "manual"`: automatic redirect following may re-send caller
392
+ // headers (including the auth cookie) to a cross-origin Location, and runtime
393
+ // header stripping cannot be relied on. Each hop is re-requested only after
394
+ // `resolveAllowedRedirect` approves the Location (allowlisted canonical HTTPS
395
+ // hosts, at most MAX_REDIRECT_HOPS hops).
396
+ for (;;) {
397
+ let hop: CookieHop;
398
+ try {
399
+ hop = await fetchWithTimeout(
400
+ currentUrl,
401
+ {
402
+ redirect: "manual",
403
+ headers: {
404
+ Cookie: `auth=${authCookie}`,
405
+ "User-Agent": "oc-go-usage-display-plugin",
406
+ Accept: "text/html,application/xhtml+xml",
407
+ },
408
+ },
409
+ async (response) => {
410
+ if (REDIRECT_STATUSES.has(response.status)) {
411
+ // The redirect body is never read: Location alone decides the next
412
+ // hop, so this resolves (and clears the abort timer) immediately.
413
+ return { kind: "redirect", location: response.headers.get("location") };
414
+ }
415
+ return { kind: "body", status: response.status, html: await response.text() };
416
+ },
417
+ );
418
+ } catch {
419
+ return unavailableSnapshot("request failed");
420
+ }
499
421
 
500
- function mockSnapshot(): UsageSnapshot {
501
- return {
502
- rolling: { percent: 42, resetInSec: 7543, status: "active", resetText: null },
503
- weekly: { percent: 15, resetInSec: null, status: "active", resetText: null },
504
- monthly: { percent: 61, resetInSec: null, status: "active", resetText: null },
505
- source: "mock",
506
- fetchedAt: Date.now(),
507
- };
422
+ if (hop.kind === "redirect") {
423
+ const decision = resolveAllowedRedirect(currentUrl, hop.location, hopsFollowed);
424
+ if (!decision.follow) return unavailableSnapshot(decision.reason);
425
+ currentUrl = decision.url;
426
+ hopsFollowed += 1;
427
+ continue;
428
+ }
429
+
430
+ const { status, html } = hop;
431
+ if (isLoginPage(currentUrl, html)) {
432
+ return unavailableSnapshot("login expired (refresh auth cookie)");
433
+ }
434
+ if (status !== 200) {
435
+ return unavailableSnapshot(`upstream returned HTTP ${status}`);
436
+ }
437
+ const usages = parseScrapedUsage(html);
438
+ if (Object.keys(usages).length === 0) {
439
+ if (isNoSubscription(html)) return unavailableSnapshot("no OpenCode Go subscription");
440
+ return unavailableSnapshot("usage markup not recognized");
441
+ }
442
+ return {
443
+ rolling: usages.rolling ?? null,
444
+ weekly: usages.weekly ?? null,
445
+ monthly: usages.monthly ?? null,
446
+ source: "scrape",
447
+ fetchedAt: Date.now(),
448
+ };
449
+ }
508
450
  }
509
451
 
510
452
  // ---------------------------------------------------------------------------
@@ -513,6 +455,12 @@ function mockSnapshot(): UsageSnapshot {
513
455
 
514
456
  async function getUsageSnapshot(): Promise<UsageSnapshot> {
515
457
  const now = Date.now();
458
+ // Mock bypasses cache for determinism: a stale disk/memory entry must
459
+ // never shadow the deterministic mock snapshot during tests. The mock
460
+ // never reads or writes the cache.
461
+ if (hostEnv(HOST, "MOCK") === "1") {
462
+ return mockSnapshot();
463
+ }
516
464
  if (memoryCache && isFresh(memoryCache.at, now)) return memoryCache.snapshot;
517
465
  const diskCached = readDiskCache(now);
518
466
  if (diskCached) {
@@ -520,14 +468,21 @@ async function getUsageSnapshot(): Promise<UsageSnapshot> {
520
468
  return diskCached;
521
469
  }
522
470
 
523
- const credentials = resolveCredentials();
471
+ const fileConfig = readFileConfig(HOST);
472
+ const credentials = resolveCredentials(fileConfig);
524
473
  if (credentials.kind === "mock") {
525
- const snapshot = mockSnapshot();
526
- memoryCache = { at: now, snapshot };
527
- return snapshot;
474
+ return mockSnapshot();
528
475
  }
529
476
  if (credentials.kind === "none") {
530
- return unavailableSnapshot("not configured (set OPENCODE_GO_API_KEY)");
477
+ // A malformed-cookie reason is only meaningful when a workspaceId is
478
+ // present (the user actually attempted cookie auth); a fully
479
+ // unconfigured setup reports the generic reason.
480
+ const workspaceId =
481
+ toNonEmptyString(hostEnv(HOST, "WORKSPACE_ID")) ?? fileConfig.workspaceId;
482
+ if (workspaceId && hasMalformedAuthCookie(fileConfig)) {
483
+ return unavailableSnapshot("not configured (malformed auth cookie)");
484
+ }
485
+ return unavailableSnapshot(`not configured (set ${hostEnvName(HOST, "API_KEY")})`);
531
486
  }
532
487
 
533
488
  const snapshot =
@@ -546,21 +501,93 @@ async function getUsageSnapshot(): Promise<UsageSnapshot> {
546
501
  // Plugin
547
502
  // ---------------------------------------------------------------------------
548
503
 
549
- export default (async () => {
550
- return {
551
- tool: {
552
- go_usage: tool({
553
- description:
554
- "Show OpenCode Go subscription usage: rolling 5h, weekly, and monthly windows. Takes no arguments.",
555
- args: {},
556
- execute: async () => {
557
- const snapshot = await getUsageSnapshot().catch(() =>
558
- unavailableSnapshot("request failed"),
559
- );
560
- const line = formatCompactLine(snapshot);
561
- return `${line}\n${JSON.stringify(snapshot, null, 2)}`;
562
- },
563
- }),
564
- },
565
- };
566
- }) satisfies Plugin;
504
+ // The ONLY export must be the default module: OpenCode's loader enumerates
505
+ // every export and invokes each as a plugin factory when the default is not a
506
+ // `{ id, server }` module. Helpers live in `./helpers.js` for exactly that
507
+ // reason. `server` returns the existing `{ tool: { go_usage } }` hook surface.
508
+
509
+ // A Hooks value is always a plain object. Drop nullish hook entries (and
510
+ // nullish tool definitions) so the loader can never dereference
511
+ // `hook.config` / `hook.provider` on a null value.
512
+ function sanitizeHooks(hooks: unknown): Hooks {
513
+ if (!isRecord(hooks)) return {};
514
+ const clean: Record<string, unknown> = {};
515
+ for (const [key, value] of Object.entries(hooks)) {
516
+ if (value === null || value === undefined) continue;
517
+ if (key === "tool" && isRecord(value)) {
518
+ const tools: Record<string, unknown> = {};
519
+ for (const [name, definition] of Object.entries(value)) {
520
+ if (definition === null || definition === undefined) continue;
521
+ tools[name] = definition;
522
+ }
523
+ clean[key] = tools;
524
+ continue;
525
+ }
526
+ clean[key] = value;
527
+ }
528
+ return clean as Hooks;
529
+ }
530
+
531
+ type LogClient = {
532
+ app?: { log?: (input: { service: string; level: string; message: string }) => unknown };
533
+ };
534
+
535
+ // Best-effort error log. The client (or its `log` method) may be absent or
536
+ // throw on a malformed input, and logging must never rethrow into a caller or
537
+ // delay plugin resolution: callers use `void logServerError(...)`.
538
+ async function logServerError(input: unknown, message: string): Promise<void> {
539
+ try {
540
+ const client = (input as { client?: LogClient } | null | undefined)?.client;
541
+ await client?.app?.log?.({
542
+ service: "oc-go-usage-display",
543
+ level: "error",
544
+ message,
545
+ });
546
+ } catch {
547
+ // Logging is best-effort; callers must never fail because of it.
548
+ }
549
+ }
550
+
551
+ // Fail-safe contract: OpenCode always starts, even if this plugin cannot. The
552
+ // factory resolves to a valid Hooks object (never undefined/null) for any
553
+ // input; on initialization failure it logs best-effort and resolves to `{}`.
554
+ const server: Plugin = async (input, _options) => {
555
+ try {
556
+ return sanitizeHooks({
557
+ tool: {
558
+ go_usage: tool({
559
+ description:
560
+ "Show OpenCode Go subscription usage: rolling 5h, weekly, and monthly windows. Takes no arguments.",
561
+ args: {},
562
+ execute: async () => {
563
+ try {
564
+ const snapshot = await getUsageSnapshot().catch(() =>
565
+ unavailableSnapshot("request failed"),
566
+ );
567
+ const line = formatServerLine(snapshot);
568
+ return `${line}\n${JSON.stringify(snapshot, null, 2)}`;
569
+ } catch (error) {
570
+ // Same output shape as the success path (line + JSON tail); the
571
+ // failure is logged best-effort and the invocation still
572
+ // resolves so it can never reject into the host.
573
+ const snapshot = unavailableSnapshot("request failed");
574
+ void logServerError(
575
+ input,
576
+ `go_usage tool execution failed: ${errorMessage(error)}`,
577
+ );
578
+ return `${formatServerLine(snapshot)}\n${JSON.stringify(snapshot, null, 2)}`;
579
+ }
580
+ },
581
+ }),
582
+ },
583
+ });
584
+ } catch (error) {
585
+ void logServerError(
586
+ input,
587
+ `Go usage plugin failed to initialize: ${errorMessage(error)}; continuing without hooks`,
588
+ );
589
+ return {};
590
+ }
591
+ };
592
+
593
+ export default { id: "oc-go-usage-display", server } satisfies PluginModule;