@haven_ai/connect 0.3.0-alpha.0 → 0.4.0-alpha.0

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.
package/dist/index.cjs CHANGED
@@ -171,6 +171,16 @@ function redactSecrets(value) {
171
171
  function redactForAutomation(value) {
172
172
  return redactSecrets(value).replace(/(?:~|\/)[^\s`"']*\/(?:identity|signer|agent)\.json\b/g, "[credential-file-redacted]").replace(/(?:~|\/)[^\s`"']*\/\.env\b/g, "[credential-env-redacted]");
173
173
  }
174
+ function withoutUserinfo(url) {
175
+ try {
176
+ const parsed = new URL(url);
177
+ parsed.username = "";
178
+ parsed.password = "";
179
+ return parsed.toString().replace(/\/+$/, "");
180
+ } catch {
181
+ return url;
182
+ }
183
+ }
174
184
  function shortAddress(address) {
175
185
  if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return address;
176
186
  return `${address.slice(0, 6)}...${address.slice(-4)}`;
@@ -532,12 +542,64 @@ async function writeConnectOutcomeRecord(directory, outcome, warn) {
532
542
  await restrictPermissions(path$1, 384, warn);
533
543
  return path$1;
534
544
  }
535
- var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS; exports.CONNECT_OUTCOME_FILENAME = void 0;
545
+ async function readConnectOutcomeRuntime(directory) {
546
+ try {
547
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, exports.CONNECT_OUTCOME_FILENAME), "utf8"));
548
+ if (typeof parsed !== "object" || parsed === null) return null;
549
+ const runtime = parsed.runtime;
550
+ return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+ async function writeMcpServerBinding(directory, binding) {
556
+ const path$1 = path.join(directory, MCP_SERVER_BINDING_FILENAME);
557
+ await promises.writeFile(path$1, `${JSON.stringify(binding, null, 2)}
558
+ `, { mode: 384 });
559
+ return path$1;
560
+ }
561
+ async function readMcpServerBinding(directory) {
562
+ try {
563
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
564
+ if (typeof parsed !== "object" || parsed === null) return null;
565
+ const record = parsed;
566
+ if (record.version !== 1 || typeof record.server_name !== "string" || typeof record.agent_id !== "string" || typeof record.api_url !== "string" || typeof record.bound_at !== "string") return null;
567
+ return record;
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+ async function clearMcpServerBinding(directory) {
573
+ try {
574
+ await promises.rm(path.join(directory, MCP_SERVER_BINDING_FILENAME));
575
+ return true;
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+ async function listMcpServerBindings(baseDir, excludeDirectory) {
581
+ const root = defaultCredentialRoot(baseDir);
582
+ let entries = [];
583
+ try {
584
+ entries = await promises.readdir(root);
585
+ } catch {
586
+ return [];
587
+ }
588
+ const out = [];
589
+ for (const entry of entries) {
590
+ const directory = path.join(root, entry);
591
+ const binding = await readMcpServerBinding(directory);
592
+ if (binding) out.push({ directory, binding });
593
+ }
594
+ return out;
595
+ }
596
+ var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS; exports.CONNECT_OUTCOME_FILENAME = void 0; var MCP_SERVER_BINDING_FILENAME;
536
597
  var init_storage = __esm({
537
598
  "src/storage.ts"() {
538
599
  REKEY_PENDING_FILENAME = "rekey-pending.json";
539
600
  REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
540
601
  exports.CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
602
+ MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
541
603
  }
542
604
  });
543
605
  function mcpPackageSpec() {
@@ -556,9 +618,9 @@ var init_runtime_manifest = __esm({
556
618
  mcpPackage: "@haven_ai/mcp",
557
619
  mcpVersion: mcp.MCP_VERSION,
558
620
  sdkPackage: "@haven_ai/sdk",
559
- sdkVersion: "0.3.0-alpha.0",
621
+ sdkVersion: "0.4.0-alpha.0",
560
622
  signerPackage: "@haven_ai/signer",
561
- signerVersion: "0.3.0-alpha.0",
623
+ signerVersion: "0.4.0-alpha.0",
562
624
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
563
625
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
564
626
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -1527,7 +1589,7 @@ async function probeLocalSignerCredential(signerPath) {
1527
1589
  }
1528
1590
  }
1529
1591
  async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1530
- return new Promise((resolve9) => {
1592
+ return new Promise((resolve10) => {
1531
1593
  const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1532
1594
  let stdout = "";
1533
1595
  let settled = false;
@@ -1539,7 +1601,7 @@ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4)
1539
1601
  settled = true;
1540
1602
  clearTimeout(timeout);
1541
1603
  child.kill();
1542
- resolve9(result);
1604
+ resolve10(result);
1543
1605
  };
1544
1606
  const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1545
1607
  child.on("error", () => finish({ status: "process_error" }));
@@ -3184,8 +3246,52 @@ async function unwireAgent(input) {
3184
3246
  }
3185
3247
  }
3186
3248
  }
3187
- await teardownLocalKeyMaterial(input.directory, identity);
3188
- return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3249
+ const bindingReleased = await clearMcpServerBinding(input.directory);
3250
+ const teardown = await decideTeardown(identity, input);
3251
+ if (teardown.status !== "retained") await teardownLocalKeyMaterial(input.directory, identity);
3252
+ return { directory: input.directory, agentId, slug, tombstoned, runtimes, teardown, bindingReleased };
3253
+ }
3254
+ async function decideTeardown(identity, input) {
3255
+ const ended = "Local recovery of a stranded delegate balance ends with it: the sweep-recovery routes accept only this agent's API key and delegate signature, and neither exists on this machine any more.";
3256
+ if (!identity?.api_key || !identity.api_url) {
3257
+ return {
3258
+ status: input.destroyKeyMaterial ? "forced" : "destroyed",
3259
+ probe: "not_probed",
3260
+ detail: "No stored API key + API URL to probe with \u2014 nothing the sweep-recovery routes would accept, so nothing to preserve."
3261
+ };
3262
+ }
3263
+ const probe = await (input.probeHostedIdentity ?? probeHostedAgentIdentity)(identity.api_key, identity.api_url, input.fetch);
3264
+ if (input.destroyKeyMaterial) {
3265
+ return {
3266
+ status: "forced",
3267
+ probe: probe.status,
3268
+ detail: `Key material destroyed under ${DESTROY_FLAG} (probe: ${probe.status}): signer.json, any parked re-key, and the API key in identity.json.`,
3269
+ remedy: ended
3270
+ };
3271
+ }
3272
+ switch (probe.status) {
3273
+ case "ok":
3274
+ return {
3275
+ status: "retained",
3276
+ probe: "ok",
3277
+ detail: "This agent is still ACTIVE on the backend: its API key and delegate key still carry spend authority, so destroying them would be a live spend-authority change. Key material kept in this directory (0o600); its MCP wiring above is gone.",
3278
+ remedy: `Revoke the agent on the Haven agent page (connect never revokes), then re-run --unwire; or, to delete the key anyway, re-run with ${DESTROY_FLAG}.`
3279
+ };
3280
+ case "unauthorized":
3281
+ return {
3282
+ status: "retained",
3283
+ probe: "unauthorized",
3284
+ detail: "This key no longer authenticates on normal routes (revoked, archived, paused, pending approval, rotated, or not a key the backend knows \u2014 it does not say which). A stranded delegate balance MAY still exist and the connector CANNOT check: this directory may hold the only local credential the sweep-recovery routes would still accept. Key material kept.",
3285
+ remedy: `Recover any stranded balance first (haven_sweep_delegate from a runtime still wired to this agent, or the Haven agent page), then re-run with ${DESTROY_FLAG} to remove the key material.`
3286
+ };
3287
+ default:
3288
+ return {
3289
+ status: "retained",
3290
+ probe: probe.status,
3291
+ detail: `Could not verify what this key is still good for (${probe.status}). Unknown is not "safe to delete": destroying it on a failed check would erase the evidence of a live key. Key material kept.`,
3292
+ remedy: `Retry when the backend is reachable, or re-run with ${DESTROY_FLAG} to remove the key material regardless.`
3293
+ };
3294
+ }
3189
3295
  }
3190
3296
  async function tombstoneDirectoryIfAbsent(input) {
3191
3297
  if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
@@ -3206,9 +3312,10 @@ async function teardownLocalKeyMaterial(directory, identity) {
3206
3312
  async function readIdentityFile(directory) {
3207
3313
  return identityAt(directory);
3208
3314
  }
3209
- var RUNTIMES;
3315
+ var RUNTIMES, DESTROY_FLAG;
3210
3316
  var init_unwire = __esm({
3211
3317
  "src/unwire.ts"() {
3318
+ init_probes();
3212
3319
  init_config_writers();
3213
3320
  init_server_names();
3214
3321
  init_signer_runtime();
@@ -3222,6 +3329,7 @@ var init_unwire = __esm({
3222
3329
  { runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
3223
3330
  { runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
3224
3331
  ];
3332
+ DESTROY_FLAG = "--destroy-key-material";
3225
3333
  }
3226
3334
  });
3227
3335
 
@@ -3233,6 +3341,174 @@ var init_rekey_messages = __esm({
3233
3341
  }
3234
3342
  });
3235
3343
 
3344
+ // src/prune-runtimes.ts
3345
+ var prune_runtimes_exports = {};
3346
+ __export(prune_runtimes_exports, {
3347
+ normalizeRuntimePath: () => normalizeRuntimePath,
3348
+ pruneSignerRuntimes: () => pruneSignerRuntimes,
3349
+ referencedRuntimeDirectories: () => referencedRuntimeDirectories,
3350
+ rollUpPruneLevel: () => rollUpPruneLevel,
3351
+ signerRuntimeRoot: () => signerRuntimeRoot
3352
+ });
3353
+ function signerRuntimeRoot(homeDir) {
3354
+ return path.join(homeDir, ".haven", "signer-runtime");
3355
+ }
3356
+ function classifyKey(key) {
3357
+ if (key.startsWith("override-")) return "override";
3358
+ if (key === exports.MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
3359
+ return "unknown";
3360
+ }
3361
+ async function directoryBytes(directory) {
3362
+ let total = 0;
3363
+ const walk = async (dir) => {
3364
+ let entries = [];
3365
+ try {
3366
+ entries = await promises.readdir(dir, { withFileTypes: true });
3367
+ } catch {
3368
+ return;
3369
+ }
3370
+ for (const entry of entries) {
3371
+ const path$1 = path.join(dir, entry.name);
3372
+ if (entry.isDirectory()) await walk(path$1);
3373
+ else if (entry.isFile()) {
3374
+ try {
3375
+ total += (await promises.stat(path$1)).size;
3376
+ } catch {
3377
+ }
3378
+ }
3379
+ }
3380
+ };
3381
+ await walk(directory);
3382
+ return total;
3383
+ }
3384
+ async function normalizeRuntimePath(path$1) {
3385
+ const resolved = path.resolve(path$1).replace(/[\\/]+$/, "");
3386
+ try {
3387
+ return await promises.realpath(resolved);
3388
+ } catch {
3389
+ return resolved;
3390
+ }
3391
+ }
3392
+ function escapeRegExp(text) {
3393
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3394
+ }
3395
+ async function referencedRuntimeDirectories(homeDir, credentialsDir) {
3396
+ const roots = /* @__PURE__ */ new Set([path.join(homeDir, ".haven", "agents")]);
3397
+ if (credentialsDir) roots.add(path.dirname(path.resolve(credentialsDir)));
3398
+ const referenced = /* @__PURE__ */ new Map();
3399
+ const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
3400
+ const rootSpellings = [.../* @__PURE__ */ new Set([path.resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
3401
+ const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
3402
+ const add = async (runtimeDirectory, by) => {
3403
+ const key = await normalizeRuntimePath(runtimeDirectory);
3404
+ const list = referenced.get(key) ?? [];
3405
+ if (!list.includes(by)) list.push(by);
3406
+ referenced.set(key, list);
3407
+ };
3408
+ for (const root of roots) {
3409
+ let entries = [];
3410
+ try {
3411
+ entries = await promises.readdir(root);
3412
+ } catch {
3413
+ continue;
3414
+ }
3415
+ for (const entry of entries) {
3416
+ const directory = path.join(root, entry);
3417
+ try {
3418
+ const sidecar = JSON.parse(await promises.readFile(path.join(directory, "signer-runtime.json"), "utf8"));
3419
+ if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
3420
+ await add(sidecar.runtime_directory, directory);
3421
+ }
3422
+ } catch {
3423
+ }
3424
+ try {
3425
+ const wrapper = await promises.readFile(path.join(directory, "bin", "haven-signer.mjs"), "utf8");
3426
+ for (const match of wrapper.matchAll(wrapperRe)) await add(path.join(runtimeRoot, match[1]), directory);
3427
+ } catch {
3428
+ }
3429
+ }
3430
+ }
3431
+ return referenced;
3432
+ }
3433
+ function rollUpPruneLevel(entries) {
3434
+ if (entries.some((e) => e.level === "failed")) return "failed";
3435
+ if (entries.some((e) => e.level === "advisory")) return "advisory";
3436
+ return "ok";
3437
+ }
3438
+ async function pruneSignerRuntimes(input, deps = {}) {
3439
+ const homeDir = deps.homeDir ?? os.homedir();
3440
+ const root = signerRuntimeRoot(homeDir);
3441
+ const measure = input.measure ?? true;
3442
+ const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
3443
+ const pin = await normalizeRuntimePath(path.join(root, exports.MCP_RUNTIME_MANIFEST.signerVersion));
3444
+ const remove = deps.rm ?? (async (directory) => promises.rm(directory, { recursive: true, force: false }));
3445
+ const entries = [];
3446
+ let keys = [];
3447
+ try {
3448
+ keys = (await promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
3449
+ } catch {
3450
+ return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
3451
+ }
3452
+ let removed = 0;
3453
+ let reclaimedBytes = 0;
3454
+ for (const key of keys) {
3455
+ const directory = path.join(root, key);
3456
+ const normalized = await normalizeRuntimePath(directory);
3457
+ const referencedBy = referenced.get(normalized) ?? [];
3458
+ const kind = classifyKey(key);
3459
+ if (referencedBy.length > 0 || normalized === pin) {
3460
+ entries.push({
3461
+ directory,
3462
+ key,
3463
+ kind,
3464
+ bytes: 0,
3465
+ referencedBy,
3466
+ action: "kept",
3467
+ level: "ok",
3468
+ detail: referencedBy.length > 0 ? `kept \u2014 named by ${referencedBy.length} credential director${referencedBy.length === 1 ? "y" : "ies"} (sidecar or wrapper)` : "kept \u2014 the connector's current pinned version (what --repair installs)"
3469
+ });
3470
+ continue;
3471
+ }
3472
+ const bytes = measure ? await directoryBytes(directory) : 0;
3473
+ if (input.dryRun) {
3474
+ entries.push({
3475
+ directory,
3476
+ key,
3477
+ kind,
3478
+ bytes,
3479
+ referencedBy,
3480
+ action: "would_remove",
3481
+ level: "advisory",
3482
+ detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
3483
+ });
3484
+ continue;
3485
+ }
3486
+ try {
3487
+ await remove(directory);
3488
+ removed += 1;
3489
+ reclaimedBytes += bytes;
3490
+ entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
3491
+ } catch (err) {
3492
+ entries.push({
3493
+ directory,
3494
+ key,
3495
+ kind,
3496
+ bytes,
3497
+ referencedBy,
3498
+ action: "failed",
3499
+ level: "failed",
3500
+ detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
3501
+ });
3502
+ }
3503
+ }
3504
+ return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
3505
+ }
3506
+ var init_prune_runtimes = __esm({
3507
+ "src/prune-runtimes.ts"() {
3508
+ init_runtime_manifest();
3509
+ }
3510
+ });
3511
+
3236
3512
  // src/rekey.ts
3237
3513
  var rekey_exports = {};
3238
3514
  __export(rekey_exports, {
@@ -3516,9 +3792,32 @@ var init_rekey_restart = __esm({
3516
3792
  var doctor_exports = {};
3517
3793
  __export(doctor_exports, {
3518
3794
  describeAccountAddressKey: () => describeAccountAddressKey,
3795
+ rollUpLevel: () => rollUpLevel,
3519
3796
  runDoctor: () => runDoctor,
3520
3797
  runRepair: () => runRepair
3521
3798
  });
3799
+ function finalizeCheck(check) {
3800
+ return { ...check, ok: check.level !== "failed" };
3801
+ }
3802
+ function rollUpLevel(checks) {
3803
+ if (checks.some((check) => check.level === "failed")) return "failed";
3804
+ if (checks.some((check) => check.level === "advisory")) return "advisory";
3805
+ return "ok";
3806
+ }
3807
+ function runtimeFlagFor(runtime) {
3808
+ return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
3809
+ }
3810
+ async function resolveDoctorRuntime(input, directory) {
3811
+ const explicit = input.runtime.trim();
3812
+ if (explicit) return { runtime: explicit, origin: "flag" };
3813
+ if (directory) {
3814
+ const recorded = await readConnectOutcomeRuntime(directory);
3815
+ if (recorded !== null && normalizeRuntimeName(recorded)) {
3816
+ return { runtime: recorded, origin: "record" };
3817
+ }
3818
+ }
3819
+ return { runtime: "", origin: "unknown" };
3820
+ }
3522
3821
  async function discoverCredentialDirectory(homeDir, explicit) {
3523
3822
  const root = explicit ? path.dirname(explicit) : path.join(homeDir, ".haven", "agents");
3524
3823
  let entries = [];
@@ -3579,13 +3878,16 @@ function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bar
3579
3878
  return isPrimary && Boolean(identity?.hosted_mcp_url && configText.includes(identity.hosted_mcp_url));
3580
3879
  }
3581
3880
  function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3881
+ return finalizeCheck(rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug));
3882
+ }
3883
+ function rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug) {
3582
3884
  const label = "Pending re-key";
3583
3885
  const nameFlag = slug ? ` --name ${slug}` : "";
3584
3886
  if (status.state === "unreadable") {
3585
3887
  return {
3586
3888
  id: "rekey_pending",
3587
3889
  label,
3588
- ok: false,
3890
+ level: "failed",
3589
3891
  detail: `A re-key was started here but ${status.path} does not parse, so neither the address it generated nor when it started can be read. The file still holds what was a private key.`,
3590
3892
  repair: `Delete ${status.path}, then start again: ${RERUN} --rekey${nameFlag}`
3591
3893
  };
@@ -3597,9 +3899,9 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3597
3899
  return {
3598
3900
  id: "rekey_pending",
3599
3901
  label,
3600
- ok: false,
3902
+ level: "failed",
3601
3903
  detail: `A re-key started ${started} has COMPLETED on Haven \u2014 the agent's signing address is already ${address}, the one this machine generated \u2014 but the local half was never finished, so the credential files here still hold the old key. Parked at ${status.path}.` + (status.state === "expired" ? " The local file is also past its 24h TTL, which --rekey-finish refuses, so the finish command below will not accept it any more." : ""),
3602
- repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you> --runtime ${runtime}`
3904
+ repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you>${runtimeFlagFor(runtime)}`
3603
3905
  };
3604
3906
  }
3605
3907
  const wedgeNote = "Haven is NOT yet on this address, so the re-key did not complete. This machine cannot tell whether the on-chain revoke on the agent page already ran: if it did not, closing this costs nothing; if it did, the agent's old delegations are revoked, no new ones were issued, and only an owner re-grant restores its spend authority (#1868). Check the agent page before assuming the harmless case.";
@@ -3607,7 +3909,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3607
3909
  return {
3608
3910
  id: "rekey_pending",
3609
3911
  label,
3610
- ok: false,
3912
+ level: "failed",
3611
3913
  detail: `A re-key started ${started} EXPIRED ${status.expiresAt ?? ""} without being finished. Its address was ${address}; the private half it generated is still on disk at ${status.path}. ` + wedgeNote,
3612
3914
  repair: `Either delete ${status.path} to drop the parked key, or start over: ${RERUN} --rekey${nameFlag}. Connect never deletes it for you \u2014 an expired TTL is a refusal to USE the key, not a licence to destroy key material you may still be mid-flow on.`
3613
3915
  };
@@ -3615,7 +3917,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3615
3917
  return {
3616
3918
  id: "rekey_pending",
3617
3919
  label,
3618
- ok: true,
3920
+ level: "ok",
3619
3921
  detail: `A re-key started ${started} is still open (expires ${status.expiresAt ?? "unknown"}). Paste this address into "Replace signing key" on the Haven agent page: ${address}. Parked at ${status.path}. ` + wedgeNote
3620
3922
  };
3621
3923
  }
@@ -3643,13 +3945,13 @@ async function runtimeSpecOverrideCheck(directory, sidecar, env) {
3643
3945
  if (shell) facts.push(shell);
3644
3946
  if (facts.length === 0) return void 0;
3645
3947
  const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
3646
- return {
3948
+ return finalizeCheck({
3647
3949
  id: "runtime_spec_override",
3648
3950
  label: "Runtime spec override",
3649
- ok: false,
3951
+ level: "failed",
3650
3952
  detail: `runtime spec overridden \u2014 not the pinned manifest (${exports.MCP_RUNTIME_MANIFEST.signerPackage}@${exports.MCP_RUNTIME_MANIFEST.signerVersion}, ${exports.MCP_RUNTIME_MANIFEST.sdkPackage}@${exports.MCP_RUNTIME_MANIFEST.sdkVersion}). ${facts.join(". ")}.`,
3651
3953
  repair: `Developer override (#2424). To return to the pinned manifest: unset ${variables}, then run ${RERUN} --doctor --repair --runtime <runtime>. If the override is intentional, this finding is the record of it.`
3652
- };
3954
+ });
3653
3955
  }
3654
3956
  async function readMcpSidecarOverride(directory) {
3655
3957
  try {
@@ -3676,6 +3978,13 @@ function describeAccountAddressKey(identity, signerFile) {
3676
3978
  return "no account address stored";
3677
3979
  }
3678
3980
  async function checksForAgent(entry, input, deps) {
3981
+ const verdicts = await verdictsForAgent(entry, input, deps);
3982
+ return {
3983
+ checks: verdicts.checks.map(finalizeCheck),
3984
+ ...verdicts.signerCapabilities ? { signerCapabilities: verdicts.signerCapabilities } : {}
3985
+ };
3986
+ }
3987
+ async function verdictsForAgent(entry, input, deps) {
3679
3988
  const { directory, identity, sidecar } = entry;
3680
3989
  const checks = [];
3681
3990
  let signerCapabilities;
@@ -3690,7 +3999,7 @@ async function checksForAgent(entry, input, deps) {
3690
3999
  checks.push({
3691
4000
  id: "credentials",
3692
4001
  label: "Agent credentials",
3693
- ok: credentialsOk,
4002
+ level: credentialsOk ? "ok" : "failed",
3694
4003
  detail: credentialsOk ? `identity.json and signer.json parse (agent ${identity?.agent_id ?? "unknown"}; ${describeAccountAddressKey(identity, signerFile)})` : "identity.json or signer.json is missing or unparseable.",
3695
4004
  ...credentialsOk ? {} : { repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` }
3696
4005
  });
@@ -3698,9 +4007,9 @@ async function checksForAgent(entry, input, deps) {
3698
4007
  checks.push({
3699
4008
  id: "signer_runtime",
3700
4009
  label: "Signer runtime (preinstalled wrapper)",
3701
- ok: false,
4010
+ level: "failed",
3702
4011
  detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
3703
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4012
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
3704
4013
  });
3705
4014
  } else if (sidecar.runtime_spec_override) {
3706
4015
  const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
@@ -3710,9 +4019,9 @@ async function checksForAgent(entry, input, deps) {
3710
4019
  checks.push({
3711
4020
  id: "signer_runtime",
3712
4021
  label: "Signer runtime (preinstalled wrapper)",
3713
- ok: matches,
4022
+ level: matches ? "ok" : "failed",
3714
4023
  detail: matches ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory} (override install \u2014 see runtime_spec_override)` : `Override runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
3715
- ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime} with the same HAVEN_*_SPEC variables set.` }
4024
+ ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)} with the same HAVEN_*_SPEC variables set.` }
3716
4025
  });
3717
4026
  } else {
3718
4027
  const intact = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
@@ -3724,9 +4033,9 @@ async function checksForAgent(entry, input, deps) {
3724
4033
  checks.push({
3725
4034
  id: "signer_runtime",
3726
4035
  label: "Signer runtime (preinstalled wrapper)",
3727
- ok,
4036
+ level: ok ? "ok" : intact ? "advisory" : "failed",
3728
4037
  detail: ok ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory}` : intact ? `Installed version ${sidecar.signer_version} does not match the connector's pinned ${exports.MCP_RUNTIME_MANIFEST.signerVersion} \u2014 intact, but outdated.` : `Runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
3729
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4038
+ ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
3730
4039
  });
3731
4040
  }
3732
4041
  const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
@@ -3737,7 +4046,7 @@ async function checksForAgent(entry, input, deps) {
3737
4046
  checks.push({
3738
4047
  id: "hosted_mcp",
3739
4048
  label: "Hosted Haven MCP",
3740
- ok: probe.status === "ok",
4049
+ level: probe.status === "ok" ? "ok" : "failed",
3741
4050
  detail: probe.status === "ok" ? `MCP tools endpoint is reachable (${hostedUrl}).` : `MCP tools endpoint probe failed: ${probe.status} (${hostedUrl}).`,
3742
4051
  ...probe.status === "ok" ? {} : {
3743
4052
  repair: "Check network access and runtime configuration for the hosted MCP URL, then re-run --doctor."
@@ -3747,7 +4056,7 @@ async function checksForAgent(entry, input, deps) {
3747
4056
  checks.push({
3748
4057
  id: "hosted_mcp",
3749
4058
  label: "Hosted Haven MCP",
3750
- ok: false,
4059
+ level: "failed",
3751
4060
  detail: "No stored API key / hosted MCP URL to probe with.",
3752
4061
  repair: `Re-run the full setup: ${RERUN} --setup <token>.`
3753
4062
  });
@@ -3765,15 +4074,15 @@ async function checksForAgent(entry, input, deps) {
3765
4074
  checks.push({
3766
4075
  id: "identity_match",
3767
4076
  label: "Hosted identity matches the local signing key",
3768
- ok: false,
4077
+ level: "failed",
3769
4078
  detail: probe.status === "unauthorized" ? "The stored API key was rejected, so the agent it authenticates as cannot be compared with the local signing key." : `Could not read the hosted identity (${probe.status}) \u2014 the comparison did not happen, so it cannot be reported as a match.`,
3770
- repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor --runtime ${input.runtime}`
4079
+ repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor${runtimeFlagFor(input.runtime)}`
3771
4080
  });
3772
4081
  } else if (!localDelegate) {
3773
4082
  checks.push({
3774
4083
  id: "identity_match",
3775
4084
  label: "Hosted identity matches the local signing key",
3776
- ok: false,
4085
+ level: "failed",
3777
4086
  detail: "signer.json holds no delegate_address to compare against the hosted identity.",
3778
4087
  repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.`
3779
4088
  });
@@ -3782,7 +4091,7 @@ async function checksForAgent(entry, input, deps) {
3782
4091
  checks.push({
3783
4092
  id: "identity_match",
3784
4093
  label: "Hosted identity matches the local signing key",
3785
- ok: same,
4094
+ level: same ? "ok" : "failed",
3786
4095
  detail: same ? `The stored API key authenticates as the agent whose signing key is in this directory (${shortAddress(localDelegate)}).` : `MISMATCH: the stored API key authenticates as agent ${probe.agentId ?? "unknown"} with delegate ${shortAddress(probe.delegateAddress ?? "unknown")}, but signer.json here holds ${shortAddress(localDelegate)}. This runtime would quote as one agent and sign as another.`,
3787
4096
  ...same ? {} : {
3788
4097
  repair: `Re-run setup for this agent so its API key and signing key come from one run: ${RERUN} --setup <token>. Do not hand-edit either file.`
@@ -3800,7 +4109,7 @@ async function checksForAgent(entry, input, deps) {
3800
4109
  checks.push({
3801
4110
  id: "signer_process",
3802
4111
  label: "Signer stdio handshake",
3803
- ok: false,
4112
+ level: "failed",
3804
4113
  detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
3805
4114
  repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
3806
4115
  });
@@ -3817,18 +4126,18 @@ async function checksForAgent(entry, input, deps) {
3817
4126
  checks.push({
3818
4127
  id: "signer_process",
3819
4128
  label: "Signer stdio handshake",
3820
- ok: probe.status === "ok",
4129
+ level: probe.status === "ok" ? "ok" : "failed",
3821
4130
  detail: probe.status === "ok" ? `Signer started, listed ${probe.toolNames?.length ?? 0} tools${probe.serverInfo?.version ? ` (v${probe.serverInfo.version})` : ""}.${compatDetail}` : `Handshake failed: ${probe.status}.`,
3822
- ...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4131
+ ...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
3823
4132
  });
3824
4133
  }
3825
4134
  } else {
3826
4135
  checks.push({
3827
4136
  id: "signer_process",
3828
4137
  label: "Signer stdio handshake",
3829
- ok: false,
4138
+ level: "failed",
3830
4139
  detail: "Skipped \u2014 no prepared signer runtime to probe.",
3831
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4140
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
3832
4141
  });
3833
4142
  }
3834
4143
  return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
@@ -3838,7 +4147,11 @@ async function runDoctor(input, deps = {}) {
3838
4147
  const checks = [];
3839
4148
  let signerCapabilities;
3840
4149
  const { directory, others, parkedOnly } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
3841
- const configPath = runtimeConfigPathFor(input.runtime, homeDir);
4150
+ const resolution = await resolveDoctorRuntime(input, directory);
4151
+ const runtime = resolution.runtime;
4152
+ const input2 = { ...input, runtime };
4153
+ const normalizedRuntime = normalizeRuntimeName(input2.runtime);
4154
+ const configPath = runtimeConfigPathFor(normalizedRuntime ?? input2.runtime, homeDir);
3842
4155
  let configText = null;
3843
4156
  if (configPath !== null) {
3844
4157
  try {
@@ -3875,7 +4188,7 @@ async function runDoctor(input, deps = {}) {
3875
4188
  // A tombstone is a deliberate record and outranks the discovery tell:
3876
4189
  // a retired directory that also holds a parked key stays `retired`.
3877
4190
  classification: tombstone ? "retired" : parkedOnly.has(dir) ? "parked" : "orphaned",
3878
- checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input.runtime, slug)] : [],
4191
+ checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)] : [],
3879
4192
  ...rekeyPending ? { rekeyPending } : {}
3880
4193
  });
3881
4194
  continue;
@@ -3890,11 +4203,11 @@ async function runDoctor(input, deps = {}) {
3890
4203
  ...rekeyPending ? { rekeyPending } : {}
3891
4204
  };
3892
4205
  if (wired) {
3893
- const result = await checksForAgent({ directory: dir, identity, sidecar }, input, deps);
4206
+ const result = await checksForAgent({ directory: dir, identity, sidecar }, input2, deps);
3894
4207
  entry.checks = result.checks;
3895
4208
  capabilitiesByDirectory.set(dir, result.signerCapabilities);
3896
4209
  } else if (rekeyPending) {
3897
- entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input.runtime, slug)];
4210
+ entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)];
3898
4211
  }
3899
4212
  inventory.push(entry);
3900
4213
  }
@@ -3909,7 +4222,7 @@ async function runDoctor(input, deps = {}) {
3909
4222
  checks.push({
3910
4223
  id: "credentials",
3911
4224
  label: "Agent credentials",
3912
- ok: false,
4225
+ level: "failed",
3913
4226
  detail: "No agent credential directory with an identity.json under ~/.haven/agents.",
3914
4227
  repair: `Run the full setup once: ${RERUN} --setup <token from the Haven dashboard>.`
3915
4228
  });
@@ -3919,7 +4232,7 @@ async function runDoctor(input, deps = {}) {
3919
4232
  if (!primaryChecksById.has("credentials")) {
3920
4233
  const result = await checksForAgent(
3921
4234
  { directory: primaryDirectory, identity: primaryIdentity, sidecar: primarySidecar },
3922
- input,
4235
+ input2,
3923
4236
  deps
3924
4237
  );
3925
4238
  signerCapabilities = result.signerCapabilities;
@@ -3930,20 +4243,37 @@ async function runDoctor(input, deps = {}) {
3930
4243
  if (check) checks.push(check);
3931
4244
  }
3932
4245
  }
3933
- if (configPath === null) {
4246
+ const runtimeOwnsNoConfig = normalizedRuntime !== null && configPath === null;
4247
+ if (configPath === null && input2.runtime !== "" && normalizedRuntime === null) {
3934
4248
  checks.push({
3935
4249
  id: "runtime_config",
3936
4250
  label: "Runtime MCP config",
3937
- ok: true,
3938
- detail: `Runtime '${input.runtime}' has no file-based config the connector owns (CLI-managed) \u2014 skipping the file check.`
4251
+ level: "failed",
4252
+ detail: `Runtime '${input2.runtime}' is not one the connector recognises. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
4253
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4254
+ });
4255
+ } else if (configPath === null && input2.runtime === "") {
4256
+ checks.push({
4257
+ id: "runtime_config",
4258
+ label: "Runtime MCP config",
4259
+ level: "failed",
4260
+ detail: `Runtime is unknown \u2014 no runtime flag was given and the connector's record in ${primaryDirectory ?? input.credentialsDir ?? "~/.haven/agents"} carries no resolvable ${exports.CONNECT_OUTCOME_FILENAME} runtime. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
4261
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4262
+ });
4263
+ } else if (configPath === null) {
4264
+ checks.push({
4265
+ id: "runtime_config",
4266
+ label: "Runtime MCP config",
4267
+ level: "ok",
4268
+ detail: `Runtime '${input2.runtime}' is configured through its own CLI or by hand (${input2.runtime === "other" ? "manual runtime" : "CLI-managed"}) and has no file-based config the connector owns \u2014 skipping the file check.`
3939
4269
  });
3940
4270
  } else if (configText === null) {
3941
4271
  checks.push({
3942
4272
  id: "runtime_config",
3943
4273
  label: "Runtime MCP config",
3944
- ok: false,
4274
+ level: "failed",
3945
4275
  detail: `No runtime config at ${configPath}.`,
3946
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4276
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}`
3947
4277
  });
3948
4278
  } else {
3949
4279
  const primaryIdentity = await readIdentity(primaryDirectory ?? "");
@@ -3955,9 +4285,9 @@ async function runDoctor(input, deps = {}) {
3955
4285
  checks.push({
3956
4286
  id: "runtime_config",
3957
4287
  label: "Runtime MCP config",
3958
- ok,
4288
+ level: ok ? "ok" : "failed",
3959
4289
  detail: ok ? `Config at ${configPath} references the hosted server and the prepared signer wrapper.` : signerViaNpx ? `Config at ${configPath} still launches the signer via npx \u2014 the pre-#1586 shape that cannot start under a 120s startup timeout.` : `Config at ${configPath} is missing the Haven entries${primarySidecar && !wrapperReferenced ? " (or references a different signer wrapper)" : ""}.`,
3960
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4290
+ ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}` }
3961
4291
  });
3962
4292
  }
3963
4293
  for (const id of ["hosted_mcp", "identity_match", "rekey_pending"]) {
@@ -4004,16 +4334,44 @@ async function runDoctor(input, deps = {}) {
4004
4334
  );
4005
4335
  }
4006
4336
  const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
4337
+ const classificationUnreliable = configText === null && runtimeOwnsNoConfig;
4338
+ const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
4007
4339
  checks.push({
4008
4340
  id: "superseded_agents",
4009
4341
  label: "Superseded agent credentials",
4010
- ok: supersededLive.length === 0,
4011
- detail: supersededLive.length > 0 ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. A host started before your latest setup keeps authenticating (and spending) as the old agent.` : `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}.`,
4012
- ...supersededLive.length > 0 ? {
4342
+ level: supersededLevel,
4343
+ detail: supersededLevel === "failed" ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. A host started before your latest setup keeps authenticating (and spending) as the old agent.` : supersededLevel === "advisory" ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. Runtime '${input2.runtime}' has no config file the connector can read, so which of these agents are wired cannot be verified from this machine: a live key here may be an agent you use deliberately, or one a host started before your latest setup is still spending as.` : `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}.`,
4344
+ ...supersededLevel === "failed" ? {
4013
4345
  repair: `Revoke ${supersededLive.join(", ")} on the Haven agent page, then remove the old director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
4346
+ } : supersededLevel === "advisory" ? {
4347
+ repair: `Check ${supersededLive.join(", ")} on the Haven agent page: revoke the ones you no longer use, then remove their director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
4014
4348
  } : {}
4015
4349
  });
4016
4350
  }
4351
+ const bindings = (await Promise.all(
4352
+ inventory.filter((entry) => entry.classification !== "retired").map(async (entry) => ({ entry, binding: await readMcpServerBinding(entry.directory) }))
4353
+ )).filter((item) => item.binding !== null);
4354
+ const byName = /* @__PURE__ */ new Map();
4355
+ for (const item of bindings) {
4356
+ const list = byName.get(item.binding.server_name) ?? [];
4357
+ list.push(item);
4358
+ byName.set(item.binding.server_name, list);
4359
+ }
4360
+ const rebound = [...byName.entries()].filter(([, items]) => items.length > 1);
4361
+ if (rebound.length > 0) {
4362
+ const parts = rebound.map(([name, items]) => {
4363
+ const ordered = [...items].sort((a, b) => a.binding.bound_at < b.binding.bound_at ? -1 : a.binding.bound_at > b.binding.bound_at ? 1 : 0);
4364
+ const backends = new Set(ordered.map((i) => withoutUserinfo(i.binding.api_url)));
4365
+ return `'${name}': ` + ordered.map((i) => `${i.binding.agent_id} on ${withoutUserinfo(i.binding.api_url)} at ${i.binding.bound_at} [${i.entry.classification}]`).join(" \u2192 ") + (backends.size > 1 ? " (BACKEND CHANGED)" : "");
4366
+ });
4367
+ checks.push({
4368
+ id: "mcp_server_name_rebound",
4369
+ label: "MCP server names bound more than once",
4370
+ level: "advisory",
4371
+ detail: `${rebound.length} MCP server name${rebound.length === 1 ? "" : "s"} changed hands on this machine (locally recorded bindings; the backend's own record is the authority for the same backend): ${parts.join("; ")}. A saved session, script or document naming the server may still mean the earlier agent.`,
4372
+ repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
4373
+ });
4374
+ }
4017
4375
  const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
4018
4376
  if (parkedElsewhere.length > 0) {
4019
4377
  const abandoned = parkedElsewhere.filter((item) => item.pending.state !== "pending");
@@ -4021,7 +4379,7 @@ async function runDoctor(input, deps = {}) {
4021
4379
  checks.push({
4022
4380
  id: "rekey_pending_elsewhere",
4023
4381
  label: "Parked re-keys in other credential directories",
4024
- ok: abandoned.length === 0,
4382
+ level: abandoned.length === 0 ? "ok" : "failed",
4025
4383
  detail: abandoned.length > 0 ? `ABANDONED re-key key material outside the agent this report describes: ${abandoned.map(describe).join(", ")}. Each holds a private key that was generated for a re-key nobody finished.` : `${parkedElsewhere.length} other director(y/ies) hold an open pending re-key: ${parkedElsewhere.map(describe).join(", ")}.`,
4026
4384
  ...abandoned.length > 0 ? {
4027
4385
  repair: "Check the Haven agent page for each before deleting: if its on-chain revoke already ran, the agent has no spend authority until you re-grant it (#1868), and that is not visible from this machine. Connect never deletes key material for you."
@@ -4030,20 +4388,34 @@ async function runDoctor(input, deps = {}) {
4030
4388
  }
4031
4389
  const signerProcess = primaryChecksById.get("signer_process");
4032
4390
  if (signerProcess) checks.push(signerProcess);
4033
- const restart = restartRequiredForRuntime(input.runtime, deps.env);
4391
+ const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
4392
+ const unused = prune.entries.filter((entry) => entry.action === "would_remove");
4393
+ if (unused.length > 0) {
4394
+ checks.push({
4395
+ id: "signer_runtime_unused",
4396
+ label: "Unused signer-runtime directories",
4397
+ level: "advisory",
4398
+ detail: `${unused.length} signer-runtime director${unused.length === 1 ? "y" : "ies"} under ${prune.root} that no credential directory names: ${unused.map((entry) => entry.key).join(", ")}. Nothing is broken; they are left over from earlier pins or overrides (sizes: --prune-signer-runtimes --dry-run).`,
4399
+ repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
4400
+ });
4401
+ }
4402
+ const restart = restartRequiredForRuntime(input2.runtime, deps.env);
4034
4403
  checks.push({
4035
4404
  id: "restart",
4036
4405
  label: "Runtime restart",
4037
- ok: true,
4038
- detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : "No restart requirement known for this runtime."
4406
+ level: "ok",
4407
+ detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : input2.runtime === "" ? "Runtime is unknown \u2014 whether a restart is needed cannot be determined. Re-run the doctor naming the runtime for a definitive answer." : "No restart requirement known for this runtime."
4039
4408
  });
4040
- const wiredOk = inventory.filter((entry) => entry.classification === "wired").every((entry) => entry.checks.every((check) => check.ok));
4409
+ const wiredChecks = inventory.filter((entry) => entry.classification === "wired").flatMap((entry) => entry.checks);
4410
+ const finalChecks = checks.map(finalizeCheck);
4411
+ const level = rollUpLevel([...finalChecks, ...wiredChecks]);
4041
4412
  return {
4042
4413
  version: 1,
4043
- ok: checks.every((check) => check.ok) && wiredOk,
4044
- runtime: input.runtime,
4414
+ ok: level !== "failed",
4415
+ level,
4416
+ runtime: input2.runtime,
4045
4417
  credentialDirectory: primaryDirectory,
4046
- checks,
4418
+ checks: finalChecks,
4047
4419
  agents: inventory,
4048
4420
  ...signerCapabilities ? { signerCapabilities } : {}
4049
4421
  };
@@ -4061,6 +4433,21 @@ async function runRepair(input, deps = {}) {
4061
4433
  messages: [`No agent credentials found to repair \u2014 run the full setup: ${RERUN} --setup <token>.`]
4062
4434
  };
4063
4435
  }
4436
+ const resolution = await resolveDoctorRuntime(input, directory);
4437
+ if (resolution.origin === "record") {
4438
+ messages.push(`Runtime not given \u2014 resolved '${resolution.runtime}' from ${path.join(directory, exports.CONNECT_OUTCOME_FILENAME)}.`);
4439
+ }
4440
+ const runtime = resolution.runtime;
4441
+ const input2 = { ...input, runtime };
4442
+ if (runtime === "") {
4443
+ return {
4444
+ ok: false,
4445
+ messages: [
4446
+ "Runtime is unknown \u2014 no runtime flag was given and the connector record carries no resolvable runtime.",
4447
+ `Repair rewrites the runtime config, so it will not guess. Re-run repair naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4448
+ ]
4449
+ };
4450
+ }
4064
4451
  let identity;
4065
4452
  try {
4066
4453
  identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
@@ -4070,7 +4457,7 @@ async function runRepair(input, deps = {}) {
4070
4457
  if (!identity.api_key || !(identity.hosted_mcp_url || identity.api_url)) {
4071
4458
  return { ok: false, messages: ["identity.json lacks the stored API key / hosted URL \u2014 re-run the full setup."] };
4072
4459
  }
4073
- const configPath = runtimeConfigPathFor(input.runtime, homeDir);
4460
+ const configPath = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir);
4074
4461
  if (configPath) {
4075
4462
  try {
4076
4463
  const existing = await promises.readFile(configPath, "utf8");
@@ -4097,7 +4484,10 @@ async function runRepair(input, deps = {}) {
4097
4484
  const names = serverNamesFor(serverName);
4098
4485
  messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
4099
4486
  const configResult = await writeRuntimeConfig({
4100
- runtime: input.runtime,
4487
+ // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
4488
+ // switches on the id, and the raw alias fell to its "manual runtime" arm
4489
+ // — a repair that reported success while writing nothing.
4490
+ runtime: normalizeRuntimeName(input2.runtime) ?? input2.runtime,
4101
4491
  hostedMcpUrl: identity.hosted_mcp_url ?? `${identity.api_url}/mcp`,
4102
4492
  apiKey: identity.api_key,
4103
4493
  identityPath: path.join(directory, "identity.json"),
@@ -4115,6 +4505,7 @@ async function runRepair(input, deps = {}) {
4115
4505
  var RERUN;
4116
4506
  var init_doctor = __esm({
4117
4507
  "src/doctor.ts"() {
4508
+ init_prune_runtimes();
4118
4509
  init_runtime_manifest();
4119
4510
  init_probes();
4120
4511
  init_signer_runtime();
@@ -4428,7 +4819,7 @@ async function pathExists2(path) {
4428
4819
  init_unwire();
4429
4820
  init_local_mcp_runtime();
4430
4821
  init_runtime_manifest();
4431
- var CONNECTOR_VERSION = "0.3.0-alpha.0";
4822
+ var CONNECTOR_VERSION = "0.4.0-alpha.0";
4432
4823
  var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4433
4824
  var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4434
4825
  function failureOutcomeFor(runtimeHint, error) {
@@ -4555,6 +4946,27 @@ async function executeConnect(options, deps, trace) {
4555
4946
  }
4556
4947
  }
4557
4948
  }
4949
+ const existingAgents = await listExistingKeyedAgents(options.credentialsDir);
4950
+ if (existingAgents.length > 0) {
4951
+ log("");
4952
+ log(
4953
+ `Heads-up (before anything is written to this machine): it already carries ${existingAgents.length} agent director${existingAgents.length === 1 ? "y" : "ies"} with stored keys \u2014 ` + existingAgents.map((a) => `${a.agentId}${a.accountAddress ? ` (spends from ${shortAddress(a.accountAddress)})` : ""}`).join(", ") + ". This setup creates a NEW agent alongside them and revokes nothing."
4954
+ );
4955
+ }
4956
+ const hostedNameForRun = serverNamesFor(serverName).hosted;
4957
+ let reboundFrom;
4958
+ const holders = (await listMcpServerBindings(options.credentialsDir)).filter(({ binding }) => binding.server_name === hostedNameForRun).sort((a, b) => a.binding.bound_at < b.binding.bound_at ? 1 : a.binding.bound_at > b.binding.bound_at ? -1 : 0);
4959
+ const newest = holders[0];
4960
+ if (newest) {
4961
+ const { binding, directory } = newest;
4962
+ const backendChanged = withoutUserinfo(binding.api_url) !== withoutUserinfo(options.apiBaseUrl);
4963
+ const previousApiUrl = withoutUserinfo(binding.api_url);
4964
+ reboundFrom = { server_name: binding.server_name, agent_id: binding.agent_id, api_url: previousApiUrl, bound_at: binding.bound_at, backend_changed: backendChanged };
4965
+ const beingReplaced = replacing?.superseded.some((entry) => entry.directory === directory) ?? false;
4966
+ log(
4967
+ `Heads-up: the MCP server name '${binding.server_name}' was bound to agent ${binding.agent_id} on ${previousApiUrl} at ${binding.bound_at} (locally recorded${holders.length > 1 ? `; ${holders.length - 1} older record${holders.length > 2 ? "s" : ""} also claim${holders.length > 2 ? "" : "s"} it` : ""}). ` + (beingReplaced ? `This run replaces that wiring, as you chose, with a new agent on ${withoutUserinfo(options.apiBaseUrl)}` : `This run rebinds it to a new agent on ${withoutUserinfo(options.apiBaseUrl)}`) + (backendChanged ? " \u2014 a DIFFERENT backend: any saved session, script or document naming this MCP server now resolves to a different backend and agent." : ".")
4968
+ );
4969
+ }
4558
4970
  const localKey = generateKey();
4559
4971
  const localApiKey = generateLocalApiKey();
4560
4972
  log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
@@ -4618,6 +5030,21 @@ async function executeConnect(options, deps, trace) {
4618
5030
  warn: log
4619
5031
  });
4620
5032
  trace.directory = credentialPaths.directory;
5033
+ try {
5034
+ const bindingRecord = {
5035
+ version: 1,
5036
+ server_name: hostedNameForRun,
5037
+ signer_name: serverNamesFor(serverName).signer,
5038
+ agent_id: registration.agent_id,
5039
+ api_url: withoutUserinfo(options.apiBaseUrl),
5040
+ // never persist `user:pass@` (#3154 doc review r2)
5041
+ ...registration.hosted_mcp_url ? { hosted_mcp_url: registration.hosted_mcp_url } : {},
5042
+ bound_at: (/* @__PURE__ */ new Date()).toISOString()
5043
+ };
5044
+ await writeMcpServerBinding(credentialPaths.directory, bindingRecord);
5045
+ } catch {
5046
+ log("Could not record the MCP server-name binding locally (non-fatal; the next setup cannot see this binding).");
5047
+ }
4621
5048
  log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
4622
5049
  log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
4623
5050
  log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
@@ -4697,6 +5124,7 @@ async function executeConnect(options, deps, trace) {
4697
5124
  replacedBy: registration.agent_id
4698
5125
  });
4699
5126
  await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
5127
+ await clearMcpServerBinding(entry.directory);
4700
5128
  retiredAgentIds.push(entry.agentId);
4701
5129
  log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
4702
5130
  } catch (err) {
@@ -4766,6 +5194,8 @@ async function executeConnect(options, deps, trace) {
4766
5194
  supersededAgentIds,
4767
5195
  supersededAgentsRetiredLocally,
4768
5196
  ...replacing ? { retiredAgentIds } : {},
5197
+ existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
5198
+ ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
4769
5199
  setupChallengeExpiresAt: setup.challenge.expires_at,
4770
5200
  approvalRequired: registration.agent_status === "pending_approval",
4771
5201
  approvalUrl: registration.approval_url
@@ -4830,6 +5260,10 @@ function completionOutcome(input) {
4830
5260
  superseded_agent_ids: input.supersededAgentIds ?? [],
4831
5261
  ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
4832
5262
  ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
5263
+ // #3122: always emitted on a completed run (empty list included), for the
5264
+ // same reason as superseded_agent_ids above.
5265
+ existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
5266
+ ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
4833
5267
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4834
5268
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
4835
5269
  };
@@ -5014,7 +5448,7 @@ function describeWaitBound(timeoutMs) {
5014
5448
  async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
5015
5449
  const intervalMs = options.intervalMs ?? 5e3;
5016
5450
  const timeoutMs = options.timeoutMs ?? 18e4;
5017
- const sleep = options.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
5451
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
5018
5452
  const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
5019
5453
  const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
5020
5454
  let waitingAnnounced = false;
@@ -5105,6 +5539,32 @@ function activationInstructionWithWhy(profile) {
5105
5539
  return profile.activationInstruction;
5106
5540
  }
5107
5541
  var RERUN_HINT = sdk.connectorRerunCommand();
5542
+ async function listExistingKeyedAgents(baseDir) {
5543
+ const root = defaultCredentialRoot(baseDir);
5544
+ let entries = [];
5545
+ try {
5546
+ entries = await promises.readdir(root);
5547
+ } catch {
5548
+ return [];
5549
+ }
5550
+ const out = [];
5551
+ for (const entry of entries) {
5552
+ const directory = path.join(root, entry);
5553
+ try {
5554
+ const identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
5555
+ if (typeof identity.api_key !== "string" || identity.api_key.length === 0) continue;
5556
+ let agent = {};
5557
+ try {
5558
+ agent = JSON.parse(await promises.readFile(path.join(directory, "agent.json"), "utf8"));
5559
+ } catch {
5560
+ }
5561
+ const { accountAddress } = readStoredAccountAddress(identity, agent);
5562
+ out.push({ agentId: typeof identity.agent_id === "string" ? identity.agent_id : entry, directory, accountAddress: accountAddress ?? null });
5563
+ } catch {
5564
+ }
5565
+ }
5566
+ return out;
5567
+ }
5108
5568
  async function listOtherAgentIds(baseDir, currentDirectory) {
5109
5569
  const root = defaultCredentialRoot(baseDir);
5110
5570
  let entries = [];
@@ -5153,6 +5613,9 @@ function parseArgs(argv, env = process.env) {
5153
5613
  let tombstoneReason;
5154
5614
  let tombstoneReplacedBy;
5155
5615
  let unwire;
5616
+ let destroyKeyMaterial = false;
5617
+ let pruneSignerRuntimes2 = false;
5618
+ let dryRun = false;
5156
5619
  let unwireDir;
5157
5620
  let replace = false;
5158
5621
  for (let i = 0; i < argv.length; i += 1) {
@@ -5180,6 +5643,12 @@ function parseArgs(argv, env = process.env) {
5180
5643
  unwireDir = next;
5181
5644
  i += 1;
5182
5645
  }
5646
+ } else if (arg === "--destroy-key-material") {
5647
+ destroyKeyMaterial = true;
5648
+ } else if (arg === "--prune-signer-runtimes") {
5649
+ pruneSignerRuntimes2 = true;
5650
+ } else if (arg === "--dry-run") {
5651
+ dryRun = true;
5183
5652
  } else if (arg === "--reason") {
5184
5653
  tombstoneReason = requireValue(argv, ++i, arg);
5185
5654
  } else if (arg === "--replaced-by") {
@@ -5222,7 +5691,7 @@ function parseArgs(argv, env = process.env) {
5222
5691
  return { options, help, json, doctor, repair, tombstone, rekey };
5223
5692
  }
5224
5693
  if (replace) {
5225
- if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
5694
+ if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
5226
5695
  throw new Error("--replace belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.");
5227
5696
  }
5228
5697
  if (options.serverName) {
@@ -5232,6 +5701,22 @@ function parseArgs(argv, env = process.env) {
5232
5701
  }
5233
5702
  options.replaceExistingWiring = true;
5234
5703
  }
5704
+ if (destroyKeyMaterial && !unwire) {
5705
+ throw new Error("--destroy-key-material only applies to --unwire.");
5706
+ }
5707
+ if (destroyKeyMaterial && unwire) unwire = { ...unwire, destroyKeyMaterial: true };
5708
+ if (dryRun && !pruneSignerRuntimes2) {
5709
+ throw new Error("--dry-run only applies to --prune-signer-runtimes.");
5710
+ }
5711
+ if (pruneSignerRuntimes2) {
5712
+ if (unwire || tombstoneDir || rekeyPhase || doctor || repair) {
5713
+ throw new Error("--prune-signer-runtimes is its own operation; run it alone.");
5714
+ }
5715
+ if (options.setupToken) {
5716
+ throw new Error("--prune-signer-runtimes takes no --setup token; it reads stored state only.");
5717
+ }
5718
+ return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir, pruneSignerRuntimes: { dryRun } };
5719
+ }
5235
5720
  if (rekey) {
5236
5721
  if (options.setupToken) {
5237
5722
  throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
@@ -5326,7 +5811,7 @@ function helpText() {
5326
5811
  " Only available for Claude Code and Codex. Default is hosted MCP + local signer.",
5327
5812
  " --json Emit one versioned, secret-free result object on stdout; progress stays on stderr.",
5328
5813
  " --doctor Diagnose an existing setup (read-only, no token): config, credentials,",
5329
- " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero on any failure.",
5814
+ " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
5330
5815
  " --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
5331
5816
  " runtime, rewrite the wrapper and runtime config from stored credentials.",
5332
5817
  " Hosted topology only (refuses to touch a --local config). No keys, no token.",
@@ -5353,6 +5838,12 @@ function helpText() {
5353
5838
  " API key are removed locally (record kept via the #2155 tombstone mirror) and",
5354
5839
  " nothing is ever revoked on the backend \u2014 that stays an owner action on the",
5355
5840
  " Haven agent page.",
5841
+ " --destroy-key-material With --unwire: destroy the signer key + stored API key even when the agent",
5842
+ " is still active or cannot be verified (#3123). Local sweep recovery ends.",
5843
+ " --prune-signer-runtimes Remove ~/.haven/signer-runtime directories no credential directory references",
5844
+ " (version- and override-keyed alike); --dry-run lists them. Exits 1 only on a",
5845
+ " removal that failed (#3123).",
5846
+ " --dry-run With --prune-signer-runtimes: report, remove nothing.",
5356
5847
  " --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
5357
5848
  " --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
5358
5849
  " --help Show this help.",
@@ -5395,6 +5886,13 @@ function failSubcommand(io, json, err, envelope, fallback) {
5395
5886
  }
5396
5887
  return 1;
5397
5888
  }
5889
+ function levelMarker(level) {
5890
+ return level === "ok" ? "\u2713" : level === "advisory" ? "!" : "\u2717";
5891
+ }
5892
+ function advisoryCount(report) {
5893
+ const others = report.agents.filter((agent) => agent.classification === "wired" && agent.directory !== report.credentialDirectory).flatMap((agent) => agent.checks);
5894
+ return [...report.checks, ...others].filter((check) => check.level === "advisory").length;
5895
+ }
5398
5896
  async function runCli(argv, io = {
5399
5897
  stdout: (message) => process.stdout.write(message),
5400
5898
  stderr: (message) => process.stderr.write(message)
@@ -5422,13 +5920,13 @@ async function runCli(argv, io = {
5422
5920
  }
5423
5921
  if (parsed.tombstone) {
5424
5922
  const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5425
- const { readFile: readFile14 } = await import('fs/promises');
5426
- const { join: join12 } = await import('path');
5923
+ const { readFile: readFile15 } = await import('fs/promises');
5924
+ const { join: join13 } = await import('path');
5427
5925
  try {
5428
5926
  let agentId = "unknown";
5429
5927
  try {
5430
5928
  const identity = JSON.parse(
5431
- await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
5929
+ await readFile15(join13(parsed.tombstone.directory, "identity.json"), "utf8")
5432
5930
  );
5433
5931
  agentId = identity.agent_id ?? "unknown";
5434
5932
  } catch {
@@ -5466,20 +5964,22 @@ async function runCli(argv, io = {
5466
5964
  }
5467
5965
  if (parsed.unwire) {
5468
5966
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
5469
- const { homedir: homedir10 } = await import('os');
5470
- const { join: join12 } = await import('path');
5471
- const homeDir = homedir10();
5472
- const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
5473
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
5967
+ const { homedir: homedir11 } = await import('os');
5968
+ const { join: join13 } = await import('path');
5969
+ const homeDir = homedir11();
5970
+ const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
5971
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
5474
5972
  try {
5475
5973
  const result = await unwireAgent2({
5476
5974
  directory,
5477
5975
  slug: parsed.options.serverName,
5478
5976
  reason: parsed.unwire.reason,
5479
5977
  replacedBy: parsed.unwire.replacedBy,
5978
+ destroyKeyMaterial: parsed.unwire.destroyKeyMaterial,
5480
5979
  homeDir
5481
5980
  });
5482
5981
  const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
5982
+ const retained = result.teardown.status === "retained";
5483
5983
  if (parsed.json) {
5484
5984
  io.stdout(
5485
5985
  `${redactSecrets(
@@ -5494,7 +5994,16 @@ async function runCli(argv, io = {
5494
5994
  label: r.label,
5495
5995
  status: r.status,
5496
5996
  ...r.detail ? { detail: r.detail } : {}
5497
- }))
5997
+ })),
5998
+ // #3122: additive — whether the local server-name binding record was released.
5999
+ binding_released: result.bindingReleased,
6000
+ // #3123: additive — what happened to the key material and why.
6001
+ teardown: {
6002
+ status: result.teardown.status,
6003
+ probe: result.teardown.probe,
6004
+ detail: result.teardown.detail,
6005
+ ...result.teardown.remedy ? { remedy: result.teardown.remedy } : {}
6006
+ }
5498
6007
  })
5499
6008
  )}
5500
6009
  `
@@ -5510,14 +6019,20 @@ async function runCli(argv, io = {
5510
6019
  io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
5511
6020
  `));
5512
6021
  }
6022
+ io.stdout(result.bindingReleased ? " \u2713 MCP server-name binding: released (the name is free for the next setup).\n" : " \u2013 MCP server-name binding: none recorded for this directory.\n");
6023
+ const t = result.teardown;
6024
+ io.stdout(redactSecrets(` ${t.status === "retained" ? "\u2717" : t.status === "forced" ? "!" : "\u2713"} Key material: ${t.status} (probe: ${t.probe}) \u2014 ${t.detail}
6025
+ `));
6026
+ if (t.remedy) io.stdout(redactSecrets(` \u21B3 ${t.remedy}
6027
+ `));
5513
6028
  io.stdout(
5514
- failures.length > 0 ? " Some entries were NOT removed (\u2717 above). Re-run `--unwire` after resolving each refusal \u2014\n it is idempotent.\n" : " Verify: `--doctor --runtime <runtime>` per host should report this agent as `retired` with a\n clean runtime-config check.\n"
6029
+ failures.length > 0 ? " Some entries were NOT removed (\u2717 above). Re-run `--unwire` after resolving each refusal \u2014\n it is idempotent.\n" : retained ? " The wiring is gone; the key material is not (\u2717 above). `--doctor` will keep reporting this directory\n as `superseded` until the key is revoked or destroyed \u2014 that is the honest state.\n" : " Verify: `--doctor --runtime <runtime>` per host should report this agent as `retired` with a\n clean runtime-config check.\n"
5515
6030
  );
5516
6031
  io.stdout(
5517
- "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot\nfrom its own start time. This directory\u2019s local key material was removed and the tombstone\nrecord + #2155 mirror survive \u2014 but nothing was REVOKED on the backend. If you have not\nalready, revoke the agent on the Haven agent page to stop it spending entirely.\n"
6032
+ "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot\nfrom its own start time. " + (retained ? "This directory\u2019s local key material was KEPT (see above); the tombstone\n" : "This directory\u2019s local key material was removed and the tombstone\n") + "record + #2155 mirror survive \u2014 but nothing was REVOKED on the backend. If you have not\nalready, revoke the agent on the Haven agent page to stop it spending entirely.\n"
5518
6033
  );
5519
6034
  }
5520
- return failures.length > 0 ? 1 : 0;
6035
+ return failures.length > 0 || retained ? 1 : 0;
5521
6036
  } catch (err) {
5522
6037
  return failSubcommand(io, parsed.json, err, { unwired: false }, {
5523
6038
  code: "unwire_failed",
@@ -5525,6 +6040,57 @@ async function runCli(argv, io = {
5525
6040
  });
5526
6041
  }
5527
6042
  }
6043
+ if (parsed.pruneSignerRuntimes) {
6044
+ const { pruneSignerRuntimes: pruneSignerRuntimes2 } = await Promise.resolve().then(() => (init_prune_runtimes(), prune_runtimes_exports));
6045
+ try {
6046
+ const report = await pruneSignerRuntimes2(
6047
+ { dryRun: parsed.pruneSignerRuntimes.dryRun },
6048
+ { credentialsDir: parsed.options.credentialsDir }
6049
+ );
6050
+ if (parsed.json) {
6051
+ io.stdout(`${redactSecrets(JSON.stringify({
6052
+ pruned: true,
6053
+ version: report.version,
6054
+ dry_run: report.dryRun,
6055
+ level: report.level,
6056
+ root: report.root,
6057
+ removed: report.removed,
6058
+ reclaimed_bytes: report.reclaimedBytes,
6059
+ entries: report.entries.map((e) => ({
6060
+ key: e.key,
6061
+ kind: e.kind,
6062
+ bytes: e.bytes,
6063
+ action: e.action,
6064
+ level: e.level,
6065
+ referenced_by: e.referencedBy,
6066
+ detail: e.detail
6067
+ }))
6068
+ }))}
6069
+ `);
6070
+ } else {
6071
+ io.stdout(`Signer runtimes under ${report.root}${report.dryRun ? " (dry run \u2014 nothing removed)" : ""}:
6072
+ `);
6073
+ if (report.entries.length === 0) io.stdout(" (none)\n");
6074
+ for (const e of report.entries) {
6075
+ const mark = e.level === "failed" ? "\u2717" : e.level === "advisory" ? "!" : e.action === "removed" ? "\u2713" : "\u2022";
6076
+ const size = e.bytes > 0 ? `${Math.round(e.bytes / 1024 / 1024)} MB` : e.action === "kept" ? "not sized" : "0 MB";
6077
+ io.stdout(redactSecrets(` ${mark} ${e.key} (${e.kind}, ${size}): ${e.detail}
6078
+ `));
6079
+ }
6080
+ io.stdout(
6081
+ report.dryRun ? `Would remove ${report.entries.filter((e) => e.action === "would_remove").length} director(y/ies); re-run without --dry-run to reclaim.
6082
+ ` : `Removed ${report.removed} director(y/ies), reclaimed ${Math.round(report.reclaimedBytes / 1024 / 1024)} MB.
6083
+ `
6084
+ );
6085
+ }
6086
+ return report.level === "failed" ? 1 : 0;
6087
+ } catch (err) {
6088
+ return failSubcommand(io, parsed.json, err, { pruned: false }, {
6089
+ code: "prune_failed",
6090
+ nextAction: "review_the_error_and_rerun_prune_which_is_idempotent"
6091
+ });
6092
+ }
6093
+ }
5528
6094
  if (parsed.rekey) {
5529
6095
  const { startRekey: startRekey2, finishRekey: finishRekey2 } = await Promise.resolve().then(() => (init_rekey(), rekey_exports));
5530
6096
  const { restartGuidance: restartGuidance2 } = await Promise.resolve().then(() => (init_rekey_restart(), rekey_restart_exports));
@@ -5601,7 +6167,7 @@ async function runCli(argv, io = {
5601
6167
  `);
5602
6168
  } else {
5603
6169
  for (const check of report.checks) {
5604
- io.stdout(redactSecrets(`${check.ok ? "\u2713" : "\u2717"} ${check.label}: ${check.detail}
6170
+ io.stdout(redactSecrets(`${levelMarker(check.level)} ${check.label}: ${check.detail}
5605
6171
  `));
5606
6172
  if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
5607
6173
  `));
@@ -5611,21 +6177,25 @@ async function runCli(argv, io = {
5611
6177
  io.stdout("\nOther agents on this machine:\n");
5612
6178
  for (const agent of otherAgents) {
5613
6179
  const name = agent.slug ? `${agent.slug} (${agent.agentId ?? "unknown"})` : agent.agentId ?? "unknown";
5614
- const failed = agent.checks.filter((check) => !check.ok);
5615
- const verdict = agent.classification === "wired" ? failed.length === 0 ? "wired, all checks passed" : `wired, ${failed.length} check(s) FAILED` : agent.classification === "parked" ? "parked re-key only \u2014 no identity.json in this directory, but key material is still there" : agent.classification;
5616
- io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : "\u2022"} ${name}: ${verdict}
6180
+ const failed = agent.checks.filter((check) => check.level === "failed");
6181
+ const advised = agent.checks.filter((check) => check.level === "advisory");
6182
+ const verdict = agent.classification === "wired" ? failed.length > 0 ? `wired, ${failed.length} check(s) FAILED` : advised.length > 0 ? `wired, ${advised.length} advisory finding(s)` : "wired, all checks passed" : agent.classification === "parked" ? "parked re-key only \u2014 no identity.json in this directory, but key material is still there" : agent.classification;
6183
+ io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : advised.length > 0 ? "!" : "\u2022"} ${name}: ${verdict}
5617
6184
  `));
5618
- for (const check of failed) {
5619
- io.stdout(redactSecrets(` \u2717 ${check.label}: ${check.detail}
6185
+ for (const check of [...failed, ...advised]) {
6186
+ io.stdout(redactSecrets(` ${levelMarker(check.level)} ${check.label}: ${check.detail}
5620
6187
  `));
5621
6188
  if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
5622
6189
  `));
5623
6190
  }
5624
6191
  }
5625
6192
  }
5626
- io.stdout(report.ok ? "All checks passed.\n" : "One or more checks FAILED \u2014 see repairs above.\n");
6193
+ io.stdout(
6194
+ report.level === "failed" ? "One or more checks FAILED \u2014 see repairs above.\n" : report.level === "advisory" ? `No failures. ${advisoryCount(report)} advisory finding(s) \u2014 see the ! line(s) above.
6195
+ ` : "All checks passed.\n"
6196
+ );
5627
6197
  }
5628
- return report.ok ? 0 : 1;
6198
+ return report.level === "failed" ? 1 : 0;
5629
6199
  } catch (err) {
5630
6200
  return failSubcommand(io, parsed.json, err, { doctor: "failed" }, {
5631
6201
  code: "doctor_failed",