@staix/agent-hub 0.12.42 → 0.12.44

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 (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -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 +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. package/templates/profiles/writing.json +1 -1
package/src/cli/launch.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
1
2
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
3
  import { claudeStateFile } from "../hub/claude-state.ts";
3
4
  import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
@@ -159,7 +160,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
159
160
  }
160
161
 
161
162
  function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
162
- const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : "claude")), temp = `${file}.${randomUUID()}.tmp`;
163
+ const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : defaultChannelPeer(undefined))), temp = `${file}.${randomUUID()}.tmp`;
163
164
  try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
164
165
  finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
165
166
  }
@@ -202,7 +203,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
202
203
  const publication = await withClaudeLaunchRecord(stateDir, () => {
203
204
  let current: ClaudeLaunchRecord;
204
205
  try {
205
- const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), stat = lstatSync(file);
206
+ const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), stat = lstatSync(file);
206
207
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
207
208
  current = JSON.parse(readFileSync(file, "utf8"));
208
209
  } catch { return; }
@@ -219,7 +220,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
219
220
  writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
220
221
  renameSync(temp, file);
221
222
  } finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
222
- try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
223
+ try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
223
224
  });
224
225
  if (publication.warning) report?.(publication.warning);
225
226
  return publication.recorded && recorded;
@@ -333,7 +334,7 @@ export function buildLaunch(
333
334
  ): Launch {
334
335
  const adapter = definitionOf(tool);
335
336
  if (!adapter) throw new Error("native launch definition unavailable");
336
- const { metadata: _metadata, ...launch } = adapter.start.launch({
337
+ const { metadata: _metadata, ...launch } = adapter.start.launch!({
337
338
  instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
338
339
  entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
339
340
  });
package/src/cli/main.js CHANGED
@@ -6,4 +6,8 @@ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number
6
6
  process.exit(1);
7
7
  }
8
8
 
9
- await import("./main.ts");
9
+ // Shell completion asks on every Tab: answer from the help entries without loading the CLI (#603).
10
+ if (process.argv[2] === "__complete") {
11
+ const { completeWords } = await import("./completion.ts");
12
+ console.log(completeWords(process.argv.slice(process.argv[3] === "--" ? 4 : 3)).join("\n"));
13
+ } else await import("./main.ts");
package/src/cli/main.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env bun
2
+ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
2
3
  import { effectiveInstanceRoles } from "../hub/instances.ts";
3
4
  import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
4
- import { readInstanceConfig } from "../hub/instance-config.ts";
5
+ import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } from "../hub/instance-config.ts";
6
+ import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
5
7
  import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
6
- import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
8
+ import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
7
9
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
8
10
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
9
11
  import { matchesPolicy } from "../hub/path-policy.ts";
@@ -73,12 +75,13 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
73
75
  import { summarize, summarizeByTask } from "../hub/report.ts";
74
76
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
75
77
  import { pathWarnings } from "../hub/conflicts.ts";
78
+ import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
76
79
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
77
80
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
78
81
  import { runConsole } from "./console.ts";
79
82
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
80
83
  import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
81
- import { renderHelp } from "./help.ts";
84
+ import { renderHelp, configuredAcpHelp } from "./help.ts";
82
85
  import { renderTailEvent } from "./tail-render.ts";
83
86
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
84
87
 
@@ -117,16 +120,32 @@ try {
117
120
  }
118
121
  } else {
119
122
  const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
120
- const declarations = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)).instances : undefined;
121
- if (markedPeer && markedRoot && declarations && Object.hasOwn(declarations, markedPeer)) {
123
+ const markedConfig = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)) : undefined;
124
+ const declarations = markedConfig?.instances;
125
+ if (markedPeer && markedRoot && markedConfig && declarations && Object.hasOwn(declarations, markedPeer)) {
122
126
  const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
123
- const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles));
127
+ const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles, markedConfig.catalog), markedConfig.catalog);
124
128
  if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
125
129
  selected = projectContext(canonicalRoot);
126
130
  } else selected = projectContext(process.cwd());
127
131
  }
128
132
  } catch (error) { fail(startupDiagnostic(error)); }
129
133
  const cwd = selected.root;
134
+ let peerConfig: ReturnType<typeof readInstanceConfig>;
135
+ const needsProjectDeclarations = cliCommandLabel(given, rest) === "unknown"
136
+ || rest.some(arg => arg === "--as" || arg.startsWith("--as="))
137
+ || !!process.env.AGENTHUB_PEER_ID && !definitionOf(process.env.AGENTHUB_PEER_ID)
138
+ || given === "help" && !!rest[0] && cliCommandLabel(rest[0], []) === "unknown";
139
+ try { peerConfig = needsProjectDeclarations ? readInstanceConfig(cwd) : { catalog: createProjectAdapterCatalog(), ignored: [] }; } catch (error) { fail(startupDiagnostic(error)); }
140
+ /** Local declarations for a command the running daemon answers. A config file that is mid-edit or invalid must not
141
+ * block it (lowering a permission, listing or reloading settings): such a read falls back to builtins and keeps its
142
+ * diagnostic for a key that only a declaration could define. */
143
+ function localDeclarations(): { scope: Pick<InstanceRoleConfig, "instances" | "catalog">; error?: string } {
144
+ try { return { scope: readInstanceRoleConfig(cwd) }; }
145
+ catch (error) { return { scope: { catalog: createProjectAdapterCatalog() }, error: startupDiagnostic(error) }; }
146
+ }
147
+ const projectCatalog = peerConfig.catalog;
148
+ const configuredCommand = !definitionOf(given) && projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined;
130
149
  const unboundCaller = detectCliIdentity(process.env);
131
150
  const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
132
151
  && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
@@ -134,8 +153,11 @@ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" ||
134
153
  let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
135
154
  let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
136
155
  try {
137
- declaredInstances = needsInstances ? readInstanceConfig(cwd).instances : undefined;
138
- nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
156
+ declaredInstances = needsInstances || configuredCommand ? peerConfig.instances : undefined;
157
+ if (configuredCommand && !(rest.length === 1 && ["--help", "-h"].includes(rest[0]!))) {
158
+ const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances);
159
+ nativeSelection = { ...(selection.peer !== given ? { instance: selection.peer } : {}), args: [...rest] };
160
+ } else nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
139
161
  } catch (error) { fail(startupDiagnostic(error)); }
140
162
  const selectedInstance = nativeSelection.instance;
141
163
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
@@ -145,10 +167,19 @@ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true
145
167
  const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
146
168
 
147
169
  /** Resolve a named peer only against this project's current trusted declarations. */
148
- const peerDefinition = (peer: unknown) => typeof peer === "string"
149
- ? definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(cwd).instances) : undefined;
170
+ const peerDefinition = (peer: unknown) => {
171
+ if (typeof peer !== "string") return undefined;
172
+ const bound = projectCatalog.definitionOf(peer, peerConfig.instances);
173
+ if (bound) return bound;
174
+ const current = readInstanceConfig(cwd);
175
+ return current.catalog.definitionOf(peer, current.instances);
176
+ };
150
177
  const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
151
- if (peer !== undefined && !definitionOf(peer)) requirePeerInstances(hub.features);
178
+ if (peer !== undefined && !definitionOf(peer)) {
179
+ const adapter = peerDefinition(peer);
180
+ if (adapter?.permission.genericAcp !== undefined && !hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
181
+ if (adapter?.kind !== peer) requirePeerInstances(hub.features);
182
+ }
152
183
  };
153
184
 
154
185
  /** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
@@ -159,8 +190,8 @@ const projectConfig = () => {
159
190
  };
160
191
  const stateDir = selected.stateDir;
161
192
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
162
- const commandLabel = cliCommandLabel(cmd, args);
163
- const commandAccess = classifyPeerCommand(cmd, args);
193
+ const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
194
+ const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
164
195
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
165
196
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
166
197
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
@@ -407,11 +438,11 @@ function fail(message: string): never {
407
438
  process.exit(1);
408
439
  }
409
440
 
410
- async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
441
+ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
411
442
  const hub = connection ?? await connect();
412
443
  try {
413
444
  if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
414
- const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
445
+ const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
415
446
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
416
447
  return res.text;
417
448
  } finally { if (!connection) hub.close(); }
@@ -761,10 +792,16 @@ const commands: Record<string, () => Promise<void> | void> = {
761
792
  help: () => {
762
793
  const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
763
794
  const topic = asksHelp(args[0]) ? undefined : args[0];
764
- const text = renderHelp(process.stdout.columns ?? 80, color, topic);
795
+ const text = topic && !definitionOf(topic) && projectCatalog.definitionOf(topic)?.permission.genericAcp !== undefined
796
+ ? configuredAcpHelp(topic) : renderHelp(process.stdout.columns ?? 80, color, topic);
765
797
  if (!text) fail(`unknown command "${topic}"; run ahub help`);
766
798
  console.log(text);
767
799
  },
800
+ completion: () => {
801
+ const script = completionScript(args[0]);
802
+ if (args.length !== 1 || !script) fail(`usage: ahub completion ${COMPLETION_SHELLS.join("|")}`);
803
+ process.stdout.write(script!);
804
+ },
768
805
  "--version": () => console.log(VERSION),
769
806
  version: () => console.log(VERSION),
770
807
 
@@ -1332,7 +1369,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1332
1369
  const [peer, mode] = positional;
1333
1370
  if (positional.length > 2 || positional.some(arg => arg.startsWith("--")) || (mode !== undefined && !isPermissionMode(mode))) fail("usage: ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]");
1334
1371
  if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1335
- if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
1372
+ const { scope: permissionScope, error: permissionScopeError } = localDeclarations();
1373
+ if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer, permissionScope.instances, permissionScope.catalog)}; approval prompts are disabled${permissionScopeError ? ` (project config unreadable: ${permissionScopeError})` : ""}`);
1336
1374
  const hub = await connect();
1337
1375
  try {
1338
1376
  const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
@@ -1381,14 +1419,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1381
1419
  const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1382
1420
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
1383
1421
  if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
1384
- const def = key === undefined ? undefined : settingDef(key);
1385
- if (typeof def === "string") fail(`${key}: ${def}`);
1422
+ const { scope: settingScope, error: settingScopeError } = localDeclarations();
1423
+ const def = key === undefined ? undefined : settingDef(key, settingScope.instances, settingScope.catalog);
1424
+ if (typeof def === "string") fail(settingScopeError && def.startsWith("unknown setting") ? `${key}: ${def}; the project config could not be read: ${settingScopeError}` : `${key}: ${def}`);
1386
1425
  if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
1387
1426
  const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
1388
1427
  const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
1389
1428
  if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
1390
1429
  if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1391
- console.error(`never-ask: ${permissionBoundary(peer!)}; approval prompts are disabled`);
1430
+ console.error(`never-ask: ${permissionBoundary(peer!, settingScope.instances, settingScope.catalog)}; approval prompts are disabled`);
1392
1431
  }
1393
1432
  const hub = await connect();
1394
1433
  try {
@@ -1438,7 +1477,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1438
1477
  return console.log(paint([{ text: result.text }], false));
1439
1478
  }
1440
1479
  if (sub === "undo") {
1441
- const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
1480
+ const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
1481
+ // A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
1482
+ if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1442
1483
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
1443
1484
  let confirm: string | undefined;
1444
1485
  if (rosterPeer) {
@@ -1656,7 +1697,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1656
1697
  facts: async () => {
1657
1698
  if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
1658
1699
  try {
1659
- const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude");
1700
+ const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined));
1660
1701
  if (out) console.log(out);
1661
1702
  } catch {
1662
1703
  // a hook that fails must not get in the way of the tool call
@@ -1665,7 +1706,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1665
1706
  "check-path": async () => {
1666
1707
  const hook = args.includes("--hook");
1667
1708
  const { one, rest } = takeFlags(args.filter((a) => a !== "--hook"), ["--peer"], []);
1668
- const peer = one["--peer"] ?? "claude";
1709
+ const peer = one["--peer"] ?? defaultChannelPeer(undefined);
1669
1710
  let target = rest[0];
1670
1711
  try {
1671
1712
  if (hook) {
@@ -1722,10 +1763,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1722
1763
  const undoWorkspace = (): string => {
1723
1764
  // Old turns, including external peers and ids declared only later, belong to the canonical checkout.
1724
1765
  if (recordedWorkspace === undefined) return cwd;
1725
- const declarations = readInstanceConfig(cwd).instances;
1766
+ const { instances: declarations, catalog } = readInstanceConfig(cwd);
1726
1767
  if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
1727
1768
  const config = projectConfig();
1728
- const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
1769
+ const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles, catalog), catalog);
1729
1770
  if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
1730
1771
  return current;
1731
1772
  };
@@ -2055,7 +2096,18 @@ async function runConductorCommand(): Promise<void> {
2055
2096
  input = { peer: args[0] };
2056
2097
  } else {
2057
2098
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2058
- const parseStart = definitionOf(cmd)?.start.conductorStartArgs;
2099
+ if (configuredCommand) {
2100
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2101
+ if (selection.preview) return runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text) });
2102
+ if (selection.sessionId) fail("conductor starts accept no session override; a person starts a configured ACP session");
2103
+ const hub = await connect();
2104
+ try {
2105
+ await requireActiveConfiguredAcp(hub, cmd, selection.peer, text => console.error(text));
2106
+ console.log(await taskOp("hub_peer_start", { peer: selection.peer, mode: "headless" }, false, hub, 70_000));
2107
+ } finally { hub.close(); }
2108
+ return;
2109
+ }
2110
+ const parseStart = projectCatalog.definitionOf(cmd)?.start.conductorStartArgs;
2059
2111
  const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
2060
2112
  if (parsed instanceof Error) fail(parsed.message);
2061
2113
  op = "hub_peer_start";
@@ -2065,7 +2117,12 @@ async function runConductorCommand(): Promise<void> {
2065
2117
  if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
2066
2118
  console.log(await taskOp(op, input));
2067
2119
  }
2068
- const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2120
+ const runConfigured = configuredCommand ? async () => {
2121
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2122
+ if (!selection.preview) assertLifecycleAvailable();
2123
+ await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2124
+ } : undefined;
2125
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2069
2126
  try {
2070
2127
  assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
2071
2128
  await run();
package/src/cli/memory.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { detectCliIdentity } from "./identity.ts";
2
2
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
- import { looksSecret, type MemoryStore } from "@staix/agent-mem";
3
+ import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
4
4
  import type { SyncOptions } from "@staix/agent-mem";
5
5
  import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
6
6
  import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
7
7
  import { currentRouting, detectSignals } from "../hub/routing.ts";
8
8
  import { loadConfig } from "../hub/daemon.ts";
9
- import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
9
+ import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
10
10
 
11
11
  export { GOAL_USAGE } from "@staix/agent-mem";
12
12
  export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
@@ -16,9 +16,12 @@ const fail = (message: string): never => { throw new Error(message); };
16
16
  const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
17
17
 
18
18
 
19
+ /** The project key: an injected store resolves the alias in its own home, the default open in the ambient one (#240). */
20
+ const projectOf = (cwd: string, provided: MemoryStore | undefined): string => provided ? memoryProjectForStore(provided, cwd) : memoryProjectFor(cwd);
21
+
19
22
  /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
20
- function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
21
- try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
23
+ function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions, cwd: string) {
24
+ try { return { ...(provided ? bindProfileMemory(provided, undefined, memoryProjectKey(cwd)) : openProfileMemory(options)), owned: !provided }; }
22
25
  catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
23
26
  }
24
27
  function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
@@ -43,9 +46,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
43
46
  const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
44
47
  if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
45
48
  const cutoff = new Date(cutoffMs).toISOString();
46
- const project = memoryProjectFor(cwd), routing = currentRouting(cwd), paths = privatePaths();
49
+ const project = projectOf(cwd, opts.store), routing = currentRouting(cwd), paths = privatePaths();
47
50
  // Review must not promote a held source while reading it: only explicit person actions may append rows.
48
- const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
51
+ const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now }, cwd);
49
52
  const store = context.store;
50
53
  try {
51
54
  context.binding.assertCurrent();
@@ -67,9 +70,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
67
70
  } finally { if (context.owned) store.close(); }
68
71
  }
69
72
  let showStale = false;
70
- const project = memoryProjectFor(cwd);
73
+ const project = projectOf(cwd, opts.store);
71
74
  const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
- screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
75
+ screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
73
76
  try {
74
77
  context.binding.assertCurrent();
75
78
  const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
@@ -92,8 +95,8 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
92
95
  export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
93
96
  const person = opts.person ?? detectCliIdentity(process.env).role === "console";
94
97
  const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
95
- const project = memoryProjectFor(cwd);
96
- const context = commandStores(opts.store, { project });
98
+ const project = projectOf(cwd, opts.store);
99
+ const context = commandStores(opts.store, { project }, cwd);
97
100
  try {
98
101
  context.binding.assertCurrent();
99
102
  const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
package/src/cli/output.ts CHANGED
@@ -246,6 +246,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
246
246
  if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
247
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"));
248
248
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
249
+ if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
249
250
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
250
251
  if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
251
252
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
@@ -32,3 +32,12 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
32
32
  unresolved: [...result.unresolved, { field: "executionCwd", reason: "verified by the daemon at instance start; preview does not start or authorize a worktree" }],
33
33
  };
34
34
  }
35
+
36
+ /** Configured commands are arbitrary machine-local argv; preview never displays their values or starts a child. */
37
+ export function configuredAcpPreview(kind: string, peer: string, resumed: boolean) {
38
+ return { tool: kind, peer, mode: "headless", cmd: "[redacted]", args: ["[redacted]"],
39
+ ...(resumed ? { sessionId: "[redacted]" } : {}),
40
+ envNames: [], redaction: "Configured executable, argv and session values are withheld.",
41
+ unresolved: [{ field: "runtime", reason: "Native identity, owned process group, accounts and daemon catalog are checked at start; preview runs nothing." }],
42
+ needs: ["A trusted config.local.json declaration and a running hub with configured_acp support."] };
43
+ }
@@ -27,7 +27,7 @@ export interface RecoveryGuidanceOptions {
27
27
 
28
28
  const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
29
29
  const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
30
- const projectDefinition = (peer: string, root: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(root).instances);
30
+ const projectDefinition = (peer: string, root: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); };
31
31
  const peers = (instances: KindMap | undefined): RecoveryNativePeer[] => [...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances ?? {})]
32
32
  .filter(peer => definitionOf(peer, instances)?.session.launchRecord)
33
33
  .sort((a, b) => Number(definitionOf(a, instances)?.transport === "claude-channel") - Number(definitionOf(b, instances)?.transport === "claude-channel"));
@@ -1,6 +1,6 @@
1
1
  import { shellQuote } from "./shell-quote.ts";
2
2
  export { shellQuote } from "./shell-quote.ts";
3
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
3
+ import { createProjectAdapterCatalog, definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
4
4
  import { readInstanceConfig, readInstanceRoleConfig } from "../hub/instance-config.ts";
5
5
  import { effectiveInstanceRoles } from "../hub/instances.ts";
6
6
  import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
@@ -179,6 +179,8 @@ const MAX_WAIT_MS = 10 * 60 * 1000;
179
179
  export const MAX_COMMAND_MS = 30_000;
180
180
  /** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
181
181
  const EXIT_SLICE_MS = 5_000;
182
+ /** #599: after the replacement reads idle, its session mapping is re-read this often until the deadline. */
183
+ const MAP_POLL_MS = 1_000;
182
184
  const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
183
185
  type AllowedEnvName = (typeof ALLOWED_ENV)[number];
184
186
 
@@ -330,8 +332,8 @@ export function recordPath(stateDir: string): string {
330
332
  function executionRoot(peer: string, projectRoot: string, captured?: string): string {
331
333
  const builtin = definitionOf(peer);
332
334
  if (builtin?.session.launchRecord) return projectRoot;
333
- const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
334
- const definition = definitionOf(peer, declarations);
335
+ const { instances: declarations, roles, catalog } = readInstanceRoleConfig(projectRoot);
336
+ const definition = catalog.definitionOf(peer, declarations);
335
337
  if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
336
338
  if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
337
339
  // #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
@@ -339,7 +341,7 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
339
341
  if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
340
342
  return projectRoot;
341
343
  }
342
- const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
344
+ const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
343
345
  if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
344
346
  return expected;
345
347
  }
@@ -554,12 +556,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
554
556
  return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
555
557
  }
556
558
 
557
- function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
559
+ function identityFrom(terminal: Record<string, unknown>, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): TerminalPeer | undefined {
558
560
  const wait = object(terminal.agentWait);
559
561
  const session = object(terminal.session);
560
562
  const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
561
563
  for (const candidate of candidates) {
562
- if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
564
+ if (typeof candidate === "string" && catalog.definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
563
565
  }
564
566
  return undefined;
565
567
  }
@@ -597,7 +599,8 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
597
599
  const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
598
600
  incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
599
601
  ...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
600
- const adapter = definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
602
+ const scope = definitionOf(peer) ? undefined : readInstanceConfig(projectRoot);
603
+ const adapter = definitionOf(peer) ?? scope!.catalog.definitionOf(peer, scope!.instances);
601
604
  const nativeTail = adapter?.session.resumeArgv?.(binding, false);
602
605
  const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
603
606
  if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
@@ -695,7 +698,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
695
698
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
696
699
  const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
697
700
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
698
- const instances = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot).instances : undefined;
701
+ const scope = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot) : { instances: undefined, catalog: createProjectAdapterCatalog() };
702
+ const { instances, catalog } = scope;
699
703
  // #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
700
704
  // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
701
705
  const rows = launchRecords(config.stateDir);
@@ -712,7 +716,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
712
716
 
713
717
  for (const [peer, ref] of requested) {
714
718
  const sessionId = ref.sessionId;
715
- const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
719
+ const adapter = catalog.definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
716
720
  if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver" && adapter.transport !== "claude-channel") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
717
721
  const ownRecord = records.find(item => item.peer === peer);
718
722
  let expectedRoot: string, expectedTerminalRoot: string;
@@ -745,7 +749,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
745
749
  }
746
750
  // A show readback is required; list can be stale between recovery steps.
747
751
  if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
748
- const identity = identityFrom(terminal, instances);
752
+ const identity = identityFrom(terminal, instances, catalog);
749
753
  const terminalSession = sessionFrom(terminal);
750
754
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
751
755
  // A recorded launcher proves ownership; the daemon's supplied session id is the
@@ -753,7 +757,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
753
757
  if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
754
758
  }
755
759
  if (!peerCandidates.length) {
756
- const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
760
+ const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances, catalog) === undefined && !records.some((item) => item.handle === terminal.handle));
757
761
  blockers.push(ownershipUnknown
758
762
  ? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
759
763
  : blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
@@ -782,7 +786,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
782
786
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
783
787
  // Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
784
788
  const { args: _launchArgs, ...observedTerminal } = terminal;
785
- const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
789
+ const sessionFile = catalog.definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
786
790
  const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
787
791
  const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, ...(extra ? { executionCwd: expectedRoot, terminalRoot: expectedTerminalRoot } : {}), sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
788
792
  bindings.push(binding);
@@ -909,16 +913,24 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
909
913
  idle = await waitForIdle(replacement, slice(), config);
910
914
  }
911
915
  if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
912
- // An idle terminal may be the login shell the launcher returned to.
913
- const launch = await launches();
914
- if (launch.gone) return exited("exited");
915
- const shown = await showBinding(replacement, config);
916
- const identity = identityFrom(shown);
917
- const session = sessionFrom(shown);
918
- const recorded = launch.live;
919
916
  const same = (value: string | undefined) => anySession || value === binding.sessionId;
920
- if ((identity !== binding.peer || !same(session)) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) {
921
- return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
917
+ let session: string | undefined;
918
+ // An idle terminal may be the login shell the launcher returned to, or a TUI that has not yet recorded its
919
+ // launch or been identified by Orca (#599): poll until it maps, contradicts, or the deadline passes.
920
+ for (;;) {
921
+ const launch = await launches();
922
+ if (launch.gone) return exited("exited");
923
+ const shown = await showBinding(replacement, config);
924
+ const scope = definitionOf(binding.peer) ? { instances: undefined, catalog: createProjectAdapterCatalog() } : readInstanceConfig(binding.projectRoot);
925
+ const identity = identityFrom(shown, scope.instances, scope.catalog);
926
+ session = sessionFrom(shown);
927
+ const recorded = launch.live;
928
+ if ((identity === binding.peer && same(session)) || (recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) break;
929
+ const contradicts = (identity !== undefined && identity !== binding.peer) || (session !== undefined && !same(session));
930
+ if (contradicts || Date.now() >= deadline) {
931
+ return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
932
+ }
933
+ await Bun.sleep(Math.max(1, Math.min(MAP_POLL_MS, deadline - Date.now())));
922
934
  }
923
935
  if (anySession && session) replacement = { ...replacement, sessionId: session };
924
936
  return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
2
2
  import { readInstanceConfig } from "../hub/instance-config.ts";
3
3
  import type { TerminalBinding } from "./terminal-recovery.ts";
4
4
 
@@ -102,7 +102,8 @@ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvid
102
102
  /** Checkable compact documentation, enumerated through the same pure decision function. */
103
103
  export function renderRestoreTransitionTable(): string {
104
104
  const launch = { packageEntrypoint: "table", command: "table", argv: [], env: {} };
105
- const binding: TerminalBinding = { peer: "codex", handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
105
+ const examplePeer = ADAPTERS.find(definition => definition.transport === "codex-appserver")!.kind;
106
+ const binding: TerminalBinding = { peer: examplePeer, handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
106
107
  const receipts: RestoreReceipt[] = [{ state: "pending" }, { state: "waiting", origin: "pending", binding }, { state: "waiting", origin: "failed", binding }, { state: "done", binding }, { state: "failed" }];
107
108
  const launchers: RestoreEvidence["launcher"][] = ["live", "gone", "unknown", "no-record-yet", "replaced", "older-instance"];
108
109
  const result = (receipt: RestoreReceipt, evidence: RestoreEvidence, unchanged = false) => {