@staix/agent-hub 0.12.43 → 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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  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 +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. package/templates/profiles/writing.json +1 -1
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";
@@ -79,7 +81,7 @@ import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
79
81
  import { runConsole } from "./console.ts";
80
82
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
81
83
  import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
82
- import { renderHelp } from "./help.ts";
84
+ import { renderHelp, configuredAcpHelp } from "./help.ts";
83
85
  import { renderTailEvent } from "./tail-render.ts";
84
86
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
85
87
 
@@ -118,16 +120,32 @@ try {
118
120
  }
119
121
  } else {
120
122
  const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
121
- const declarations = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)).instances : undefined;
122
- 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)) {
123
126
  const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
124
- 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);
125
128
  if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
126
129
  selected = projectContext(canonicalRoot);
127
130
  } else selected = projectContext(process.cwd());
128
131
  }
129
132
  } catch (error) { fail(startupDiagnostic(error)); }
130
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;
131
149
  const unboundCaller = detectCliIdentity(process.env);
132
150
  const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
133
151
  && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
@@ -135,8 +153,11 @@ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" ||
135
153
  let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
136
154
  let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
137
155
  try {
138
- declaredInstances = needsInstances ? readInstanceConfig(cwd).instances : undefined;
139
- 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);
140
161
  } catch (error) { fail(startupDiagnostic(error)); }
141
162
  const selectedInstance = nativeSelection.instance;
142
163
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
@@ -146,10 +167,19 @@ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true
146
167
  const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
147
168
 
148
169
  /** Resolve a named peer only against this project's current trusted declarations. */
149
- const peerDefinition = (peer: unknown) => typeof peer === "string"
150
- ? 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
+ };
151
177
  const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
152
- 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
+ }
153
183
  };
154
184
 
155
185
  /** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
@@ -160,8 +190,8 @@ const projectConfig = () => {
160
190
  };
161
191
  const stateDir = selected.stateDir;
162
192
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
163
- const commandLabel = cliCommandLabel(cmd, args);
164
- const commandAccess = classifyPeerCommand(cmd, args);
193
+ const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
194
+ const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
165
195
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
166
196
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
167
197
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
@@ -408,11 +438,11 @@ function fail(message: string): never {
408
438
  process.exit(1);
409
439
  }
410
440
 
411
- 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> {
412
442
  const hub = connection ?? await connect();
413
443
  try {
414
444
  if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
415
- 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));
416
446
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
417
447
  return res.text;
418
448
  } finally { if (!connection) hub.close(); }
@@ -762,7 +792,8 @@ const commands: Record<string, () => Promise<void> | void> = {
762
792
  help: () => {
763
793
  const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
764
794
  const topic = asksHelp(args[0]) ? undefined : args[0];
765
- 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);
766
797
  if (!text) fail(`unknown command "${topic}"; run ahub help`);
767
798
  console.log(text);
768
799
  },
@@ -1338,7 +1369,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1338
1369
  const [peer, mode] = positional;
1339
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]");
1340
1371
  if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1341
- 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})` : ""}`);
1342
1374
  const hub = await connect();
1343
1375
  try {
1344
1376
  const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
@@ -1387,14 +1419,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1387
1419
  const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1388
1420
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
1389
1421
  if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
1390
- const def = key === undefined ? undefined : settingDef(key);
1391
- 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}`);
1392
1425
  if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
1393
1426
  const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
1394
1427
  const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
1395
1428
  if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
1396
1429
  if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1397
- 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`);
1398
1431
  }
1399
1432
  const hub = await connect();
1400
1433
  try {
@@ -1444,7 +1477,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1444
1477
  return console.log(paint([{ text: result.text }], false));
1445
1478
  }
1446
1479
  if (sub === "undo") {
1447
- 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}`);
1448
1483
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
1449
1484
  let confirm: string | undefined;
1450
1485
  if (rosterPeer) {
@@ -1662,7 +1697,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1662
1697
  facts: async () => {
1663
1698
  if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
1664
1699
  try {
1665
- 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));
1666
1701
  if (out) console.log(out);
1667
1702
  } catch {
1668
1703
  // a hook that fails must not get in the way of the tool call
@@ -1671,7 +1706,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1671
1706
  "check-path": async () => {
1672
1707
  const hook = args.includes("--hook");
1673
1708
  const { one, rest } = takeFlags(args.filter((a) => a !== "--hook"), ["--peer"], []);
1674
- const peer = one["--peer"] ?? "claude";
1709
+ const peer = one["--peer"] ?? defaultChannelPeer(undefined);
1675
1710
  let target = rest[0];
1676
1711
  try {
1677
1712
  if (hook) {
@@ -1728,10 +1763,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1728
1763
  const undoWorkspace = (): string => {
1729
1764
  // Old turns, including external peers and ids declared only later, belong to the canonical checkout.
1730
1765
  if (recordedWorkspace === undefined) return cwd;
1731
- const declarations = readInstanceConfig(cwd).instances;
1766
+ const { instances: declarations, catalog } = readInstanceConfig(cwd);
1732
1767
  if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
1733
1768
  const config = projectConfig();
1734
- 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);
1735
1770
  if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
1736
1771
  return current;
1737
1772
  };
@@ -2061,7 +2096,18 @@ async function runConductorCommand(): Promise<void> {
2061
2096
  input = { peer: args[0] };
2062
2097
  } else {
2063
2098
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2064
- 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;
2065
2111
  const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
2066
2112
  if (parsed instanceof Error) fail(parsed.message);
2067
2113
  op = "hub_peer_start";
@@ -2071,7 +2117,12 @@ async function runConductorCommand(): Promise<void> {
2071
2117
  if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
2072
2118
  console.log(await taskOp(op, input));
2073
2119
  }
2074
- 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`));
2075
2126
  try {
2076
2127
  assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
2077
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";
@@ -332,8 +332,8 @@ export function recordPath(stateDir: string): string {
332
332
  function executionRoot(peer: string, projectRoot: string, captured?: string): string {
333
333
  const builtin = definitionOf(peer);
334
334
  if (builtin?.session.launchRecord) return projectRoot;
335
- const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
336
- const definition = definitionOf(peer, declarations);
335
+ const { instances: declarations, roles, catalog } = readInstanceRoleConfig(projectRoot);
336
+ const definition = catalog.definitionOf(peer, declarations);
337
337
  if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
338
338
  if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
339
339
  // #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
@@ -341,7 +341,7 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
341
341
  if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
342
342
  return projectRoot;
343
343
  }
344
- const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
344
+ const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
345
345
  if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
346
346
  return expected;
347
347
  }
@@ -556,12 +556,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
556
556
  return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
557
557
  }
558
558
 
559
- function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
559
+ function identityFrom(terminal: Record<string, unknown>, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): TerminalPeer | undefined {
560
560
  const wait = object(terminal.agentWait);
561
561
  const session = object(terminal.session);
562
562
  const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
563
563
  for (const candidate of candidates) {
564
- 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;
565
565
  }
566
566
  return undefined;
567
567
  }
@@ -599,7 +599,8 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
599
599
  const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
600
600
  incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
601
601
  ...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
602
- 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);
603
604
  const nativeTail = adapter?.session.resumeArgv?.(binding, false);
604
605
  const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
605
606
  if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
@@ -697,7 +698,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
697
698
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
698
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);
699
700
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
700
- 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;
701
703
  // #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
702
704
  // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
703
705
  const rows = launchRecords(config.stateDir);
@@ -714,7 +716,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
714
716
 
715
717
  for (const [peer, ref] of requested) {
716
718
  const sessionId = ref.sessionId;
717
- const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
719
+ const adapter = catalog.definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
718
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; }
719
721
  const ownRecord = records.find(item => item.peer === peer);
720
722
  let expectedRoot: string, expectedTerminalRoot: string;
@@ -747,7 +749,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
747
749
  }
748
750
  // A show readback is required; list can be stale between recovery steps.
749
751
  if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
750
- const identity = identityFrom(terminal, instances);
752
+ const identity = identityFrom(terminal, instances, catalog);
751
753
  const terminalSession = sessionFrom(terminal);
752
754
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
753
755
  // A recorded launcher proves ownership; the daemon's supplied session id is the
@@ -755,7 +757,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
755
757
  if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
756
758
  }
757
759
  if (!peerCandidates.length) {
758
- 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));
759
761
  blockers.push(ownershipUnknown
760
762
  ? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
761
763
  : blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
@@ -784,7 +786,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
784
786
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
785
787
  // Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
786
788
  const { args: _launchArgs, ...observedTerminal } = terminal;
787
- const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
789
+ const sessionFile = catalog.definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
788
790
  const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
789
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 };
790
792
  bindings.push(binding);
@@ -919,7 +921,8 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
919
921
  const launch = await launches();
920
922
  if (launch.gone) return exited("exited");
921
923
  const shown = await showBinding(replacement, config);
922
- const identity = identityFrom(shown);
924
+ const scope = definitionOf(binding.peer) ? { instances: undefined, catalog: createProjectAdapterCatalog() } : readInstanceConfig(binding.projectRoot);
925
+ const identity = identityFrom(shown, scope.instances, scope.catalog);
923
926
  session = sessionFrom(shown);
924
927
  const recorded = launch.live;
925
928
  if ((identity === binding.peer && same(session)) || (recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) break;
@@ -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) => {
@@ -35,7 +35,7 @@ export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
35
35
  const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
36
36
 
37
37
  /** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
38
- const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
38
+ const projectDefinition = (peer: string, projectRoot: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(projectRoot); return bound.catalog.definitionOf(peer, bound.instances); };
39
39
 
40
40
  const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
41
41
  const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
@@ -144,7 +144,14 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
144
144
 
145
145
  /** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
146
146
  export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
147
- const declared = Object.keys(readInstanceConfig(projectRoot).instances ?? {});
147
+ const bound = readInstanceConfig(projectRoot);
148
+ const configured = Object.keys(bound.adapters ?? {});
149
+ const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
150
+ if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
151
+ const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? Object.entries(observedPeers) : undefined;
152
+ if (!rows || !rows.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || rows.some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
153
+ }
154
+ const declared = Object.keys(bound.instances ?? {});
148
155
  if (!declared.length) return undefined;
149
156
  if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
150
157
  if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
@@ -178,10 +185,16 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
178
185
  }
179
186
  // A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
180
187
  // "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
181
- if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
182
- state: base.state, peers: [], blockers: base.state === "stopped" ? snapshotError ? [snapshotError.message] : [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
188
+ if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) {
189
+ let blockers: string[];
190
+ if (base.state === "stopped") blockers = snapshotError ? [snapshotError.message] : [];
191
+ else if (base.state === "incompatible") blockers = ["manual-bootstrap-required: source lacks the recovery contract; use its matching CLI"];
192
+ else blockers = [base.error ?? base.state];
193
+ return {
194
+ state: base.state, peers: [], blockers,
183
195
  ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
184
- };
196
+ };
197
+ }
185
198
  let status = base.status;
186
199
  if (!status && control && sourceProtocol === PROTOCOL) return {
187
200
  state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
@@ -613,6 +626,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
613
626
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
614
627
  }
615
628
  if (cached && op.targetFeatures !== undefined) {
629
+ if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
616
630
  if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
617
631
  return { ...target, targetFeatures: [...op.targetFeatures] };
618
632
  }
@@ -624,6 +638,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
624
638
  if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
625
639
  if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
626
640
  const targetFeatures: readonly string[] = [...metadata.features];
641
+ if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
627
642
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
628
643
  if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
629
644
  const verified = { ...target, targetFeatures };
@@ -674,7 +689,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
674
689
  // #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
675
690
  const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
676
691
  if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
677
- if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
692
+ if (transcript === "missing" && !planned.freshStart?.includes(binding.peer)) {
678
693
  throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
679
694
  }
680
695
  const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
@@ -1,3 +1,4 @@
1
+ import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
1
2
  import { definitionOf } from "../adapters/registry.ts";
2
3
  import { readInstanceConfig } from "../hub/instance-config.ts";
3
4
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
@@ -18,7 +19,7 @@ import { RecoveryPresentationError, type RecoveryPresentationCause, type Recover
18
19
  const projectDefinition = (peer: string, root?: string) => {
19
20
  const builtin = definitionOf(peer);
20
21
  if (builtin || !root) return builtin;
21
- try { return definitionOf(peer, readInstanceConfig(root).instances); } catch { return undefined; }
22
+ try { const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); } catch { return undefined; }
22
23
  };
23
24
 
24
25
 
@@ -894,7 +895,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
894
895
  };
895
896
  await target();
896
897
  attempted = true;
897
- await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
898
+ await driver.restore(planned, progress, op, nativeRestoreGroup(projectDefinition(native, planned.project.root)?.transport), save, native);
898
899
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
899
900
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
900
901
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);