@venturewild/workspace 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -83
- package/server/bin/wild-workspace.mjs +1096 -995
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +579 -579
- package/server/src/bazaar/index.mjs +75 -75
- package/server/src/bazaar/mcp-server.mjs +328 -328
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
- package/server/src/canvas/core.mjs +446 -421
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -0
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +2766 -2475
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +225 -0
- package/server/src/workspaces.mjs +111 -0
- package/web/dist/assets/index-NXZN2LU2.css +1 -0
- package/web/dist/assets/index-PAS8Inwp.js +91 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BxRx8EsD.js +0 -91
- package/web/dist/assets/index-DoOPBr3s.css +0 -1
package/server/src/usage.mjs
CHANGED
|
@@ -1,405 +1,405 @@
|
|
|
1
|
-
// Usage + context service — the data behind the floating "Usage" canvas block (§8 of
|
|
2
|
-
// docs/agent-engine-and-commands-design.md). Two numbers, two sources:
|
|
3
|
-
//
|
|
4
|
-
// 1. Plan utilization (Session 5-hour + Weekly 7-day %) — AUTHORITATIVE, from
|
|
5
|
-
// Anthropic's own OAuth usage endpoint. The same endpoint ccstatusline reads.
|
|
6
|
-
// 2. Context % ("working memory") — LOCAL, computed from the
|
|
7
|
-
// latest assistant turn's token usage (no network). See computeContext().
|
|
8
|
-
//
|
|
9
|
-
// DESIGN RULES baked in here (don't relitigate — see §8):
|
|
10
|
-
// - The endpoint is UNOFFICIAL/community-documented → we DEGRADE, never throw. A
|
|
11
|
-
// failure keeps the last-good value and flips status to 'stale'/'unavailable';
|
|
12
|
-
// the UI shows that honestly. Core UX never depends on it.
|
|
13
|
-
// - `User-Agent: claude-code/<version>` is MANDATORY — omitting it is a hard 429.
|
|
14
|
-
// - Poll every 60–120s and ALWAYS re-poll: utilization climbs WITHIN a window as the
|
|
15
|
-
// user consumes, so `resets_at` only drives the countdown + a post-reset refresh —
|
|
16
|
-
// it never suppresses polling.
|
|
17
|
-
// - On 429, back off (don't hammer) and serve stale.
|
|
18
|
-
// - We READ the user's Claude OAuth token locally (new trust surface). It only ever
|
|
19
|
-
// leaves the box to api.anthropic.com — never logged, never sent elsewhere. A
|
|
20
|
-
// one-time disclosure (stored ack under ~/.wild-workspace) gates first activation.
|
|
21
|
-
//
|
|
22
|
-
// VERIFIED EMPIRICALLY (2026-06-11, this box, `claude` 2.1.172):
|
|
23
|
-
// - ~/.claude/.credentials.json = { claudeAiOauth: { accessToken, expiresAt, … } }.
|
|
24
|
-
// - A real turn's usage: input_tokens=2, cache_creation_input_tokens=45504 → an
|
|
25
|
-
// input-only context gauge reads ~20× too low. Numerator = sum of the three.
|
|
26
|
-
// - Records store the model as `claude-opus-4-8` with the `[1m]` suffix STRIPPED, so
|
|
27
|
-
// a 1M-context session can't be distinguished from 200k by model name alone — the
|
|
28
|
-
// window map below defaults accordingly; see WINDOW_CALIBRATION note.
|
|
29
|
-
|
|
30
|
-
import fs from 'node:fs';
|
|
31
|
-
import path from 'node:path';
|
|
32
|
-
import os from 'node:os';
|
|
33
|
-
import { execFileSync } from 'node:child_process';
|
|
34
|
-
|
|
35
|
-
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
|
36
|
-
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
37
|
-
|
|
38
|
-
// Poll cadence. The TTL *is* the cadence — we re-poll on it (see header). Kept in the
|
|
39
|
-
// 60–120s band the design fixed (rate-limit-friendly); default 90s.
|
|
40
|
-
export const DEFAULT_POLL_MS = 90_000;
|
|
41
|
-
// After a transient 429 we back off to this before the next attempt.
|
|
42
|
-
export const BACKOFF_MS = 5 * 60_000;
|
|
43
|
-
// Per-request network timeout — never let a hung socket stall the timer.
|
|
44
|
-
const FETCH_TIMEOUT_MS = 10_000;
|
|
45
|
-
|
|
46
|
-
// --- credential resolution -------------------------------------------------
|
|
47
|
-
// Order (per §8): CLAUDE_CONFIG_DIR → ~/.claude/.credentials.json → macOS keychain.
|
|
48
|
-
// Returns { token, expiresAt } or null. Never throws (degrade).
|
|
49
|
-
|
|
50
|
-
function readCredsFile(file) {
|
|
51
|
-
try {
|
|
52
|
-
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
-
const o = raw.claudeAiOauth || raw; // tolerate both shapes
|
|
54
|
-
const token = o.accessToken || o.access_token;
|
|
55
|
-
if (typeof token !== 'string' || !token) return null;
|
|
56
|
-
const expiresAt = o.expiresAt ?? o.expires_at ?? null;
|
|
57
|
-
return { token, expiresAt };
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function readKeychain() {
|
|
64
|
-
// macOS only. `security` returns the secret on stdout; any failure → null. The
|
|
65
|
-
// secret name follows Claude Code's convention ("Claude Code-credentials").
|
|
66
|
-
try {
|
|
67
|
-
const out = execFileSync(
|
|
68
|
-
'security',
|
|
69
|
-
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
70
|
-
{ encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
71
|
-
);
|
|
72
|
-
const raw = JSON.parse(out.trim());
|
|
73
|
-
const o = raw.claudeAiOauth || raw;
|
|
74
|
-
const token = o.accessToken || o.access_token;
|
|
75
|
-
if (typeof token !== 'string' || !token) return null;
|
|
76
|
-
return { token, expiresAt: o.expiresAt ?? o.expires_at ?? null };
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function readOAuthToken({
|
|
83
|
-
env = process.env,
|
|
84
|
-
platform = process.platform,
|
|
85
|
-
home = os.homedir(),
|
|
86
|
-
} = {}) {
|
|
87
|
-
// 1. CLAUDE_CONFIG_DIR/.credentials.json
|
|
88
|
-
const configDir = env.CLAUDE_CONFIG_DIR;
|
|
89
|
-
if (configDir) {
|
|
90
|
-
const hit = readCredsFile(path.join(configDir, '.credentials.json'));
|
|
91
|
-
if (hit) return hit;
|
|
92
|
-
}
|
|
93
|
-
// 2. ~/.claude/.credentials.json
|
|
94
|
-
const hit = readCredsFile(path.join(home, '.claude', '.credentials.json'));
|
|
95
|
-
if (hit) return hit;
|
|
96
|
-
// 3. macOS keychain
|
|
97
|
-
if (platform === 'darwin') {
|
|
98
|
-
const kc = readKeychain();
|
|
99
|
-
if (kc) return kc;
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// True when a token is present but already past its expiry. v1 does NOT refresh
|
|
105
|
-
// tokens (out of scope) — an expired token degrades to 'unavailable' so the user
|
|
106
|
-
// is told honestly rather than seeing a silent 401 loop.
|
|
107
|
-
export function isExpired(cred, now = Date.now()) {
|
|
108
|
-
if (!cred || cred.expiresAt == null) return false;
|
|
109
|
-
const exp = Number(cred.expiresAt);
|
|
110
|
-
if (!Number.isFinite(exp)) return false;
|
|
111
|
-
// expiresAt is epoch ms in the credentials file.
|
|
112
|
-
return exp <= now;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// --- claude version (for the mandatory UA) ---------------------------------
|
|
116
|
-
// Best-effort, detected once and cached. The `claude-code/` PREFIX is what the
|
|
117
|
-
// endpoint requires (omit → 429); the exact version is cosmetic, so a sane fallback
|
|
118
|
-
// is fine if detection fails. Override via WILD_WORKSPACE_CLAUDE_VERSION (tests do).
|
|
119
|
-
|
|
120
|
-
let _versionCache;
|
|
121
|
-
export function claudeVersion(env = process.env) {
|
|
122
|
-
if (env.WILD_WORKSPACE_CLAUDE_VERSION) return env.WILD_WORKSPACE_CLAUDE_VERSION;
|
|
123
|
-
if (_versionCache !== undefined) return _versionCache;
|
|
124
|
-
try {
|
|
125
|
-
const out = execFileSync('claude', ['--version'], {
|
|
126
|
-
encoding: 'utf8',
|
|
127
|
-
timeout: 5000,
|
|
128
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
129
|
-
});
|
|
130
|
-
const m = out.match(/(\d+\.\d+\.\d+)/);
|
|
131
|
-
_versionCache = m ? m[1] : '2.1.172';
|
|
132
|
-
} catch {
|
|
133
|
-
_versionCache = '2.1.172'; // recent known-good fallback
|
|
134
|
-
}
|
|
135
|
-
return _versionCache;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function userAgent(env = process.env) {
|
|
139
|
-
return `claude-code/${claudeVersion(env)}`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// --- the network call ------------------------------------------------------
|
|
143
|
-
// Returns { ok:true, status, body } on a 2xx with JSON, else { ok:false, status,
|
|
144
|
-
// error }. NEVER throws — the caller's degrade logic keys off the shape.
|
|
145
|
-
|
|
146
|
-
export async function fetchUsage({
|
|
147
|
-
token,
|
|
148
|
-
ua,
|
|
149
|
-
fetchImpl = globalThis.fetch,
|
|
150
|
-
timeoutMs = FETCH_TIMEOUT_MS,
|
|
151
|
-
} = {}) {
|
|
152
|
-
if (!token) return { ok: false, status: 0, error: 'no_token' };
|
|
153
|
-
if (typeof fetchImpl !== 'function') return { ok: false, status: 0, error: 'no_fetch' };
|
|
154
|
-
const ctrl = new AbortController();
|
|
155
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
156
|
-
try {
|
|
157
|
-
const res = await fetchImpl(USAGE_ENDPOINT, {
|
|
158
|
-
method: 'GET',
|
|
159
|
-
headers: {
|
|
160
|
-
Authorization: `Bearer ${token}`,
|
|
161
|
-
'anthropic-beta': OAUTH_BETA,
|
|
162
|
-
'User-Agent': ua, // ← MANDATORY (omit → hard 429)
|
|
163
|
-
'Content-Type': 'application/json',
|
|
164
|
-
},
|
|
165
|
-
signal: ctrl.signal,
|
|
166
|
-
});
|
|
167
|
-
if (!res.ok) return { ok: false, status: res.status, error: `http_${res.status}` };
|
|
168
|
-
const body = await res.json();
|
|
169
|
-
return { ok: true, status: res.status, body };
|
|
170
|
-
} catch (e) {
|
|
171
|
-
return { ok: false, status: 0, error: e?.name === 'AbortError' ? 'timeout' : 'network' };
|
|
172
|
-
} finally {
|
|
173
|
-
clearTimeout(timer);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// --- parse the usage body --------------------------------------------------
|
|
178
|
-
// Tolerant: any missing block degrades to null/absent rather than throwing. Shape
|
|
179
|
-
// (per §8): { five_hour, seven_day, seven_day_opus, seven_day_sonnet, extra_usage }.
|
|
180
|
-
|
|
181
|
-
function pctBlock(b) {
|
|
182
|
-
if (!b || typeof b !== 'object') return null;
|
|
183
|
-
const u = Number(b.utilization);
|
|
184
|
-
return {
|
|
185
|
-
utilization: Number.isFinite(u) ? Math.max(0, Math.min(100, u)) : 0,
|
|
186
|
-
resets_at: typeof b.resets_at === 'string' ? b.resets_at : null,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function parseUsage(body = {}) {
|
|
191
|
-
if (!body || typeof body !== 'object') body = {};
|
|
192
|
-
const out = {
|
|
193
|
-
five_hour: pctBlock(body.five_hour),
|
|
194
|
-
seven_day: pctBlock(body.seven_day),
|
|
195
|
-
seven_day_opus: pctBlock(body.seven_day_opus),
|
|
196
|
-
seven_day_sonnet: pctBlock(body.seven_day_sonnet),
|
|
197
|
-
};
|
|
198
|
-
const ex = body.extra_usage;
|
|
199
|
-
if (ex && typeof ex === 'object') {
|
|
200
|
-
out.extra_usage = {
|
|
201
|
-
is_enabled: !!ex.is_enabled,
|
|
202
|
-
monthly_limit: Number(ex.monthly_limit) || 0,
|
|
203
|
-
used_credits: Number(ex.used_credits) || 0,
|
|
204
|
-
utilization: Number.isFinite(Number(ex.utilization)) ? Number(ex.utilization) : 0,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return out;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// --- context gauge (LOCAL, no network) -------------------------------------
|
|
211
|
-
//
|
|
212
|
-
// WINDOW_CALIBRATION (the documented residual — see §8/§9):
|
|
213
|
-
// - Numerator = input + cache_creation + cache_read (verified; input-only is ~10–20×
|
|
214
|
-
// low because cache dominates the prompt).
|
|
215
|
-
// - Denominator = the model's context window. Records DON'T carry context_window_size
|
|
216
|
-
// and STRIP the `[1m]` suffix, so we map by model name + detect a `[1m]` suffix when
|
|
217
|
-
// the live stream-json provides one. The 1M variant therefore UNDER-detects from
|
|
218
|
-
// historical records (defaults to 200k) — acceptable for a "working memory" gauge,
|
|
219
|
-
// not an exact clone of Claude's /context. If we adopt Claude's statusline hook
|
|
220
|
-
// later, prefer its context_window_size over this map.
|
|
221
|
-
// - Residual #2: does /context measure toward the full window or the auto-compact
|
|
222
|
-
// threshold? We measure toward the FULL window (ccstatusline's convention) and label
|
|
223
|
-
// it "working memory", not Claude's exact %.
|
|
224
|
-
|
|
225
|
-
const K = 1000;
|
|
226
|
-
const DEFAULT_WINDOW = 200 * K;
|
|
227
|
-
const ONE_M = 1000 * K;
|
|
228
|
-
// Prefix → window. Keep claude + the glm-* models this workspace has seen; unknown →
|
|
229
|
-
// DEFAULT_WINDOW. (Non-Claude models get NO plan-usage, but the context gauge still works.)
|
|
230
|
-
const WINDOWS = [
|
|
231
|
-
['claude-opus', 200 * K],
|
|
232
|
-
['claude-sonnet', 200 * K],
|
|
233
|
-
['claude-haiku', 200 * K],
|
|
234
|
-
['claude-fable', 200 * K],
|
|
235
|
-
['glm', 200 * K],
|
|
236
|
-
];
|
|
237
|
-
|
|
238
|
-
export function windowFor(model) {
|
|
239
|
-
if (!model || typeof model !== 'string') return DEFAULT_WINDOW;
|
|
240
|
-
const m = model.toLowerCase();
|
|
241
|
-
// 1M-context variants advertise themselves with a [1m] / -1m marker WHEN present.
|
|
242
|
-
if (m.includes('[1m]') || m.includes('-1m') || m.endsWith(':1m')) return ONE_M;
|
|
243
|
-
for (const [prefix, w] of WINDOWS) if (m.startsWith(prefix)) return w;
|
|
244
|
-
return DEFAULT_WINDOW;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function computeContext(usage = {}, model = '') {
|
|
248
|
-
const input = Number(usage.input_tokens) || 0;
|
|
249
|
-
const cacheCreate = Number(usage.cache_creation_input_tokens) || 0;
|
|
250
|
-
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
251
|
-
const used = input + cacheCreate + cacheRead;
|
|
252
|
-
const window = windowFor(model);
|
|
253
|
-
const pct = window > 0 ? Math.max(0, Math.min(100, (used / window) * 100)) : 0;
|
|
254
|
-
return { used, window, pct, model: model || null };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// --- disclosure ack (server-side, one-time per USER) -----------------------
|
|
258
|
-
// Stored under ~/.wild-workspace/usage/ (CLAUDE.md #1 — never the synced workspace,
|
|
259
|
-
// never localStorage which would be per-browser/device). One file, last-write-wins.
|
|
260
|
-
|
|
261
|
-
function defaultUsageDir(env = process.env, home = os.homedir()) {
|
|
262
|
-
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
|
|
263
|
-
return path.join(base, 'usage');
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// --- the stateful service --------------------------------------------------
|
|
267
|
-
// One instance per server. Owns the cached snapshot, the poll timer, the degrade
|
|
268
|
-
// logic, the local context gauge, and the disclosure ack. `onUpdate(state)` fires on
|
|
269
|
-
// every snapshot change (the WS push path mirrors the bazaar meter — no client poll).
|
|
270
|
-
|
|
271
|
-
export function createUsageService({
|
|
272
|
-
env = process.env,
|
|
273
|
-
platform = process.platform,
|
|
274
|
-
home = os.homedir(),
|
|
275
|
-
baseDir, // override the ack dir (tests)
|
|
276
|
-
fetchImpl = globalThis.fetch,
|
|
277
|
-
pollMs = DEFAULT_POLL_MS,
|
|
278
|
-
onUpdate = null,
|
|
279
|
-
now = () => Date.now(),
|
|
280
|
-
} = {}) {
|
|
281
|
-
const dir = baseDir || defaultUsageDir(env, home);
|
|
282
|
-
const ackFile = path.join(dir, 'disclosure.json');
|
|
283
|
-
|
|
284
|
-
// status: 'loading' (never fetched) | 'live' (fresh good data) | 'stale' (had data,
|
|
285
|
-
// last refresh failed) | 'unavailable' (no token / expired / never got data).
|
|
286
|
-
let snap = {
|
|
287
|
-
status: 'loading',
|
|
288
|
-
plan: null, // parsed usage blocks (five_hour, seven_day, …)
|
|
289
|
-
context: null, // { used, window, pct, model }
|
|
290
|
-
fetchedAt: null, // ms of the last GOOD fetch
|
|
291
|
-
error: null, // last error marker (for the UI's honest message)
|
|
292
|
-
};
|
|
293
|
-
let timer = null;
|
|
294
|
-
let backoffUntil = 0;
|
|
295
|
-
|
|
296
|
-
function emit() {
|
|
297
|
-
if (typeof onUpdate === 'function') {
|
|
298
|
-
try { onUpdate(snapshot()); } catch { /* a bad listener never breaks the timer */ }
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// One refresh cycle. NEVER throws. Returns the new snapshot.
|
|
303
|
-
async function refresh() {
|
|
304
|
-
// Respect a 429 backoff window: serve stale, don't hammer.
|
|
305
|
-
if (now() < backoffUntil) {
|
|
306
|
-
if (snap.status === 'live') snap = { ...snap, status: 'stale', error: 'backoff' };
|
|
307
|
-
return snapshot();
|
|
308
|
-
}
|
|
309
|
-
const cred = readOAuthToken({ env, platform, home });
|
|
310
|
-
if (!cred) {
|
|
311
|
-
snap = { ...snap, status: 'unavailable', error: 'no_token' };
|
|
312
|
-
emit();
|
|
313
|
-
return snapshot();
|
|
314
|
-
}
|
|
315
|
-
if (isExpired(cred, now())) {
|
|
316
|
-
// v1 does not refresh tokens — tell the user honestly.
|
|
317
|
-
snap = { ...snap, status: 'unavailable', error: 'expired_token' };
|
|
318
|
-
emit();
|
|
319
|
-
return snapshot();
|
|
320
|
-
}
|
|
321
|
-
const res = await fetchUsage({ token: cred.token, ua: userAgent(env), fetchImpl });
|
|
322
|
-
if (res.ok) {
|
|
323
|
-
snap = {
|
|
324
|
-
status: 'live',
|
|
325
|
-
plan: parseUsage(res.body),
|
|
326
|
-
context: snap.context, // context is updated separately by setContext()
|
|
327
|
-
fetchedAt: now(),
|
|
328
|
-
error: null,
|
|
329
|
-
};
|
|
330
|
-
emit();
|
|
331
|
-
return snapshot();
|
|
332
|
-
}
|
|
333
|
-
// Degrade. On 429 set a backoff. Keep last-good plan data if we had any.
|
|
334
|
-
if (res.status === 429) backoffUntil = now() + BACKOFF_MS;
|
|
335
|
-
const hadData = snap.plan != null;
|
|
336
|
-
snap = {
|
|
337
|
-
...snap,
|
|
338
|
-
status: hadData ? 'stale' : 'unavailable',
|
|
339
|
-
error: res.error || `http_${res.status}`,
|
|
340
|
-
};
|
|
341
|
-
emit();
|
|
342
|
-
return snapshot();
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// The local context gauge — fed by the server when an assistant turn reports usage
|
|
346
|
-
// (see index.mjs wiring). No network. Pushes an update so the gauge moves per-turn.
|
|
347
|
-
function setContext(usage, model) {
|
|
348
|
-
snap = { ...snap, context: computeContext(usage || {}, model || '') };
|
|
349
|
-
emit();
|
|
350
|
-
return snap.context;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function snapshot() {
|
|
354
|
-
return {
|
|
355
|
-
status: snap.status,
|
|
356
|
-
plan: snap.plan,
|
|
357
|
-
context: snap.context,
|
|
358
|
-
fetchedAt: snap.fetchedAt,
|
|
359
|
-
error: snap.error,
|
|
360
|
-
// The ack travels with the snapshot so the UI knows whether to show the
|
|
361
|
-
// one-time disclosure before first activation.
|
|
362
|
-
disclosed: isDisclosed(),
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// --- disclosure ack ---
|
|
367
|
-
function isDisclosed() {
|
|
368
|
-
try {
|
|
369
|
-
const v = JSON.parse(fs.readFileSync(ackFile, 'utf8'));
|
|
370
|
-
return v?.acknowledged === true;
|
|
371
|
-
} catch {
|
|
372
|
-
return false;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
function acknowledge() {
|
|
376
|
-
try {
|
|
377
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
378
|
-
const tmp = `${ackFile}.${process.pid}.tmp`;
|
|
379
|
-
fs.writeFileSync(tmp, JSON.stringify({ acknowledged: true, ts: now() }, null, 2));
|
|
380
|
-
fs.renameSync(tmp, ackFile);
|
|
381
|
-
} catch {
|
|
382
|
-
/* read-only fs — degrade to not-persisted (will re-ask, harmless) */
|
|
383
|
-
}
|
|
384
|
-
return true;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// --- timer ---
|
|
388
|
-
function start() {
|
|
389
|
-
if (timer) return; // idempotent
|
|
390
|
-
// Lazy first fetch (don't block startup); then the steady cadence.
|
|
391
|
-
refresh();
|
|
392
|
-
timer = setInterval(refresh, pollMs);
|
|
393
|
-
if (timer.unref) timer.unref(); // never keep the process alive for a gauge
|
|
394
|
-
}
|
|
395
|
-
function stop() {
|
|
396
|
-
if (timer) { clearInterval(timer); timer = null; }
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return {
|
|
400
|
-
dir, ackFile,
|
|
401
|
-
refresh, snapshot, setContext,
|
|
402
|
-
isDisclosed, acknowledge,
|
|
403
|
-
start, stop,
|
|
404
|
-
};
|
|
405
|
-
}
|
|
1
|
+
// Usage + context service — the data behind the floating "Usage" canvas block (§8 of
|
|
2
|
+
// docs/agent-engine-and-commands-design.md). Two numbers, two sources:
|
|
3
|
+
//
|
|
4
|
+
// 1. Plan utilization (Session 5-hour + Weekly 7-day %) — AUTHORITATIVE, from
|
|
5
|
+
// Anthropic's own OAuth usage endpoint. The same endpoint ccstatusline reads.
|
|
6
|
+
// 2. Context % ("working memory") — LOCAL, computed from the
|
|
7
|
+
// latest assistant turn's token usage (no network). See computeContext().
|
|
8
|
+
//
|
|
9
|
+
// DESIGN RULES baked in here (don't relitigate — see §8):
|
|
10
|
+
// - The endpoint is UNOFFICIAL/community-documented → we DEGRADE, never throw. A
|
|
11
|
+
// failure keeps the last-good value and flips status to 'stale'/'unavailable';
|
|
12
|
+
// the UI shows that honestly. Core UX never depends on it.
|
|
13
|
+
// - `User-Agent: claude-code/<version>` is MANDATORY — omitting it is a hard 429.
|
|
14
|
+
// - Poll every 60–120s and ALWAYS re-poll: utilization climbs WITHIN a window as the
|
|
15
|
+
// user consumes, so `resets_at` only drives the countdown + a post-reset refresh —
|
|
16
|
+
// it never suppresses polling.
|
|
17
|
+
// - On 429, back off (don't hammer) and serve stale.
|
|
18
|
+
// - We READ the user's Claude OAuth token locally (new trust surface). It only ever
|
|
19
|
+
// leaves the box to api.anthropic.com — never logged, never sent elsewhere. A
|
|
20
|
+
// one-time disclosure (stored ack under ~/.wild-workspace) gates first activation.
|
|
21
|
+
//
|
|
22
|
+
// VERIFIED EMPIRICALLY (2026-06-11, this box, `claude` 2.1.172):
|
|
23
|
+
// - ~/.claude/.credentials.json = { claudeAiOauth: { accessToken, expiresAt, … } }.
|
|
24
|
+
// - A real turn's usage: input_tokens=2, cache_creation_input_tokens=45504 → an
|
|
25
|
+
// input-only context gauge reads ~20× too low. Numerator = sum of the three.
|
|
26
|
+
// - Records store the model as `claude-opus-4-8` with the `[1m]` suffix STRIPPED, so
|
|
27
|
+
// a 1M-context session can't be distinguished from 200k by model name alone — the
|
|
28
|
+
// window map below defaults accordingly; see WINDOW_CALIBRATION note.
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
|
|
35
|
+
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
|
36
|
+
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
37
|
+
|
|
38
|
+
// Poll cadence. The TTL *is* the cadence — we re-poll on it (see header). Kept in the
|
|
39
|
+
// 60–120s band the design fixed (rate-limit-friendly); default 90s.
|
|
40
|
+
export const DEFAULT_POLL_MS = 90_000;
|
|
41
|
+
// After a transient 429 we back off to this before the next attempt.
|
|
42
|
+
export const BACKOFF_MS = 5 * 60_000;
|
|
43
|
+
// Per-request network timeout — never let a hung socket stall the timer.
|
|
44
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
45
|
+
|
|
46
|
+
// --- credential resolution -------------------------------------------------
|
|
47
|
+
// Order (per §8): CLAUDE_CONFIG_DIR → ~/.claude/.credentials.json → macOS keychain.
|
|
48
|
+
// Returns { token, expiresAt } or null. Never throws (degrade).
|
|
49
|
+
|
|
50
|
+
function readCredsFile(file) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
const o = raw.claudeAiOauth || raw; // tolerate both shapes
|
|
54
|
+
const token = o.accessToken || o.access_token;
|
|
55
|
+
if (typeof token !== 'string' || !token) return null;
|
|
56
|
+
const expiresAt = o.expiresAt ?? o.expires_at ?? null;
|
|
57
|
+
return { token, expiresAt };
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readKeychain() {
|
|
64
|
+
// macOS only. `security` returns the secret on stdout; any failure → null. The
|
|
65
|
+
// secret name follows Claude Code's convention ("Claude Code-credentials").
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync(
|
|
68
|
+
'security',
|
|
69
|
+
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
70
|
+
{ encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
71
|
+
);
|
|
72
|
+
const raw = JSON.parse(out.trim());
|
|
73
|
+
const o = raw.claudeAiOauth || raw;
|
|
74
|
+
const token = o.accessToken || o.access_token;
|
|
75
|
+
if (typeof token !== 'string' || !token) return null;
|
|
76
|
+
return { token, expiresAt: o.expiresAt ?? o.expires_at ?? null };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readOAuthToken({
|
|
83
|
+
env = process.env,
|
|
84
|
+
platform = process.platform,
|
|
85
|
+
home = os.homedir(),
|
|
86
|
+
} = {}) {
|
|
87
|
+
// 1. CLAUDE_CONFIG_DIR/.credentials.json
|
|
88
|
+
const configDir = env.CLAUDE_CONFIG_DIR;
|
|
89
|
+
if (configDir) {
|
|
90
|
+
const hit = readCredsFile(path.join(configDir, '.credentials.json'));
|
|
91
|
+
if (hit) return hit;
|
|
92
|
+
}
|
|
93
|
+
// 2. ~/.claude/.credentials.json
|
|
94
|
+
const hit = readCredsFile(path.join(home, '.claude', '.credentials.json'));
|
|
95
|
+
if (hit) return hit;
|
|
96
|
+
// 3. macOS keychain
|
|
97
|
+
if (platform === 'darwin') {
|
|
98
|
+
const kc = readKeychain();
|
|
99
|
+
if (kc) return kc;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// True when a token is present but already past its expiry. v1 does NOT refresh
|
|
105
|
+
// tokens (out of scope) — an expired token degrades to 'unavailable' so the user
|
|
106
|
+
// is told honestly rather than seeing a silent 401 loop.
|
|
107
|
+
export function isExpired(cred, now = Date.now()) {
|
|
108
|
+
if (!cred || cred.expiresAt == null) return false;
|
|
109
|
+
const exp = Number(cred.expiresAt);
|
|
110
|
+
if (!Number.isFinite(exp)) return false;
|
|
111
|
+
// expiresAt is epoch ms in the credentials file.
|
|
112
|
+
return exp <= now;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- claude version (for the mandatory UA) ---------------------------------
|
|
116
|
+
// Best-effort, detected once and cached. The `claude-code/` PREFIX is what the
|
|
117
|
+
// endpoint requires (omit → 429); the exact version is cosmetic, so a sane fallback
|
|
118
|
+
// is fine if detection fails. Override via WILD_WORKSPACE_CLAUDE_VERSION (tests do).
|
|
119
|
+
|
|
120
|
+
let _versionCache;
|
|
121
|
+
export function claudeVersion(env = process.env) {
|
|
122
|
+
if (env.WILD_WORKSPACE_CLAUDE_VERSION) return env.WILD_WORKSPACE_CLAUDE_VERSION;
|
|
123
|
+
if (_versionCache !== undefined) return _versionCache;
|
|
124
|
+
try {
|
|
125
|
+
const out = execFileSync('claude', ['--version'], {
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
timeout: 5000,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
129
|
+
});
|
|
130
|
+
const m = out.match(/(\d+\.\d+\.\d+)/);
|
|
131
|
+
_versionCache = m ? m[1] : '2.1.172';
|
|
132
|
+
} catch {
|
|
133
|
+
_versionCache = '2.1.172'; // recent known-good fallback
|
|
134
|
+
}
|
|
135
|
+
return _versionCache;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function userAgent(env = process.env) {
|
|
139
|
+
return `claude-code/${claudeVersion(env)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- the network call ------------------------------------------------------
|
|
143
|
+
// Returns { ok:true, status, body } on a 2xx with JSON, else { ok:false, status,
|
|
144
|
+
// error }. NEVER throws — the caller's degrade logic keys off the shape.
|
|
145
|
+
|
|
146
|
+
export async function fetchUsage({
|
|
147
|
+
token,
|
|
148
|
+
ua,
|
|
149
|
+
fetchImpl = globalThis.fetch,
|
|
150
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!token) return { ok: false, status: 0, error: 'no_token' };
|
|
153
|
+
if (typeof fetchImpl !== 'function') return { ok: false, status: 0, error: 'no_fetch' };
|
|
154
|
+
const ctrl = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetchImpl(USAGE_ENDPOINT, {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
headers: {
|
|
160
|
+
Authorization: `Bearer ${token}`,
|
|
161
|
+
'anthropic-beta': OAUTH_BETA,
|
|
162
|
+
'User-Agent': ua, // ← MANDATORY (omit → hard 429)
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
},
|
|
165
|
+
signal: ctrl.signal,
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) return { ok: false, status: res.status, error: `http_${res.status}` };
|
|
168
|
+
const body = await res.json();
|
|
169
|
+
return { ok: true, status: res.status, body };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { ok: false, status: 0, error: e?.name === 'AbortError' ? 'timeout' : 'network' };
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- parse the usage body --------------------------------------------------
|
|
178
|
+
// Tolerant: any missing block degrades to null/absent rather than throwing. Shape
|
|
179
|
+
// (per §8): { five_hour, seven_day, seven_day_opus, seven_day_sonnet, extra_usage }.
|
|
180
|
+
|
|
181
|
+
function pctBlock(b) {
|
|
182
|
+
if (!b || typeof b !== 'object') return null;
|
|
183
|
+
const u = Number(b.utilization);
|
|
184
|
+
return {
|
|
185
|
+
utilization: Number.isFinite(u) ? Math.max(0, Math.min(100, u)) : 0,
|
|
186
|
+
resets_at: typeof b.resets_at === 'string' ? b.resets_at : null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function parseUsage(body = {}) {
|
|
191
|
+
if (!body || typeof body !== 'object') body = {};
|
|
192
|
+
const out = {
|
|
193
|
+
five_hour: pctBlock(body.five_hour),
|
|
194
|
+
seven_day: pctBlock(body.seven_day),
|
|
195
|
+
seven_day_opus: pctBlock(body.seven_day_opus),
|
|
196
|
+
seven_day_sonnet: pctBlock(body.seven_day_sonnet),
|
|
197
|
+
};
|
|
198
|
+
const ex = body.extra_usage;
|
|
199
|
+
if (ex && typeof ex === 'object') {
|
|
200
|
+
out.extra_usage = {
|
|
201
|
+
is_enabled: !!ex.is_enabled,
|
|
202
|
+
monthly_limit: Number(ex.monthly_limit) || 0,
|
|
203
|
+
used_credits: Number(ex.used_credits) || 0,
|
|
204
|
+
utilization: Number.isFinite(Number(ex.utilization)) ? Number(ex.utilization) : 0,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- context gauge (LOCAL, no network) -------------------------------------
|
|
211
|
+
//
|
|
212
|
+
// WINDOW_CALIBRATION (the documented residual — see §8/§9):
|
|
213
|
+
// - Numerator = input + cache_creation + cache_read (verified; input-only is ~10–20×
|
|
214
|
+
// low because cache dominates the prompt).
|
|
215
|
+
// - Denominator = the model's context window. Records DON'T carry context_window_size
|
|
216
|
+
// and STRIP the `[1m]` suffix, so we map by model name + detect a `[1m]` suffix when
|
|
217
|
+
// the live stream-json provides one. The 1M variant therefore UNDER-detects from
|
|
218
|
+
// historical records (defaults to 200k) — acceptable for a "working memory" gauge,
|
|
219
|
+
// not an exact clone of Claude's /context. If we adopt Claude's statusline hook
|
|
220
|
+
// later, prefer its context_window_size over this map.
|
|
221
|
+
// - Residual #2: does /context measure toward the full window or the auto-compact
|
|
222
|
+
// threshold? We measure toward the FULL window (ccstatusline's convention) and label
|
|
223
|
+
// it "working memory", not Claude's exact %.
|
|
224
|
+
|
|
225
|
+
const K = 1000;
|
|
226
|
+
const DEFAULT_WINDOW = 200 * K;
|
|
227
|
+
const ONE_M = 1000 * K;
|
|
228
|
+
// Prefix → window. Keep claude + the glm-* models this workspace has seen; unknown →
|
|
229
|
+
// DEFAULT_WINDOW. (Non-Claude models get NO plan-usage, but the context gauge still works.)
|
|
230
|
+
const WINDOWS = [
|
|
231
|
+
['claude-opus', 200 * K],
|
|
232
|
+
['claude-sonnet', 200 * K],
|
|
233
|
+
['claude-haiku', 200 * K],
|
|
234
|
+
['claude-fable', 200 * K],
|
|
235
|
+
['glm', 200 * K],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
export function windowFor(model) {
|
|
239
|
+
if (!model || typeof model !== 'string') return DEFAULT_WINDOW;
|
|
240
|
+
const m = model.toLowerCase();
|
|
241
|
+
// 1M-context variants advertise themselves with a [1m] / -1m marker WHEN present.
|
|
242
|
+
if (m.includes('[1m]') || m.includes('-1m') || m.endsWith(':1m')) return ONE_M;
|
|
243
|
+
for (const [prefix, w] of WINDOWS) if (m.startsWith(prefix)) return w;
|
|
244
|
+
return DEFAULT_WINDOW;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function computeContext(usage = {}, model = '') {
|
|
248
|
+
const input = Number(usage.input_tokens) || 0;
|
|
249
|
+
const cacheCreate = Number(usage.cache_creation_input_tokens) || 0;
|
|
250
|
+
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
251
|
+
const used = input + cacheCreate + cacheRead;
|
|
252
|
+
const window = windowFor(model);
|
|
253
|
+
const pct = window > 0 ? Math.max(0, Math.min(100, (used / window) * 100)) : 0;
|
|
254
|
+
return { used, window, pct, model: model || null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- disclosure ack (server-side, one-time per USER) -----------------------
|
|
258
|
+
// Stored under ~/.wild-workspace/usage/ (CLAUDE.md #1 — never the synced workspace,
|
|
259
|
+
// never localStorage which would be per-browser/device). One file, last-write-wins.
|
|
260
|
+
|
|
261
|
+
function defaultUsageDir(env = process.env, home = os.homedir()) {
|
|
262
|
+
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
|
|
263
|
+
return path.join(base, 'usage');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- the stateful service --------------------------------------------------
|
|
267
|
+
// One instance per server. Owns the cached snapshot, the poll timer, the degrade
|
|
268
|
+
// logic, the local context gauge, and the disclosure ack. `onUpdate(state)` fires on
|
|
269
|
+
// every snapshot change (the WS push path mirrors the bazaar meter — no client poll).
|
|
270
|
+
|
|
271
|
+
export function createUsageService({
|
|
272
|
+
env = process.env,
|
|
273
|
+
platform = process.platform,
|
|
274
|
+
home = os.homedir(),
|
|
275
|
+
baseDir, // override the ack dir (tests)
|
|
276
|
+
fetchImpl = globalThis.fetch,
|
|
277
|
+
pollMs = DEFAULT_POLL_MS,
|
|
278
|
+
onUpdate = null,
|
|
279
|
+
now = () => Date.now(),
|
|
280
|
+
} = {}) {
|
|
281
|
+
const dir = baseDir || defaultUsageDir(env, home);
|
|
282
|
+
const ackFile = path.join(dir, 'disclosure.json');
|
|
283
|
+
|
|
284
|
+
// status: 'loading' (never fetched) | 'live' (fresh good data) | 'stale' (had data,
|
|
285
|
+
// last refresh failed) | 'unavailable' (no token / expired / never got data).
|
|
286
|
+
let snap = {
|
|
287
|
+
status: 'loading',
|
|
288
|
+
plan: null, // parsed usage blocks (five_hour, seven_day, …)
|
|
289
|
+
context: null, // { used, window, pct, model }
|
|
290
|
+
fetchedAt: null, // ms of the last GOOD fetch
|
|
291
|
+
error: null, // last error marker (for the UI's honest message)
|
|
292
|
+
};
|
|
293
|
+
let timer = null;
|
|
294
|
+
let backoffUntil = 0;
|
|
295
|
+
|
|
296
|
+
function emit() {
|
|
297
|
+
if (typeof onUpdate === 'function') {
|
|
298
|
+
try { onUpdate(snapshot()); } catch { /* a bad listener never breaks the timer */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// One refresh cycle. NEVER throws. Returns the new snapshot.
|
|
303
|
+
async function refresh() {
|
|
304
|
+
// Respect a 429 backoff window: serve stale, don't hammer.
|
|
305
|
+
if (now() < backoffUntil) {
|
|
306
|
+
if (snap.status === 'live') snap = { ...snap, status: 'stale', error: 'backoff' };
|
|
307
|
+
return snapshot();
|
|
308
|
+
}
|
|
309
|
+
const cred = readOAuthToken({ env, platform, home });
|
|
310
|
+
if (!cred) {
|
|
311
|
+
snap = { ...snap, status: 'unavailable', error: 'no_token' };
|
|
312
|
+
emit();
|
|
313
|
+
return snapshot();
|
|
314
|
+
}
|
|
315
|
+
if (isExpired(cred, now())) {
|
|
316
|
+
// v1 does not refresh tokens — tell the user honestly.
|
|
317
|
+
snap = { ...snap, status: 'unavailable', error: 'expired_token' };
|
|
318
|
+
emit();
|
|
319
|
+
return snapshot();
|
|
320
|
+
}
|
|
321
|
+
const res = await fetchUsage({ token: cred.token, ua: userAgent(env), fetchImpl });
|
|
322
|
+
if (res.ok) {
|
|
323
|
+
snap = {
|
|
324
|
+
status: 'live',
|
|
325
|
+
plan: parseUsage(res.body),
|
|
326
|
+
context: snap.context, // context is updated separately by setContext()
|
|
327
|
+
fetchedAt: now(),
|
|
328
|
+
error: null,
|
|
329
|
+
};
|
|
330
|
+
emit();
|
|
331
|
+
return snapshot();
|
|
332
|
+
}
|
|
333
|
+
// Degrade. On 429 set a backoff. Keep last-good plan data if we had any.
|
|
334
|
+
if (res.status === 429) backoffUntil = now() + BACKOFF_MS;
|
|
335
|
+
const hadData = snap.plan != null;
|
|
336
|
+
snap = {
|
|
337
|
+
...snap,
|
|
338
|
+
status: hadData ? 'stale' : 'unavailable',
|
|
339
|
+
error: res.error || `http_${res.status}`,
|
|
340
|
+
};
|
|
341
|
+
emit();
|
|
342
|
+
return snapshot();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The local context gauge — fed by the server when an assistant turn reports usage
|
|
346
|
+
// (see index.mjs wiring). No network. Pushes an update so the gauge moves per-turn.
|
|
347
|
+
function setContext(usage, model) {
|
|
348
|
+
snap = { ...snap, context: computeContext(usage || {}, model || '') };
|
|
349
|
+
emit();
|
|
350
|
+
return snap.context;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function snapshot() {
|
|
354
|
+
return {
|
|
355
|
+
status: snap.status,
|
|
356
|
+
plan: snap.plan,
|
|
357
|
+
context: snap.context,
|
|
358
|
+
fetchedAt: snap.fetchedAt,
|
|
359
|
+
error: snap.error,
|
|
360
|
+
// The ack travels with the snapshot so the UI knows whether to show the
|
|
361
|
+
// one-time disclosure before first activation.
|
|
362
|
+
disclosed: isDisclosed(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- disclosure ack ---
|
|
367
|
+
function isDisclosed() {
|
|
368
|
+
try {
|
|
369
|
+
const v = JSON.parse(fs.readFileSync(ackFile, 'utf8'));
|
|
370
|
+
return v?.acknowledged === true;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function acknowledge() {
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
378
|
+
const tmp = `${ackFile}.${process.pid}.tmp`;
|
|
379
|
+
fs.writeFileSync(tmp, JSON.stringify({ acknowledged: true, ts: now() }, null, 2));
|
|
380
|
+
fs.renameSync(tmp, ackFile);
|
|
381
|
+
} catch {
|
|
382
|
+
/* read-only fs — degrade to not-persisted (will re-ask, harmless) */
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- timer ---
|
|
388
|
+
function start() {
|
|
389
|
+
if (timer) return; // idempotent
|
|
390
|
+
// Lazy first fetch (don't block startup); then the steady cadence.
|
|
391
|
+
refresh();
|
|
392
|
+
timer = setInterval(refresh, pollMs);
|
|
393
|
+
if (timer.unref) timer.unref(); // never keep the process alive for a gauge
|
|
394
|
+
}
|
|
395
|
+
function stop() {
|
|
396
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
dir, ackFile,
|
|
401
|
+
refresh, snapshot, setContext,
|
|
402
|
+
isDisclosed, acknowledge,
|
|
403
|
+
start, stop,
|
|
404
|
+
};
|
|
405
|
+
}
|