agent-dealer 1.2.2 → 1.2.4

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 (34) hide show
  1. package/bundle/server/dist/capacity/claude-events.js +27 -10
  2. package/bundle/server/dist/capacity/claude-local-cache.js +794 -0
  3. package/bundle/server/dist/capacity/claude-local-cache.test.js +524 -0
  4. package/bundle/server/dist/capacity/cursor-individual-credentials.js +203 -16
  5. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +186 -2
  6. package/bundle/server/dist/capacity/cursor-individual.js +69 -2
  7. package/bundle/server/dist/capacity/cursor-individual.test.js +268 -2
  8. package/bundle/server/dist/capacity/muse-host.js +778 -0
  9. package/bundle/server/dist/capacity/muse-host.test.js +480 -0
  10. package/bundle/server/dist/capacity/muse-lifecycle.test.js +96 -0
  11. package/bundle/server/dist/capacity/muse-probe.js +250 -0
  12. package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
  13. package/bundle/server/dist/capacity/muse.js +320 -63
  14. package/bundle/server/dist/capacity/muse.test.js +8 -51
  15. package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
  16. package/bundle/server/dist/index.js +15 -2
  17. package/bundle/server/dist/routes/index.js +25 -9
  18. package/bundle/server/dist/routes/runtime-capacity.test.js +20 -7
  19. package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
  20. package/bundle/server/dist/runners/muse-serve-session.js +314 -0
  21. package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
  22. package/bundle/server/package.json +2 -2
  23. package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
  24. package/bundle/server/static-ui/assets/{index-DUYzboWh.css → index-CYqRh_-S.css} +1 -1
  25. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
  26. package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
  27. package/bundle/server/static-ui/index.html +2 -2
  28. package/bundle/shared/package.json +1 -1
  29. package/dist/doctor.d.ts +72 -0
  30. package/dist/doctor.js +235 -1
  31. package/dist/doctor.test.d.ts +1 -0
  32. package/dist/doctor.test.js +276 -0
  33. package/package.json +1 -1
  34. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +0 -60
@@ -0,0 +1,794 @@
1
+ // packages/server/src/capacity/claude-local-cache.ts
2
+ //
3
+ // NOT-268: Claude account capacity — local-first source ladder with a
4
+ // one-hour paid fallback.
5
+ //
6
+ // Goal: keep Claude 5H/1W useful between Dealer runs. Production's last
7
+ // Dealer-observed sample can be days old (no recent Dealer-managed Claude
8
+ // run), while Claude Code itself maintains exact provider usage in the
9
+ // `cachedUsageUtilization` key of `~/.claude.json` on every run — including
10
+ // interactive runs outside Dealer. So the freshest valid observation wins
11
+ // from this ladder:
12
+ //
13
+ // 1. Existing Dealer `rate_limit_event` ingestion (claude-events.ts — kept
14
+ // as-is, session-end, per-window newer-wins).
15
+ // 2. Read-only local cache: the `cachedUsageUtilization` subtree of
16
+ // `~/.claude.json` (this module — free, ingested on every capacity
17
+ // read; only that subtree is ever parsed, the rest of the config —
18
+ // accountUuid, email, credentials, projects — is never retained).
19
+ // 3. One minimal bounded paid probe when every valid 5H/1W observation is
20
+ // older than 60 minutes. This is the default; operators can explicitly
21
+ // disable it with `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off`.
22
+ //
23
+ // Both file sources write the same `claude_unified_five_hour` /
24
+ // `claude_unified_seven_day` window keys through the shared
25
+ // `recordClaudeWindowReadings` newer-wins path, so freshness arbitration is
26
+ // automatic per window and an older cache can never clobber a newer event
27
+ // (or vice versa). `source` stays `observed_event` for both; `evidenceRef`
28
+ // tells them apart (`claude-session:unified-windows` vs
29
+ // `claude-cache:cachedUsageUtilization` vs `claude-probe:minimal-print`).
30
+ //
31
+ // Local-cache safety: only the `cachedUsageUtilization` subtree
32
+ // (`fetchedAtMs`, `utilization.five_hour`, `utilization.seven_day`,
33
+ // `utilization.limits[]`) is ever read. `accountUuid`, email, credentials,
34
+ // extra-usage spend, experiments, and the full raw object are never
35
+ // persisted, returned, or logged. Only the account-wide five-hour and
36
+ // seven-day windows are normalized — every other bucket is dropped.
37
+ //
38
+ // Observed provider shapes (verified against a real `~/.claude.json` at
39
+ // 2.1.283 — the cache is percent-scale, not 0–1 fractions):
40
+ // - Named fields: `{ utilization: <0–100 percent>, resets_at: <ISO-8601> }`.
41
+ // Bare `utilization` ≤ 1 still reads as a fraction (compatibility); values
42
+ // in (1, 100] read as percent. Anything else is malformed — rejected.
43
+ // - `limits[]`: `{ kind|group: "session"|"weekly_all", percent: <0–100>,
44
+ // resets_at: <ISO-8601>, ... }`. Model-specific and overage entries are
45
+ // dropped — only the account-wide pair is ever normalized.
46
+ //
47
+ // Probe argv (verified live at 2.1.283 — `claude -p --max-turns 1 --model
48
+ // <bogus>` parses flags and fails only on model resolution, spending
49
+ // nothing, even though `--help` hides the flag):
50
+ // - `--model haiku` — the cheapest supported alias (matches
51
+ // runners/models.ts `haiku` "latest alias").
52
+ // - `--max-turns 1` — hard one-turn bound, belt-and-braces with the
53
+ // structural bound below.
54
+ // - `--tools ""` + `--strict-mcp-config` (with no `--mcp-config`) — no tools,
55
+ // no MCP. With no tools the model cannot continue past its first response,
56
+ // the fixed minimal prompt asks for a single word, and
57
+ // `--max-budget-usd 0.01` hard-caps spend.
58
+ // - `--no-session-persistence` — the probe leaves no resumable session.
59
+ // - `--output-format stream-json` (+ `--verbose`, matching Dealer's own
60
+ // stream-json parsing) so `rate_limit_event`s can be ingested.
61
+ // - `--bare` is deliberately NOT used: it restricts auth to
62
+ // ANTHROPIC_API_KEY/apiKeyHelper and would bypass the account's OAuth
63
+ // login — the probe must bill to the account whose capacity it measures.
64
+ // - Ambient settings are kept (auth must resolve); no worktree is created
65
+ // (cwd is the OS temp dir) and nothing touches Dealer workflow/session
66
+ // rows, worktrees, commits, PRs, or queue events — the probe spawns
67
+ // `claude` directly, never through the coordinator.
68
+ //
69
+ // If a live proof ever shows the minimal probe does not reliably emit 5H/1W
70
+ // (neither in its stream nor via the cache side effect), the probe is a
71
+ // recurring paid no-op: disable the fallback and revise this ticket instead of
72
+ // shipping it. The `no_windows` diagnostic below exists to make that visible.
73
+ import { spawn } from "node:child_process";
74
+ import fs from "node:fs";
75
+ import os from "node:os";
76
+ import path from "node:path";
77
+ import { getDataDir } from "../db/index.js";
78
+ import { resolveClaudeBin } from "../cli-env.js";
79
+ import { parseNdjson } from "../runners/stream-json.js";
80
+ import { listCapacitySnapshots } from "../repository/runtime-capacity.js";
81
+ import { configuredCapacityRuntimes } from "./service.js";
82
+ import { CLAUDE_RUNTIME, extractClaudeCapacityFromEvents, normalizeClaudeResetsAt, recordClaudeCapacityFromEvents, recordClaudeWindowReadings, } from "./claude-events.js";
83
+ /** Override for the Claude cache file (tests, smoke). */
84
+ export const CLAUDE_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
85
+ /** Paid-fallback setting. Unset defaults to PAID_AFTER_1H; `off` disables it. */
86
+ export const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
87
+ export const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
88
+ export const CLAUDE_CAPACITY_REFRESH_OFF_VALUE = "off";
89
+ /** Upper bound for one probe spawn (ms). */
90
+ export const CLAUDE_CAPACITY_PROBE_TIMEOUT_ENV = "AGENT_DEALER_CLAUDE_PROBE_TIMEOUT_MS";
91
+ export const CLAUDE_PROBE_TIMEOUT_MS_DEFAULT = 90_000;
92
+ /** Static evidence pointers — never credentials or raw payloads. */
93
+ export const CLAUDE_CACHE_EVIDENCE_REF = "claude-cache:cachedUsageUtilization";
94
+ export const CLAUDE_PROBE_EVIDENCE_REF = "claude-probe:minimal-print";
95
+ /** Cheapest supported model alias for the probe (cf. runners/models.ts). */
96
+ export const CLAUDE_PROBE_MODEL = "haiku";
97
+ /** Fixed minimal prompt — asserted in tests; never interpolated. */
98
+ export const CLAUDE_PROBE_PROMPT = "Reply with exactly: ok";
99
+ /** Hard spend cap for the probe (USD). */
100
+ export const CLAUDE_PROBE_MAX_BUDGET_USD = 0.01;
101
+ /** A 5H/1W observation newer than this suppresses the paid probe. */
102
+ export const CLAUDE_PROBE_STALE_AFTER_MS = 60 * 60 * 1000;
103
+ /** Minimum gap between probe attempts (per account); failures back off. */
104
+ export const CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS = 60 * 60 * 1000;
105
+ export const CLAUDE_PROBE_BACKOFF_CAP_MS = 8 * 60 * 60 * 1000;
106
+ /** Account-wide window identities for the local cache (exact, lowercase). */
107
+ const FIVE_HOUR_LIMIT_NAMES = new Set(["five_hour", "session"]);
108
+ const WEEKLY_LIMIT_NAMES = new Set(["seven_day", "weekly", "weekly_all"]);
109
+ const CACHE_WINDOW_IDENTITIES = {
110
+ five_hour: {
111
+ windowKey: "claude_unified_five_hour",
112
+ providerBucket: "five_hour",
113
+ durationMinutes: 300,
114
+ criticalRole: "five_hour",
115
+ },
116
+ weekly: {
117
+ windowKey: "claude_unified_seven_day",
118
+ providerBucket: "seven_day",
119
+ durationMinutes: 10080,
120
+ criticalRole: "weekly",
121
+ },
122
+ };
123
+ function asFiniteNumber(value) {
124
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
125
+ }
126
+ function pickNumber(candidates) {
127
+ for (const c of candidates) {
128
+ const n = asFiniteNumber(c);
129
+ if (n !== null)
130
+ return n;
131
+ }
132
+ return null;
133
+ }
134
+ function pickString(candidates) {
135
+ for (const c of candidates) {
136
+ if (typeof c === "string" && c.length > 0)
137
+ return c;
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Path to the Claude Code config file whose `cachedUsageUtilization` key
143
+ * carries the local 5H/1W observation (override env for tests/smoke points
144
+ * at a fixture file instead). Only that key is ever parsed — see
145
+ * `extractClaudeCacheSubtree`.
146
+ */
147
+ export function claudeCacheFilePath() {
148
+ const override = process.env[CLAUDE_CACHE_FILE_ENV]?.trim();
149
+ if (override)
150
+ return override;
151
+ return path.join(process.env.HOME ?? os.homedir(), ".claude.json");
152
+ }
153
+ export function claudeProbeTimeoutMs() {
154
+ const raw = process.env[CLAUDE_CAPACITY_PROBE_TIMEOUT_ENV];
155
+ if (raw !== undefined && raw !== "") {
156
+ const n = Number(raw);
157
+ if (Number.isFinite(n) && n > 0)
158
+ return n;
159
+ }
160
+ return CLAUDE_PROBE_TIMEOUT_MS_DEFAULT;
161
+ }
162
+ /**
163
+ * Fixed probe argv. Pinned by tests: the fixed minimal prompt, cheapest
164
+ * model, `--max-turns 1`, no tools, no MCP, no session persistence, stream
165
+ * JSON, ≤$0.01 budget.
166
+ */
167
+ export function buildClaudeProbeArgv() {
168
+ return [
169
+ "-p",
170
+ CLAUDE_PROBE_PROMPT,
171
+ "--model",
172
+ CLAUDE_PROBE_MODEL,
173
+ "--max-turns",
174
+ "1",
175
+ "--tools",
176
+ "",
177
+ "--strict-mcp-config",
178
+ "--no-session-persistence",
179
+ "--output-format",
180
+ "stream-json",
181
+ "--verbose",
182
+ "--max-budget-usd",
183
+ String(CLAUDE_PROBE_MAX_BUDGET_USD),
184
+ ];
185
+ }
186
+ /**
187
+ * Utilization scale for one cache window entry. Explicit percent spellings
188
+ * (`usedPercent`, `percent`, …) are 0–100. Bare `utilization` (and its
189
+ * `used*` spellings) is dual-scale, matching the observed provider data: a
190
+ * real `~/.claude.json` at 2.1.283 carries percent values there
191
+ * (`{ utilization: 9, … }`), while older shapes carry 0–1 fractions — so
192
+ * ≤ 1 reads as a fraction and (1, 100] reads as percent. Anything else —
193
+ * wrong type, negative, or above 100 on both scales — is malformed and the
194
+ * window is rejected, never guessed.
195
+ */
196
+ function cacheEntryScale(entry) {
197
+ const usedPercent = pickNumber([
198
+ entry.usedPercent,
199
+ entry.used_percent,
200
+ entry.utilizationPercent,
201
+ entry.utilization_percent,
202
+ entry.percent,
203
+ ]);
204
+ if (usedPercent !== null) {
205
+ if (usedPercent < 0 || usedPercent > 100)
206
+ return null;
207
+ return { usedPercent, usedFraction: null };
208
+ }
209
+ // Explicit fraction spellings stay strict 0–1.
210
+ const strictFraction = pickNumber([entry.usedFraction, entry.used_fraction]);
211
+ if (strictFraction !== null) {
212
+ if (strictFraction < 0 || strictFraction > 1)
213
+ return null;
214
+ return { usedPercent: null, usedFraction: strictFraction };
215
+ }
216
+ // Bare `utilization`/`used` is dual-scale (fraction ≤ 1, percent above).
217
+ const usedValue = pickNumber([entry.utilization, entry.used]);
218
+ if (usedValue === null)
219
+ return null;
220
+ if (usedValue < 0 || usedValue > 100)
221
+ return null;
222
+ if (usedValue <= 1)
223
+ return { usedPercent: null, usedFraction: usedValue };
224
+ return { usedPercent: usedValue, usedFraction: null };
225
+ }
226
+ function cacheWindowReading(role, entry, observedAt, observedMs, evidenceRef) {
227
+ const identity = CACHE_WINDOW_IDENTITIES[role];
228
+ let scale = null;
229
+ let resetRaw = null;
230
+ if (typeof entry === "number") {
231
+ const f = asFiniteNumber(entry);
232
+ if (f === null || f < 0 || f > 1)
233
+ return null;
234
+ scale = { usedPercent: null, usedFraction: f };
235
+ }
236
+ else if (entry && typeof entry === "object") {
237
+ const w = entry;
238
+ scale = cacheEntryScale(w);
239
+ if (!scale)
240
+ return null;
241
+ resetRaw =
242
+ w.resets_at ?? w.resetsAt ?? w.reset_at ?? w.resetAt ?? w.resetsAtMs ?? null;
243
+ }
244
+ else {
245
+ return null;
246
+ }
247
+ const resetAt = normalizeClaudeResetsAt(resetRaw);
248
+ if (resetAt !== null) {
249
+ const resetMs = Date.parse(resetAt);
250
+ // A past reset is never current capacity — reject the window so the
251
+ // last-good row survives instead of being overwritten by expired data.
252
+ if (!Number.isFinite(resetMs) || resetMs <= observedMs)
253
+ return null;
254
+ }
255
+ return {
256
+ windowKey: identity.windowKey,
257
+ providerBucket: identity.providerBucket,
258
+ durationMinutes: identity.durationMinutes,
259
+ providerLabel: identity.providerBucket,
260
+ usedValue: scale.usedPercent ?? scale.usedFraction,
261
+ usedUnit: scale.usedPercent !== null ? "percent" : "fraction",
262
+ ...(scale.usedPercent !== null
263
+ ? { usedPercent: scale.usedPercent }
264
+ : { usedFraction: scale.usedFraction }),
265
+ resetAt,
266
+ observedAt,
267
+ source: "observed_event",
268
+ evidenceRef,
269
+ criticalRole: identity.criticalRole,
270
+ };
271
+ }
272
+ function limitEntryRole(name) {
273
+ const b = name.trim().toLowerCase();
274
+ if (FIVE_HOUR_LIMIT_NAMES.has(b))
275
+ return "five_hour";
276
+ if (WEEKLY_LIMIT_NAMES.has(b))
277
+ return "weekly";
278
+ return null;
279
+ }
280
+ /**
281
+ * Normalize the `utilization` subtree of `cachedUsageUtilization` into
282
+ * account-wide 5H/1W readings. `fetchedAtMs` is the observed time.
283
+ *
284
+ * - `utilization.limits[]` is preferred when it carries the explicit
285
+ * account-wide windows (`session` → five-hour, `weekly_all` → weekly,
286
+ * named via `kind`/`group`, scaled via `percent`); any other limit entry
287
+ * (model-specific, overage) is dropped — only the account-wide pair is
288
+ * ever normalized.
289
+ * - The named `five_hour` / `seven_day` fields are the compatibility path
290
+ * for whichever role `limits[]` does not cover. Bare `utilization` values
291
+ * are dual-scale (≤ 1 fraction, above percent to 100).
292
+ * - Returns null when nothing usable is present: missing/future
293
+ * `fetchedAtMs`, no account-wide window with a usable scale, or every
294
+ * window rejected (malformed scale, expired reset). Account identity,
295
+ * extra-usage spend, experiments, and the raw object never leave this
296
+ * function.
297
+ */
298
+ export function parseClaudeCachedUtilization(raw, nowMs = Date.now(), evidenceRef = CLAUDE_CACHE_EVIDENCE_REF) {
299
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
300
+ return null;
301
+ const root = raw;
302
+ const fetchedAtMs = asFiniteNumber(root.fetchedAtMs);
303
+ if (fetchedAtMs === null || fetchedAtMs <= 0 || fetchedAtMs > nowMs)
304
+ return null;
305
+ const observedAt = new Date(fetchedAtMs).toISOString();
306
+ const utilization = root.utilization;
307
+ if (!utilization || typeof utilization !== "object" || Array.isArray(utilization)) {
308
+ return null;
309
+ }
310
+ const u = utilization;
311
+ const byRole = new Map();
312
+ const consider = (role, entry) => {
313
+ if (byRole.has(role))
314
+ return;
315
+ const reading = cacheWindowReading(role, entry, observedAt, fetchedAtMs, evidenceRef);
316
+ if (reading)
317
+ byRole.set(role, reading);
318
+ };
319
+ // Preferred path: explicit account-wide entries in limits[]. Real
320
+ // entries name their window via `kind`/`group` (`session`, `weekly_all`)
321
+ // and scale via `percent`; the `name`/`window`/… + `utilization` spellings
322
+ // are the compatibility path for older shapes.
323
+ const limits = u.limits;
324
+ if (Array.isArray(limits)) {
325
+ for (const item of limits) {
326
+ if (!item || typeof item !== "object" || Array.isArray(item))
327
+ continue;
328
+ const w = item;
329
+ const name = pickString([
330
+ w.name,
331
+ w.window,
332
+ w.bucket,
333
+ w.key,
334
+ w.id,
335
+ w.kind,
336
+ w.group,
337
+ ]);
338
+ if (!name)
339
+ continue;
340
+ const role = limitEntryRole(name);
341
+ if (!role)
342
+ continue;
343
+ consider(role, item);
344
+ }
345
+ }
346
+ // Compatibility path: named fields fill whichever role limits[] missed.
347
+ const named = u;
348
+ if (!byRole.has("five_hour") && named.five_hour !== undefined) {
349
+ consider("five_hour", named.five_hour);
350
+ }
351
+ if (!byRole.has("weekly") && named.seven_day !== undefined) {
352
+ consider("weekly", named.seven_day);
353
+ }
354
+ return byRole.size > 0 ? [...byRole.values()] : null;
355
+ }
356
+ /**
357
+ * Extract the `cachedUsageUtilization` subtree from a parsed config file.
358
+ * A bare cache object (no such key — the shape override fixtures use) is
359
+ * accepted as-is so tests can point the override at a minimal file. The
360
+ * caller must drop the input immediately after: siblings carry accountUuid,
361
+ * email, credentials, and projects, none of which may persist.
362
+ */
363
+ export function extractClaudeCacheSubtree(parsed) {
364
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
365
+ return null;
366
+ const root = parsed;
367
+ const subtree = root.cachedUsageUtilization;
368
+ if (subtree && typeof subtree === "object" && !Array.isArray(subtree))
369
+ return subtree;
370
+ return parsed;
371
+ }
372
+ /**
373
+ * Read-only local-cache read: parse `~/.claude.json`, extract only the
374
+ * `cachedUsageUtilization` subtree, normalize it, and drop everything else.
375
+ * Returns null when the file is missing, unparsable, or carries no usable
376
+ * 5H/1W observation — never throws, never spawns Claude, never spends
377
+ * money.
378
+ */
379
+ export function readClaudeLocalCache(nowMs = Date.now()) {
380
+ let text;
381
+ try {
382
+ text = fs.readFileSync(claudeCacheFilePath(), "utf8");
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ let parsed;
388
+ try {
389
+ parsed = JSON.parse(text);
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ const windows = parseClaudeCachedUtilization(extractClaudeCacheSubtree(parsed), nowMs);
395
+ if (!windows)
396
+ return null;
397
+ return { runtime: CLAUDE_RUNTIME, windows, unavailable: [] };
398
+ }
399
+ /**
400
+ * Ingest the local cache through the shared newer-wins path. Returns the
401
+ * persisted window count, or null when no usable observation exists. A stale
402
+ * cache ingests with its true `fetchedAtMs` (read-time rules render it N/A
403
+ * with honest age); an older cache never overwrites a newer event row.
404
+ */
405
+ export function ingestClaudeLocalCache(nowMs = Date.now()) {
406
+ const result = readClaudeLocalCache(nowMs);
407
+ if (!result)
408
+ return null;
409
+ return recordClaudeWindowReadings(result.windows, CLAUDE_RUNTIME, nowMs);
410
+ }
411
+ // ---------------------------------------------------------------------------
412
+ // Paid fallback probe (enabled by default, explicit off switch)
413
+ // ---------------------------------------------------------------------------
414
+ /**
415
+ * Paid probing defaults on when the setting is absent/empty. The documented
416
+ * `paid-after-1h` value is accepted explicitly; `off` and unrecognized values
417
+ * disable spending so a typo never silently changes the configured policy.
418
+ */
419
+ export function isClaudePaidFallbackEnabled() {
420
+ const setting = process.env[CLAUDE_CAPACITY_REFRESH_ENV];
421
+ return setting === undefined || setting === "" || setting === CLAUDE_CAPACITY_REFRESH_PAID_VALUE;
422
+ }
423
+ /** Bounded direct spawn of `claude` — never the coordinator, never a run. */
424
+ export function defaultProbeRunner(bin, argv, opts) {
425
+ return new Promise((resolve) => {
426
+ let child;
427
+ try {
428
+ child = spawn(bin, argv, {
429
+ cwd: opts.cwd,
430
+ env: { ...process.env },
431
+ stdio: ["ignore", "pipe", "ignore"],
432
+ });
433
+ }
434
+ catch (err) {
435
+ resolve({
436
+ stdout: "",
437
+ exitCode: null,
438
+ timedOut: false,
439
+ spawnError: err?.code ?? "spawn_failed",
440
+ });
441
+ return;
442
+ }
443
+ const chunks = [];
444
+ let size = 0;
445
+ let settled = false;
446
+ const finish = (out) => {
447
+ if (settled)
448
+ return;
449
+ settled = true;
450
+ clearTimeout(timer);
451
+ try {
452
+ child?.kill();
453
+ }
454
+ catch {
455
+ // Already exited — nothing to signal.
456
+ }
457
+ resolve(out);
458
+ };
459
+ const timer = setTimeout(() => {
460
+ finish({ stdout: chunks.join(""), exitCode: null, timedOut: true, spawnError: null });
461
+ }, opts.timeoutMs);
462
+ timer.unref?.();
463
+ child.stdout?.on("data", (buf) => {
464
+ const s = buf.toString();
465
+ // Cap retained output: only rate_limit events are parsed, the probe's
466
+ // own text is never kept, logged, or returned.
467
+ if (size + s.length <= 4 * 1024 * 1024) {
468
+ chunks.push(s);
469
+ size += s.length;
470
+ }
471
+ });
472
+ child.on("error", (err) => {
473
+ finish({
474
+ stdout: chunks.join(""),
475
+ exitCode: null,
476
+ timedOut: false,
477
+ spawnError: err?.code ?? "spawn_failed",
478
+ });
479
+ });
480
+ child.on("close", (code) => {
481
+ finish({ stdout: chunks.join(""), exitCode: code, timedOut: false, spawnError: null });
482
+ });
483
+ });
484
+ }
485
+ function probeCostFromEvents(events) {
486
+ for (let i = events.length - 1; i >= 0; i--) {
487
+ const e = events[i];
488
+ if (e.type !== "result")
489
+ continue;
490
+ const cost = e.total_cost_usd;
491
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
492
+ return cost;
493
+ }
494
+ return null;
495
+ }
496
+ function rolesCoveredByReadings(readings) {
497
+ const out = new Set();
498
+ for (const w of readings) {
499
+ if (w.criticalRole === "five_hour" || w.criticalRole === "weekly")
500
+ out.add(w.criticalRole);
501
+ }
502
+ return out;
503
+ }
504
+ function maxReadingObservedMs(readings) {
505
+ let max = null;
506
+ for (const w of readings) {
507
+ if (!w.observedAt)
508
+ continue;
509
+ const ms = Date.parse(w.observedAt);
510
+ if (!Number.isFinite(ms))
511
+ continue;
512
+ max = max === null ? ms : Math.max(max, ms);
513
+ }
514
+ return max;
515
+ }
516
+ /** Operator-safe probe log: static kinds and numbers only. */
517
+ function logProbeOutcome(outcome) {
518
+ console.error(`[claude-capacity] probe ${outcome.ok ? "ok" : `failed: ${outcome.failureKind ?? "unknown"}`} ` +
519
+ `(cost_usd=${outcome.costUsd ?? "unknown"})`);
520
+ }
521
+ /**
522
+ * Capacity diagnostic log: the actual probe cost/result per attempt, without
523
+ * prompt/output/credentials. Append-only JSON lines under the data dir.
524
+ */
525
+ export function probeDiagnosticLogPath() {
526
+ return path.join(getDataDir(), "capacity", "claude-probe.log");
527
+ }
528
+ function appendProbeDiagnostic(entry) {
529
+ try {
530
+ const file = probeDiagnosticLogPath();
531
+ fs.mkdirSync(path.dirname(file), { recursive: true });
532
+ fs.appendFileSync(file, `${JSON.stringify(entry)}\n`);
533
+ }
534
+ catch {
535
+ // Diagnostics are advisory — a full disk must not fail the probe path.
536
+ }
537
+ }
538
+ /**
539
+ * Run one minimal bounded probe and ingest whatever 5H/1W it yields — first
540
+ * the stream's `rate_limit_event`s, then a re-read of the local cache (the
541
+ * probe run itself refreshes Claude's own cache file, so the side effect
542
+ * counts even when the stream carries no windows). Success means the union
543
+ * covers both critical roles. Never throws; never creates Dealer
544
+ * workflow/session rows, worktrees, commits, PRs, or queue events.
545
+ */
546
+ export async function runClaudeCapacityProbe(nowMs = Date.now(), opts = {}) {
547
+ const startedRealMs = Date.now();
548
+ const bin = opts.bin ?? resolveClaudeBin();
549
+ const argv = buildClaudeProbeArgv();
550
+ const timeoutMs = opts.timeoutMs ?? claudeProbeTimeoutMs();
551
+ const runner = opts.runner ?? defaultProbeRunner;
552
+ // Pre-probe cache age (read BEFORE the spawn): the re-read below only
553
+ // counts toward success when the probe actually refreshed the file
554
+ // (strictly newer observation) — a stale-but-parseable file must not turn
555
+ // a failed probe into a success.
556
+ let preProbeCacheObservedMs = null;
557
+ try {
558
+ preProbeCacheObservedMs = maxReadingObservedMs(readClaudeLocalCache(nowMs)?.windows ?? []);
559
+ }
560
+ catch {
561
+ preProbeCacheObservedMs = null;
562
+ }
563
+ let spawnResult;
564
+ try {
565
+ spawnResult = await runner(bin, argv, { timeoutMs, cwd: os.tmpdir() });
566
+ }
567
+ catch {
568
+ spawnResult = { stdout: "", exitCode: null, timedOut: false, spawnError: "runner_threw" };
569
+ }
570
+ const durationMs = Date.now() - startedRealMs;
571
+ const fail = (failureKind, extra = {}) => {
572
+ const out = {
573
+ ok: false,
574
+ windowsUpdated: [],
575
+ costUsd: null,
576
+ exitCode: spawnResult.exitCode,
577
+ timedOut: spawnResult.timedOut,
578
+ durationMs,
579
+ failureKind,
580
+ ...extra,
581
+ };
582
+ logProbeOutcome(out);
583
+ appendProbeDiagnostic({
584
+ ts: new Date(nowMs).toISOString(),
585
+ event: "claude_capacity_probe",
586
+ trigger: "stale_60m",
587
+ model: CLAUDE_PROBE_MODEL,
588
+ budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
589
+ ...out,
590
+ });
591
+ return out;
592
+ };
593
+ if (spawnResult.spawnError)
594
+ return fail("spawn");
595
+ if (spawnResult.timedOut)
596
+ return fail("timeout");
597
+ let events = [];
598
+ try {
599
+ events = parseNdjson(spawnResult.stdout);
600
+ }
601
+ catch {
602
+ events = [];
603
+ }
604
+ const costUsd = probeCostFromEvents(events);
605
+ // Ingest the stream first (event timestamps clamp to nowMs inside).
606
+ let streamRoles = new Set();
607
+ try {
608
+ const extracted = extractClaudeCapacityFromEvents(events, nowMs, nowMs);
609
+ if (extracted)
610
+ streamRoles = rolesCoveredByReadings(extracted.windows);
611
+ recordClaudeCapacityFromEvents(events, CLAUDE_RUNTIME, nowMs, nowMs);
612
+ }
613
+ catch {
614
+ // A broken stream must not fail the probe path — the cache re-read below
615
+ // may still yield fresh rows.
616
+ }
617
+ // Then the cache side effect: the probe run refreshes Claude's own file.
618
+ // Ingest regardless (newer-wins protects stored rows), but only a
619
+ // strictly newer observation counts toward success. The re-read uses a
620
+ // post-spawn clock: Claude writes `fetchedAtMs` during the probe, so it is
621
+ // later than the pre-spawn `nowMs` and would be rejected as future
622
+ // against the stale stamp.
623
+ const postProbeNowMs = Math.max(nowMs, Date.now());
624
+ let cacheRoles = new Set();
625
+ try {
626
+ const reread = readClaudeLocalCache(postProbeNowMs);
627
+ if (reread) {
628
+ const afterObservedMs = maxReadingObservedMs(reread.windows);
629
+ if (afterObservedMs !== null &&
630
+ (preProbeCacheObservedMs === null || afterObservedMs > preProbeCacheObservedMs)) {
631
+ cacheRoles = rolesCoveredByReadings(reread.windows);
632
+ }
633
+ recordClaudeWindowReadings(reread.windows, CLAUDE_RUNTIME, postProbeNowMs);
634
+ }
635
+ }
636
+ catch {
637
+ // Advisory — stream coverage below still counts.
638
+ }
639
+ const covered = new Set([...streamRoles, ...cacheRoles]);
640
+ if (spawnResult.exitCode !== 0 && covered.size === 0)
641
+ return fail("nonzero_exit", { costUsd });
642
+ if (!covered.has("five_hour") || !covered.has("weekly")) {
643
+ // The probe spent money but produced no 5H/1W pair — a `no_windows`
644
+ // streak is the signal to disable the opt-in and revise the ticket
645
+ // rather than ship a recurring paid no-op.
646
+ return fail("no_windows", { costUsd });
647
+ }
648
+ const out = {
649
+ ok: true,
650
+ windowsUpdated: [CACHE_WINDOW_IDENTITIES.five_hour.windowKey, CACHE_WINDOW_IDENTITIES.weekly.windowKey],
651
+ costUsd,
652
+ exitCode: spawnResult.exitCode,
653
+ timedOut: false,
654
+ durationMs,
655
+ failureKind: null,
656
+ };
657
+ logProbeOutcome(out);
658
+ appendProbeDiagnostic({
659
+ ts: new Date(nowMs).toISOString(),
660
+ event: "claude_capacity_probe",
661
+ trigger: "stale_60m",
662
+ model: CLAUDE_PROBE_MODEL,
663
+ budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
664
+ ...out,
665
+ });
666
+ return out;
667
+ }
668
+ // ---------------------------------------------------------------------------
669
+ // Freshness gate, single-flight, backoff
670
+ // ---------------------------------------------------------------------------
671
+ /**
672
+ * Newest valid account-wide 5H/1W observation (event or cache — both share
673
+ * window keys and critical roles). A row counts when it carries a remaining
674
+ * %, its reset is absent or future, and its observed time is not in the
675
+ * future. Returns null when no such sample exists.
676
+ */
677
+ export function newestValidClaudeObservationMs(nowMs = Date.now()) {
678
+ let max = null;
679
+ for (const row of listCapacitySnapshots(CLAUDE_RUNTIME)) {
680
+ if (row.criticalRole !== "five_hour" && row.criticalRole !== "weekly")
681
+ continue;
682
+ if (row.remainingPercent === null)
683
+ continue;
684
+ if (row.resetAt !== null) {
685
+ const resetMs = Date.parse(row.resetAt);
686
+ if (!Number.isFinite(resetMs) || resetMs <= nowMs)
687
+ continue;
688
+ }
689
+ const observedMs = Date.parse(row.observedAt);
690
+ if (!Number.isFinite(observedMs) || observedMs > nowMs)
691
+ continue;
692
+ max = max === null ? observedMs : Math.max(max, observedMs);
693
+ }
694
+ return max;
695
+ }
696
+ let claudeProbeInFlight = null;
697
+ let lastClaudeProbeAttemptMs = 0;
698
+ let consecutiveClaudeProbeFailures = 0;
699
+ /** Test helper — clear single-flight, attempt, and backoff state. */
700
+ export function resetClaudeCapacityRefreshState() {
701
+ claudeProbeInFlight = null;
702
+ lastClaudeProbeAttemptMs = 0;
703
+ consecutiveClaudeProbeFailures = 0;
704
+ }
705
+ /** Test helper — observe backoff/attempt state without spawning. */
706
+ export function claudeProbeRefreshStateForTests() {
707
+ return {
708
+ lastAttemptMs: lastClaudeProbeAttemptMs,
709
+ consecutiveFailures: consecutiveClaudeProbeFailures,
710
+ inFlight: claudeProbeInFlight !== null,
711
+ };
712
+ }
713
+ function probeCooldownMs() {
714
+ const shift = Math.min(consecutiveClaudeProbeFailures, 3);
715
+ return Math.min(CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS * 2 ** shift, CLAUDE_PROBE_BACKOFF_CAP_MS);
716
+ }
717
+ /**
718
+ * On-demand paid fallback: when `claude_code` is configured and no valid
719
+ * 5H/1W observation is newer than 60 minutes, run one minimal bounded probe
720
+ * (single-flight across concurrent readers; at most one attempt per account
721
+ * per 60 minutes, backing off exponentially on failure). Explicitly disabled
722
+ * is a strict no-op — no spawn, no spend. Never throws, never touches Dealer
723
+ * workflow/session state or `runtime_availability`.
724
+ */
725
+ export async function maybeProbeClaudeCapacity(nowMs = Date.now(), opts = {}) {
726
+ try {
727
+ if (!isClaudePaidFallbackEnabled())
728
+ return { probed: false, reason: "disabled" };
729
+ if (!configuredCapacityRuntimes().includes(CLAUDE_RUNTIME)) {
730
+ return { probed: false, reason: "unconfigured" };
731
+ }
732
+ const newest = newestValidClaudeObservationMs(nowMs);
733
+ if (newest !== null && nowMs - newest < CLAUDE_PROBE_STALE_AFTER_MS) {
734
+ return { probed: false, reason: "fresh" };
735
+ }
736
+ // Single-flight first: readers arriving while a probe runs share it
737
+ // rather than hitting the cooldown the owner just stamped.
738
+ if (claudeProbeInFlight) {
739
+ const shared = await claudeProbeInFlight;
740
+ return {
741
+ probed: true,
742
+ reason: "shared",
743
+ ok: shared.ok,
744
+ windowsUpdated: shared.windowsUpdated,
745
+ costUsd: shared.costUsd,
746
+ failureKind: shared.failureKind,
747
+ };
748
+ }
749
+ if (nowMs - lastClaudeProbeAttemptMs < probeCooldownMs()) {
750
+ return { probed: false, reason: "backoff" };
751
+ }
752
+ lastClaudeProbeAttemptMs = nowMs;
753
+ const run = runClaudeCapacityProbe(nowMs, opts);
754
+ claudeProbeInFlight = run;
755
+ try {
756
+ const result = await run;
757
+ consecutiveClaudeProbeFailures = result.ok ? 0 : consecutiveClaudeProbeFailures + 1;
758
+ return {
759
+ probed: true,
760
+ reason: "completed",
761
+ ok: result.ok,
762
+ windowsUpdated: result.windowsUpdated,
763
+ costUsd: result.costUsd,
764
+ failureKind: result.failureKind,
765
+ };
766
+ }
767
+ finally {
768
+ if (claudeProbeInFlight === run)
769
+ claudeProbeInFlight = null;
770
+ }
771
+ }
772
+ catch {
773
+ return { probed: false, reason: "error" };
774
+ }
775
+ }
776
+ /**
777
+ * Full on-demand refresh for `GET /api/runtime-capacity`: ingest the free
778
+ * local cache first, then consider the paid probe. Best-effort — never
779
+ * throws. The route serves the stored snapshot regardless.
780
+ */
781
+ export async function refreshClaudeCapacityIfStale(nowMs = Date.now(), opts = {}) {
782
+ // Skip the file read entirely when no Claude account is configured:
783
+ // ~/.claude.json can be several MB and this runs on every capacity poll.
784
+ if (!configuredCapacityRuntimes().includes(CLAUDE_RUNTIME)) {
785
+ return { probed: false, reason: "unconfigured" };
786
+ }
787
+ try {
788
+ ingestClaudeLocalCache(nowMs);
789
+ }
790
+ catch {
791
+ // Advisory — the probe gate below still applies.
792
+ }
793
+ return maybeProbeClaudeCapacity(nowMs, opts);
794
+ }