agent-dealer 1.2.1 → 1.2.3

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