@staix/agent-hub 0.12.38 → 0.12.40

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 (86) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +19 -10
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/budget.md +2 -0
  6. package/docs/agent-notes/bus.md +2 -2
  7. package/docs/agent-notes/daemon.md +10 -5
  8. package/docs/agent-notes/models.md +4 -4
  9. package/docs/agent-notes/tasks.md +3 -0
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +167 -30
  13. package/docs/quickstart.md +25 -5
  14. package/docs/security.md +20 -8
  15. package/docs/smoke.md +2 -2
  16. package/docs/specs/2026-09-19-agent-hub-design.md +167 -72
  17. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  20. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  21. package/docs/verified.json +38 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +679 -193
  25. package/src/adapters/acp.ts +54 -3
  26. package/src/adapters/claude-channel.ts +35 -4
  27. package/src/adapters/codex-appserver.ts +12 -3
  28. package/src/adapters/definitions/builtin-metadata.ts +6 -4
  29. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  30. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  31. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  32. package/src/adapters/definitions/native-cli-entry-jcode.ts +90 -11
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  34. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  35. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  36. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  37. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  38. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  39. package/src/adapters/jcode.ts +165 -2
  40. package/src/adapters/local-worker.ts +21 -37
  41. package/src/adapters/pi.ts +7 -0
  42. package/src/adapters/registry.ts +1 -1
  43. package/src/cli/console-state.ts +1 -0
  44. package/src/cli/console.ts +11 -1
  45. package/src/cli/help.ts +13 -8
  46. package/src/cli/identity-audit.ts +33 -7
  47. package/src/cli/jcode-mcp.ts +1 -1
  48. package/src/cli/main.ts +138 -28
  49. package/src/cli/output.ts +5 -2
  50. package/src/cli/preview.ts +2 -2
  51. package/src/cli/status-lines.ts +1 -1
  52. package/src/cli/terminal-recovery.ts +54 -4
  53. package/src/cli/upgrade-interactive.ts +5 -0
  54. package/src/cli/upgrade-runtime.ts +13 -1
  55. package/src/cli/upgrade.ts +67 -1
  56. package/src/hub/board.ts +4 -2
  57. package/src/hub/bus.ts +8 -3
  58. package/src/hub/checks.ts +15 -5
  59. package/src/hub/conductor.ts +2 -1
  60. package/src/hub/config-apply.ts +20 -2
  61. package/src/hub/config-reload.ts +2 -8
  62. package/src/hub/control-client.ts +26 -3
  63. package/src/hub/daemon.ts +166 -67
  64. package/src/hub/delivery-superseded.ts +19 -2
  65. package/src/hub/events.ts +4 -1
  66. package/src/hub/hub-tools.ts +4 -4
  67. package/src/hub/inference.ts +4 -8
  68. package/src/hub/lifecycle.ts +3 -3
  69. package/src/hub/main-checkout.ts +36 -0
  70. package/src/hub/memory-profile-check.ts +6 -2
  71. package/src/hub/memory-profile.ts +18 -1
  72. package/src/hub/peers.ts +4 -2
  73. package/src/hub/ports.ts +1 -0
  74. package/src/hub/routing.ts +24 -14
  75. package/src/hub/settings.ts +1 -1
  76. package/src/hub/start-mode.ts +1 -1
  77. package/src/hub/task-repository.ts +136 -0
  78. package/src/hub/tasks.ts +101 -21
  79. package/src/hub/terminal-launch.ts +130 -0
  80. package/src/hub/usage.ts +44 -18
  81. package/src/models/route/config.ts +1 -1
  82. package/src/omniroute/client.ts +7 -19
  83. package/templates/AGENTS.block.md +1 -0
  84. package/templates/routing.toml +10 -44
  85. package/src/switchyard/config.ts +0 -45
  86. package/src/switchyard/sidecar.ts +0 -136
@@ -22,7 +22,7 @@ const splitLabel = (label: string): [string, string[]] => { const [command = "un
22
22
  * the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
23
23
  * Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
24
24
  */
25
- export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
25
+ function reserveCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]) {
26
26
  const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
27
27
  if (!valid(row)) throw new Error("invalid CLI audit identifiers");
28
28
  const dir = join(stateDir, "cli-audit");
@@ -36,13 +36,39 @@ export function recordCliAudit(stateDir: string, peer: string, command: string,
36
36
  const text = JSON.stringify(row) + "\n";
37
37
  if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
38
38
  const pending = join(dir, `${row.id}.tmp`);
39
+ try { writeFileSync(pending, text, { mode: 0o600, flag: "wx" }); }
40
+ catch (error) { try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ } throw error; }
41
+ return {
42
+ id: row.id,
43
+ commit(outcome: CliAudit["outcome"]) {
44
+ if (!valid({ ...row, outcome })) throw new Error("invalid CLI audit identifiers");
45
+ writeFileSync(pending, JSON.stringify({ ...row, outcome }) + "\n", { mode: 0o600 });
46
+ renameSync(pending, join(dir, `${row.id}.json`));
47
+ },
48
+ close() { try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ } },
49
+ };
50
+ }
51
+
52
+ export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
53
+ const pending = reserveCliAudit(stateDir, peer, command, outcome);
54
+ try { pending.commit(outcome); }
55
+ finally { pending.close(); }
56
+ }
57
+
58
+ /** Reserve the bounded audit before connecting; old drains never see an unsupported ready row. */
59
+ export async function recordDelegatedCliAudit(stateDir: string, peer: string, command: string, features: () => Promise<readonly string[]>): Promise<string> {
60
+ let pending: ReturnType<typeof reserveCliAudit>;
61
+ try { pending = reserveCliAudit(stateDir, peer, command, "delegated"); }
62
+ catch { throw new Error("live-test delegation audit unavailable; nothing was executed"); }
39
63
  try {
40
- writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
41
- renameSync(pending, join(dir, `${row.id}.json`));
42
- } catch (error) {
43
- try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
44
- throw error;
45
- }
64
+ let supported = false;
65
+ try { supported = (await features()).includes("cli_audit_delegated"); }
66
+ catch { /* unavailable authentication is not proof of an audit consumer */ }
67
+ try { pending.commit(supported ? "delegated" : "refused"); }
68
+ catch { throw new Error("live-test delegation audit unavailable; nothing was executed"); }
69
+ if (!supported) throw new Error("running hub cannot retain delegated CLI audits; upgrade it from a person's terminal, or use the audited upgrade/recovery path; nothing was executed");
70
+ return pending.id;
71
+ } finally { pending.close(); }
46
72
  }
47
73
 
48
74
  /** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
@@ -1,2 +1,2 @@
1
1
  /** Compatibility exports; the project MCP layer is native adapter policy. */
2
- export { JCODE_MCP_FILE, JCODE_MCP_NAME, jcodeMcpEntry, jcodeMcpReady, configureJcodeMcp } from "../adapters/definitions/native-cli-jcode-mcp.ts";
2
+ export { JCODE_MCP_FILE, JCODE_MCP_NAME, jcodeMcpEntry, jcodeMcpReady, configureJcodeMcp, jcodeExtraMcpServers } from "../adapters/definitions/native-cli-jcode-mcp.ts";
package/src/cli/main.ts CHANGED
@@ -17,12 +17,12 @@ import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts"
17
17
  import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
18
18
  import { attachWatch } from "./watch.ts";
19
19
  import { verifiedJcodeCommand } from "../adapters/jcode.ts";
20
- import { configureJcodeMcp } from "./jcode-mcp.ts";
20
+ import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
21
21
  import { qwenSessionId } from "../hub/crash.ts";
22
22
  import { isPermissionMode } from "../hub/permission-mode.ts";
23
23
  import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
24
24
  import { startModeOf } from "../hub/start-mode.ts";
25
- import { currentRouting } from "../hub/routing.ts";
25
+ import { OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
26
26
  import { spawn, spawnSync } from "node:child_process";
27
27
  import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
28
  import { randomUUID } from "node:crypto";
@@ -50,13 +50,13 @@ import { createInterface } from "node:readline/promises";
50
50
  import { createInterface as lineReader } from "node:readline";
51
51
  import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
52
52
  import { childEnv } from "../hub/child-process.ts";
53
- import { abortRecovery, createOperation, disposeRecovery, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
53
+ import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
54
54
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
55
55
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
56
56
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
57
57
  import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
58
58
  import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
59
- import { recordTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
59
+ import { recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
60
60
  import { stopAndArchiveSummary } from "./recovery-guidance.ts";
61
61
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
62
62
  import { setupOllamaModel } from "./models-setup.ts";
@@ -74,7 +74,7 @@ import { summarize, summarizeByTask } from "../hub/report.ts";
74
74
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
75
75
  import { pathWarnings } from "../hub/conflicts.ts";
76
76
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
77
- import { recordCliAudit } from "./identity-audit.ts";
77
+ import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
78
78
  import { runConsole } from "./console.ts";
79
79
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
80
80
  import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
@@ -167,7 +167,7 @@ const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "
167
167
  const identity = delegatedPeer ? { role: "console" as const } : caller;
168
168
  function audit(outcome: "run" | "refused" | "invalid"): void {
169
169
  if (caller.role === "console") return;
170
- try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, delegatedPeer && outcome === "run" ? "delegated" : outcome); }
170
+ try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, outcome); }
171
171
  catch {
172
172
  if (delegatedPeer) fail("live-test delegation audit unavailable; nothing was executed");
173
173
  console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)");
@@ -176,7 +176,42 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
176
176
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
177
177
  if (delegationRefused) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
178
178
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
179
- if (identity.role === "tools" || delegatedPeer) audit("run");
179
+ // Recovery keeps its authoritative delegation audit in the operation receipt, independent of an old source drain.
180
+ // With no daemon manifest, local bootstrap commands have no consumer to negotiate with; use the compatible outcome.
181
+ const recoveryAudit = ["upgrade", "restart", "recovery", "recovery-run"].includes(cmd);
182
+ const noAuditConsumer = !existsSync(join(stateDir, "status.json")) && !existsSync(join(stateDir, "hub.pid"));
183
+ let delegatedAuditHub: ControlClient | undefined;
184
+ let delegatedConnectionTaken = false;
185
+ let releaseAuditInstance: (() => void) | undefined;
186
+ if (delegatedPeer && !recoveryAudit && !noAuditConsumer) {
187
+ let published = false;
188
+ try {
189
+ const auditId = await recordDelegatedCliAudit(stateDir, delegatedPeer, commandLabel, async () => {
190
+ // The validated invocation already has operational console authority; a tools hello would add a roster gate.
191
+ delegatedAuditHub = await ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
192
+ return delegatedAuditHub.instanceId ? delegatedAuditHub.features : [];
193
+ });
194
+ published = true;
195
+ releaseAuditInstance = ControlClient.pinInstance(stateDir, delegatedAuditHub!.instanceId!);
196
+ assertDelegatedAuditConsumer();
197
+ const retained = await delegatedAuditHub!.request({ t: "cli_audit_ack", auditId }, 3000);
198
+ if (retained.ok !== true || retained.auditId !== auditId || retained.instanceId !== delegatedAuditHub!.instanceId) {
199
+ throw new Error("live-test audit retention was not confirmed; nothing was executed; inspect the hub or upgrade it before retrying");
200
+ }
201
+ } catch (error) {
202
+ releaseAuditInstance?.(); delegatedAuditHub?.close();
203
+ if (published) {
204
+ try { recordCliAudit(stateDir, delegatedPeer, commandLabel, "refused"); }
205
+ catch { fail("live-test delegation audit unavailable; nothing was executed"); }
206
+ }
207
+ fail((error as Error).message);
208
+ }
209
+ } else if (identity.role === "tools" || delegatedPeer) audit("run");
210
+ function assertDelegatedAuditConsumer(): void {
211
+ if (delegatedAuditHub && ((!delegatedConnectionTaken && !delegatedAuditHub.isOpen) || readControl(stateDir)?.instanceId !== delegatedAuditHub.instanceId)) {
212
+ throw new Error("delegated audit consumer changed; refusing further actions; inspect the result before retrying");
213
+ }
214
+ }
180
215
  // Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
181
216
  const liveTestArgs = liveTest ? ["--live-test"] : [];
182
217
  const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
@@ -199,7 +234,12 @@ function printOutput(lines: Span[][], color: boolean): void {
199
234
  }
200
235
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
201
236
  const lifecycle = { inspectProject, startProject, stopProject };
202
- const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
237
+ const connect = async () => {
238
+ assertDelegatedAuditConsumer();
239
+ if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
240
+ return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
241
+ : { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
242
+ };
203
243
 
204
244
  function registeredProject(): Project {
205
245
  const registry = new Registry();
@@ -255,8 +295,8 @@ function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi", e
255
295
  process.exit(res.status ?? 1);
256
296
  }
257
297
 
258
- function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, executionCwd = cwd): never {
259
- const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(selectedInstance ?? "pi", extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
298
+ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, peer: string, executionCwd = cwd): never {
299
+ const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(peer, extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
260
300
  delete env.AGENTHUB_RECOVERY_OPERATION;
261
301
  delete env.AGENTHUB_UNATTENDED;
262
302
  const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env });
@@ -509,7 +549,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
509
549
  if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
510
550
  // The check above takes seconds: a Ctrl+C in that time is still a no.
511
551
  if (interrupted()) throw new Error("interrupted; nothing was started");
512
- const operation = createOperation(plan, preserveSource(plan));
552
+ const operation = createOperation(plan, preserveSource(plan), undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
513
553
  spawnRecovery(operation, true);
514
554
  return operation;
515
555
  },
@@ -583,7 +623,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
583
623
  const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
584
624
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
585
625
  const sourceRoot = preserveSource(plan);
586
- const operation = createOperation(plan, sourceRoot);
626
+ const operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
587
627
  if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
588
628
  else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
589
629
  }
@@ -707,8 +747,11 @@ const commands: Record<string, () => Promise<void> | void> = {
707
747
  else if (humanRecovery(false)) printRecoveryStatus(operation, `runner ${runner} is still working on this operation`, display.options);
708
748
  else console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
709
749
  }
710
- else if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
711
- else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
750
+ else {
751
+ if (delegatedPeer) operation = recordRecoveryDelegation(operation.id, { peer: delegatedPeer, command: "recovery resume" });
752
+ if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
753
+ else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
754
+ }
712
755
  },
713
756
  "recovery-run": async () => {
714
757
  if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
@@ -854,12 +897,12 @@ const commands: Record<string, () => Promise<void> | void> = {
854
897
  if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
855
898
  const hub = await nativeConnect(); hub.close();
856
899
  }
857
- const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
900
+ const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, person: caller.role === "console", definition: entry.definition,
858
901
  ...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
859
902
  projectConfig, connect: nativeConnect, fail,
860
903
  exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
861
- execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
862
- helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
904
+ execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
905
+ helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
863
906
  buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
864
907
  nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
865
908
  buildKimiTuiLaunch, startModeOf },
@@ -1551,7 +1594,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1551
1594
  // #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
1552
1595
  if (args.filter(arg => arg !== "--json")[0] === "profile-check") {
1553
1596
  if (args.some(arg => arg !== "profile-check" && arg !== "--json")) fail("usage: ahub memory profile-check [--json]");
1554
- const result = await checkMemoryProfiles(selectedMemoryProfile());
1597
+ let selected: ReturnType<typeof selectedMemoryProfile>;
1598
+ try { selected = selectedMemoryProfile(); } catch (e) { return fail((e as Error).message); } // an inactive split names the resume remedy
1599
+ const result = await checkMemoryProfiles(selected);
1555
1600
  console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : formatMemoryProfileCheck(result));
1556
1601
  if (!result.ready) process.exitCode = 1;
1557
1602
  return;
@@ -1887,9 +1932,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1887
1932
  const config = loadConfig(cwd);
1888
1933
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
1889
1934
  for (const line of config.retired ?? []) row(false, "retired setting", line);
1935
+ const { routing, errors: routingErrors } = inspectDoctorRouting(cwd);
1936
+ for (const error of routingErrors) row(false, "routing", error);
1890
1937
  for (const probe of nativeDoctorOptionalProbes(config)) {
1891
1938
  if (probe.kind === "source-identity") row(verifiedJcodeCommand(probe.command, cwd), probe.name, probe.detail);
1892
- else {
1939
+ else if (probe.kind === "jcode-mcp") {
1940
+ const { servers, unreadable } = jcodeExtraMcpServers(cwd, probe.home);
1941
+ row(servers.length || unreadable.length ? false : true, probe.name,
1942
+ [servers.length ? `${servers.length} besides the hub's own would load in the hub-owned jcode (${servers.join(", ")}); jcode has no switch to choose them, so remove them from its config or use another JCODE_HOME` : "",
1943
+ unreadable.length ? `unreadable or unsafe, not listed: ${unreadable.join(", ")}` : ""].filter(Boolean).join("; ") || "none besides the hub's own");
1944
+ } else {
1893
1945
  const v = version(probe.command[0]!);
1894
1946
  row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
1895
1947
  }
@@ -1898,15 +1950,18 @@ const commands: Record<string, () => Promise<void> | void> = {
1898
1950
  const gateway = await omni.base();
1899
1951
  row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
1900
1952
  row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
1901
- if (gateway && omni.apiKey()) {
1953
+ if (!routing) {
1954
+ row(undefined, "routing models", "routing could not be read");
1955
+ } else if (gateway && omni.apiKey()) {
1902
1956
  try {
1903
- const fixed = currentRouting(cwd).local.fixed_model;
1904
- const served = (await omni.models()).includes(fixed);
1905
- row(served, "local fixed_model", served ? `${fixed} served` : `${fixed} is not served by the gateway`);
1906
- } catch { row(undefined, "local fixed_model", "could not read the gateway model inventory"); }
1907
- }
1908
- const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
1909
- row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
1957
+ const aliases = new Set([routing.local.fixed_model, ...Object.values(routing.classes).flatMap(policy => policy.fixed_model ? [policy.fixed_model] : []),
1958
+ ...Object.values(routing.hub_routes ?? {}).flatMap(route => [route.efficient ?? "fast", route.capable ?? "coding", ...(route.judge ? [route.judge] : [])])]);
1959
+ const served = new Set(await omni.models());
1960
+ const missing = [...aliases].filter(alias => !served.has(alias)).sort();
1961
+ for (const alias of missing) row(false, "routing model", `${alias} is not served by the gateway`);
1962
+ if (!missing.length) row(true, "routing models", `all ${aliases.size} routing aliases are served`);
1963
+ } catch { row(undefined, "routing models", "could not read the gateway model inventory"); }
1964
+ } else row(undefined, "routing models", "gateway model inventory unavailable");
1910
1965
  const mlxConfig = config.mlx;
1911
1966
  if (mlxConfig.enabled === false) row(true, "pi mlx", "disabled (mlx.enabled=false; local endpoint not probed)");
1912
1967
  else {
@@ -1931,6 +1986,60 @@ const commands: Record<string, () => Promise<void> | void> = {
1931
1986
  },
1932
1987
  };
1933
1988
 
1989
+ function sanitizeRoutingParseError(file: "routing.toml" | "routing.local.toml", error: unknown): string {
1990
+ const raw = ((error as Error)?.message || "invalid TOML").split("\n")[0]!.trim();
1991
+ const cleaned = raw
1992
+ .replace(/^(TOML Parse error: Strings must be quoted): ".*"$/, "$1")
1993
+ .replace(/^(TOML Parse error: Cannot redefine (?:key|table)) '.*'$/, "$1")
1994
+ .replace(/^Invalid regular expression: \/.*\/[a-z]*: /, "Invalid regular expression: ");
1995
+ return cleaned.startsWith(`${file}:`) ? `.agenthub/${cleaned}`
1996
+ : cleaned.startsWith(`.agenthub/${file}:`) ? cleaned
1997
+ : `.agenthub/${file}: ${cleaned}`;
1998
+ }
1999
+
2000
+ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[] } {
2001
+ const errors: string[] = [];
2002
+ let baseText: string | undefined;
2003
+ try {
2004
+ baseText = readFileSync(join(cwd, ".agenthub", "routing.toml"), "utf8");
2005
+ } catch (error) {
2006
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") baseText = routingText(cwd);
2007
+ else errors.push(sanitizeRoutingParseError("routing.toml", error));
2008
+ }
2009
+ let baseRouting: Routing | undefined;
2010
+ if (baseText !== undefined) {
2011
+ try {
2012
+ baseRouting = parseRouting(baseText);
2013
+ } catch (error) {
2014
+ errors.push(sanitizeRoutingParseError("routing.toml", error));
2015
+ }
2016
+ }
2017
+ let overlayText: string | undefined;
2018
+ try {
2019
+ overlayText = readFileSync(join(cwd, ".agenthub", OVERLAY_FILE), "utf8");
2020
+ } catch (error) {
2021
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
2022
+ errors.push(sanitizeRoutingParseError("routing.local.toml", error));
2023
+ }
2024
+ }
2025
+ let overlay: RoutingOverlay | undefined;
2026
+ if (overlayText !== undefined) {
2027
+ try {
2028
+ overlay = parseOverlay(overlayText);
2029
+ } catch (error) {
2030
+ errors.push(sanitizeRoutingParseError("routing.local.toml", error));
2031
+ }
2032
+ }
2033
+ if (errors.length || !baseText) return { errors };
2034
+ if (overlay === undefined) return { routing: baseRouting, errors };
2035
+ try {
2036
+ return { routing: parseRouting(baseText, overlay), errors };
2037
+ } catch (error) {
2038
+ errors.push(sanitizeRoutingParseError("routing.local.toml", error));
2039
+ return { errors };
2040
+ }
2041
+ }
2042
+
1934
2043
  async function runConductorCommand(): Promise<void> {
1935
2044
  let op: string;
1936
2045
  let input: Record<string, unknown>;
@@ -1958,8 +2067,9 @@ async function runConductorCommand(): Promise<void> {
1958
2067
  }
1959
2068
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
1960
2069
  try {
2070
+ assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
1961
2071
  await run();
1962
2072
  } catch (e) {
1963
2073
  audit("refused");
1964
2074
  fail((e as Error).message);
1965
- }
2075
+ } finally { releaseAuditInstance?.(); delegatedAuditHub?.close(); }
package/src/cli/output.ts CHANGED
@@ -10,13 +10,15 @@ export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" |
10
10
  export interface StatusOutput {
11
11
  pendingRestart?: readonly { key: string; reason: string }[];
12
12
  retainedConfig?: readonly { key: string; reason: string }[];
13
+ /** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
14
+ mainCheckout?: string;
13
15
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
14
16
  pendingTaskOffers?: readonly { task: number; peer: string }[];
15
17
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
16
18
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
17
19
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
18
20
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
19
- tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
21
+ tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
20
22
  }
21
23
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
22
24
  export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
@@ -192,6 +194,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
192
194
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
193
195
  if (data.cwd) out.push(...lines(data.cwd, columns));
194
196
  for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
197
+ if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
195
198
  if (data.retainedConfig?.length) {
196
199
  out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
197
200
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
@@ -244,6 +247,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
244
247
  if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
245
248
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
246
249
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
250
+ if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
247
251
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
248
252
  return out;
249
253
  });
@@ -255,7 +259,6 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
255
259
  out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
256
260
  for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
257
261
  }
258
- if (data.switchyard) out.push(...detail("switchyard", data.switchyard, columns));
259
262
  const counts = Object.entries(data.tasks ?? {}).map(([state, count]) => `${count} ${state}`).join(", ");
260
263
  if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
261
264
  return out;
@@ -4,7 +4,7 @@ import { childEnv } from "../hub/child-process.ts";
4
4
  import { startModeOf } from "../hub/start-mode.ts";
5
5
  import { instanceLaunchArguments, buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
6
6
  import { kimiMcpPlan } from "./kimi-mcp.ts";
7
- import { jcodeCommand } from "../adapters/jcode.ts";
7
+ import { jcodeCommand, jcodeTuiCommand } from "../adapters/jcode.ts";
8
8
  import { qwenSessionId } from "../hub/crash.ts";
9
9
  import { relayModelIds } from "../models/relay.ts";
10
10
  import { buildPiLaunch } from "../pi/launch.ts";
@@ -24,7 +24,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
24
24
  const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
25
25
  baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
26
26
  helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
27
- startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
27
+ startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
28
28
  });
29
29
  if (!selected.instance) return result;
30
30
  return { ...result, instance: selected.instance,
@@ -1,7 +1,7 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
5
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
12
12
  import { atomicPrivateJSON } from "../hub/recovery-store.ts";
13
+ import type { ControlClient } from "../hub/control-client.ts";
13
14
 
14
15
  /** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
15
16
  export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
@@ -73,6 +74,7 @@ export interface RecoveryBlocker {
73
74
  | "ownership-unknown"
74
75
  | "ambiguous-terminal"
75
76
  | "terminal-unready"
77
+ | "terminal-orphaned"
76
78
  | "command-error"
77
79
  | "ambiguous-create"
78
80
  | "launcher-exited"
@@ -443,7 +445,7 @@ export async function launcherOf(peer: TerminalPeer, projectRoot: string, option
443
445
  * Capture the Orca terminal handed to an ahub launcher. This is the authenticated
444
446
  * identity source for legacy Orca payloads that omit agent/session metadata.
445
447
  */
446
- export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<RecordedTerminalLaunch | undefined> {
448
+ export async function prepareTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<RecordedTerminalLaunch | undefined> {
447
449
  const config = normalizeOptions(options);
448
450
  const handle = process.env.ORCA_TERMINAL_HANDLE;
449
451
  const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
@@ -465,17 +467,59 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
465
467
  process.env.AGENTHUB_LAUNCH_ID = launchId;
466
468
  process.env.AGENTHUB_INSTANCE_ID = instanceId;
467
469
  const row: RecordedTerminalLaunch = { peer, projectRoot, ...(!definitionOf(peer) ? { executionCwd: expectedRoot, terminalRoot: actualRoot } : {}), stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
470
+ return row;
471
+ }
472
+
473
+ /** Decode a console candidate against this daemon, never against paths supplied by that candidate. */
474
+ export function terminalLaunchCandidate(value: unknown, projectRoot: string, stateDir: string, instanceId: string): RecordedTerminalLaunch | undefined {
475
+ if (!validRecord(value) || JSON.stringify(value).length > 32 * 1024 || value.projectRoot !== projectRoot || value.stateDir !== stateDir || value.instanceId !== instanceId ||
476
+ !Number.isSafeInteger(value.launcherPid) || value.launcherPid <= 0 || value.launchId.length > 128 || value.launcherSignature.length > 256) return undefined;
477
+ try {
478
+ const expected = executionRoot(value.peer, projectRoot, value.executionCwd);
479
+ if (value.executionCwd !== undefined && value.executionCwd !== expected || value.terminalRoot !== undefined && value.terminalRoot !== projectRoot && value.terminalRoot !== expected) return undefined;
480
+ return { peer: value.peer, projectRoot, stateDir, instanceId, launcherPid: value.launcherPid, launcherSignature: value.launcherSignature,
481
+ launchId: value.launchId, handle: value.handle, incarnationId: value.incarnationId, worktreeId: value.worktreeId,
482
+ ...(value.executionCwd ? { executionCwd: value.executionCwd } : {}), ...(value.terminalRoot ? { terminalRoot: value.terminalRoot } : {}), env: allowedEnv({ env: object(value.env) }) };
483
+ } catch { return undefined; }
484
+ }
485
+
486
+ /** Old daemons may launch the TUI, but cannot turn a pre-attach candidate into recovery authority. */
487
+ export async function registerTerminalLaunch(client: Pick<ControlClient, "features" | "request">, row: RecordedTerminalLaunch, operationId?: string): Promise<void> {
488
+ if (!client.features.includes("terminal_owner")) {
489
+ console.error("terminal launcher is unrecorded; upgrade the running hub for managed terminal recovery");
490
+ return;
491
+ }
492
+ const result = await client.request({ t: "terminal_launch", launch: row, ...(operationId ? { operationId } : {}) });
493
+ if (result.ok !== true) throw new Error("terminal launch registration failed; inspect ahub status before launching again");
494
+ }
495
+
496
+ /** Capture alone is not ownership: a refused native child must leave the live row untouched. */
497
+ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions, isCurrentOwner?: (row: RecordedTerminalLaunch) => boolean): Promise<RecordedTerminalLaunch | undefined> {
498
+ const row = await prepareTerminalLaunch(peer, projectRoot, stateDir, instanceId, options);
499
+ if (!row || !isCurrentOwner) return undefined;
500
+ return await publishTerminalLaunch(stateDir, row, () => isCurrentOwner(row), options) ? row : undefined;
501
+ }
502
+
503
+ /** Only the daemon's current native-owner proof may publish a captured candidate. No await after its final fence. */
504
+ export async function publishTerminalLaunch(stateDir: string, row: RecordedTerminalLaunch, isCurrent: () => boolean, options?: CommandRunner | TerminalRecoveryOptions): Promise<boolean> {
505
+ const config = normalizeOptions(options);
506
+ if (!isCurrent() || await launcherState(row, config.processIdentity, config.legacyIdentity) !== "live") return false;
507
+ const terminal = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", row.handle, "--json"]));
508
+ const expectedRoot = executionRoot(row.peer, row.projectRoot, row.executionCwd);
509
+ if (nestedString(terminal, ["handle"]) !== row.handle || nestedString(terminal, ["incarnationId"]) !== row.incarnationId ||
510
+ nestedString(terminal, ["worktreeId"]) !== row.worktreeId || nestedString(terminal, ["worktreePath", "projectRoot"]) !== (row.terminalRoot ?? expectedRoot)) return false;
511
+ if (await launcherState(row, config.processIdentity, config.legacyIdentity) !== "live" || !terminalLaunchCandidate(row, row.projectRoot, stateDir, row.instanceId) || !isCurrent()) return false;
468
512
  const existing = launchRecords(stateDir);
469
513
  // #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
470
514
  // then read those launchers as gone.
471
515
  if (existing === "unreadable") {
472
- throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
516
+ throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, row.peer, row.handle));
473
517
  }
474
518
  // #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
475
- const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
519
+ const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === row.peer && item.projectRoot === row.projectRoot && item.instanceId === row.instanceId));
476
520
  rows.push(row);
477
521
  writeLaunchRecords(stateDir, rows);
478
- return row;
522
+ return true;
479
523
  }
480
524
 
481
525
  function rootMatches(actual: unknown, expected: string): boolean {
@@ -567,6 +611,7 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
567
611
  code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
568
612
  code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
569
613
  code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
614
+ code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh the recovery plan` :
570
615
  code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
571
616
  code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
572
617
  code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
@@ -610,6 +655,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
610
655
  if (!completeIdentity(shown)) return unknown();
611
656
  const issue = verifyBindingData(shown, binding);
612
657
  if (issue) return { state: "changed", blocker: issue };
658
+ if (shown.orphaned === true && shown.connected === true) return { state: "unknown", blocker: blocker("terminal-orphaned", "the saved terminal is orphaned but still connected", binding.peer, binding.handle) };
613
659
  if (shown.orphaned === true) return { state: "orphaned", blocker: blocker("terminal-gone", "the saved terminal is orphaned", binding.peer, binding.handle) };
614
660
  if (shown.connected === false) return unknown();
615
661
  return { state: "present" };
@@ -725,6 +771,10 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
725
771
  blockers.push(blocker("stale-terminal", `terminal metadata lacks a stable handle, incarnation or worktree id`, peer, handle));
726
772
  continue;
727
773
  }
774
+ if (terminal.orphaned === true && terminal.connected === true) {
775
+ blockers.push(blocker("terminal-orphaned", `terminal ${handle} is orphaned but still connected`, peer, handle));
776
+ continue;
777
+ }
728
778
  if (terminal.orphaned === true || terminal.connected === false) {
729
779
  blockers.push(blocker("terminal-unready", `terminal ${handle} is orphaned or disconnected`, peer, handle));
730
780
  continue;
@@ -84,6 +84,7 @@ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string =>
84
84
  export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
85
85
  if (peer.state === "offline") return "offline";
86
86
  if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
87
+ if (planned.unrecordedTerminals?.includes(peer.id)) return "unmanaged";
87
88
  const unrecorded = definitionOf(peer.id)?.session.unrecordedTerminal;
88
89
  if (unrecorded === "always" || unrecorded === "tui" && peer.args?.mode === "tui") return "unmanaged";
89
90
  return "headless";
@@ -91,10 +92,14 @@ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "
91
92
 
92
93
  function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
93
94
  if (peer.state === "offline") return "offline, left as it is";
95
+ if (planned.unrecordedTerminals?.includes(peer.id)) return planned.reconnectOnly?.includes(peer.id)
96
+ ? "attached, launcher unrecorded; reconnects by itself (its terminal is left alone)"
97
+ : "attached, launcher unrecorded; end it in its terminal or resume the original conversation, then refresh the plan";
94
98
  if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
95
99
  if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
96
100
  const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
97
101
  if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
102
+ if (peerKind(planned, peer) === "unmanaged") return "attached, terminal ownership unverified; resolve its evidence or end it in its terminal or resume the original conversation, then refresh the plan";
98
103
  return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
99
104
  }
100
105
 
@@ -252,6 +252,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
252
252
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
253
253
  const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
254
254
  const reconnectOnly: string[] = [];
255
+ const unrecordedTerminals: string[] = [];
255
256
  for (const peer of source.peers) {
256
257
  if (peer.state === "offline") continue;
257
258
  const adapter = projectDefinition(peer.id, project.root);
@@ -271,6 +272,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
271
272
  }
272
273
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
273
274
  if (!launcher) {
275
+ unrecordedTerminals.push(peer.id);
274
276
  delete peer.sessionId;
275
277
  if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
276
278
  else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
@@ -300,6 +302,16 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
300
302
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
301
303
  });
302
304
  blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
305
+ for (const peer of source.peers) {
306
+ if (peer.state === "offline" || unrecordedTerminals.includes(peer.id) || terminals.bindings.some(binding => binding.peer === peer.id)) continue;
307
+ const unrecorded = projectDefinition(peer.id, project.root)?.session.unrecordedTerminal;
308
+ if (unrecorded !== "always" && !(unrecorded === "tui" && peer.args?.mode === "tui")) continue;
309
+ const recorded = await launcherOf(peer.id, project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
310
+ // Unknown or unreadable evidence remains unknown; exact Orca ownership above is an independent positive proof.
311
+ if (recorded && recorded.state !== "gone") continue;
312
+ unrecordedTerminals.push(peer.id);
313
+ blockers.push(`${peer.id}: attached, launcher unrecorded; no current live launcher or verified terminal binding; manual-required; next action: end it in its terminal, or resume the original conversation through its native resume flow in an Orca terminal, then make a new plan`);
314
+ }
303
315
  const freshStart: string[] = [];
304
316
  for (const binding of terminals.bindings) if (projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout") {
305
317
  const transcript = sessionState(binding);
@@ -309,7 +321,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
309
321
  ? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
310
322
  : `${unresumable(binding)}, and the hub cannot show that no turn ran on it, so its conversation may not come back after the restart; manual-required; next action: end that Codex session and close its Orca terminal ${binding.handle} (ahub codex starts a new one later), then make a new plan`);
311
323
  }
312
- body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(freshStart.length ? { freshStart } : {}) });
324
+ body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(unrecordedTerminals.length ? { unrecordedTerminals } : {}), ...(freshStart.length ? { freshStart } : {}) });
313
325
  }
314
326
  if (!body.projects.length) body.blockers.push("no running registered projects in scope");
315
327
  return { ...body, fingerprint: planFingerprint(body) };