agent-dealer 1.2.3 → 1.2.5

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 (43) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +25 -2
  2. package/bundle/server/dist/adapters/agent-health.test.js +55 -2
  3. package/bundle/server/dist/adapters/github.js +110 -12
  4. package/bundle/server/dist/adapters/github.test.js +274 -3
  5. package/bundle/server/dist/adapters/muse-capability.js +330 -0
  6. package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
  7. package/bundle/server/dist/capacity/claude-local-cache.js +222 -63
  8. package/bundle/server/dist/capacity/claude-local-cache.test.js +179 -30
  9. package/bundle/server/dist/capacity/muse-host.js +111 -23
  10. package/bundle/server/dist/capacity/muse-host.test.js +125 -1
  11. package/bundle/server/dist/capacity/muse-lifecycle.test.js +2 -3
  12. package/bundle/server/dist/capacity/muse-probe.js +250 -0
  13. package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
  14. package/bundle/server/dist/capacity/muse.js +16 -6
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/commands.js +35 -8
  17. package/bundle/server/dist/coordinator/developer-effect.js +40 -6
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
  19. package/bundle/server/dist/coordinator/human-resolution.js +31 -1
  20. package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
  21. package/bundle/server/dist/coordinator/prompts.js +15 -0
  22. package/bundle/server/dist/coordinator/prompts.test.js +20 -0
  23. package/bundle/server/dist/coordinator/spawn.js +7 -0
  24. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  25. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  26. package/bundle/server/dist/routes/human-actions.js +10 -1
  27. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  28. package/bundle/server/dist/routes/index.js +17 -14
  29. package/bundle/server/dist/routes/runtime-capacity.test.js +11 -3
  30. package/bundle/server/dist/runners/muse-serve-session.js +7 -6
  31. package/bundle/server/package.json +2 -2
  32. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  33. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +60 -0
  34. package/bundle/server/static-ui/index.html +2 -2
  35. package/bundle/shared/dist/agents.d.ts +15 -15
  36. package/bundle/shared/dist/agents.js +6 -0
  37. package/bundle/shared/dist/index.d.ts +7 -7
  38. package/bundle/shared/package.json +1 -1
  39. package/dist/doctor.d.ts +22 -3
  40. package/dist/doctor.js +46 -12
  41. package/dist/doctor.test.js +41 -10
  42. package/package.json +1 -1
  43. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -1,8 +1,8 @@
1
1
  // packages/server/src/capacity/claude-local-cache.test.ts
2
2
  //
3
3
  // NOT-268: Claude capacity local-first ladder — local cache 5H/1W plus a
4
- // one-hour paid fallback. No test here performs a live provider request:
5
- // every probe spawn goes through an injected fake runner.
4
+ // free `/usage` refresh after one hour stale. No test here performs a live
5
+ // provider request: every probe spawn goes through an injected fake runner.
6
6
  import { test, before, beforeEach, afterEach } from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import fs from "node:fs";
@@ -42,17 +42,39 @@ function fullCacheFixture(fetchedAtMs) {
42
42
  },
43
43
  });
44
44
  }
45
- function probeStreamFixture(observedIso, costUsd = 0.0003) {
45
+ // Mirrors a real `claude -p "/usage"` stream at 2.1.283: a local-command
46
+ // result event carrying `usage_report.rate_limits.limits[]` (verified live
47
+ // 2026-09-27 — see claude-local-cache.ts module header), followed by the
48
+ // terminal `result` event. Real runs cost $0 (no model call); `costUsd`
49
+ // defaults to 0 but stays overridable for the cost-plumbing assertions.
50
+ function probeStreamFixture(observedIso, costUsd = 0) {
46
51
  return [
47
52
  JSON.stringify({
48
- type: "rate_limit_event",
53
+ type: "assistant",
49
54
  timestamp: observedIso,
50
- rate_limit_info: {
51
- status: "allowed",
52
- unifiedWindows: [
53
- { window: "five_hour", utilization: 0.2, resetsAt: FIVE_HOUR_RESET_SEC },
54
- { window: "seven_day", utilization: 0.4, resetsAt: SEVEN_DAY_RESET_SEC },
55
- ],
55
+ local_command_run: { command: "usage", args: "" },
56
+ usage_report: {
57
+ session: { total_cost_usd: 0 },
58
+ rate_limits: {
59
+ limits: [
60
+ {
61
+ kind: "session",
62
+ group: "session",
63
+ percent: 20,
64
+ resets_at: new Date(FIVE_HOUR_RESET_SEC * 1000).toISOString(),
65
+ severity: "normal",
66
+ is_active: false,
67
+ },
68
+ {
69
+ kind: "weekly_all",
70
+ group: "weekly",
71
+ percent: 40,
72
+ resets_at: new Date(SEVEN_DAY_RESET_SEC * 1000).toISOString(),
73
+ severity: "normal",
74
+ is_active: true,
75
+ },
76
+ ],
77
+ },
56
78
  },
57
79
  }),
58
80
  JSON.stringify({
@@ -263,21 +285,40 @@ test("a 2-hour-old cache ingests with its true age and reads expired, never live
263
285
  assert.equal(w.unavailableReason, "expired");
264
286
  }
265
287
  });
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 "]) {
288
+ test("refresh defaults on; explicit off and unrecognized values never spawn", async () => {
289
+ for (const value of [undefined, "", "paid-after-1h"]) {
268
290
  if (value === undefined)
269
291
  delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
270
292
  else
271
293
  process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
294
+ assert.equal(isClaudePaidFallbackEnabled(), true);
295
+ }
296
+ for (const value of ["off", "auto", "paid-after-1h "]) {
297
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
272
298
  assert.equal(isClaudePaidFallbackEnabled(), false);
273
299
  const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
274
300
  assert.deepEqual(outcome, { probed: false, reason: "disabled" });
275
301
  }
276
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
302
+ delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
277
303
  assert.equal(isClaudePaidFallbackEnabled(), true);
278
304
  });
279
- test("fresh sample suppresses the probe; stale data triggers exactly one", async () => {
280
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
305
+ test("extractClaudeUsageReportLimits reads the /usage local-command result", async () => {
306
+ const { extractClaudeUsageReportLimits } = await import("./claude-local-cache.js");
307
+ const observedIso = new Date(NOW_MS).toISOString();
308
+ const events = probeStreamFixture(observedIso).split("\n").map((l) => JSON.parse(l));
309
+ const readings = extractClaudeUsageReportLimits(events, NOW_MS);
310
+ assert.ok(readings);
311
+ assert.equal(readings.length, 2);
312
+ const fiveHour = readings.find((w) => w.criticalRole === "five_hour");
313
+ const weekly = readings.find((w) => w.criticalRole === "weekly");
314
+ assert.equal(fiveHour.usedPercent, 20);
315
+ assert.equal(weekly.usedPercent, 40);
316
+ assert.equal(fiveHour.observedAt, observedIso);
317
+ assert.equal(fiveHour.evidenceRef, "claude-probe:usage-command");
318
+ // No usage_report anywhere in the stream — nothing fabricated.
319
+ assert.equal(extractClaudeUsageReportLimits([{ type: "result" }], NOW_MS), null);
320
+ });
321
+ test("default-on refresh suppresses a fresh sample; stale data triggers exactly one", async () => {
281
322
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 10 * 60_000));
282
323
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
283
324
  assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - 60 * 60_000);
@@ -300,8 +341,7 @@ test("fresh sample suppresses the probe; stale data triggers exactly one", async
300
341
  assert.equal(outcomes.filter((o) => o.reason === "shared").length, 4);
301
342
  assert.ok(outcomes.every((o) => o.probed && o.ok));
302
343
  });
303
- test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn, ≤$0.01", async () => {
304
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
344
+ test("probe argv pins the fixed /usage command plus structural model-turn safeguards", async () => {
305
345
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
306
346
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
307
347
  let seenBin = "";
@@ -318,7 +358,7 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
318
358
  assert.equal(seenBin, "/fake/claude");
319
359
  assert.deepEqual(seenArgv, [
320
360
  "-p",
321
- "Reply with exactly: ok",
361
+ "/usage",
322
362
  "--model",
323
363
  "haiku",
324
364
  "--max-turns",
@@ -333,13 +373,14 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
333
373
  "--max-budget-usd",
334
374
  "0.01",
335
375
  ]);
336
- // Semantic pins behind the exact match: cheapest model, hard one-turn
337
- // bound, tools fully off, MCP restricted to none passed, stream JSON,
338
- // hard budget cap.
376
+ // Semantic pins behind the exact match: no MCP config passed, cheapest
377
+ // model + hard one-turn bound + no tools kept as structural safeguards
378
+ // (2026-09-27 review hardening) even though `/usage` never reaches the
379
+ // model on 2.1.283, plus the defensive budget cap.
380
+ assert.ok(!seenArgv.includes("--mcp-config"));
339
381
  assert.equal(seenArgv[seenArgv.indexOf("--model") + 1], "haiku");
340
382
  assert.equal(seenArgv[seenArgv.indexOf("--max-turns") + 1], "1");
341
383
  assert.equal(seenArgv[seenArgv.indexOf("--tools") + 1], "");
342
- assert.ok(!seenArgv.includes("--mcp-config"));
343
384
  const budget = Number(seenArgv[seenArgv.indexOf("--max-budget-usd") + 1]);
344
385
  assert.ok(Number.isFinite(budget) && budget <= 0.01);
345
386
  assert.equal(seenArgv.filter((a) => a === "-p").length, 1);
@@ -353,7 +394,7 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
353
394
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
354
395
  const marker = "probe-stream-marker-must-never-reach-log";
355
396
  const runner = async () => ({
356
- stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString(), 0.0007)}\n${JSON.stringify({ type: "assistant", text: marker })}`,
397
+ stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString())}\n${JSON.stringify({ type: "assistant", text: marker })}`,
357
398
  exitCode: 0,
358
399
  timedOut: false,
359
400
  spawnError: null,
@@ -361,28 +402,134 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
361
402
  const result = await runClaudeCapacityProbe(NOW_MS, { runner });
362
403
  assert.equal(result.ok, true);
363
404
  assert.equal(result.failureKind, null);
364
- assert.equal(result.costUsd, 0.0007);
405
+ assert.equal(result.costUsd, 0);
365
406
  assert.deepEqual(result.windowsUpdated, ["claude_unified_five_hour", "claude_unified_seven_day"]);
366
407
  const rows = listCapacitySnapshots("claude_code");
367
408
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 80);
368
409
  assert.equal(rows.find((r) => r.providerBucket === "seven_day").remainingPercent, 60);
369
410
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").observedAt, new Date(NOW_MS).toISOString());
370
411
  const logText = fs.readFileSync(probeDiagnosticLogPath(), "utf8");
371
- assert.ok(logText.includes('"costUsd":0.0007'));
412
+ assert.ok(logText.includes('"costUsd":0'));
372
413
  assert.ok(!logText.includes("Reply with exactly"));
373
414
  assert.ok(!logText.includes(marker));
374
415
  });
375
- test("probe success via the cache side effect when the stream carries no windows", async () => {
416
+ test("probe rejects and ingests nothing when the result reports nonzero cost", async () => {
417
+ // Reviewer-requested hardening (PR #165): any charge means /usage did not
418
+ // resolve locally as expected — reject the whole run rather than trusting
419
+ // windows a real (unexpected) model turn happened to produce.
420
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
421
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
422
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
423
+ const runner = async () => ({
424
+ stdout: probeStreamFixture(new Date(NOW_MS).toISOString(), 0.0007),
425
+ exitCode: 0,
426
+ timedOut: false,
427
+ spawnError: null,
428
+ });
429
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
430
+ assert.equal(result.ok, false);
431
+ assert.equal(result.failureKind, "unexpected_cost");
432
+ assert.equal(result.costUsd, 0.0007);
433
+ assert.deepEqual(result.windowsUpdated, []);
434
+ // Last-good rows from before the rejected run are untouched.
435
+ const rows = listCapacitySnapshots("claude_code");
436
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
437
+ });
438
+ test("probe rejects and ingests nothing when cost cannot be verified as zero", async () => {
439
+ // Reviewer-requested hardening, round 2 (PR #165): a stream with the
440
+ // local-command marker and well-formed windows, but a `result` event that
441
+ // omits `total_cost_usd` (costUsd === null), must fail closed exactly like
442
+ // a confirmed positive charge — null is not evidence of zero cost.
443
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
444
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
445
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
446
+ const runner = async () => ({
447
+ stdout: [
448
+ JSON.stringify({
449
+ type: "assistant",
450
+ timestamp: new Date(NOW_MS).toISOString(),
451
+ local_command_run: { command: "usage", args: "" },
452
+ usage_report: {
453
+ rate_limits: {
454
+ limits: [
455
+ { kind: "session", percent: 20, resets_at: new Date(FIVE_HOUR_RESET_SEC * 1000).toISOString() },
456
+ { kind: "weekly_all", percent: 40, resets_at: new Date(SEVEN_DAY_RESET_SEC * 1000).toISOString() },
457
+ ],
458
+ },
459
+ },
460
+ }),
461
+ // No `result` event at all — total_cost_usd is unknowable.
462
+ ].join("\n"),
463
+ exitCode: 0,
464
+ timedOut: false,
465
+ spawnError: null,
466
+ });
467
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
468
+ assert.equal(result.ok, false);
469
+ assert.equal(result.failureKind, "unexpected_cost");
470
+ assert.equal(result.costUsd, null);
471
+ assert.deepEqual(result.windowsUpdated, []);
472
+ const rows = listCapacitySnapshots("claude_code");
473
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
474
+ });
475
+ test("probe rejects and ingests nothing when the local-command marker is absent", async () => {
476
+ // Reviewer-requested hardening (PR #165): a stream that never shows
477
+ // `/usage` resolving locally must not be trusted, even if it happens to
478
+ // carry a well-formed usage_report (e.g. a different CLI build echoing it
479
+ // from a real turn) or a fresh cache-file side effect.
480
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
481
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
482
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
483
+ const runner = async () => {
484
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS + 5_000));
485
+ return {
486
+ stdout: [
487
+ JSON.stringify({
488
+ type: "assistant",
489
+ timestamp: new Date(NOW_MS).toISOString(),
490
+ usage_report: {
491
+ rate_limits: { limits: [{ kind: "session", percent: 20, resets_at: null }] },
492
+ },
493
+ }),
494
+ JSON.stringify({ type: "result", is_error: false, total_cost_usd: 0 }),
495
+ ].join("\n"),
496
+ exitCode: 0,
497
+ timedOut: false,
498
+ spawnError: null,
499
+ };
500
+ };
501
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
502
+ assert.equal(result.ok, false);
503
+ assert.equal(result.failureKind, "not_local_command");
504
+ assert.deepEqual(result.windowsUpdated, []);
505
+ const rows = listCapacitySnapshots("claude_code");
506
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
507
+ });
508
+ test("probe success via the cache side effect when usage_report carries no windows", async () => {
376
509
  process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
377
510
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
378
- // The stream is empty but the probe run itself refreshes Claude's own
379
- // cache file — the re-read must count as success.
511
+ // The local-command marker is present (so the run is trusted) and cost is
512
+ // $0, but usage_report carries no usable limits[] — the cache re-read
513
+ // (the probe run itself refreshes Claude's own cache file) still counts
514
+ // as corroborating success.
380
515
  const runner = async () => {
381
516
  // A real probe refreshes Claude's own cache file while it runs, so the
382
517
  // new `fetchedAtMs` is later than the pre-spawn `NOW_MS` stamp — the
383
518
  // post-spawn re-read must accept it, not reject it as future.
384
519
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS + 5_000));
385
- return { stdout: "", exitCode: 0, timedOut: false, spawnError: null };
520
+ return {
521
+ stdout: [
522
+ JSON.stringify({
523
+ type: "assistant",
524
+ timestamp: new Date(NOW_MS).toISOString(),
525
+ local_command_run: { command: "usage", args: "" },
526
+ }),
527
+ JSON.stringify({ type: "result", is_error: false, total_cost_usd: 0 }),
528
+ ].join("\n"),
529
+ exitCode: 0,
530
+ timedOut: false,
531
+ spawnError: null,
532
+ };
386
533
  };
387
534
  const result = await runClaudeCapacityProbe(NOW_MS, { runner });
388
535
  assert.equal(result.ok, true);
@@ -404,7 +551,9 @@ test("probe failure preserves last-good rows and enters bounded backoff", async
404
551
  const first = await maybeProbeClaudeCapacity(NOW_MS, { runner: failing });
405
552
  assert.equal(first.probed, true);
406
553
  assert.equal(first.ok, false);
407
- assert.equal(first.failureKind, "nonzero_exit");
554
+ // Unparsable stdout ("not-json") means no local-command marker is found —
555
+ // that check runs before the nonzero-exit fallback.
556
+ assert.equal(first.failureKind, "not_local_command");
408
557
  // Last-good rows are untouched by the failed probe (0.17 used → 83 left).
409
558
  const rows = listCapacitySnapshots("claude_code");
410
559
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
@@ -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
@@ -85,13 +87,32 @@
85
87
  // shell restriction) is unaffected.
86
88
  // This question was escalated twice on this ticket (product_scope_decision,
87
89
  // resolved both times); the decision above is final for NOT-270's scope.
88
- import { spawn } from "node:child_process";
90
+ import { execFile, spawn } from "node:child_process";
89
91
  import fs from "node:fs";
90
92
  import os from "node:os";
91
93
  import path from "node:path";
92
94
  import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
95
+ import { getDataDir } from "../db/index.js";
96
+ import { parseMuseVersion } from "../adapters/muse-capability.js";
93
97
  import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
94
98
  import { MSP_USAGE_CHANGED, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, assertMuseExecMethod, assertMuseReadOnlyMethod, hasMuseCredential, ingestMuseUsagePayload, museCapacityTimeoutMs, museClassifyRpcError, museEvidenceRef, museRefreshThrottleMs, museUuidv7, noteMuseCapacityFailure, parseMuseRpcLine, } from "./muse.js";
99
+ function readResolvedMuseVersion() {
100
+ return new Promise((resolve) => {
101
+ execFile(resolveMuseBin(), ["--version"], { encoding: "utf8", timeout: 30_000, env: { ...process.env, ...MUSE_CLI_ENV } }, (err, stdout) => resolve(err ? null : parseMuseVersion(stdout)));
102
+ });
103
+ }
104
+ /**
105
+ * NOT-273: dedicated cwd for the coordinator-owned `muse serve` child.
106
+ * The spawn must never inherit the server process's ambient working
107
+ * directory (in a maintainer's `npm run dev` checkout that is the repo
108
+ * itself, where a stray `git add . && git commit` in the child would land
109
+ * real commits on the checkout). Created if missing.
110
+ */
111
+ export function resolveMuseHostCwd() {
112
+ const dir = path.join(getDataDir(), "capacity", "muse-host-home");
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ return dir;
115
+ }
95
116
  /**
96
117
  * NOT-270 serve lane: the owned host must run workers under the same
97
118
  * posture as the exec lane's per-attempt settings — no MCP servers, no
@@ -128,6 +149,8 @@ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
128
149
  }
129
150
  return { configHome, dataHome, authLinked };
130
151
  }
152
+ const KEYCHAIN_UNREADABLE_RE = /keychain item .* unreadable|OSStatus\s+-?\d+/i;
153
+ const AUTH_FAILURE_RE = /credential|auth|login|sign[ -]?in|\b401\b|\b403\b/i;
131
154
  function readOnlyRequest(id, method, params) {
132
155
  assertMuseReadOnlyMethod(method);
133
156
  return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
@@ -167,6 +190,14 @@ export class MuseCapacityHost {
167
190
  observedOnConnection = false;
168
191
  /** Increments on every spawn — tests use it to prove host reuse/restart. */
169
192
  connectionEpoch = 0;
193
+ /**
194
+ * NOT-277: the Muse version the live child runs — the on-disk binary's reported version, read
195
+ * immediately before the spawn and again after the handshake; null when unknown or when the
196
+ * binary changed across the spawn. A long-lived host keeps running the build it started on
197
+ * after an auto-update, so developer work only uses it when this is the capability-checked
198
+ * version (see `ensureStartedOnVersion`).
199
+ */
200
+ runningVersion = null;
170
201
  /**
171
202
  * Server-owned XDG home for the host (worker settings + auth link).
172
203
  * Built lazily on first spawn, reused across restarts, removed on
@@ -237,6 +268,7 @@ export class MuseCapacityHost {
237
268
  const child = this.child;
238
269
  this.child = null;
239
270
  this.dead = true;
271
+ this.runningVersion = null;
240
272
  for (const [, p] of this.pending) {
241
273
  try {
242
274
  p.resolve({});
@@ -453,24 +485,51 @@ export class MuseCapacityHost {
453
485
  this.starting = null;
454
486
  }
455
487
  }
488
+ /**
489
+ * NOT-277: start (or reuse) the host, and report whether it runs `version`. A live host on any
490
+ * other (or an unknown) version is restarted onto the current on-disk binary when it is idle;
491
+ * while real turns are using it, it is left alone and this returns false (the caller runs its
492
+ * work on the exec lane instead). Never true for a host not known to run `version`.
493
+ */
494
+ async ensureStartedOnVersion(version) {
495
+ if (!(await this.ensureStarted()))
496
+ return false;
497
+ if (this.runningVersion === version)
498
+ return true;
499
+ if (this.hasInflightExecution() || this.inflightRead)
500
+ return false;
501
+ this.killChild();
502
+ if (!(await this.ensureStarted()))
503
+ return false;
504
+ return this.runningVersion === version;
505
+ }
456
506
  async start() {
457
507
  this.killChild();
508
+ const readVersion = this.opts.readVersion ?? (this.opts.command ? async () => null : readResolvedMuseVersion);
509
+ const versionBefore = await readVersion();
458
510
  const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
459
511
  const mergedEnv = this.mergedEnv();
460
512
  if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
513
+ this.startFailure = "credential_missing";
461
514
  return false;
462
515
  }
463
516
  const command = this.opts.command ?? resolveMuseBin();
464
517
  const args = this.opts.args ?? [...MUSE_SERVE_ARGV];
465
518
  let child;
466
519
  try {
467
- child = spawn(command, args, {
520
+ // NOT-273: explicit cwd — never inherit the server process's ambient
521
+ // working directory (which in dev is the repo checkout itself).
522
+ const cwd = this.opts.cwd ?? resolveMuseHostCwd();
523
+ const spawnFn = this.opts.spawnImpl ?? spawn;
524
+ child = spawnFn(command, args, {
525
+ cwd,
468
526
  stdio: ["pipe", "pipe", "pipe"],
469
527
  env: { ...mergedEnv, ...MUSE_CLI_ENV },
470
528
  });
471
529
  }
472
530
  catch (err) {
473
- this.startFailure = err?.code === "ENOENT" ? "unsupported" : null;
531
+ this.startFailure =
532
+ err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
474
533
  return false;
475
534
  }
476
535
  this.child = child;
@@ -505,14 +564,17 @@ export class MuseCapacityHost {
505
564
  return;
506
565
  }
507
566
  });
508
- child.on("error", () => {
567
+ child.on("error", (err) => {
509
568
  if (!this.isCurrentChild(child))
510
569
  return;
570
+ this.startFailure =
571
+ err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
511
572
  this.killChild();
512
573
  });
513
574
  child.stdin?.on("error", () => {
514
575
  if (!this.isCurrentChild(child))
515
576
  return;
577
+ this.startFailure = "transport_error";
516
578
  this.killChild();
517
579
  });
518
580
  child.on("close", () => {
@@ -521,6 +583,15 @@ export class MuseCapacityHost {
521
583
  // its replacement must not kill the new host.
522
584
  if (!this.isCurrentChild(child))
523
585
  return;
586
+ if (KEYCHAIN_UNREADABLE_RE.test(this.stderrTail)) {
587
+ this.startFailure = "keychain_unreadable";
588
+ }
589
+ else if (AUTH_FAILURE_RE.test(this.stderrTail)) {
590
+ this.startFailure = "auth_rejected";
591
+ }
592
+ else if (this.startFailure === null) {
593
+ this.startFailure = "host_exited";
594
+ }
524
595
  this.killChild();
525
596
  });
526
597
  const initId = `muse-capacity-init-${this.connectionEpoch}`;
@@ -529,7 +600,14 @@ export class MuseCapacityHost {
529
600
  if (init?.error) {
530
601
  const kind = museClassifyRpcError(init.error);
531
602
  this.startFailure =
532
- kind === "auth" ? "auth" : kind === "unsupported" ? "unsupported" : null;
603
+ kind === "auth"
604
+ ? "auth_rejected"
605
+ : kind === "unsupported"
606
+ ? "unsupported"
607
+ : "handshake_rejected";
608
+ }
609
+ else if (this.startFailure === null) {
610
+ this.startFailure = this.dead ? "host_exited" : "handshake_timeout";
533
611
  }
534
612
  this.killChild();
535
613
  return false;
@@ -541,6 +619,11 @@ export class MuseCapacityHost {
541
619
  this.killChild();
542
620
  return false;
543
621
  }
622
+ // The spawned build is the on-disk one only if the binary did not change across the spawn.
623
+ const versionAfter = await readVersion();
624
+ if (this.isCurrentChild(child)) {
625
+ this.runningVersion = versionBefore !== null && versionBefore === versionAfter ? versionBefore : null;
626
+ }
544
627
  return true;
545
628
  }
546
629
  startFailure = null;
@@ -579,15 +662,20 @@ export class MuseCapacityHost {
579
662
  this.startFailure = null;
580
663
  const started = await this.ensureStarted();
581
664
  if (!started) {
582
- if (this.startFailure === "unsupported") {
665
+ // `ensureStarted` records the failure asynchronously. TypeScript does
666
+ // not model that mutation across the awaited call, so widen the field
667
+ // back to its declared type before selecting the fallback category.
668
+ const diagnostic = this.startFailure ?? "spawn_failed";
669
+ if (diagnostic === "unsupported" || diagnostic === "binary_missing") {
670
+ console.error(`[muse-capacity] host start failed: ${diagnostic}`);
583
671
  await noteMuseCapacityFailure("unsupported");
584
- return { status: "failure", reason: "unsupported" };
672
+ return { status: "failure", reason: "unsupported", diagnostic };
585
673
  }
586
674
  // No credential, spawn failure, timeout, or bad exit before the
587
675
  // handshake: honest `missing`, last-good rows preserved.
588
- console.error("[muse-capacity] host start failed: missing");
676
+ console.error(`[muse-capacity] host start failed: ${diagnostic}`);
589
677
  await noteMuseCapacityFailure("missing");
590
- return { status: "missing" };
678
+ return { status: "missing", diagnostic };
591
679
  }
592
680
  this.readSeq += 1;
593
681
  const id = `muse-capacity-${this.connectionEpoch}-${this.readSeq}`;
@@ -601,23 +689,23 @@ export class MuseCapacityHost {
601
689
  if (!this.hasInflightExecution())
602
690
  this.killChild();
603
691
  await noteMuseCapacityFailure("missing");
604
- return { status: "missing" };
692
+ return { status: "missing", diagnostic: "read_timeout" };
605
693
  }
606
694
  if (res.error) {
607
695
  const kind = museClassifyRpcError(res.error);
608
696
  if (kind === "auth") {
609
697
  console.error("[muse-capacity] host read failed: unauthenticated");
610
698
  await noteMuseCapacityFailure("missing");
611
- return { status: "missing" };
699
+ return { status: "missing", diagnostic: "auth_rejected" };
612
700
  }
613
701
  if (kind === "unsupported") {
614
702
  console.error("[muse-capacity] host read failed: unsupported");
615
703
  await noteMuseCapacityFailure("unsupported");
616
- return { status: "failure", reason: "unsupported" };
704
+ return { status: "failure", reason: "unsupported", diagnostic: "unsupported" };
617
705
  }
618
706
  console.error("[muse-capacity] host read failed: malformed");
619
707
  await noteMuseCapacityFailure("unparsable");
620
- return { status: "failure", reason: "unparsable" };
708
+ return { status: "failure", reason: "unparsable", diagnostic: "read_rejected" };
621
709
  }
622
710
  let payload = res.result;
623
711
  if (payload &&
@@ -633,7 +721,7 @@ export class MuseCapacityHost {
633
721
  // Fresh/unobserved host: `usage` omitted — honest `missing`, never a
634
722
  // write, so a newer known pair is never overwritten or deleted.
635
723
  await noteMuseCapacityFailure("missing");
636
- return { status: "missing" };
724
+ return { status: "missing", diagnostic: "unobserved" };
637
725
  }
638
726
  return { status: "observed", written: outcome.written, skippedStale: outcome.skippedStale };
639
727
  }