oc-go-usage-display 1.1.0 → 2.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.
Files changed (42) hide show
  1. package/README.md +246 -93
  2. package/bin/lib.js +395 -37
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +22 -17
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +31 -52
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +117 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +582 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +249 -159
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +699 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1364 -0
  18. package/dist/plugins/oc-go-usage-display.ts +699 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1108 -0
  20. package/dist/shared.d.ts +87 -8
  21. package/dist/shared.d.ts.map +1 -1
  22. package/dist/shared.js +596 -31
  23. package/dist/shared.js.map +1 -1
  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 +408 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts +0 -17
  29. package/dist/tui.d.ts.map +1 -1
  30. package/dist/tui.js +229 -282
  31. package/dist/tui.js.map +1 -1
  32. package/dist/tui.kilo.d.ts +8 -0
  33. package/dist/tui.kilo.d.ts.map +1 -0
  34. package/dist/tui.kilo.js +502 -0
  35. package/dist/tui.kilo.js.map +1 -0
  36. package/package.json +49 -15
  37. package/src/helpers.ts +749 -0
  38. package/src/index.ts +316 -175
  39. package/src/shared.ts +693 -32
  40. package/src/tui-shared.tsx +755 -0
  41. package/src/tui.kilo.tsx +817 -0
  42. package/src/tui.tsx +352 -366
package/src/index.ts CHANGED
@@ -12,43 +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
39
  import * as path from "node:path";
36
40
  import {
37
- CONFIG_DIR,
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,
38
50
  extractSnapshotFromApiPayload,
39
51
  extractWindow,
40
- formatResetDuration,
52
+ hostEnv,
53
+ hostEnvName,
54
+ isLimitedStatus,
41
55
  isRecord,
42
56
  mockSnapshot,
43
57
  readAuthJsonApiKey,
58
+ resolveHostRoots,
59
+ resolveUsageHost,
60
+ safeJoinPath,
44
61
  toFiniteNumber,
45
62
  toNonEmptyString,
46
63
  unavailableSnapshot,
47
64
  } from "./shared.js";
48
- import type { UsageSnapshot, UsageWindow } from "./shared.js";
49
-
50
- // Re-exported so `dist/index.js` keeps the helper surface used by tests.
51
- export { formatResetDuration };
65
+ import type { UsageHost, UsageSnapshot, UsageWindow } from "./shared.js";
52
66
 
53
67
  // ---------------------------------------------------------------------------
54
68
  // Constants
@@ -58,8 +72,23 @@ const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
58
72
  const CACHE_TTL_MS = 60_000;
59
73
  const FETCH_TIMEOUT_MS = 10_000;
60
74
 
61
- const FILE_CONFIG_PATH = path.join(CONFIG_DIR, "oc-go-usage-display.json");
62
- 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";
77
+
78
+ const WINDOW_KEYS: readonly WindowKey[] = ["rolling", "weekly", "monthly"];
79
+
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
+ );
63
92
 
64
93
  // ---------------------------------------------------------------------------
65
94
  // Trusted types (parsed at the boundary, trusted internally)
@@ -71,64 +100,27 @@ type Credentials =
71
100
  | { kind: "mock" }
72
101
  | { kind: "none" };
73
102
 
74
- function formatWindow(window: UsageWindow | null): string {
75
- if (!window) return "n/a";
76
- return `${window.percent}%`;
77
- }
78
-
79
- export function formatCompactLine(snapshot: UsageSnapshot): string {
80
- if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
81
- const reason = snapshot.apiError ?? "unknown error";
82
- return `Go n/a (${reason})`;
83
- }
84
- const rollingReset =
85
- formatResetDuration(snapshot.rolling?.resetInSec ?? null) ??
86
- snapshot.rolling?.resetText ??
87
- null;
88
- const rollingText =
89
- snapshot.rolling === null
90
- ? "5h n/a"
91
- : `5h ${snapshot.rolling.percent}%${rollingReset ? ` (reset ${rollingReset})` : ""}`;
92
- return `Go ${rollingText} | 7d ${formatWindow(snapshot.weekly)} | 30d ${formatWindow(snapshot.monthly)}`;
93
- }
94
-
95
103
  // ---------------------------------------------------------------------------
96
104
  // Credentials (boundary: env + optional JSON file; never logged)
97
105
  // ---------------------------------------------------------------------------
98
106
 
99
- function readFileConfig(): { workspaceId: string | null; authCookie: string | null } {
100
- let raw: string;
101
- try {
102
- raw = fs.readFileSync(FILE_CONFIG_PATH, "utf8");
103
- } catch {
104
- return { workspaceId: null, authCookie: null };
105
- }
106
- let parsed: unknown;
107
- try {
108
- parsed = JSON.parse(raw);
109
- } catch {
110
- return { workspaceId: null, authCookie: null };
111
- }
112
- if (!isRecord(parsed)) return { workspaceId: null, authCookie: null };
113
- return {
114
- workspaceId: toNonEmptyString(parsed.workspaceId),
115
- authCookie: toNonEmptyString(parsed.authCookie),
116
- };
117
- }
118
-
119
- function resolveCredentials(): Credentials {
120
- if (process.env.OPENCODE_GO_MOCK === "1") return { kind: "mock" };
107
+ function resolveCredentials(fileConfig: FileConfig = readFileConfig(HOST)): Credentials {
108
+ if (hostEnv(HOST, "MOCK") === "1") return { kind: "mock" };
121
109
 
122
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY);
110
+ const apiKey = toNonEmptyString(hostEnv(HOST, "API_KEY"));
123
111
  if (apiKey) return { kind: "apiKey", apiKey };
124
112
 
125
- const authJsonKey = readAuthJsonApiKey();
113
+ const authJsonKey = readAuthJsonApiKey(HOST);
126
114
  if (authJsonKey) return { kind: "apiKey", apiKey: authJsonKey };
127
115
 
128
- const fileConfig = readFileConfig();
129
- const workspaceId = toNonEmptyString(process.env.OPENCODE_GO_WORKSPACE_ID) ?? fileConfig.workspaceId;
130
- const authCookie = toNonEmptyString(process.env.OPENCODE_GO_AUTH_COOKIE) ?? fileConfig.authCookie;
131
- 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
+ }
132
124
 
133
125
  return { kind: "none" };
134
126
  }
@@ -140,7 +132,7 @@ function resolveCredentials(): Credentials {
140
132
  let memoryCache: { at: number; snapshot: UsageSnapshot } | null = null;
141
133
 
142
134
  function isFresh(at: number, now: number): boolean {
143
- return now - at < CACHE_TTL_MS;
135
+ return at <= now && now - at < CACHE_TTL_MS;
144
136
  }
145
137
 
146
138
  function readDiskCache(now: number): UsageSnapshot | null {
@@ -162,14 +154,23 @@ function readDiskCache(now: number): UsageSnapshot | null {
162
154
  if (!isRecord(parsed.snapshot)) return null;
163
155
  const snapshot = parsed.snapshot;
164
156
  if (!("rolling" in snapshot && "weekly" in snapshot && "monthly" in snapshot)) return null;
165
- // Validate cached windows instead of blindly trusting the shape; a corrupt
166
- // entry is dropped so the next fetch repopulates the cache.
167
- for (const key of ["rolling", "weekly", "monthly"] as const) {
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) {
168
167
  const cachedWindow = snapshot[key];
169
168
  if (cachedWindow === null) continue;
170
- if (extractWindow(cachedWindow) === null) return null;
169
+ const window = extractWindow(cachedWindow);
170
+ if (window === null) return null;
171
+ windows[key] = window;
171
172
  }
172
- return snapshot as UsageSnapshot;
173
+ return { ...snapshot, ...windows } as UsageSnapshot;
173
174
  }
174
175
 
175
176
  function writeDiskCache(snapshot: UsageSnapshot): void {
@@ -189,11 +190,22 @@ function writeDiskCache(snapshot: UsageSnapshot): void {
189
190
  // Fetch helpers
190
191
  // ---------------------------------------------------------------------------
191
192
 
192
- 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> {
193
204
  const controller = new AbortController();
194
205
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
195
206
  try {
196
- 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);
197
209
  } finally {
198
210
  clearTimeout(timer);
199
211
  }
@@ -203,40 +215,51 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise<Respons
203
215
  // evolve); scrape helpers below stay server-local. ---
204
216
 
205
217
  async function fetchViaApiKey(apiKey: string): Promise<UsageSnapshot> {
206
- let response: Response;
207
218
  try {
208
- response = await fetchWithTimeout(API_USAGE_URL, {
209
- headers: {
210
- Authorization: `Bearer ${apiKey}`,
211
- Accept: "application/json",
219
+ return await fetchWithTimeout(
220
+ API_USAGE_URL,
221
+ {
222
+ headers: {
223
+ Authorization: `Bearer ${apiKey}`,
224
+ Accept: "application/json",
225
+ },
212
226
  },
213
- });
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
+ );
214
247
  } catch {
215
248
  return unavailableSnapshot("request failed");
216
249
  }
217
- if (response.status === 401 || response.status === 403) {
218
- return unavailableSnapshot("API key rejected (401/403)");
219
- }
220
- if (!response.ok) {
221
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
222
- }
223
- let payload: unknown;
224
- try {
225
- payload = await response.json();
226
- } catch {
227
- return unavailableSnapshot("unexpected API response shape");
228
- }
229
- return extractSnapshotFromApiPayload(payload) ?? unavailableSnapshot("unexpected API response shape");
230
250
  }
231
251
 
232
252
  // --- Cookie path: workspace page scrape (ported from opencode-go-hud) ---
233
253
 
234
- const USAGE_KEYS: Record<"rolling" | "weekly" | "monthly", string> = {
254
+ const USAGE_KEYS: Record<WindowKey, string> = {
235
255
  rolling: "rollingUsage",
236
256
  weekly: "weeklyUsage",
237
257
  monthly: "monthlyUsage",
238
258
  };
239
- 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"];
240
263
  const LOGIN_TITLE_MARKER = "<title>OpenAuth</title>";
241
264
 
242
265
  function isLoginPage(finalUrl: string, html: string): boolean {
@@ -281,26 +304,29 @@ function extractUsageBlock(html: string, key: string): string | null {
281
304
 
282
305
  function parseIntField(block: string, field: string): number | null {
283
306
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*(-?\\d+)`).exec(block);
284
- return match ? Number.parseInt(match[1], 10) : null;
307
+ const digits = match?.[1];
308
+ return digits === undefined ? null : Number.parseInt(digits, 10);
285
309
  }
286
310
 
287
311
  function parseStrField(block: string, field: string): string | null {
288
312
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*"([^"]*)"`).exec(block);
289
- return match ? match[1] : null;
313
+ return match?.[1] ?? null;
290
314
  }
291
315
 
292
- function parseInlineUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
293
- const result: Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> = {};
316
+ function parseInlineUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
317
+ const result: Partial<Record<WindowKey, UsageWindow>> = {};
294
318
  for (const [name, key] of Object.entries(USAGE_KEYS)) {
295
- const window = name as "rolling" | "weekly" | "monthly";
319
+ const window = name as WindowKey;
296
320
  const block = extractUsageBlock(html, key);
297
321
  if (!block) continue;
298
322
  const percent = parseIntField(block, "usagePercent");
299
323
  if (percent === null) continue;
324
+ const status = parseStrField(block, "status");
300
325
  result[window] = {
301
326
  percent,
327
+ status,
328
+ limited: isLimitedStatus(status),
302
329
  resetInSec: parseIntField(block, "resetInSec"),
303
- status: parseStrField(block, "status"),
304
330
  resetText: null,
305
331
  };
306
332
  }
@@ -315,75 +341,112 @@ function cleanResetText(raw: string): string | null {
315
341
  return cleaned || null;
316
342
  }
317
343
 
318
- function parseDomUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
319
- const result: Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> = {};
344
+ function parseDomUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
345
+ const result: Partial<Record<WindowKey, UsageWindow>> = {};
320
346
  const itemStarts: number[] = [];
321
347
  for (const match of html.matchAll(/data-slot="usage-item"/g)) {
322
348
  if (match.index !== undefined) itemStarts.push(match.index);
323
349
  }
324
350
  for (let idx = 0; idx < DOM_ORDER.length; idx++) {
325
- if (idx >= itemStarts.length) break;
326
- 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);
327
356
  const valueMatch =
328
357
  /data-slot="usage-value">\s*(?:<!--[\s\S]*?-->)?\s*(\d+)/s.exec(segment) ??
329
358
  /width:\s*(\d+)%/.exec(segment);
330
- if (!valueMatch) continue;
359
+ const percent = valueMatch?.[1];
360
+ if (percent === undefined) continue;
331
361
  const resetMatch = /data-slot="reset-time">\s*([\s\S]*?)<\/span>/.exec(segment);
332
- result[DOM_ORDER[idx]] = {
333
- percent: Number.parseInt(valueMatch[1], 10),
334
- resetInSec: null,
362
+ const resetText = resetMatch?.[1];
363
+ result[window] = {
364
+ percent: Number.parseInt(percent, 10),
335
365
  status: null,
336
- resetText: resetMatch ? cleanResetText(resetMatch[1]) : null,
366
+ limited: false,
367
+ resetInSec: null,
368
+ resetText: resetText !== undefined ? cleanResetText(resetText) : null,
337
369
  };
338
370
  }
339
371
  return result;
340
372
  }
341
373
 
342
- function parseScrapedUsage(html: string): Partial<Record<"rolling" | "weekly" | "monthly", UsageWindow>> {
374
+ function parseScrapedUsage(html: string): Partial<Record<WindowKey, UsageWindow>> {
343
375
  const inline = parseInlineUsage(html);
344
376
  if (Object.keys(inline).length === 3) return inline;
345
377
  return { ...parseDomUsage(html), ...inline };
346
378
  }
347
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
+
348
386
  async function fetchViaCookie(workspaceId: string, authCookie: string): Promise<UsageSnapshot> {
349
387
  const workspaceUrl = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
350
- let response: Response;
351
- try {
352
- response = await fetchWithTimeout(workspaceUrl, {
353
- redirect: "follow",
354
- headers: {
355
- Cookie: `auth=${authCookie}`,
356
- "User-Agent": "oc-go-usage-display-plugin",
357
- Accept: "text/html,application/xhtml+xml",
358
- },
359
- });
360
- } catch {
361
- return unavailableSnapshot("request failed");
362
- }
363
- let html = "";
364
- try {
365
- html = await response.text();
366
- } catch {
367
- return unavailableSnapshot("request failed");
368
- }
369
- if (isLoginPage(response.url, html)) {
370
- return unavailableSnapshot("login expired (refresh auth cookie)");
371
- }
372
- if (response.status !== 200) {
373
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
374
- }
375
- const usages = parseScrapedUsage(html);
376
- if (Object.keys(usages).length === 0) {
377
- if (isNoSubscription(html)) return unavailableSnapshot("no OpenCode Go subscription");
378
- return unavailableSnapshot("usage markup not recognized");
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
+ }
421
+
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
+ };
379
449
  }
380
- return {
381
- rolling: usages.rolling ?? null,
382
- weekly: usages.weekly ?? null,
383
- monthly: usages.monthly ?? null,
384
- source: "scrape",
385
- fetchedAt: Date.now(),
386
- };
387
450
  }
388
451
 
389
452
  // ---------------------------------------------------------------------------
@@ -393,11 +456,10 @@ async function fetchViaCookie(workspaceId: string, authCookie: string): Promise<
393
456
  async function getUsageSnapshot(): Promise<UsageSnapshot> {
394
457
  const now = Date.now();
395
458
  // Mock bypasses cache for determinism: a stale disk/memory entry must
396
- // never shadow the deterministic mock snapshot during tests.
397
- if (process.env.OPENCODE_GO_MOCK === "1") {
398
- const snapshot = mockSnapshot();
399
- memoryCache = { at: now, snapshot };
400
- return snapshot;
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();
401
463
  }
402
464
  if (memoryCache && isFresh(memoryCache.at, now)) return memoryCache.snapshot;
403
465
  const diskCached = readDiskCache(now);
@@ -406,14 +468,21 @@ async function getUsageSnapshot(): Promise<UsageSnapshot> {
406
468
  return diskCached;
407
469
  }
408
470
 
409
- const credentials = resolveCredentials();
471
+ const fileConfig = readFileConfig(HOST);
472
+ const credentials = resolveCredentials(fileConfig);
410
473
  if (credentials.kind === "mock") {
411
- const snapshot = mockSnapshot();
412
- memoryCache = { at: now, snapshot };
413
- return snapshot;
474
+ return mockSnapshot();
414
475
  }
415
476
  if (credentials.kind === "none") {
416
- 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")})`);
417
486
  }
418
487
 
419
488
  const snapshot =
@@ -432,21 +501,93 @@ async function getUsageSnapshot(): Promise<UsageSnapshot> {
432
501
  // Plugin
433
502
  // ---------------------------------------------------------------------------
434
503
 
435
- export default (async () => {
436
- return {
437
- tool: {
438
- go_usage: tool({
439
- description:
440
- "Show OpenCode Go subscription usage: rolling 5h, weekly, and monthly windows. Takes no arguments.",
441
- args: {},
442
- execute: async () => {
443
- const snapshot = await getUsageSnapshot().catch(() =>
444
- unavailableSnapshot("request failed"),
445
- );
446
- const line = formatCompactLine(snapshot);
447
- return `${line}\n${JSON.stringify(snapshot, null, 2)}`;
448
- },
449
- }),
450
- },
451
- };
452
- }) 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;