@venturewild/workspace 0.3.6 → 0.3.8

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.
@@ -0,0 +1,213 @@
1
+ // Skill discovery + the Powers-block curation store (§7/§8). There is NO native
2
+ // "list commands" API in Claude Code, so we discover the user's skills the cleanest
3
+ // way the design settled on: SCAN the skill directories and parse YAML frontmatter.
4
+ //
5
+ // - project skills: <workspace>/.claude/skills/<name>/SKILL.md
6
+ // - project commands: <workspace>/.claude/commands/<name>.md
7
+ // - user skills: ~/.claude/skills/<name>/SKILL.md
8
+ //
9
+ // Precedence (per §2): project > user. The Powers block is USER-CURATED — we surface
10
+ // what the user has (their own + a teammate's, both trusted by membership) with NO
11
+ // VW-prescribed core forced. The "─ from Bazaar ─" zone stays empty/LOCKED until the
12
+ // Shelf trust/provenance Class-D gate ships (a tap runs third-party instructions with
13
+ // bypassPermissions), so this module never returns Bazaar skills.
14
+ //
15
+ // Invocation is NOT done here: a Powers tap injects `/<name>` as a chat message, which
16
+ // Claude Code expands+runs headless (verified §2) — the same stdin path we already use.
17
+ //
18
+ // Curation (which skills are pinned/hidden + their order) lives under
19
+ // ~/.wild-workspace/powers/ (CLAUDE.md #1 — never the synced repo, never localStorage).
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+
25
+ // Bounds so a workspace stuffed with skills can't bloat the store/UI.
26
+ const CAP = { skills: 80, name: 64, desc: 280 };
27
+
28
+ // --- frontmatter parse -----------------------------------------------------
29
+ // Minimal, dependency-free. We only need a few scalar keys; the description often
30
+ // CONTAINS colons ("Usage: /foo"), so we split on the FIRST colon only. Never throws.
31
+
32
+ export function parseFrontmatter(text = '') {
33
+ const out = {};
34
+ if (typeof text !== 'string') return out;
35
+ // Frontmatter is the block between the first two `---` fences at the top.
36
+ const m = text.match(/^?---\s*\r?\n([\s\S]*?)\r?\n---/);
37
+ if (!m) return out;
38
+ for (const rawLine of m[1].split(/\r?\n/)) {
39
+ const line = rawLine.trim();
40
+ if (!line || line.startsWith('#')) continue;
41
+ const i = line.indexOf(':');
42
+ if (i < 0) continue;
43
+ const key = line.slice(0, i).trim();
44
+ let val = line.slice(i + 1).trim();
45
+ // strip matching surrounding quotes
46
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
47
+ val = val.slice(1, -1);
48
+ }
49
+ out[key] = val;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function asBool(v, dflt) {
55
+ if (v === undefined) return dflt;
56
+ const s = String(v).trim().toLowerCase();
57
+ if (s === 'true' || s === 'yes' || s === '1') return true;
58
+ if (s === 'false' || s === 'no' || s === '0') return false;
59
+ return dflt;
60
+ }
61
+
62
+ function clip(s, n) {
63
+ return String(s || '').slice(0, n);
64
+ }
65
+
66
+ // Build one skill record from a parsed frontmatter + its fallback name.
67
+ function toSkill(fm, fallbackName, source) {
68
+ const name = clip(fm.name || fallbackName, CAP.name).trim();
69
+ if (!name) return null;
70
+ return {
71
+ name,
72
+ description: clip(fm.description || '', CAP.desc).trim(),
73
+ source, // 'project' | 'user'
74
+ // user-invocable defaults TRUE (most skills can be run as /name); only an explicit
75
+ // `user-invocable: false` opts out of being a tappable Power.
76
+ userInvocable: asBool(fm['user-invocable'], true),
77
+ };
78
+ }
79
+
80
+ function readFileSafe(file) {
81
+ try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
82
+ }
83
+
84
+ function listDirs(dir) {
85
+ try {
86
+ return fs.readdirSync(dir, { withFileTypes: true })
87
+ .filter((e) => e.isDirectory())
88
+ .map((e) => e.name);
89
+ } catch { return []; }
90
+ }
91
+
92
+ function listFiles(dir, ext) {
93
+ try {
94
+ return fs.readdirSync(dir, { withFileTypes: true })
95
+ .filter((e) => e.isFile() && e.name.endsWith(ext))
96
+ .map((e) => e.name);
97
+ } catch { return []; }
98
+ }
99
+
100
+ // Scan one skills root (<root>/<name>/SKILL.md) → skill records.
101
+ function scanSkillsRoot(root, source) {
102
+ const out = [];
103
+ for (const name of listDirs(root)) {
104
+ const text = readFileSafe(path.join(root, name, 'SKILL.md'));
105
+ if (text == null) continue;
106
+ const s = toSkill(parseFrontmatter(text), name, source);
107
+ if (s) out.push(s);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Scan a commands root (<root>/<name>.md) → skill records.
113
+ function scanCommandsRoot(root, source) {
114
+ const out = [];
115
+ for (const file of listFiles(root, '.md')) {
116
+ const text = readFileSafe(path.join(root, file));
117
+ if (text == null) continue;
118
+ const s = toSkill(parseFrontmatter(text), file.replace(/\.md$/, ''), source);
119
+ if (s) out.push(s);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Discover the workspace's skills. Project entries take precedence over same-named
126
+ * user entries (§2). Returns ONLY user-invocable skills (the Powers block taps run
127
+ * them as /name), capped. Never throws — a missing dir just yields fewer skills.
128
+ */
129
+ export function discoverSkills({ workspaceDir = process.cwd(), home = os.homedir() } = {}) {
130
+ const project = [
131
+ ...scanSkillsRoot(path.join(workspaceDir, '.claude', 'skills'), 'project'),
132
+ ...scanCommandsRoot(path.join(workspaceDir, '.claude', 'commands'), 'project'),
133
+ ];
134
+ const user = scanSkillsRoot(path.join(home, '.claude', 'skills'), 'user');
135
+
136
+ // Dedup by name, project wins.
137
+ const byName = new Map();
138
+ for (const s of user) byName.set(s.name, s);
139
+ for (const s of project) byName.set(s.name, s); // overrides user
140
+ return [...byName.values()]
141
+ .filter((s) => s.userInvocable)
142
+ .sort((a, b) => a.name.localeCompare(b.name))
143
+ .slice(0, CAP.skills);
144
+ }
145
+
146
+ // --- curation store --------------------------------------------------------
147
+ // The user's arrangement: `order` (pinned, in display order) + `hidden` (subtracted).
148
+ // A discovered skill not in either is shown after the pinned ones (so a NEW skill
149
+ // appears without the user having to add it — "add/subtract/arrange", subtract is opt-in).
150
+
151
+ export function defaultPowersDir(env = process.env, home = os.homedir()) {
152
+ const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
153
+ return path.join(base, 'powers');
154
+ }
155
+
156
+ function readJsonSafe(file, fallback) {
157
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
158
+ }
159
+
160
+ export function createPowers({ baseDir, workspaceDir, home = os.homedir(), env = process.env } = {}) {
161
+ const dir = baseDir || defaultPowersDir(env, home);
162
+ const curationFile = path.join(dir, 'curation.json');
163
+
164
+ function getCuration() {
165
+ const v = readJsonSafe(curationFile, null);
166
+ const order = Array.isArray(v?.order) ? v.order.filter((s) => typeof s === 'string') : [];
167
+ const hidden = Array.isArray(v?.hidden) ? v.hidden.filter((s) => typeof s === 'string') : [];
168
+ return { order, hidden };
169
+ }
170
+
171
+ function saveCuration(raw = {}) {
172
+ const order = Array.isArray(raw.order)
173
+ ? [...new Set(raw.order.filter((s) => typeof s === 'string').map((s) => s.slice(0, CAP.name)))].slice(0, CAP.skills)
174
+ : [];
175
+ const hidden = Array.isArray(raw.hidden)
176
+ ? [...new Set(raw.hidden.filter((s) => typeof s === 'string').map((s) => s.slice(0, CAP.name)))].slice(0, CAP.skills)
177
+ : [];
178
+ const data = { order, hidden, ts: Date.now() };
179
+ try {
180
+ fs.mkdirSync(dir, { recursive: true });
181
+ const tmp = `${curationFile}.${process.pid}.tmp`;
182
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
183
+ fs.renameSync(tmp, curationFile);
184
+ } catch { /* read-only fs — degrade to not-persisted */ }
185
+ return data;
186
+ }
187
+
188
+ // The merged view the UI renders: discovered skills, ordered by curation, with a
189
+ // `hidden` flag. Pinned (in `order`) first, then the rest alphabetically.
190
+ function state() {
191
+ const discovered = discoverSkills({ workspaceDir, home });
192
+ const { order, hidden } = getCuration();
193
+ const hiddenSet = new Set(hidden);
194
+ const rank = new Map(order.map((n, i) => [n, i]));
195
+ const skills = discovered
196
+ .map((s) => ({ ...s, hidden: hiddenSet.has(s.name) }))
197
+ .sort((a, b) => {
198
+ const ra = rank.has(a.name) ? rank.get(a.name) : Infinity;
199
+ const rb = rank.has(b.name) ? rank.get(b.name) : Infinity;
200
+ if (ra !== rb) return ra - rb;
201
+ return a.name.localeCompare(b.name);
202
+ });
203
+ return {
204
+ skills,
205
+ curation: { order, hidden },
206
+ // The marketplace zone is specified but GATED (Class-D, not built) — always
207
+ // empty + locked here so the UI can render the "─ from Bazaar ─" boundary.
208
+ bazaar: { locked: true, skills: [] },
209
+ };
210
+ }
211
+
212
+ return { dir, curationFile, getCuration, saveCuration, state };
213
+ }
@@ -0,0 +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
+ }