@venturewild/workspace 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -83
  4. package/server/bin/wild-workspace.mjs +995 -995
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -453
  9. package/server/src/bazaar/core.mjs +579 -579
  10. package/server/src/bazaar/index.mjs +75 -75
  11. package/server/src/bazaar/mcp-server.mjs +328 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
  24. package/server/src/canvas/core.mjs +421 -421
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/config.mjs +404 -404
  28. package/server/src/daemon-bin.mjs +110 -110
  29. package/server/src/daemon-supervisor.mjs +285 -285
  30. package/server/src/doctor.mjs +375 -375
  31. package/server/src/inbox.mjs +86 -86
  32. package/server/src/index.mjs +2475 -2365
  33. package/server/src/logpaths.mjs +98 -98
  34. package/server/src/observability.mjs +45 -45
  35. package/server/src/operator.mjs +92 -92
  36. package/server/src/pairing.mjs +137 -137
  37. package/server/src/service.mjs +515 -515
  38. package/server/src/session-reporter.mjs +201 -201
  39. package/server/src/settings.mjs +145 -0
  40. package/server/src/share.mjs +182 -182
  41. package/server/src/skills.mjs +213 -0
  42. package/server/src/supervisor.mjs +647 -647
  43. package/server/src/support-consent.mjs +133 -133
  44. package/server/src/sync.mjs +248 -248
  45. package/server/src/transcript.mjs +121 -121
  46. package/server/src/turn-mcp.mjs +46 -46
  47. package/server/src/usage.mjs +405 -0
  48. package/web/dist/assets/index-BxRx8EsD.js +91 -0
  49. package/web/dist/assets/index-DoOPBr3s.css +1 -0
  50. package/web/dist/index.html +2 -2
  51. package/web/dist/assets/index-B7cOsWLt.js +0 -91
  52. package/web/dist/assets/index-Dl0VT5e6.css +0 -1
@@ -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
+ }