@staix/agent-hub 0.12.43 → 0.12.45

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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
@@ -1,5 +1,6 @@
1
+ import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
1
2
  import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
2
- import { definitionOf, kindOf, type KindMap } from "../adapters/registry.ts";
3
+ import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
4
 
4
5
  /** Recovery reads this declaration before delegating to another package's CLI. */
5
6
  export const LIVE_TEST_SUPPORTED = true;
@@ -20,7 +21,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
20
21
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
21
22
  || (command === "memory" && sub !== "graph")
22
23
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
23
- || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept"))
24
+ || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
24
25
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
25
26
  }
26
27
 
@@ -42,7 +43,7 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
42
43
  if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
43
44
  if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
44
45
  if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
45
- const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
46
+ const native = nativeCliMarkerPeer(claude !== undefined, codex !== undefined);
46
47
  if (hub !== undefined && native !== undefined && (kindOf(hub, instances) ?? hub) !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
47
48
  const peer = hub ?? native;
48
49
  return peer === undefined ? { role: "console" } : { role: "tools", peer };
@@ -51,9 +52,9 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
51
52
  export type PeerCommandAccess = "allowed" | "conductor" | "console";
52
53
 
53
54
  /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
54
- export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
55
+ export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
55
56
  if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
56
- const native = definitionOf(command);
57
+ const native = catalog?.definitionOf(command) ?? definitionOf(command);
57
58
  if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
58
59
  if (command === "pet" && args.includes("--window")) return "console";
59
60
  if (command === "permission" || command === "stop" || command === "watch") return "console";
@@ -82,11 +83,11 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
82
83
  }
83
84
 
84
85
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
85
- export function cliCommandLabel(command: string, args: readonly string[]): string {
86
+ export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
86
87
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
87
88
  if (command === "board" && args[0] === "import") return "board import";
88
89
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
89
- if (!definitionOf(command) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
90
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
90
91
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
91
92
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
92
93
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
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.ts CHANGED
@@ -1,9 +1,12 @@
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 { staleInstanceKey } from "../hub/roster.ts";
7
+ import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
5
8
  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";
9
+ import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
7
10
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
8
11
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
9
12
  import { matchesPolicy } from "../hub/path-policy.ts";
@@ -79,7 +82,7 @@ import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
79
82
  import { runConsole } from "./console.ts";
80
83
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
81
84
  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";
85
+ import { renderHelp, configuredAcpHelp } from "./help.ts";
83
86
  import { renderTailEvent } from "./tail-render.ts";
84
87
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
85
88
 
@@ -118,16 +121,32 @@ try {
118
121
  }
119
122
  } else {
120
123
  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)) {
124
+ const markedConfig = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)) : undefined;
125
+ const declarations = markedConfig?.instances;
126
+ if (markedPeer && markedRoot && markedConfig && declarations && Object.hasOwn(declarations, markedPeer)) {
123
127
  const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
124
- const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles));
128
+ const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles, markedConfig.catalog), markedConfig.catalog);
125
129
  if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
126
130
  selected = projectContext(canonicalRoot);
127
131
  } else selected = projectContext(process.cwd());
128
132
  }
129
133
  } catch (error) { fail(startupDiagnostic(error)); }
130
134
  const cwd = selected.root;
135
+ let peerConfig: ReturnType<typeof readInstanceConfig>;
136
+ const needsProjectDeclarations = cliCommandLabel(given, rest) === "unknown"
137
+ || rest.some(arg => arg === "--as" || arg.startsWith("--as="))
138
+ || !!process.env.AGENTHUB_PEER_ID && !definitionOf(process.env.AGENTHUB_PEER_ID)
139
+ || given === "help" && !!rest[0] && cliCommandLabel(rest[0], []) === "unknown";
140
+ try { peerConfig = needsProjectDeclarations ? readInstanceConfig(cwd) : { catalog: createProjectAdapterCatalog(), ignored: [] }; } catch (error) { fail(startupDiagnostic(error)); }
141
+ /** Local declarations for a command the running daemon answers. A config file that is mid-edit or invalid must not
142
+ * block it (lowering a permission, listing or reloading settings): such a read falls back to builtins and keeps its
143
+ * diagnostic for a key that only a declaration could define. */
144
+ function localDeclarations(): { scope: Pick<InstanceRoleConfig, "instances" | "catalog">; error?: string } {
145
+ try { return { scope: readInstanceRoleConfig(cwd) }; }
146
+ catch (error) { return { scope: { catalog: createProjectAdapterCatalog() }, error: startupDiagnostic(error) }; }
147
+ }
148
+ const projectCatalog = peerConfig.catalog;
149
+ const configuredCommand = !definitionOf(given) && projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined;
131
150
  const unboundCaller = detectCliIdentity(process.env);
132
151
  const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
133
152
  && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
@@ -135,8 +154,11 @@ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" ||
135
154
  let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
136
155
  let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
137
156
  try {
138
- declaredInstances = needsInstances ? readInstanceConfig(cwd).instances : undefined;
139
- nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
157
+ declaredInstances = needsInstances || configuredCommand ? peerConfig.instances : undefined;
158
+ if (configuredCommand && !(rest.length === 1 && ["--help", "-h"].includes(rest[0]!))) {
159
+ const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances);
160
+ nativeSelection = { ...(selection.peer !== given ? { instance: selection.peer } : {}), args: [...rest] };
161
+ } else nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
140
162
  } catch (error) { fail(startupDiagnostic(error)); }
141
163
  const selectedInstance = nativeSelection.instance;
142
164
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
@@ -146,10 +168,19 @@ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true
146
168
  const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
147
169
 
148
170
  /** 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;
171
+ const peerDefinition = (peer: unknown) => {
172
+ if (typeof peer !== "string") return undefined;
173
+ const bound = projectCatalog.definitionOf(peer, peerConfig.instances);
174
+ if (bound) return bound;
175
+ const current = readInstanceConfig(cwd);
176
+ return current.catalog.definitionOf(peer, current.instances);
177
+ };
151
178
  const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
152
- if (peer !== undefined && !definitionOf(peer)) requirePeerInstances(hub.features);
179
+ if (peer !== undefined && !definitionOf(peer)) {
180
+ const adapter = peerDefinition(peer);
181
+ if (adapter?.permission.genericAcp !== undefined && !hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
182
+ if (adapter?.kind !== peer) requirePeerInstances(hub.features);
183
+ }
153
184
  };
154
185
 
155
186
  /** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
@@ -160,8 +191,8 @@ const projectConfig = () => {
160
191
  };
161
192
  const stateDir = selected.stateDir;
162
193
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
163
- const commandLabel = cliCommandLabel(cmd, args);
164
- const commandAccess = classifyPeerCommand(cmd, args);
194
+ const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
195
+ const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
165
196
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
166
197
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
167
198
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
@@ -408,11 +439,11 @@ function fail(message: string): never {
408
439
  process.exit(1);
409
440
  }
410
441
 
411
- async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
442
+ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
412
443
  const hub = connection ?? await connect();
413
444
  try {
414
445
  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);
446
+ const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
416
447
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
417
448
  return res.text;
418
449
  } finally { if (!connection) hub.close(); }
@@ -762,7 +793,8 @@ const commands: Record<string, () => Promise<void> | void> = {
762
793
  help: () => {
763
794
  const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
764
795
  const topic = asksHelp(args[0]) ? undefined : args[0];
765
- const text = renderHelp(process.stdout.columns ?? 80, color, topic);
796
+ const text = topic && !definitionOf(topic) && projectCatalog.definitionOf(topic)?.permission.genericAcp !== undefined
797
+ ? configuredAcpHelp(topic) : renderHelp(process.stdout.columns ?? 80, color, topic);
766
798
  if (!text) fail(`unknown command "${topic}"; run ahub help`);
767
799
  console.log(text);
768
800
  },
@@ -1199,6 +1231,17 @@ const commands: Record<string, () => Promise<void> | void> = {
1199
1231
  if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
1200
1232
  return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
1201
1233
  }
1234
+ if (sub === "accept-departure") {
1235
+ const head = args[2], rawReason = args.slice(3).join(" "), reason = rawReason.trim();
1236
+ if (!Number.isSafeInteger(id) || id! <= 0 || typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || !reason || rawReason.length > 2048) fail("usage: ahub sdlc accept-departure <task> <head> <reason...>");
1237
+ const hub = await connect();
1238
+ try {
1239
+ const reply = await hub.request({ t: "sdlc", op: "accept-departure", id, head, reason }, 30_000);
1240
+ if (!reply.ok) fail(reply.error);
1241
+ console.log(`task #${id}: plan departure accepted for the reviewed head`);
1242
+ } finally { hub.close(); }
1243
+ return;
1244
+ }
1202
1245
  if (sub === "accept") {
1203
1246
  // #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
1204
1247
  const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
@@ -1284,10 +1327,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1284
1327
  if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
1285
1328
  return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
1286
1329
  }
1287
- const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
1330
+ const { one, many, rest } = takeFlags(args, ["--head", "--findings"], ["--unmet"]);
1288
1331
  const [id, verdict, ...note] = rest;
1289
- if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
1290
- console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1332
+ if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>] | ahub review brief <id>");
1333
+ let findings: unknown;
1334
+ if (one["--findings"] !== undefined) {
1335
+ try { findings = JSON.parse(one["--findings"]); } catch { fail("--findings must be a JSON array"); }
1336
+ if (!Array.isArray(findings)) fail("--findings must be a JSON array");
1337
+ }
1338
+ console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(findings === undefined ? {} : { findings }), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1291
1339
  },
1292
1340
 
1293
1341
  remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
@@ -1338,7 +1386,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1338
1386
  const [peer, mode] = positional;
1339
1387
  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
1388
  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`);
1389
+ const { scope: permissionScope, error: permissionScopeError } = localDeclarations();
1390
+ 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
1391
  const hub = await connect();
1343
1392
  try {
1344
1393
  const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
@@ -1358,7 +1407,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1358
1407
  roster: async () => {
1359
1408
  const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
1360
1409
  const [sub = "list", peer, ...extra] = positional;
1361
- if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
1410
+ if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer) && !(sub === "remove" && typeof peer === "string" && staleInstanceKey(peer, projectCatalog))) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
1362
1411
  const hub = await connect();
1363
1412
  try {
1364
1413
  requireNamedPeerFeature(hub, peer);
@@ -1368,7 +1417,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1368
1417
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
1369
1418
  return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
1370
1419
  }
1371
- const key = `roster.${peer}`, value = sub === "add";
1420
+ // A key whose instance is no longer declared is deleted (null), never set to false: there is no peer left for it to switch off (#225).
1421
+ const key = `roster.${peer}`, stale = sub === "remove" && !peerDefinition(peer) && staleInstanceKey(peer!, projectCatalog), value = stale ? null : sub === "add";
1372
1422
  const preview = await request({ t: "settings_preview", key, value });
1373
1423
  printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
1374
1424
  if (sub === "remove" && !flags.includes("--yes")) {
@@ -1387,14 +1437,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1387
1437
  const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1388
1438
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
1389
1439
  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}`);
1440
+ const { scope: settingScope, error: settingScopeError } = localDeclarations();
1441
+ const def = key === undefined ? undefined : settingDef(key, settingScope.instances, settingScope.catalog);
1442
+ if (typeof def === "string") fail(settingScopeError && def.startsWith("unknown setting") ? `${key}: ${def}; the project config could not be read: ${settingScopeError}` : `${key}: ${def}`);
1392
1443
  if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
1393
1444
  const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
1394
1445
  const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
1395
1446
  if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
1396
1447
  if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
1397
- console.error(`never-ask: ${permissionBoundary(peer!)}; approval prompts are disabled`);
1448
+ console.error(`never-ask: ${permissionBoundary(peer!, settingScope.instances, settingScope.catalog)}; approval prompts are disabled`);
1398
1449
  }
1399
1450
  const hub = await connect();
1400
1451
  try {
@@ -1444,7 +1495,9 @@ const commands: Record<string, () => Promise<void> | void> = {
1444
1495
  return console.log(paint([{ text: result.text }], false));
1445
1496
  }
1446
1497
  if (sub === "undo") {
1447
- const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
1498
+ const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
1499
+ // A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
1500
+ if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1448
1501
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
1449
1502
  let confirm: string | undefined;
1450
1503
  if (rosterPeer) {
@@ -1662,7 +1715,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1662
1715
  facts: async () => {
1663
1716
  if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
1664
1717
  try {
1665
- const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude");
1718
+ const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined));
1666
1719
  if (out) console.log(out);
1667
1720
  } catch {
1668
1721
  // a hook that fails must not get in the way of the tool call
@@ -1671,7 +1724,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1671
1724
  "check-path": async () => {
1672
1725
  const hook = args.includes("--hook");
1673
1726
  const { one, rest } = takeFlags(args.filter((a) => a !== "--hook"), ["--peer"], []);
1674
- const peer = one["--peer"] ?? "claude";
1727
+ const peer = one["--peer"] ?? defaultChannelPeer(undefined);
1675
1728
  let target = rest[0];
1676
1729
  try {
1677
1730
  if (hook) {
@@ -1728,10 +1781,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1728
1781
  const undoWorkspace = (): string => {
1729
1782
  // Old turns, including external peers and ids declared only later, belong to the canonical checkout.
1730
1783
  if (recordedWorkspace === undefined) return cwd;
1731
- const declarations = readInstanceConfig(cwd).instances;
1784
+ const { instances: declarations, catalog } = readInstanceConfig(cwd);
1732
1785
  if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
1733
1786
  const config = projectConfig();
1734
- const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
1787
+ const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles, catalog), catalog);
1735
1788
  if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
1736
1789
  return current;
1737
1790
  };
@@ -2061,7 +2114,18 @@ async function runConductorCommand(): Promise<void> {
2061
2114
  input = { peer: args[0] };
2062
2115
  } else {
2063
2116
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2064
- const parseStart = definitionOf(cmd)?.start.conductorStartArgs;
2117
+ if (configuredCommand) {
2118
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2119
+ if (selection.preview) return runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text) });
2120
+ if (selection.sessionId) fail("conductor starts accept no session override; a person starts a configured ACP session");
2121
+ const hub = await connect();
2122
+ try {
2123
+ await requireActiveConfiguredAcp(hub, cmd, selection.peer, text => console.error(text));
2124
+ console.log(await taskOp("hub_peer_start", { peer: selection.peer, mode: "headless" }, false, hub, 70_000));
2125
+ } finally { hub.close(); }
2126
+ return;
2127
+ }
2128
+ const parseStart = projectCatalog.definitionOf(cmd)?.start.conductorStartArgs;
2065
2129
  const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
2066
2130
  if (parsed instanceof Error) fail(parsed.message);
2067
2131
  op = "hub_peer_start";
@@ -2071,7 +2135,12 @@ async function runConductorCommand(): Promise<void> {
2071
2135
  if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
2072
2136
  console.log(await taskOp(op, input));
2073
2137
  }
2074
- const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2138
+ const runConfigured = configuredCommand ? async () => {
2139
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2140
+ if (!selection.preview) assertLifecycleAvailable();
2141
+ await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2142
+ } : undefined;
2143
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2075
2144
  try {
2076
2145
  assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
2077
2146
  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;