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.
- package/README.md +246 -93
- package/bin/lib.js +395 -37
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +22 -17
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +31 -52
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +117 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +582 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -159
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +699 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1364 -0
- package/dist/plugins/oc-go-usage-display.ts +699 -0
- package/dist/plugins/oc-go-usage-display.tsx +1108 -0
- package/dist/shared.d.ts +87 -8
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +596 -31
- package/dist/shared.js.map +1 -1
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +408 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts +0 -17
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +229 -282
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +49 -15
- package/src/helpers.ts +749 -0
- package/src/index.ts +316 -175
- package/src/shared.ts +693 -32
- package/src/tui-shared.tsx +755 -0
- package/src/tui.kilo.tsx +817 -0
- 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/
|
|
21
|
-
// ~/.local/share/opencode/auth.json),
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
100
|
-
|
|
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(
|
|
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
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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<
|
|
254
|
+
const USAGE_KEYS: Record<WindowKey, string> = {
|
|
235
255
|
rolling: "rollingUsage",
|
|
236
256
|
weekly: "weeklyUsage",
|
|
237
257
|
monthly: "monthlyUsage",
|
|
238
258
|
};
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
313
|
+
return match?.[1] ?? null;
|
|
290
314
|
}
|
|
291
315
|
|
|
292
|
-
function parseInlineUsage(html: string): Partial<Record<
|
|
293
|
-
const result: Partial<Record<
|
|
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
|
|
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<
|
|
319
|
-
const result: Partial<Record<
|
|
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
|
-
|
|
326
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
362
|
+
const resetText = resetMatch?.[1];
|
|
363
|
+
result[window] = {
|
|
364
|
+
percent: Number.parseInt(percent, 10),
|
|
335
365
|
status: null,
|
|
336
|
-
|
|
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<
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
471
|
+
const fileConfig = readFileConfig(HOST);
|
|
472
|
+
const credentials = resolveCredentials(fileConfig);
|
|
410
473
|
if (credentials.kind === "mock") {
|
|
411
|
-
|
|
412
|
-
memoryCache = { at: now, snapshot };
|
|
413
|
-
return snapshot;
|
|
474
|
+
return mockSnapshot();
|
|
414
475
|
}
|
|
415
476
|
if (credentials.kind === "none") {
|
|
416
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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;
|