agent-dealer 1.2.3 → 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.
@@ -16,9 +16,9 @@
16
16
  // `~/.claude.json` (this module — free, ingested on every capacity
17
17
  // read; only that subtree is ever parsed, the rest of the config —
18
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.
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
22
  //
23
23
  // Both file sources write the same `claude_unified_five_hour` /
24
24
  // `claude_unified_seven_day` window keys through the shared
@@ -68,7 +68,7 @@
68
68
  //
69
69
  // If a live proof ever shows the minimal probe does not reliably emit 5H/1W
70
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
71
+ // recurring paid no-op: disable the fallback and revise this ticket instead of
72
72
  // shipping it. The `no_windows` diagnostic below exists to make that visible.
73
73
  import { spawn } from "node:child_process";
74
74
  import fs from "node:fs";
@@ -82,9 +82,10 @@ import { configuredCapacityRuntimes } from "./service.js";
82
82
  import { CLAUDE_RUNTIME, extractClaudeCapacityFromEvents, normalizeClaudeResetsAt, recordClaudeCapacityFromEvents, recordClaudeWindowReadings, } from "./claude-events.js";
83
83
  /** Override for the Claude cache file (tests, smoke). */
84
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. */
85
+ /** Paid-fallback setting. Unset defaults to PAID_AFTER_1H; `off` disables it. */
86
86
  export const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
87
87
  export const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
88
+ export const CLAUDE_CAPACITY_REFRESH_OFF_VALUE = "off";
88
89
  /** Upper bound for one probe spawn (ms). */
89
90
  export const CLAUDE_CAPACITY_PROBE_TIMEOUT_ENV = "AGENT_DEALER_CLAUDE_PROBE_TIMEOUT_MS";
90
91
  export const CLAUDE_PROBE_TIMEOUT_MS_DEFAULT = 90_000;
@@ -408,11 +409,16 @@ export function ingestClaudeLocalCache(nowMs = Date.now()) {
408
409
  return recordClaudeWindowReadings(result.windows, CLAUDE_RUNTIME, nowMs);
409
410
  }
410
411
  // ---------------------------------------------------------------------------
411
- // Paid fallback probe (explicit opt-in only)
412
+ // Paid fallback probe (enabled by default, explicit off switch)
412
413
  // ---------------------------------------------------------------------------
413
- /** True only under the exact opt-in; any other value disables paid probing. */
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
+ */
414
419
  export function isClaudePaidFallbackEnabled() {
415
- return process.env[CLAUDE_CAPACITY_REFRESH_ENV] === CLAUDE_CAPACITY_REFRESH_PAID_VALUE;
420
+ const setting = process.env[CLAUDE_CAPACITY_REFRESH_ENV];
421
+ return setting === undefined || setting === "" || setting === CLAUDE_CAPACITY_REFRESH_PAID_VALUE;
416
422
  }
417
423
  /** Bounded direct spawn of `claude` — never the coordinator, never a run. */
418
424
  export function defaultProbeRunner(bin, argv, opts) {
@@ -712,8 +718,8 @@ function probeCooldownMs() {
712
718
  * On-demand paid fallback: when `claude_code` is configured and no valid
713
719
  * 5H/1W observation is newer than 60 minutes, run one minimal bounded probe
714
720
  * (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
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
717
723
  * workflow/session state or `runtime_availability`.
718
724
  */
719
725
  export async function maybeProbeClaudeCapacity(nowMs = Date.now(), opts = {}) {
@@ -263,12 +263,16 @@ test("a 2-hour-old cache ingests with its true age and reads expired, never live
263
263
  assert.equal(w.unavailableReason, "expired");
264
264
  }
265
265
  });
266
- test("disabled fallback never spawns: every non-opt-in value is a strict no-op", async () => {
267
- for (const value of [undefined, "", "off", "auto", "paid-after-1h "]) {
266
+ test("paid fallback defaults on; explicit off and unrecognized values never spawn", async () => {
267
+ for (const value of [undefined, "", "paid-after-1h"]) {
268
268
  if (value === undefined)
269
269
  delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
270
270
  else
271
271
  process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
272
+ assert.equal(isClaudePaidFallbackEnabled(), true);
273
+ }
274
+ for (const value of ["off", "auto", "paid-after-1h "]) {
275
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
272
276
  assert.equal(isClaudePaidFallbackEnabled(), false);
273
277
  const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
274
278
  assert.deepEqual(outcome, { probed: false, reason: "disabled" });
@@ -276,8 +280,8 @@ test("disabled fallback never spawns: every non-opt-in value is a strict no-op",
276
280
  process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
277
281
  assert.equal(isClaudePaidFallbackEnabled(), true);
278
282
  });
279
- test("fresh sample suppresses the probe; stale data triggers exactly one", async () => {
280
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
283
+ test("default-on fallback suppresses a fresh sample; stale data triggers exactly one", async () => {
284
+ delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
281
285
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 10 * 60_000));
282
286
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
283
287
  assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - 60 * 60_000);
@@ -44,9 +44,11 @@
44
44
  // clears the sentinel.
45
45
  // - Capacity stays independent from `runtime_availability` hard-cap
46
46
  // admission: this module never reads or writes health rows.
47
- // - The client enforces the read-only allowlist (`initialize`,
48
- // `initialized`, `usage/read`) — a capacity read by itself never sends
49
- // `session/start`, a prompt, a turn, a tool, or any other billable method.
47
+ // - The free read path enforces the read-only allowlist (`initialize`,
48
+ // `initialized`, `usage/read`) and never starts billable work. The separate
49
+ // default-on fallback in `muse-probe.ts` may create a dedicated restricted
50
+ // host for one fixed, bounded turn after the complete pair has been absent
51
+ // for an hour.
50
52
  //
51
53
  // Execution precondition (NOT-269): only a host that observes the account's
52
54
  // provider traffic can answer `usage/read`. Real Dealer Muse turns run
@@ -54,12 +56,12 @@
54
56
  // below, driven by runners/muse-serve-session.ts) — that traffic is the
55
57
  // observation, so the session-boundary refresh hook
56
58
  // (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) is the
57
- // final `usage/read` that populates 5H/1W. No synthetic model prompt is
58
- // ever issued to refresh capacity. When the serve execution lane cannot
59
- // admit a turn (host unavailable, unimplemented method, auth), the session
60
- // falls back to the legacy `muse exec` subprocess before any model work
61
- // starts — the host stays unobserved and the read stays honest N/A with
62
- // last-good rows preserved.
59
+ // final `usage/read` that populates 5H/1W. If no genuine turn has populated
60
+ // a complete current pair for an hour, `muse-probe.ts` may use its own host
61
+ // for the bounded paid fallback. When the normal serve execution lane cannot
62
+ // admit a developer turn, that session still falls back to the legacy
63
+ // `muse exec` subprocess before any model work starts; last-good capacity
64
+ // rows remain preserved.
63
65
  //
64
66
  // PRODUCT DECISION (2026-09-26, resolved via human_action on this ticket):
65
67
  // the runner migration is in scope for NOT-270, in this same PR, not a
@@ -128,6 +130,8 @@ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
128
130
  }
129
131
  return { configHome, dataHome, authLinked };
130
132
  }
133
+ const KEYCHAIN_UNREADABLE_RE = /keychain item .* unreadable|OSStatus\s+-?\d+/i;
134
+ const AUTH_FAILURE_RE = /credential|auth|login|sign[ -]?in|\b401\b|\b403\b/i;
131
135
  function readOnlyRequest(id, method, params) {
132
136
  assertMuseReadOnlyMethod(method);
133
137
  return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
@@ -458,6 +462,7 @@ export class MuseCapacityHost {
458
462
  const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
459
463
  const mergedEnv = this.mergedEnv();
460
464
  if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
465
+ this.startFailure = "credential_missing";
461
466
  return false;
462
467
  }
463
468
  const command = this.opts.command ?? resolveMuseBin();
@@ -470,7 +475,8 @@ export class MuseCapacityHost {
470
475
  });
471
476
  }
472
477
  catch (err) {
473
- this.startFailure = err?.code === "ENOENT" ? "unsupported" : null;
478
+ this.startFailure =
479
+ err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
474
480
  return false;
475
481
  }
476
482
  this.child = child;
@@ -505,14 +511,17 @@ export class MuseCapacityHost {
505
511
  return;
506
512
  }
507
513
  });
508
- child.on("error", () => {
514
+ child.on("error", (err) => {
509
515
  if (!this.isCurrentChild(child))
510
516
  return;
517
+ this.startFailure =
518
+ err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
511
519
  this.killChild();
512
520
  });
513
521
  child.stdin?.on("error", () => {
514
522
  if (!this.isCurrentChild(child))
515
523
  return;
524
+ this.startFailure = "transport_error";
516
525
  this.killChild();
517
526
  });
518
527
  child.on("close", () => {
@@ -521,6 +530,15 @@ export class MuseCapacityHost {
521
530
  // its replacement must not kill the new host.
522
531
  if (!this.isCurrentChild(child))
523
532
  return;
533
+ if (KEYCHAIN_UNREADABLE_RE.test(this.stderrTail)) {
534
+ this.startFailure = "keychain_unreadable";
535
+ }
536
+ else if (AUTH_FAILURE_RE.test(this.stderrTail)) {
537
+ this.startFailure = "auth_rejected";
538
+ }
539
+ else if (this.startFailure === null) {
540
+ this.startFailure = "host_exited";
541
+ }
524
542
  this.killChild();
525
543
  });
526
544
  const initId = `muse-capacity-init-${this.connectionEpoch}`;
@@ -529,7 +547,14 @@ export class MuseCapacityHost {
529
547
  if (init?.error) {
530
548
  const kind = museClassifyRpcError(init.error);
531
549
  this.startFailure =
532
- kind === "auth" ? "auth" : kind === "unsupported" ? "unsupported" : null;
550
+ kind === "auth"
551
+ ? "auth_rejected"
552
+ : kind === "unsupported"
553
+ ? "unsupported"
554
+ : "handshake_rejected";
555
+ }
556
+ else if (this.startFailure === null) {
557
+ this.startFailure = this.dead ? "host_exited" : "handshake_timeout";
533
558
  }
534
559
  this.killChild();
535
560
  return false;
@@ -579,15 +604,20 @@ export class MuseCapacityHost {
579
604
  this.startFailure = null;
580
605
  const started = await this.ensureStarted();
581
606
  if (!started) {
582
- if (this.startFailure === "unsupported") {
607
+ // `ensureStarted` records the failure asynchronously. TypeScript does
608
+ // not model that mutation across the awaited call, so widen the field
609
+ // back to its declared type before selecting the fallback category.
610
+ const diagnostic = this.startFailure ?? "spawn_failed";
611
+ if (diagnostic === "unsupported" || diagnostic === "binary_missing") {
612
+ console.error(`[muse-capacity] host start failed: ${diagnostic}`);
583
613
  await noteMuseCapacityFailure("unsupported");
584
- return { status: "failure", reason: "unsupported" };
614
+ return { status: "failure", reason: "unsupported", diagnostic };
585
615
  }
586
616
  // No credential, spawn failure, timeout, or bad exit before the
587
617
  // handshake: honest `missing`, last-good rows preserved.
588
- console.error("[muse-capacity] host start failed: missing");
618
+ console.error(`[muse-capacity] host start failed: ${diagnostic}`);
589
619
  await noteMuseCapacityFailure("missing");
590
- return { status: "missing" };
620
+ return { status: "missing", diagnostic };
591
621
  }
592
622
  this.readSeq += 1;
593
623
  const id = `muse-capacity-${this.connectionEpoch}-${this.readSeq}`;
@@ -601,23 +631,23 @@ export class MuseCapacityHost {
601
631
  if (!this.hasInflightExecution())
602
632
  this.killChild();
603
633
  await noteMuseCapacityFailure("missing");
604
- return { status: "missing" };
634
+ return { status: "missing", diagnostic: "read_timeout" };
605
635
  }
606
636
  if (res.error) {
607
637
  const kind = museClassifyRpcError(res.error);
608
638
  if (kind === "auth") {
609
639
  console.error("[muse-capacity] host read failed: unauthenticated");
610
640
  await noteMuseCapacityFailure("missing");
611
- return { status: "missing" };
641
+ return { status: "missing", diagnostic: "auth_rejected" };
612
642
  }
613
643
  if (kind === "unsupported") {
614
644
  console.error("[muse-capacity] host read failed: unsupported");
615
645
  await noteMuseCapacityFailure("unsupported");
616
- return { status: "failure", reason: "unsupported" };
646
+ return { status: "failure", reason: "unsupported", diagnostic: "unsupported" };
617
647
  }
618
648
  console.error("[muse-capacity] host read failed: malformed");
619
649
  await noteMuseCapacityFailure("unparsable");
620
- return { status: "failure", reason: "unparsable" };
650
+ return { status: "failure", reason: "unparsable", diagnostic: "read_rejected" };
621
651
  }
622
652
  let payload = res.result;
623
653
  if (payload &&
@@ -633,7 +663,7 @@ export class MuseCapacityHost {
633
663
  // Fresh/unobserved host: `usage` omitted — honest `missing`, never a
634
664
  // write, so a newer known pair is never overwritten or deleted.
635
665
  await noteMuseCapacityFailure("missing");
636
- return { status: "missing" };
666
+ return { status: "missing", diagnostic: "unobserved" };
637
667
  }
638
668
  return { status: "observed", written: outcome.written, skippedStale: outcome.skippedStale };
639
669
  }
@@ -14,8 +14,7 @@
14
14
  // - the first observation arrives as a `usage/changed` notification;
15
15
  // - a fresh/unobserved host reads `missing` (restart is fresh — the client
16
16
  // holds no cross-process usage state);
17
- // - resume/turn methods are refused before write, so refresh can never
18
- // manufacture a billable call.
17
+ // - resume/turn methods are refused before write on the free adapter path.
19
18
  import { test } from "node:test";
20
19
  import assert from "node:assert/strict";
21
20
  import { assertMuseReadOnlyMethod, museUsageToReadings, readMuseCapacity, requestMuseUsage, } from "./muse.js";
@@ -90,7 +89,7 @@ test("lifecycle: a fresh host with no observation reads missing — twice", asyn
90
89
  assert.equal(result.unavailable[0].reason, "missing");
91
90
  }
92
91
  });
93
- test("lifecycle: refresh can never manufacture a session, turn, or prompt", () => {
92
+ test("lifecycle: the free read adapter cannot manufacture a session, turn, or prompt", () => {
94
93
  for (const m of ["session/start", "session/resume", "session/prompt", "turn/start", "exec"]) {
95
94
  assert.throws(() => assertMuseReadOnlyMethod(m), /refusing non-read method/);
96
95
  }
@@ -0,0 +1,250 @@
1
+ // packages/server/src/capacity/muse-probe.ts
2
+ //
3
+ // Reliable Muse 5H/1W fallback. Muse's stable protocol only exposes
4
+ // subscription capacity after the same `muse serve` process has observed
5
+ // real provider traffic. A read-only poll, session resume, or exec log cannot
6
+ // populate it (NOT-269). When the free sources have no complete current pair
7
+ // for one hour, this module runs one tiny bounded turn on a dedicated owned
8
+ // host, then performs `usage/read` on that exact host.
9
+ //
10
+ // The fallback is default-on and can be disabled with
11
+ // `AGENT_DEALER_MUSE_CAPACITY_REFRESH=off`. It is single-flight, attempted at
12
+ // most once per hour, and backs off 1h -> 2h -> 4h -> 8h after failures. The
13
+ // host disables shell and workspace writes and keeps restricted network;
14
+ // Muse 1.4's serve protocol has no per-turn max-step or disable-web-tools
15
+ // switch, so the fixed one-word prompt plus wall-clock cancellation is the
16
+ // tightest supported bound. No transcript, prompt, credential, or account
17
+ // payload is logged.
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
22
+ import { getDataDir } from "../db/index.js";
23
+ import { listCapacitySnapshots } from "../repository/runtime-capacity.js";
24
+ import { runMuseServeTurn } from "../runners/muse-serve-session.js";
25
+ import { configuredCapacityRuntimes } from "./service.js";
26
+ import { maybeRefreshMuseCapacityFromHost, MuseCapacityHost, } from "./muse-host.js";
27
+ import { MUSE_RUNTIME } from "./muse.js";
28
+ export const MUSE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_MUSE_CAPACITY_REFRESH";
29
+ export const MUSE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
30
+ export const MUSE_CAPACITY_REFRESH_OFF_VALUE = "off";
31
+ export const MUSE_CAPACITY_PROBE_TIMEOUT_ENV = "AGENT_DEALER_MUSE_CAPACITY_PROBE_TIMEOUT_MS";
32
+ export const MUSE_CAPACITY_PROBE_TIMEOUT_MS_DEFAULT = 60_000;
33
+ export const MUSE_CAPACITY_PROBE_STALE_AFTER_MS = 60 * 60 * 1000;
34
+ export const MUSE_CAPACITY_PROBE_ATTEMPT_COOLDOWN_MS = 60 * 60 * 1000;
35
+ export const MUSE_CAPACITY_PROBE_BACKOFF_CAP_MS = 8 * 60 * 60 * 1000;
36
+ export const MUSE_CAPACITY_PROBE_PROMPT = "Reply with exactly: OK";
37
+ export const MUSE_CAPACITY_PROBE_SERVE_ARGV = [
38
+ "serve",
39
+ "--sandbox-network",
40
+ "restricted",
41
+ "--disable-write",
42
+ "--disable-shell",
43
+ ];
44
+ export function isMusePaidFallbackEnabled() {
45
+ const setting = process.env[MUSE_CAPACITY_REFRESH_ENV];
46
+ if (setting === MUSE_CAPACITY_REFRESH_PAID_VALUE)
47
+ return true;
48
+ if (setting !== undefined && setting !== "")
49
+ return false;
50
+ // Backward compatibility: this older switch used to disable every Muse
51
+ // capacity refresh. Do not turn an existing no-refresh deployment into a
52
+ // spender on upgrade. An explicit paid-after-1h above may override it.
53
+ if (process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS === "off")
54
+ return false;
55
+ return true;
56
+ }
57
+ export function museCapacityProbeTimeoutMs() {
58
+ const raw = process.env[MUSE_CAPACITY_PROBE_TIMEOUT_ENV];
59
+ if (raw !== undefined && raw !== "") {
60
+ const n = Number(raw);
61
+ if (Number.isFinite(n) && n > 0)
62
+ return n;
63
+ }
64
+ return MUSE_CAPACITY_PROBE_TIMEOUT_MS_DEFAULT;
65
+ }
66
+ function validRoleObservedAtMs(role, nowMs) {
67
+ const row = listCapacitySnapshots(MUSE_RUNTIME).find((w) => w.criticalRole === role);
68
+ if (!row || row.remainingPercent === null || row.unavailableReason !== null || row.source === "unavailable") {
69
+ return null;
70
+ }
71
+ const observedMs = Date.parse(row.observedAt);
72
+ if (!Number.isFinite(observedMs) || observedMs > nowMs)
73
+ return null;
74
+ if (row.resetAt !== null) {
75
+ const resetMs = Date.parse(row.resetAt);
76
+ if (!Number.isFinite(resetMs) || resetMs <= nowMs)
77
+ return null;
78
+ }
79
+ return observedMs;
80
+ }
81
+ /** Both halves must be valid and under one hour old. A fresh 5H window may
82
+ * never hide a missing/stale 1W sibling (or vice versa). */
83
+ export function hasFreshMuseCapacityPair(nowMs = Date.now()) {
84
+ const fiveHour = validRoleObservedAtMs("five_hour", nowMs);
85
+ const weekly = validRoleObservedAtMs("weekly", nowMs);
86
+ return (fiveHour !== null &&
87
+ weekly !== null &&
88
+ nowMs - fiveHour < MUSE_CAPACITY_PROBE_STALE_AFTER_MS &&
89
+ nowMs - weekly < MUSE_CAPACITY_PROBE_STALE_AFTER_MS);
90
+ }
91
+ function museProbeDiagnosticLogPath() {
92
+ return path.join(getDataDir(), "capacity", "muse-probe.log");
93
+ }
94
+ function appendMuseProbeDiagnostic(nowMs, result) {
95
+ try {
96
+ const file = museProbeDiagnosticLogPath();
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.appendFileSync(file, `${JSON.stringify({
99
+ ts: new Date(nowMs).toISOString(),
100
+ event: "muse_capacity_probe",
101
+ trigger: "stale_60m",
102
+ model: MUSE_CODE_CONTRIBUTOR_MODEL,
103
+ ...result,
104
+ })}\n`);
105
+ }
106
+ catch {
107
+ // Diagnostics are advisory; capacity refresh must never fail on logging.
108
+ }
109
+ }
110
+ /** One bounded real turn followed by a read on the same restricted host. */
111
+ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
112
+ const startedRealMs = Date.now();
113
+ const timeoutMs = opts.timeoutMs ?? museCapacityProbeTimeoutMs();
114
+ const ownsHost = opts.host === undefined;
115
+ const host = opts.host ??
116
+ new MuseCapacityHost({
117
+ ...opts.hostOptions,
118
+ args: opts.hostOptions?.args ?? [...MUSE_CAPACITY_PROBE_SERVE_ARGV],
119
+ timeoutMs: Math.min(opts.hostOptions?.timeoutMs ?? timeoutMs, timeoutMs),
120
+ });
121
+ let result;
122
+ try {
123
+ const turn = await runMuseServeTurn({
124
+ host,
125
+ prompt: MUSE_CAPACITY_PROBE_PROMPT,
126
+ model: MUSE_CODE_CONTRIBUTOR_MODEL,
127
+ cwd: os.tmpdir(),
128
+ timeoutMs,
129
+ rpcTimeoutMs: Math.min(15_000, timeoutMs),
130
+ });
131
+ if (!turn.admitted) {
132
+ result = {
133
+ ok: false,
134
+ admitted: false,
135
+ terminal: null,
136
+ failureKind: "unadmitted",
137
+ windowsUpdated: [],
138
+ durationMs: Date.now() - startedRealMs,
139
+ };
140
+ }
141
+ else {
142
+ await host.readUsage();
143
+ const checkNowMs = Math.max(nowMs, Date.now());
144
+ const ok = hasFreshMuseCapacityPair(checkNowMs);
145
+ result = {
146
+ ok,
147
+ admitted: true,
148
+ terminal: turn.terminal,
149
+ failureKind: ok
150
+ ? null
151
+ : turn.timedOut
152
+ ? "timeout"
153
+ : turn.failure !== null
154
+ ? "turn_failed"
155
+ : "no_windows",
156
+ windowsUpdated: ok ? ["rolling_all_models", "weekly_all_models"] : [],
157
+ durationMs: Date.now() - startedRealMs,
158
+ };
159
+ }
160
+ }
161
+ catch {
162
+ result = {
163
+ ok: false,
164
+ admitted: false,
165
+ terminal: null,
166
+ failureKind: "exception",
167
+ windowsUpdated: [],
168
+ durationMs: Date.now() - startedRealMs,
169
+ };
170
+ }
171
+ finally {
172
+ if (ownsHost)
173
+ await host.shutdown().catch(() => undefined);
174
+ }
175
+ appendMuseProbeDiagnostic(nowMs, result);
176
+ const outcome = result.ok ? "ok" : `failed: ${result.failureKind ?? "unknown"}`;
177
+ console.error(`[muse-capacity] fallback ${outcome}`);
178
+ return result;
179
+ }
180
+ let museProbeInFlight = null;
181
+ let lastMuseProbeAttemptMs = 0;
182
+ let consecutiveMuseProbeFailures = 0;
183
+ function museProbeCooldownMs() {
184
+ const shift = Math.min(consecutiveMuseProbeFailures, 3);
185
+ return Math.min(MUSE_CAPACITY_PROBE_ATTEMPT_COOLDOWN_MS * 2 ** shift, MUSE_CAPACITY_PROBE_BACKOFF_CAP_MS);
186
+ }
187
+ export function resetMuseCapacityProbeStateForTests() {
188
+ museProbeInFlight = null;
189
+ lastMuseProbeAttemptMs = 0;
190
+ consecutiveMuseProbeFailures = 0;
191
+ }
192
+ export async function maybeProbeMuseCapacity(nowMs = Date.now(), opts = {}) {
193
+ try {
194
+ if (!isMusePaidFallbackEnabled())
195
+ return { probed: false, reason: "disabled" };
196
+ if (!configuredCapacityRuntimes().includes(MUSE_RUNTIME)) {
197
+ return { probed: false, reason: "unconfigured" };
198
+ }
199
+ if (hasFreshMuseCapacityPair(nowMs))
200
+ return { probed: false, reason: "fresh" };
201
+ if (museProbeInFlight) {
202
+ const shared = await museProbeInFlight;
203
+ return {
204
+ probed: true,
205
+ reason: "shared",
206
+ ok: shared.ok,
207
+ failureKind: shared.failureKind,
208
+ windowsUpdated: shared.windowsUpdated,
209
+ };
210
+ }
211
+ if (nowMs - lastMuseProbeAttemptMs < museProbeCooldownMs()) {
212
+ return { probed: false, reason: "backoff" };
213
+ }
214
+ lastMuseProbeAttemptMs = nowMs;
215
+ const run = runMuseCapacityProbe(nowMs, opts);
216
+ museProbeInFlight = run;
217
+ try {
218
+ const result = await run;
219
+ consecutiveMuseProbeFailures = result.ok ? 0 : consecutiveMuseProbeFailures + 1;
220
+ return {
221
+ probed: true,
222
+ reason: "completed",
223
+ ok: result.ok,
224
+ failureKind: result.failureKind,
225
+ windowsUpdated: result.windowsUpdated,
226
+ };
227
+ }
228
+ finally {
229
+ if (museProbeInFlight === run)
230
+ museProbeInFlight = null;
231
+ }
232
+ }
233
+ catch {
234
+ return { probed: false, reason: "error" };
235
+ }
236
+ }
237
+ /** Free read first; only a still-missing/stale complete pair reaches the
238
+ * bounded paid fallback. Route callers run this in the background. */
239
+ export async function refreshMuseCapacityIfStale(nowMs = Date.now(), opts = {}) {
240
+ if (!configuredCapacityRuntimes().includes(MUSE_RUNTIME)) {
241
+ return { probed: false, reason: "unconfigured" };
242
+ }
243
+ try {
244
+ await maybeRefreshMuseCapacityFromHost({}, nowMs);
245
+ }
246
+ catch {
247
+ // The paid gate below still decides from durable last-good rows.
248
+ }
249
+ return maybeProbeMuseCapacity(nowMs, opts);
250
+ }
@@ -0,0 +1,183 @@
1
+ // Default-on one-hour Muse capacity fallback. Fake MSP host only: no live
2
+ // provider request and no paid model call in this suite.
3
+ import { beforeEach, test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-probe-"));
9
+ import { MuseCapacityHost } from "./muse-host.js";
10
+ import { hasFreshMuseCapacityPair, isMusePaidFallbackEnabled, maybeProbeMuseCapacity, MUSE_CAPACITY_PROBE_SERVE_ARGV, MUSE_CAPACITY_REFRESH_ENV, resetMuseCapacityProbeStateForTests, runMuseCapacityProbe, } from "./muse-probe.js";
11
+ import { ingestMuseUsagePayload } from "./muse.js";
12
+ const { migrate } = await import("../db/index.js");
13
+ const { createAgent } = await import("../repository/agents.js");
14
+ const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
15
+ const FAKE = new URL("./fixtures/fake-muse-serve.mjs", import.meta.url).pathname;
16
+ function hostOpts(mode, extraEnv = {}) {
17
+ const now = Date.now();
18
+ return {
19
+ command: process.execPath,
20
+ args: [FAKE],
21
+ env: {
22
+ META_API_KEY: "test-fake-key",
23
+ FAKE_MSP_MODE: mode,
24
+ FAKE_MSP_NOW_MS: String(now),
25
+ ...extraEnv,
26
+ },
27
+ timeoutMs: 10_000,
28
+ };
29
+ }
30
+ function payload(nowMs, roles = "both") {
31
+ return {
32
+ usage: {
33
+ observedAtMs: nowMs,
34
+ tier: "synthetic",
35
+ window: {
36
+ usedPercent: 25,
37
+ resetsAtMs: nowMs + 2 * 3600_000,
38
+ windowDurationMins: 300,
39
+ },
40
+ ...(roles === "both"
41
+ ? { weekly: { usedPercent: 40, resetsAtMs: nowMs + 3 * 24 * 3600_000 } }
42
+ : {}),
43
+ },
44
+ };
45
+ }
46
+ function recordedMethods(file) {
47
+ if (!fs.existsSync(file))
48
+ return [];
49
+ return fs
50
+ .readFileSync(file, "utf8")
51
+ .split("\n")
52
+ .filter(Boolean)
53
+ .map((line) => String(JSON.parse(line).method ?? ""));
54
+ }
55
+ beforeEach(() => {
56
+ migrate();
57
+ clearAllCapacitySnapshots();
58
+ resetMuseCapacityProbeStateForTests();
59
+ delete process.env[MUSE_CAPACITY_REFRESH_ENV];
60
+ delete process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS;
61
+ const existing = createAgent({
62
+ name: `muse-probe-${Date.now()}-${Math.random()}`,
63
+ runtime: "muse_code",
64
+ deckId: "33333333-3333-4333-8333-333333333333",
65
+ });
66
+ assert.equal(existing.runtime, "muse_code");
67
+ });
68
+ test("fallback defaults on; explicit off and unrecognized values disable spending", async () => {
69
+ for (const value of [undefined, "", "paid-after-1h"]) {
70
+ if (value === undefined)
71
+ delete process.env[MUSE_CAPACITY_REFRESH_ENV];
72
+ else
73
+ process.env[MUSE_CAPACITY_REFRESH_ENV] = value;
74
+ assert.equal(isMusePaidFallbackEnabled(), true);
75
+ }
76
+ for (const value of ["off", "auto", "paid-after-1h "]) {
77
+ process.env[MUSE_CAPACITY_REFRESH_ENV] = value;
78
+ assert.equal(isMusePaidFallbackEnabled(), false);
79
+ assert.deepEqual(await maybeProbeMuseCapacity(Date.now()), {
80
+ probed: false,
81
+ reason: "disabled",
82
+ });
83
+ }
84
+ });
85
+ test("legacy refresh off remains no-spend unless paid fallback is explicitly enabled", () => {
86
+ process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS = "off";
87
+ delete process.env[MUSE_CAPACITY_REFRESH_ENV];
88
+ assert.equal(isMusePaidFallbackEnabled(), false);
89
+ process.env[MUSE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
90
+ assert.equal(isMusePaidFallbackEnabled(), true);
91
+ });
92
+ test("a complete pair under one hour suppresses the fallback", async () => {
93
+ const now = Date.now();
94
+ await ingestMuseUsagePayload(payload(now - 59 * 60_000));
95
+ assert.equal(hasFreshMuseCapacityPair(now), true);
96
+ assert.deepEqual(await maybeProbeMuseCapacity(now), { probed: false, reason: "fresh" });
97
+ });
98
+ test("one missing half triggers one minimal turn and stores exactly 5H plus 1W", async () => {
99
+ const now = Date.now();
100
+ await ingestMuseUsagePayload(payload(now - 5 * 60_000, "five_hour"));
101
+ assert.equal(hasFreshMuseCapacityPair(now), false, "a fresh half is not a complete pair");
102
+ const host = new MuseCapacityHost(hostOpts("serve-turn-full"));
103
+ try {
104
+ const outcome = await maybeProbeMuseCapacity(now, { host, timeoutMs: 10_000 });
105
+ assert.equal(outcome.probed, true);
106
+ assert.equal(outcome.reason, "completed");
107
+ assert.equal(outcome.ok, true);
108
+ assert.deepEqual(outcome.windowsUpdated?.sort(), ["rolling_all_models", "weekly_all_models"]);
109
+ const roles = listCapacitySnapshots("muse_code")
110
+ .filter((w) => w.criticalRole !== null)
111
+ .map((w) => w.criticalRole)
112
+ .sort();
113
+ assert.deepEqual(roles, ["five_hour", "weekly"]);
114
+ }
115
+ finally {
116
+ await host.shutdown();
117
+ }
118
+ });
119
+ test("concurrent stale readers single-flight one turn", async () => {
120
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-probe-record-"));
121
+ const record = path.join(dir, "methods.jsonl");
122
+ const host = new MuseCapacityHost(hostOpts("serve-turn-full", { FAKE_MSP_RECORD: record }));
123
+ try {
124
+ const now = Date.now();
125
+ const outcomes = await Promise.all(Array.from({ length: 5 }, () => maybeProbeMuseCapacity(now, { host, timeoutMs: 10_000 })));
126
+ assert.equal(outcomes.filter((o) => o.reason === "completed").length, 1);
127
+ assert.equal(outcomes.filter((o) => o.reason === "shared").length, 4);
128
+ assert.ok(outcomes.every((o) => o.probed && o.ok));
129
+ assert.equal(recordedMethods(record).filter((m) => m === "turn/start").length, 1);
130
+ }
131
+ finally {
132
+ await host.shutdown();
133
+ }
134
+ });
135
+ test("failed admission preserves rows and enters hourly backoff", async () => {
136
+ const now = Date.now();
137
+ await ingestMuseUsagePayload(payload(now - 61 * 60_000));
138
+ const before = listCapacitySnapshots("muse_code").map((w) => ({
139
+ key: w.windowKey,
140
+ remaining: w.remainingPercent,
141
+ observedAt: w.observedAt,
142
+ }));
143
+ const host = new MuseCapacityHost(hostOpts("serve-turn-rejected"));
144
+ try {
145
+ const first = await maybeProbeMuseCapacity(now, { host, timeoutMs: 10_000 });
146
+ assert.equal(first.probed, true);
147
+ assert.equal(first.ok, false);
148
+ assert.equal(first.failureKind, "unadmitted");
149
+ assert.deepEqual(await maybeProbeMuseCapacity(now + 60_000, { host, timeoutMs: 10_000 }), {
150
+ probed: false,
151
+ reason: "backoff",
152
+ });
153
+ assert.deepEqual(listCapacitySnapshots("muse_code").map((w) => ({
154
+ key: w.windowKey,
155
+ remaining: w.remainingPercent,
156
+ observedAt: w.observedAt,
157
+ })), before);
158
+ }
159
+ finally {
160
+ await host.shutdown();
161
+ }
162
+ });
163
+ test("production probe host disables shell/write and keeps restricted network", () => {
164
+ assert.deepEqual([...MUSE_CAPACITY_PROBE_SERVE_ARGV], [
165
+ "serve",
166
+ "--sandbox-network",
167
+ "restricted",
168
+ "--disable-write",
169
+ "--disable-shell",
170
+ ]);
171
+ });
172
+ test("direct probe success requires the complete pair", async () => {
173
+ const host = new MuseCapacityHost(hostOpts("serve-turn-full"));
174
+ try {
175
+ const result = await runMuseCapacityProbe(Date.now(), { host, timeoutMs: 10_000 });
176
+ assert.equal(result.ok, true);
177
+ assert.equal(result.admitted, true);
178
+ assert.equal(result.failureKind, null);
179
+ }
180
+ finally {
181
+ await host.shutdown();
182
+ }
183
+ });
@@ -9,14 +9,15 @@
9
9
  // retired: only a host that observed the account's provider traffic can
10
10
  // answer `usage/read` / emit `usage/changed`.
11
11
  //
12
- // Reads observed capacity through the stable Muse Session Protocol without
13
- // sending a prompt and without consuming model tokens: `initialize` →
12
+ // The free read phase reads observed capacity through the stable Muse Session
13
+ // Protocol without sending a prompt or consuming model tokens: `initialize` →
14
14
  // `initialized` → `usage/read` on the owned host, with `usage/changed`
15
15
  // notifications ingested as received. The client enforces a read-only
16
16
  // allowlist (`initialize`, `initialized`, `usage/read`) — any other method
17
17
  // throws before it is written, so a capacity read can never start a
18
- // session, send a prompt, or run model work. It never touches the Keychain
19
- // or undocumented endpoints.
18
+ // session, send a prompt, or run model work. The separate default-on fallback
19
+ // in `muse-probe.ts` runs only after this phase still lacks a complete current
20
+ // pair for an hour. Neither path touches the Keychain or undocumented endpoints.
20
21
  //
21
22
  // Stable `usage/read` shape:
22
23
  // result: {
@@ -48,6 +49,11 @@ import fs from "node:fs";
48
49
  import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
49
50
  import { normalizeAdapterWindow, normalizeUnavailableWindow, } from "./adapter.js";
50
51
  export const MUSE_RUNTIME = "muse_code";
52
+ /** Muse observations remain the last-known current value until the one-hour
53
+ * fallback boundary. This avoids the shared adapter's generic 15-minute N/A
54
+ * gap before a bounded refresh is eligible to run. */
55
+ export const MUSE_CAPACITY_STALE_AFTER_MS = 60 * 60 * 1000;
56
+ export const MUSE_CAPACITY_EXPIRES_AFTER_MS = 60 * 60 * 1000;
51
57
  /** Methods this client may ever send. Anything else throws before write. */
52
58
  export const MSP_READ_ONLY_METHODS = ["initialize", "initialized", "usage/read"];
53
59
  /**
@@ -189,6 +195,8 @@ function windowReadingFromEntry(kind, entry, observedAt) {
189
195
  usedPercent,
190
196
  resetAt,
191
197
  observedAt,
198
+ staleAfterMs: MUSE_CAPACITY_STALE_AFTER_MS,
199
+ expiresAfterMs: MUSE_CAPACITY_EXPIRES_AFTER_MS,
192
200
  source: "supported_protocol",
193
201
  // Muse reports exactly this one rolling/weekly pair — it is always the
194
202
  // account-wide critical window, never a model-specific extra.
@@ -868,8 +876,10 @@ export function createMuseCapacityAdapter(opts = {}) {
868
876
  export const MUSE_REFRESH_THROTTLE_MS_DEFAULT = 5 * 60 * 1000;
869
877
  /**
870
878
  * Throttle bound for production refreshes. Override with
871
- * `AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS` (milliseconds); `off` disables
872
- * refresh entirely. Non-positive or unparsable values fall back to the default.
879
+ * `AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS` (milliseconds); `off` disables the
880
+ * free host read and, for backward-compatible no-spend behavior, also disables
881
+ * the paid fallback unless `AGENT_DEALER_MUSE_CAPACITY_REFRESH=paid-after-1h`
882
+ * is explicit. Non-positive or unparsable values fall back to the default.
873
883
  * Shared with the owned host (`./muse-host.js`).
874
884
  */
875
885
  export function museRefreshThrottleMs() {
@@ -8,7 +8,7 @@ import { getLinearUsageSnapshot } from "../adapters/linear-graphql.js";
8
8
  import { listRuntimeModels } from "../runners/models.js";
9
9
  import { configuredCapacityRuntimes, getRuntimeCapacitySnapshot } from "../capacity/service.js";
10
10
  import { refreshClaudeCapacityIfStale } from "../capacity/claude-local-cache.js";
11
- import { maybeRefreshMuseCapacityFromHost } from "../capacity/muse-host.js";
11
+ import { refreshMuseCapacityIfStale } from "../capacity/muse-probe.js";
12
12
  import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
13
13
  import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
14
14
  import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
@@ -63,13 +63,12 @@ export async function registerRoutes(app) {
63
63
  // NOT-245: provider-neutral capacity read model — one entry per configured
64
64
  // runtime account with its windows, freshness, and explicit unavailable
65
65
  // reasons. Normalized snapshots only; evidence stays server-side.
66
- // NOT-270: the only production trigger for Muse capacity — a throttled
67
- // (default 5 min), bounded, best-effort refresh through the server-owned
68
- // long-lived `muse serve` host when `muse_code` is configured. The
69
- // refresh runs in the background without blocking the read: GET serves
70
- // the last-known snapshot immediately so a slow or hanging host (bounded
71
- // by the capacity timeout) can never stall the Agents page. Failures
72
- // preserve last-good rows as N/A and never fail the read.
66
+ // Muse capacity: first take the free throttled read from the server-owned
67
+ // execution host. If there is still no complete observation under an hour
68
+ // old, the default-on bounded fallback runs one minimal turn on a dedicated
69
+ // restricted host and reads 5H/1W from that same host. This stays in the
70
+ // background: GET serves last-good immediately and never waits on model
71
+ // work. `AGENT_DEALER_MUSE_CAPACITY_REFRESH=off` disables paid fallback.
73
72
  // NOT-246: on-demand Codex refresh — when the stored Codex snapshot is
74
73
  // stale the read triggers one bounded, non-billable App Server poll
75
74
  // (single-flight, never health rows, never throws); fresh snapshots and
@@ -79,9 +78,9 @@ export async function registerRoutes(app) {
79
78
  // Claude Code's own free cache (plain file read, never a spawn) and then
80
79
  // considers the paid probe without blocking the read: a bounded Haiku
81
80
  // probe fires at most once per hour and only under the explicit
82
- // `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=paid-after-1h` opt-in when every
83
- // valid 5H/1W observation is older than 60 minutes. Disabled is a strict
84
- // no-op (no spawn, no spend). Failures never break the read below.
81
+ // valid 5H/1W observation is older than 60 minutes. This is the default;
82
+ // `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` is a strict no-op (no spawn,
83
+ // no spend). Failures never break the read below.
85
84
  app.get("/api/runtime-capacity", async () => {
86
85
  try {
87
86
  void refreshClaudeCapacityIfStale().catch(() => {
@@ -93,7 +92,7 @@ export async function registerRoutes(app) {
93
92
  }
94
93
  try {
95
94
  if (configuredCapacityRuntimes().includes("muse_code")) {
96
- void maybeRefreshMuseCapacityFromHost().catch(() => {
95
+ void refreshMuseCapacityIfStale().catch(() => {
97
96
  // Best-effort: failures preserve last-good rows via the ingest path.
98
97
  });
99
98
  }
@@ -15,6 +15,12 @@ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
15
15
  // Never spawn a real provider from route tests: the on-demand Codex refresh is
16
16
  // covered against the fake App Server in codex-app-server.test.ts.
17
17
  process.env.AGENT_DEALER_CODEX_CAPACITY_REFRESH = "off";
18
+ // Claude's paid-after-1h fallback is default-on in production. Route tests
19
+ // exercise stored/read behavior only and must never launch a real paid probe.
20
+ process.env.AGENT_DEALER_CLAUDE_CAPACITY_REFRESH = "off";
21
+ // Muse's one-hour fallback is also default-on; route tests cover the free
22
+ // host trigger only and must never run a real model turn.
23
+ process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH = "off";
18
24
  // NOT-268: the route also ingests the real ~/.claude.json cache on every read. Point it at a
19
25
  // path that never exists so a developer's own Claude usage never leaks extra windows into the
20
26
  // fixture-controlled assertions below (this passed in CI, which has no such file, but failed
@@ -62,9 +68,10 @@ test("GET /api/runtime-capacity returns normalized entries without evidence", as
62
68
  assert.ok(!raw.includes("evidence"), "no evidence pointers leak to the browser");
63
69
  await app.close();
64
70
  });
65
- test("GET triggers the Muse refresh when muse_code is configured", async () => {
66
- // NOT-270: the route is the only production trigger for Muse capacity
67
- // (owned host). No credential here (env key removed, empty login dir),
71
+ test("GET triggers the free Muse refresh when muse_code is configured", async () => {
72
+ // The route is the production trigger for Muse capacity. This suite sets
73
+ // the paid fallback off, so it exercises only the free owned-host read.
74
+ // No credential here (env key removed, empty login dir),
68
75
  // so the refresh short-circuits to a `missing` sentinel without spawning
69
76
  // anything live.
70
77
  clearAllCapacitySnapshots();
@@ -1,12 +1,13 @@
1
1
  // packages/server/src/runners/muse-serve-session.ts
2
2
  //
3
- // NOT-270: run a real Dealer Muse developer turn through the server-owned
4
- // `muse serve` host (the NOT-269 proven lifecycle). Only a host that
3
+ // NOT-270: run a Muse turn through a caller-owned `muse serve` host (the
4
+ // NOT-269 proven lifecycle). Only a host that
5
5
  // observes the account's provider traffic can answer `usage/read`, so the
6
- // observation opportunity IS the real session: `session/start` +
7
- // `turn/start` on the owned host, then the session-boundary `usage/read`
8
- // (the existing refresh hook) populates 5H/1W. No synthetic prompt is ever
9
- // sent — the turn below carries the genuine Dealer developer prompt.
6
+ // observation opportunity is a `session/start` + `turn/start` on that host.
7
+ // Production callers use this for genuine Dealer developer work and, when
8
+ // capacity has been unavailable for an hour, the dedicated bounded capacity
9
+ // probe. The capacity probe owns a separate restricted host and shuts it down
10
+ // after its final `usage/read`.
10
11
  //
11
12
  // Wire contract (stable MSP, Muse 1.4.x):
12
13
  // session/start {commandId UUIDv7, workspaceRoot, modelId, approvalMode}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.2.3",
25
+ "@agent-dealer/shared": "1.2.4",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/shared",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/dist/doctor.d.ts CHANGED
@@ -23,9 +23,10 @@ export declare function describeCursorIndividualLogin(status: {
23
23
  export declare function checkCursorIndividualLogin(): Promise<CursorIndividualLoginReport>;
24
24
  /** Env override for the Claude cache file (tests/smoke). */
25
25
  export declare const CLAUDE_CAPACITY_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
26
- /** Paid-fallback opt-in env and its only enabling value. */
26
+ /** Paid-fallback setting: enabled by default; `off` disables it. */
27
27
  export declare const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
28
28
  export declare const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
29
+ export declare const CLAUDE_CAPACITY_REFRESH_OFF_VALUE = "off";
29
30
  /** Which local 5H/1W cache state the capacity ladder would read. */
30
31
  export type ClaudeCapacitySourceKind = "fresh" | "stale" | "missing";
31
32
  export interface ClaudeCapacitySourceReport {
@@ -42,11 +43,25 @@ export declare function describeClaudeCapacitySource(status: {
42
43
  ageMs: number | null;
43
44
  } | null | undefined): ClaudeCapacitySourceReport;
44
45
  /**
45
- * Map the paid-fallback setting to an opt-in warning line, or null when the
46
- * probe is disabled (the quiet default — no spend possible, nothing to say).
46
+ * Map the paid-fallback setting to its warning line, or null when explicitly
47
+ * disabled (or unrecognized). Unset/empty is the paid-after-1h default.
47
48
  * Pure (no I/O): unit tests pin both states here.
48
49
  */
49
50
  export declare function describeClaudeProbeOptIn(setting: string | undefined): string | null;
51
+ /** Paid-fallback setting: enabled by default; `off` disables it. */
52
+ export declare const MUSE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_MUSE_CAPACITY_REFRESH";
53
+ export declare const MUSE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
54
+ export declare const MUSE_CAPACITY_REFRESH_OFF_VALUE = "off";
55
+ /** Legacy free-read throttle; `off` also preserves no-spend behavior when the
56
+ * new setting is absent. */
57
+ export declare const MUSE_CAPACITY_REFRESH_MS_ENV = "AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS";
58
+ /**
59
+ * Match the server's Muse paid-fallback precedence exactly. The explicit new
60
+ * paid value overrides a legacy free-refresh `off`; otherwise that legacy
61
+ * switch remains no-spend for backward compatibility. Unknown new values
62
+ * fail closed. Pure (no I/O) so doctor never risks a provider request.
63
+ */
64
+ export declare function describeMuseProbeOptIn(setting: string | undefined, legacyRefreshMs: string | undefined): string | null;
50
65
  /**
51
66
  * Read the `cachedUsageUtilization.fetchedAtMs` timestamp out of Claude
52
67
  * Code's config file (override first, then `~/.claude.json`) — the same key
package/dist/doctor.js CHANGED
@@ -117,6 +117,9 @@ export async function runDoctor() {
117
117
  const probe = describeClaudeProbeOptIn(process.env.AGENT_DEALER_CLAUDE_CAPACITY_REFRESH);
118
118
  if (probe)
119
119
  console.warn(probe);
120
+ const museProbe = describeMuseProbeOptIn(process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH, process.env.AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS);
121
+ if (museProbe)
122
+ console.warn(museProbe);
120
123
  }
121
124
  catch {
122
125
  // Informational only: a broken probe must never fail doctor.
@@ -261,9 +264,10 @@ export async function checkCursorIndividualLogin() {
261
264
  // ---------------------------------------------------------------------------
262
265
  /** Env override for the Claude cache file (tests/smoke). */
263
266
  export const CLAUDE_CAPACITY_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
264
- /** Paid-fallback opt-in env and its only enabling value. */
267
+ /** Paid-fallback setting: enabled by default; `off` disables it. */
265
268
  export const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
266
269
  export const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
270
+ export const CLAUDE_CAPACITY_REFRESH_OFF_VALUE = "off";
267
271
  /**
268
272
  * Map a cache-file presence/age probe to the doctor line. Pure (no I/O):
269
273
  * unit tests pin all three states here.
@@ -298,17 +302,42 @@ function formatCacheAge(ageMs) {
298
302
  return `${Math.floor(hours / 24)}d`;
299
303
  }
300
304
  /**
301
- * Map the paid-fallback setting to an opt-in warning line, or null when the
302
- * probe is disabled (the quiet default — no spend possible, nothing to say).
305
+ * Map the paid-fallback setting to its warning line, or null when explicitly
306
+ * disabled (or unrecognized). Unset/empty is the paid-after-1h default.
303
307
  * Pure (no I/O): unit tests pin both states here.
304
308
  */
305
309
  export function describeClaudeProbeOptIn(setting) {
306
- if (setting === CLAUDE_CAPACITY_REFRESH_PAID_VALUE) {
307
- return ("⚠ Claude capacity paid fallback: armed (one ≤$0.01 Haiku probe after 1h stale — " +
308
- `unset ${CLAUDE_CAPACITY_REFRESH_ENV} to disable)`);
310
+ if (setting === undefined || setting === "" || setting === CLAUDE_CAPACITY_REFRESH_PAID_VALUE) {
311
+ return ("⚠ Claude capacity paid fallback: enabled (one ≤$0.01 Haiku probe after 1h stale — " +
312
+ `set ${CLAUDE_CAPACITY_REFRESH_ENV}=${CLAUDE_CAPACITY_REFRESH_OFF_VALUE} to disable)`);
309
313
  }
310
314
  return null;
311
315
  }
316
+ // ---------------------------------------------------------------------------
317
+ // Muse capacity paid-fallback reporting for doctor.
318
+ // ---------------------------------------------------------------------------
319
+ /** Paid-fallback setting: enabled by default; `off` disables it. */
320
+ export const MUSE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_MUSE_CAPACITY_REFRESH";
321
+ export const MUSE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
322
+ export const MUSE_CAPACITY_REFRESH_OFF_VALUE = "off";
323
+ /** Legacy free-read throttle; `off` also preserves no-spend behavior when the
324
+ * new setting is absent. */
325
+ export const MUSE_CAPACITY_REFRESH_MS_ENV = "AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS";
326
+ /**
327
+ * Match the server's Muse paid-fallback precedence exactly. The explicit new
328
+ * paid value overrides a legacy free-refresh `off`; otherwise that legacy
329
+ * switch remains no-spend for backward compatibility. Unknown new values
330
+ * fail closed. Pure (no I/O) so doctor never risks a provider request.
331
+ */
332
+ export function describeMuseProbeOptIn(setting, legacyRefreshMs) {
333
+ const enabled = setting === MUSE_CAPACITY_REFRESH_PAID_VALUE ||
334
+ ((setting === undefined || setting === "") && legacyRefreshMs !== "off");
335
+ if (!enabled)
336
+ return null;
337
+ return ("⚠ Muse capacity paid fallback: enabled when Muse is configured " +
338
+ "(one restricted contributor-model turn after 1h stale — " +
339
+ `set ${MUSE_CAPACITY_REFRESH_ENV}=${MUSE_CAPACITY_REFRESH_OFF_VALUE} to disable)`);
340
+ }
312
341
  /**
313
342
  * Read the `cachedUsageUtilization.fetchedAtMs` timestamp out of Claude
314
343
  * Code's config file (override first, then `~/.claude.json`) — the same key
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
9
9
  import fs from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import Database from "better-sqlite3";
13
14
  // Local mirror of the server module's env/key contract
14
15
  // (packages/server/src/capacity/cursor-individual-credentials.ts). A static
@@ -45,7 +46,7 @@ test("fixture env/key names match the server module's live contract", async () =
45
46
  assert.equal(mod.CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV, CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV);
46
47
  assert.equal(mod.CURSOR_INDIVIDUAL_HOME_ENV, CURSOR_INDIVIDUAL_HOME_ENV);
47
48
  });
48
- import { CLAUDE_CAPACITY_CACHE_FILE_ENV, CLAUDE_CAPACITY_REFRESH_ENV, CLAUDE_CAPACITY_REFRESH_PAID_VALUE, CURSOR_INDIVIDUAL_LOGIN_LINES, checkClaudeCapacitySource, checkCursorIndividualLogin, describeClaudeCapacitySource, describeClaudeProbeOptIn, describeCursorIndividualLogin, } from "./doctor.js";
49
+ import { CLAUDE_CAPACITY_CACHE_FILE_ENV, CLAUDE_CAPACITY_REFRESH_ENV, CLAUDE_CAPACITY_REFRESH_PAID_VALUE, CURSOR_INDIVIDUAL_LOGIN_LINES, MUSE_CAPACITY_REFRESH_ENV, MUSE_CAPACITY_REFRESH_MS_ENV, MUSE_CAPACITY_REFRESH_PAID_VALUE, checkClaudeCapacitySource, checkCursorIndividualLogin, describeClaudeCapacitySource, describeClaudeProbeOptIn, describeCursorIndividualLogin, describeMuseProbeOptIn, } from "./doctor.js";
49
50
  const SECRET = "doctor-fixture-secret-must-never-print";
50
51
  const USER_ID = "user_doctor_fixture";
51
52
  function fixtureJwt(sub) {
@@ -188,15 +189,44 @@ test("describe maps each cache state to its static line", () => {
188
189
  assert.match(report.line, /no local 5H\/1W cache/);
189
190
  }
190
191
  });
191
- test("probe opt-in line appears only under the exact paid value", () => {
192
- assert.equal(describeClaudeProbeOptIn(undefined), null);
193
- assert.equal(describeClaudeProbeOptIn(""), null);
192
+ test("probe warning appears for the default and explicit paid value", () => {
193
+ for (const setting of [undefined, "", CLAUDE_CAPACITY_REFRESH_PAID_VALUE]) {
194
+ const armed = describeClaudeProbeOptIn(setting);
195
+ assert.ok(armed);
196
+ assert.match(armed, /enabled/);
197
+ assert.match(armed, /≤\$0\.01/);
198
+ assert.match(armed, /=off/);
199
+ }
194
200
  assert.equal(describeClaudeProbeOptIn("off"), null);
195
201
  assert.equal(describeClaudeProbeOptIn("auto"), null);
196
- const armed = describeClaudeProbeOptIn(CLAUDE_CAPACITY_REFRESH_PAID_VALUE);
197
- assert.ok(armed);
198
- assert.match(armed, /armed/);
199
- assert.match(armed, /≤\$0\.01/);
202
+ });
203
+ test("Muse paid-fallback warning matches server defaults and legacy no-spend precedence", () => {
204
+ for (const setting of [undefined, "", MUSE_CAPACITY_REFRESH_PAID_VALUE]) {
205
+ const armed = describeMuseProbeOptIn(setting, undefined);
206
+ assert.ok(armed);
207
+ assert.match(armed, /Muse capacity paid fallback: enabled/);
208
+ assert.match(armed, /restricted contributor-model turn/);
209
+ assert.match(armed, new RegExp(`${MUSE_CAPACITY_REFRESH_ENV}=off`));
210
+ }
211
+ assert.equal(describeMuseProbeOptIn("off", undefined), null);
212
+ assert.equal(describeMuseProbeOptIn("auto", undefined), null);
213
+ assert.equal(describeMuseProbeOptIn(undefined, "off"), null);
214
+ assert.equal(describeMuseProbeOptIn("", "off"), null);
215
+ assert.ok(describeMuseProbeOptIn(MUSE_CAPACITY_REFRESH_PAID_VALUE, "off"), "the explicit new paid value overrides the legacy free-refresh switch");
216
+ });
217
+ test("Muse doctor env names and paid value match the server contract", () => {
218
+ const here = path.dirname(fileURLToPath(import.meta.url));
219
+ const candidates = [
220
+ path.resolve(here, "..", "..", "server", "src", "capacity", "muse-probe.ts"),
221
+ path.resolve(here, "..", "..", "server", "dist", "capacity", "muse-probe.js"),
222
+ ];
223
+ const src = candidates.find((file) => fs.existsSync(file));
224
+ assert.ok(src, "the server Muse probe module must exist for the contract pin");
225
+ const text = fs.readFileSync(src, "utf8");
226
+ assert.ok(text.includes(`"${MUSE_CAPACITY_REFRESH_ENV}"`));
227
+ assert.ok(text.includes(`"${MUSE_CAPACITY_REFRESH_PAID_VALUE}"`));
228
+ assert.ok(text.includes("AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS"));
229
+ assert.equal(MUSE_CAPACITY_REFRESH_MS_ENV, "AGENT_DEALER_MUSE_CAPACITY_REFRESH_MS");
200
230
  });
201
231
  test("check reads fetchedAtMs from the config key, never mtime or the real home", async () => {
202
232
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dealer",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Human control plane for agent execution — queue, plan approval, audit",
5
5
  "license": "MIT",
6
6
  "type": "module",