@staix/agent-hub 0.12.34 → 0.12.35

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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/budget.md +3 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +5 -1
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +4 -1
  9. package/docs/gsd-collaboration-analysis.md +5 -1
  10. package/docs/operations.md +67 -13
  11. package/docs/quickstart.md +8 -2
  12. package/docs/sdlc.md +59 -34
  13. package/docs/security.md +3 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
  15. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  16. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  17. package/docs/verified.json +21 -16
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +15438 -14863
  21. package/src/adapters/acp.ts +3 -1
  22. package/src/adapters/claude-channel.ts +59 -29
  23. package/src/adapters/codex-appserver.ts +1 -1
  24. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  25. package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
  26. package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
  27. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  28. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  29. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  31. package/src/adapters/definitions/native-recovery.ts +2 -2
  32. package/src/adapters/definitions/qwen-native.ts +2 -2
  33. package/src/adapters/local-worker.ts +3 -2
  34. package/src/adapters/pi.ts +3 -1
  35. package/src/adapters/registry-types.ts +3 -0
  36. package/src/adapters/registry.ts +24 -8
  37. package/src/cli/identity.ts +3 -3
  38. package/src/cli/launch.ts +29 -4
  39. package/src/cli/main.ts +101 -28
  40. package/src/cli/preview.ts +16 -5
  41. package/src/cli/recovery-guidance.ts +39 -22
  42. package/src/cli/terminal-recovery.ts +68 -16
  43. package/src/cli/upgrade-restore-state.ts +16 -7
  44. package/src/cli/upgrade-runtime.ts +67 -39
  45. package/src/cli/upgrade.ts +43 -27
  46. package/src/hub/budget.ts +55 -3
  47. package/src/hub/bus.ts +1 -1
  48. package/src/hub/conductor.ts +16 -10
  49. package/src/hub/config-apply.ts +13 -2
  50. package/src/hub/config-reload.ts +11 -1
  51. package/src/hub/config-trust.ts +1 -1
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/crash.ts +14 -5
  54. package/src/hub/daemon.ts +296 -125
  55. package/src/hub/execution-budget.ts +3 -3
  56. package/src/hub/hub-tools.ts +17 -6
  57. package/src/hub/instance-budget.ts +73 -0
  58. package/src/hub/instance-config.ts +47 -0
  59. package/src/hub/instance-execution.ts +50 -0
  60. package/src/hub/instances.ts +100 -0
  61. package/src/hub/lifecycle-schema.ts +7 -3
  62. package/src/hub/permission-mode.ts +7 -7
  63. package/src/hub/roster.ts +7 -6
  64. package/src/hub/routing.ts +82 -18
  65. package/src/hub/sdlc-queue.ts +2 -2
  66. package/src/hub/sdlc.ts +84 -38
  67. package/src/hub/settings.ts +34 -6
  68. package/src/hub/snapshots.ts +40 -9
  69. package/src/hub/start-mode.ts +7 -4
  70. package/src/hub/status-roster.ts +8 -0
  71. package/src/hub/tasks.ts +53 -23
  72. package/src/hub/text-projection.ts +7 -3
  73. package/src/hub/workflow.ts +30 -52
  74. package/src/pi/launch.ts +4 -3
  75. package/src/ui/index.html +1 -1
  76. package/templates/AGENTS.block.md +2 -1
package/src/cli/launch.ts CHANGED
@@ -7,15 +7,40 @@ import { processLiveness, processSignature } from "../pi/process-signature.ts";
7
7
  import { realPath } from "../hub/project.ts";
8
8
  import { peerChildEnv, processTable } from "../hub/child-process.ts";
9
9
  import { definitionOf } from "../adapters/registry.ts";
10
+ import type { InstancesConfig } from "../hub/instances.ts";
10
11
  import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
11
12
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
12
13
 
14
+ /** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
15
+ export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[] } {
16
+ if (!definitionOf(command)) return { args: [...raw] };
17
+ const args: string[] = [];
18
+ let instance: string | undefined;
19
+ for (let index = 0; index < raw.length; index++) {
20
+ const arg = raw[index]!;
21
+ if (arg === "--") { args.push(...raw.slice(index)); break; }
22
+ if (arg !== "--as" && !arg.startsWith("--as=")) { args.push(arg); continue; }
23
+ if (instance !== undefined) throw new Error("--as may appear only once");
24
+ const id = arg === "--as" ? raw[++index] : arg.slice("--as=".length);
25
+ if (!id || id.startsWith("--")) throw new Error("--as needs a declared instance id");
26
+ const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
27
+ if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
28
+ instance = id;
29
+ }
30
+ return { ...(instance ? { instance } : {}), args };
31
+ }
32
+
33
+ /** Only an authenticated daemon hello can authorize extra-instance effects. */
34
+ export function requirePeerInstances(features: unknown): void {
35
+ if (!Array.isArray(features) || !features.includes("peer_instances")) throw new Error("running hub lacks peer_instances; upgrade it before starting an extra instance");
36
+ }
37
+
13
38
  /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
14
- export function nativeLaunchEnv(tool: "claude" | "codex" | "pi" | "kimi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
39
+ export function nativeLaunchEnv(tool: string, source: NodeJS.ProcessEnv, instances?: InstancesConfig): NodeJS.ProcessEnv {
15
40
  const env = peerChildEnv(tool, source);
16
41
  delete env.AGENTHUB_UNATTENDED;
17
42
  // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
18
- const definition = definitionOf(tool);
43
+ const definition = definitionOf(tool, instances);
19
44
  if (definition?.identity.mcp === "channel-plugin") env.AGENTHUB_CHANNEL = "1";
20
45
  if (definition?.attach?.pullOnlyForms?.includes("tui")) delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
21
46
  return env;
@@ -289,12 +314,12 @@ export function sessionSettings(tee?: StatusLineTee, facts?: FactsHook): string
289
314
  export function buildLaunch(
290
315
  tool: "claude" | "codex",
291
316
  userArgs: string[],
292
- ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
317
+ ctx: { instance?: string; unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
293
318
  ): Launch {
294
319
  const adapter = definitionOf(tool);
295
320
  if (!adapter) throw new Error("native launch definition unavailable");
296
321
  const { metadata: _metadata, ...launch } = adapter.start.launch({
297
- instance: tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
322
+ instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
298
323
  entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
299
324
  });
300
325
  return launch;
package/src/cli/main.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env bun
2
+ import { effectiveInstanceRoles } from "../hub/instances.ts";
3
+ import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
4
+ import { readInstanceConfig } from "../hub/instance-config.ts";
5
+ import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
2
6
  import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
3
7
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
4
8
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
@@ -27,7 +31,7 @@ import { basename, dirname, join, relative, resolve } from "node:path";
27
31
  import { ControlClient, readControl } from "../hub/control-client.ts";
28
32
  import { loadConfig } from "../hub/daemon.ts";
29
33
  import { factsHook } from "./facts-hook.ts";
30
- import { hubHome, projectContext, realPath } from "../hub/project.ts";
34
+ import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
31
35
  import { Registry, type Project } from "../hub/registry.ts";
32
36
  import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
33
37
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
@@ -36,7 +40,7 @@ import { OmniRoute } from "../omniroute/client.ts";
36
40
  import { MemoryClient } from "../memory/client.ts";
37
41
  import { init, planInit } from "./init.ts";
38
42
  import { launcherPreview } from "./preview.ts";
39
- import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
43
+ import { instanceLaunchArguments, requirePeerInstances, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
40
44
  import { configureKimiMcp } from "./kimi-mcp.ts";
41
45
  import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
42
46
  import { CLASSES } from "../hub/board.ts";
@@ -101,11 +105,6 @@ while (argv[0] === "--project" || argv[0] === "--live-test") {
101
105
  }
102
106
  }
103
107
  const [given = "help", ...rest] = argv;
104
- // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
105
- // (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
106
- const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
107
- const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
108
- const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
109
108
  let selected: { root: string; stateDir: string };
110
109
  try {
111
110
  if (selector) {
@@ -116,9 +115,42 @@ try {
116
115
  const registered = known ?? projects.find((p) => p.root === context.root);
117
116
  selected = registered ?? context;
118
117
  }
119
- } else selected = projectContext(process.cwd());
120
- } catch (error) { fail((error as Error).message); }
118
+ } else {
119
+ const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
120
+ const declarations = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)).instances : undefined;
121
+ if (markedPeer && markedRoot && declarations && Object.hasOwn(declarations, markedPeer)) {
122
+ const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
123
+ const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles));
124
+ if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
125
+ selected = projectContext(canonicalRoot);
126
+ } else selected = projectContext(process.cwd());
127
+ }
128
+ } catch (error) { fail(startupDiagnostic(error)); }
121
129
  const cwd = selected.root;
130
+ const unboundCaller = detectCliIdentity(process.env);
131
+ const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
132
+ && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
133
+ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" || arg.startsWith("--as=")) || markerNeedsDeclaration;
134
+ let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
135
+ let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
136
+ try {
137
+ declaredInstances = needsInstances ? readInstanceConfig(cwd).instances : undefined;
138
+ nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
139
+ } catch (error) { fail(startupDiagnostic(error)); }
140
+ const selectedInstance = nativeSelection.instance;
141
+ // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
142
+ // (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
143
+ const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
144
+ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true && nativeSelection.args.length === 1 && asksHelp(nativeSelection.args[0])) ? "help" : given;
145
+ const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
146
+
147
+ /** Resolve a named peer only against this project's current trusted declarations. */
148
+ const peerDefinition = (peer: unknown) => typeof peer === "string"
149
+ ? definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(cwd).instances) : undefined;
150
+ const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
151
+ if (peer !== undefined && !definitionOf(peer)) requirePeerInstances(hub.features);
152
+ };
153
+
122
154
  /** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
123
155
  const projectConfig = () => {
124
156
  const config = loadConfig(cwd);
@@ -126,7 +158,7 @@ const projectConfig = () => {
126
158
  return config;
127
159
  };
128
160
  const stateDir = selected.stateDir;
129
- const caller = detectCliIdentity(process.env);
161
+ const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
130
162
  const commandLabel = cliCommandLabel(cmd, args);
131
163
  const commandAccess = classifyPeerCommand(cmd, args);
132
164
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
@@ -216,18 +248,18 @@ async function healthy(): Promise<boolean> {
216
248
  finally { hub?.close(); }
217
249
  }
218
250
 
219
- function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi"): never {
220
- const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
221
- const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
251
+ function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi", executionCwd = cwd): never {
252
+ const env = tool ? nativeLaunchEnv(selectedInstance ?? tool, childEnv(), declaredInstances) : childEnv();
253
+ const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
222
254
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
223
255
  process.exit(res.status ?? 1);
224
256
  }
225
257
 
226
- function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
227
- const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
258
+ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, executionCwd = cwd): never {
259
+ const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(selectedInstance ?? "pi", extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
228
260
  delete env.AGENTHUB_RECOVERY_OPERATION;
229
261
  delete env.AGENTHUB_UNATTENDED;
230
- const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
262
+ const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env });
231
263
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
232
264
  process.exit(res.status ?? 1);
233
265
  }
@@ -324,6 +356,12 @@ async function orphanDoctor(kill: boolean, options: ReturnType<typeof outputOpti
324
356
  if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
325
357
  }
326
358
 
359
+ /** Startup refusals remain single-line and bounded even for malformed local declarations. */
360
+ function startupDiagnostic(error: unknown): string {
361
+ const message = error instanceof Error ? error.message : "invalid startup configuration";
362
+ return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
363
+ }
364
+
327
365
  function fail(message: string): never {
328
366
  console.error(`ahub: ${message}`);
329
367
  process.exit(1);
@@ -552,7 +590,10 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
552
590
 
553
591
  /** Strict person command: recorded identity only, no session or terminal overrides. */
554
592
  function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
555
- if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || definitionOf(argv[2]!)?.session.launchRecord !== true) return undefined;
593
+ if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || !/^[a-z][a-z0-9-]{0,31}$/.test(argv[2]!)) return undefined;
594
+ const builtin = definitionOf(argv[2]!);
595
+ if (builtin && builtin.session.launchRecord !== true) return undefined;
596
+ // Extra peers are resolved against the operation's canonical project by the recovery core, never by suffix.
556
597
  return { id: argv[1]!, peer: argv[2] as TerminalPeer };
557
598
  }
558
599
 
@@ -805,13 +846,25 @@ const commands: Record<string, () => Promise<void> | void> = {
805
846
  ...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
806
847
  // Preserve the original guard before native effects; previews remain read-only and precede this guard.
807
848
  if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
808
- return entry.execute({ args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
809
- projectConfig, connect, fail, exec, execWithEnv, takeFlags,
810
- helpers: { launcherPreview, readControl, recordTerminalLaunch, recoveryLock, processSignature,
849
+ const nativeConnect = async () => {
850
+ const hub = await connect();
851
+ try { if (selectedInstance) requirePeerInstances(hub.features); return hub; }
852
+ catch (error) { hub.close(); throw error; }
853
+ };
854
+ if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
855
+ const hub = await nativeConnect(); hub.close();
856
+ }
857
+ const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
858
+ ...(selectedInstance ? { instance: selectedInstance } : {}),
859
+ projectConfig, connect: nativeConnect, fail,
860
+ exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
861
+ execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
862
+ helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
811
863
  buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
812
864
  nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
813
865
  buildKimiTuiLaunch, startModeOf },
814
- });
866
+ };
867
+ return entry.execute(context);
815
868
  }])),
816
869
 
817
870
  models: async () => {
@@ -897,7 +950,7 @@ const commands: Record<string, () => Promise<void> | void> = {
897
950
 
898
951
  watch: async () => {
899
952
  const peer = args[0];
900
- if (typeof peer !== "string" || !definitionOf(peer)?.capabilities?.watch) fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
953
+ if (typeof peer !== "string" || !peerDefinition(peer)?.capabilities?.watch) fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
901
954
  if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
902
955
  const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
903
956
  if (color instanceof Error) fail(color.message);
@@ -1202,9 +1255,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1202
1255
  roster: async () => {
1203
1256
  const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
1204
1257
  const [sub = "list", peer, ...extra] = positional;
1205
- if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !definitionOf(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]");
1258
+ 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]");
1206
1259
  const hub = await connect();
1207
1260
  try {
1261
+ requireNamedPeerFeature(hub, peer);
1208
1262
  const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused")); return reply; };
1209
1263
  if (sub === "list") {
1210
1264
  const view = await request({ t: "settings_get" });
@@ -1565,7 +1619,22 @@ const commands: Record<string, () => Promise<void> | void> = {
1565
1619
  if (!turn.ended) fail(`turn ${id} is still running`);
1566
1620
  const { start_tree: startTree, end_tree: endTree } = turn;
1567
1621
  if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
1568
- const repo = repoOf(cwd) ?? fail(`${cwd} is not in a git work tree`);
1622
+ const recordedWorkspace = turnRecords(t => t.workspace(id), undefined);
1623
+ const undoWorkspace = (): string => {
1624
+ // Old turns, including external peers and ids declared only later, belong to the canonical checkout.
1625
+ if (recordedWorkspace === undefined) return cwd;
1626
+ const declarations = readInstanceConfig(cwd).instances;
1627
+ if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
1628
+ const config = projectConfig();
1629
+ const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
1630
+ if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
1631
+ return current;
1632
+ };
1633
+ const undoCwd = undoWorkspace();
1634
+ const requireUndoWorkspace = () => {
1635
+ if (realPath(undoWorkspace()) !== realPath(undoCwd)) fail(`turn ${id}'s execution workspace changed; nothing was restored`);
1636
+ };
1637
+ const repo = repoOf(undoCwd) ?? fail(`${undoCwd} is not in a git work tree`);
1569
1638
  if (!hasTree(repo, startTree) || !hasTree(repo, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
1570
1639
  // Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
1571
1640
  const undoConfig = projectConfig();
@@ -1587,11 +1656,12 @@ const commands: Record<string, () => Promise<void> | void> = {
1587
1656
  if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
1588
1657
  console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
1589
1658
  console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
1590
- const projections = new TextProjections({ cwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
1591
- const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(cwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
1659
+ const projections = new TextProjections({ cwd: undoCwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
1660
+ const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(undoCwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
1592
1661
  if (preview) console.log(terminalText("Undo preview (end snapshot to start snapshot):\n" + preview));
1593
1662
 
1594
1663
  if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
1664
+ requireUndoWorkspace();
1595
1665
  if (args.includes("--context")) {
1596
1666
  console.log(await taskOp("turn_revert", { turn: id }));
1597
1667
  reverted = true;
@@ -1599,6 +1669,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1599
1669
  // A peer may have written meanwhile (the revert alone can take seconds): plan again right before restoring.
1600
1670
  const second = plan();
1601
1671
  if (second.restore.join("\0") !== first.restore.join("\0")) fail(`the project changed while undoing; nothing was restored, run ahub undo again${reverted ? " without --context (Codex's conversation was already reverted)" : ""}`);
1672
+ requireUndoWorkspace();
1602
1673
  restore(repo, startTree, second.restore);
1603
1674
  if (second.restore.length) console.log(`restored to their state before ${id}`);
1604
1675
  },
@@ -1706,9 +1777,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1706
1777
  }
1707
1778
  if (["assign", "clear"].includes(args[0] ?? "")) {
1708
1779
  const [op, peer, id] = args;
1709
- if (!peer || !definitionOf(peer ?? "") || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
1780
+ if (!peer || !peerDefinition(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
1710
1781
  const hub = await connect();
1711
1782
  try {
1783
+ requireNamedPeerFeature(hub, peer);
1712
1784
  const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
1713
1785
  const row = (view.rows as SettingRow[]).find(row => row.key === `catalog_roles.${peer}`); if (!row) fail("this hub does not support catalog role settings");
1714
1786
  const current = Array.isArray(row!.pending) ? row!.pending : Array.isArray(row!.value) ? row!.value : [];
@@ -1825,8 +1897,9 @@ async function runConductorCommand(): Promise<void> {
1825
1897
  if (parsed instanceof Error) fail(parsed.message);
1826
1898
  op = "hub_peer_start";
1827
1899
  // #269: a mode may only repeat the peer's start mode, which a person sets; without one the hub takes that mode.
1828
- input = { peer: cmd, ...parsed };
1900
+ input = { peer: selectedInstance ?? cmd, ...parsed };
1829
1901
  }
1902
+ if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
1830
1903
  console.log(await taskOp(op, input));
1831
1904
  }
1832
1905
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
@@ -2,7 +2,7 @@ import { definitionOf, builtinPreviewOf } from "../adapters/registry.ts";
2
2
  import { loadConfig } from "../hub/daemon.ts";
3
3
  import { childEnv } from "../hub/child-process.ts";
4
4
  import { startModeOf } from "../hub/start-mode.ts";
5
- import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
5
+ import { instanceLaunchArguments, buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
6
6
  import { kimiMcpPlan } from "./kimi-mcp.ts";
7
7
  import { jcodeCommand } from "../adapters/jcode.ts";
8
8
  import { qwenSessionId } from "../hub/crash.ts";
@@ -10,14 +10,25 @@ import { relayModelIds } from "../models/relay.ts";
10
10
  import { buildPiLaunch } from "../pi/launch.ts";
11
11
 
12
12
  /** Read-only builtin preview. Native redaction uses the same launch builders as actual startup. */
13
- export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
14
- const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
13
+ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean, requestedInstance?: string) {
15
14
  const config = loadConfig(cwd);
15
+ const selected = instanceLaunchArguments(tool, requestedInstance ? ["--as", requestedInstance, ...raw] : raw, config.instances);
16
+ const args = selected.args.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
16
17
  const definition = definitionOf(tool), preview = builtinPreviewOf(tool);
17
18
  if (!definition || !preview) throw new Error("native preview definition unavailable");
18
- return preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
19
+ if (selected.instance && definition.start.explicitHeadlessMode) {
20
+ const mode = args.indexOf("--mode");
21
+ if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
22
+ if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
23
+ }
24
+ const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
19
25
  baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
20
- helpers: { buildLaunch, claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
26
+ helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
21
27
  startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
22
28
  });
29
+ if (!selected.instance) return result;
30
+ return { ...result, instance: selected.instance,
31
+ ...(result.mcp ? { mcp: { ...result.mcp, action: "session-scoped MCP identity; shared project file unchanged", changed: false } } : {}),
32
+ unresolved: [...result.unresolved, { field: "executionCwd", reason: "verified by the daemon at instance start; preview does not start or authorize a worktree" }],
33
+ };
23
34
  }
@@ -1,11 +1,12 @@
1
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
1
+ import { ADAPTERS, definitionOf, type AdapterDefinition, type KindMap } from "../adapters/registry.ts";
2
+ import { readInstanceConfig } from "../hub/instance-config.ts";
2
3
  import { recordedFileState } from "./recovery-session-file.ts";
3
4
  import { decodeRestoreReceipt } from "./upgrade-restore-state.ts";
4
5
  import { shellQuote } from "./terminal-recovery.ts";
5
6
  import type { TerminalBinding } from "./terminal-recovery.ts";
6
7
  import type { Inspection, PlannedProject, ProjectProgress, RecoveryOperation, RecoveryPeer } from "./upgrade.ts";
7
8
 
8
- export type RecoveryNativePeer = "codex" | "pi" | "claude";
9
+ export type RecoveryNativePeer = string;
9
10
  export type RecordedSessionOverride = Pick<TerminalBinding, "sessionId" | "sessionFile">;
10
11
  export interface RecordedPeerGuidance {
11
12
  peer: RecoveryNativePeer;
@@ -26,30 +27,35 @@ export interface RecoveryGuidanceOptions {
26
27
 
27
28
  const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
28
29
  const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
29
- const peers = (): RecoveryNativePeer[] => ADAPTERS.filter(adapter => adapter.session.launchRecord)
30
- .sort((a, b) => Number(a.transport === "claude-channel") - Number(b.transport === "claude-channel"))
31
- .map(adapter => adapter.kind as RecoveryNativePeer);
30
+ const projectDefinition = (peer: string, root: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(root).instances);
31
+ const peers = (instances: KindMap | undefined): RecoveryNativePeer[] => [...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances ?? {})]
32
+ .filter(peer => definitionOf(peer, instances)?.session.launchRecord)
33
+ .sort((a, b) => Number(definitionOf(a, instances)?.transport === "claude-channel") - Number(definitionOf(b, instances)?.transport === "claude-channel"));
32
34
 
33
- function sourceSession(peer: RecoveryNativePeer, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
34
- const sessionId = source?.[definitionOf(peer)?.session.idField ?? "sessionId"];
35
- const sessionFile = definitionOf(peer)?.session.crashIdentity ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
35
+ function sourceSession(adapter: AdapterDefinition, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
36
+ const sessionId = source?.[adapter.session.idField];
37
+ const sessionFile = adapter.session.crashIdentity ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
36
38
  return { ...(text(sessionId) ? { sessionId } : {}), ...(text(sessionFile) ? { sessionFile } : {}) };
37
39
  }
38
40
 
39
41
  /** Fixed person commands for the existing Pi archive exit; this helper performs no lifecycle action. */
40
- export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string): string {
41
- return `Run ${stopAndArchiveCommand}; after stop-and-archive, run ${argv("ahub", "--project", planned.project.root, "pi")}. A proven missing archived bookmark is retired by the normal Pi start without a fresh fallback in that same call; repeat the normal start as its notice directs.`;
42
+ export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string, peer?: RecoveryNativePeer): string {
43
+ const adapter = peer ? projectDefinition(peer, planned.project.root) : undefined;
44
+ if (peer && (!adapter?.session.launchRecord || adapter.session.personFresh !== false)) throw new Error("recorded archive-exit peer has no current trusted Pi recovery declaration");
45
+ const instance = peer && adapter && peer !== adapter.kind ? ["--as", peer] : [];
46
+ return `Run ${stopAndArchiveCommand}; after stop-and-archive, run ${argv("ahub", "--project", planned.project.root, "pi", ...instance)}. A proven missing archived bookmark is retired by the normal Pi start without a fresh fallback in that same call; repeat the normal start as its notice directs.`;
42
47
  }
43
48
 
44
49
  /** Accepted identity outlives an incarnation's repair fence. Malformed evidence never means absence. */
45
- export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer): TerminalBinding | undefined {
50
+ export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer, options: { authority?: "current" | "historical" } = {}): TerminalBinding | undefined {
46
51
  if (progress.id !== planned.project.id) throw new Error("recorded binding project mismatch");
52
+ if (options.authority !== "historical" && !projectDefinition(peer, planned.project.root)?.session.launchRecord) throw new Error("recorded binding peer has no current trusted recovery declaration");
47
53
  const binding = (value: unknown): TerminalBinding => {
48
- const receipt = decodeRestoreReceipt({ state: "done", binding: value });
54
+ const receipt = decodeRestoreReceipt({ state: "done", binding: value }, options);
49
55
  if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== planned.project.root) throw new Error("recorded binding does not match its peer and project");
50
56
  return receipt.binding;
51
57
  };
52
- const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
58
+ const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`], options);
53
59
  if (restored?.state === "done" || restored?.state === "waiting") return binding(restored.binding);
54
60
  if (progress.reattachments !== undefined) {
55
61
  if (!progress.reattachments || typeof progress.reattachments !== "object" || Array.isArray(progress.reattachments)) throw new Error("invalid recorded reattachment markers");
@@ -73,7 +79,18 @@ export function resolveAcceptedBinding(planned: PlannedProject, progress: Projec
73
79
  * current receipts and the original plan. A malformed current receipt otherwise stays unavailable.
74
80
  */
75
81
  export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectProgress, options: RecoveryGuidanceOptions = {}): RecordedPeerGuidance[] {
76
- return peers().map(peer => {
82
+ let instances: KindMap | undefined;
83
+ try { instances = readInstanceConfig(planned.project.root).instances; }
84
+ catch {
85
+ // ponytail: failed-config guidance shows at most 64 recorded extra ids, the declaration ceiling;
86
+ // share an exported instance limit if that ceiling grows. These rows confer no launch authority.
87
+ const base = peers(undefined), recorded = [...planned.terminals.map(value => (value as { peer?: unknown })?.peer),
88
+ ...Object.keys(progress.reattachments ?? {}), ...Object.keys(progress.terminals).filter(key => key.startsWith("restored:")).map(key => key.slice("restored:".length))];
89
+ const extra = [...new Set(recorded.filter((peer): peer is string => typeof peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(peer) && !base.includes(peer)))].slice(0, 64);
90
+ return [...base, ...extra].map(peer => ({ peer, note: `${peer}: recorded session identity unavailable; project instance configuration could not be read; inspect the retained recovery receipt before choosing a conversation` }));
91
+ }
92
+ return peers(instances).map(peer => {
93
+ const adapter = definitionOf(peer, instances)!;
77
94
  let session: { sessionId?: string; sessionFile?: string } | undefined;
78
95
  let unavailable = false;
79
96
  const override = options.recorded && Object.hasOwn(options.recorded, peer) ? options.recorded[peer] : undefined;
@@ -81,25 +98,25 @@ export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectP
81
98
  else {
82
99
  try {
83
100
  session = resolveAcceptedBinding(planned, progress, peer)
84
- ?? sourceSession(peer, planned.source.peers.find(row => row.id === peer));
101
+ ?? sourceSession(adapter, planned.source.peers.find(row => row.id === peer));
85
102
  } catch { unavailable = true; }
86
103
  }
87
104
  const sessionId = text(session?.sessionId) ? session.sessionId : undefined;
88
- const sessionFile = definitionOf(peer)?.session.crashIdentity && text(session?.sessionFile) ? session.sessionFile : undefined;
105
+ const sessionFile = adapter.session.crashIdentity && text(session?.sessionFile) ? session.sessionFile : undefined;
89
106
  if (unavailable || (!sessionId && !sessionFile)) return { peer, note: `${peer}: recorded session identity unavailable; inspect the retained recovery receipt before choosing a conversation` };
90
107
  const prefix = ["ahub", "--project", planned.project.root];
91
- const adapter = definitionOf(peer)!;
92
108
  const tail = adapter.session.resumeArgv?.({ sessionId: sessionId ?? "", ...(sessionFile ? { sessionFile } : {}) }, false);
93
109
  const start = adapter.cli.startWords("tui");
94
110
  // Person guidance preserves its legacy implied startup form; terminal creation pins the explicit form.
95
111
  const personTail = tail && start.every((word, index) => tail[index] === word) ? [tail[0]!, ...tail.slice(start.length)] : tail;
96
- const command = personTail ? argv(...prefix, ...personTail) : undefined;
97
- let note = definitionOf(peer)?.transport === "claude-channel"
112
+ const instanceTail = personTail && peer !== adapter.kind ? [personTail[0]!, "--as", peer, ...personTail.slice(1)] : personTail;
113
+ const command = instanceTail ? argv(...prefix, ...instanceTail) : undefined;
114
+ let note = adapter.transport === "claude-channel"
98
115
  ? "The existing Claude channel may reconnect while its native session remains open. Transcript availability is unknown; the recorded resume command is guidance, not proof that the transcript can be resumed."
99
- : definitionOf(peer)?.session.idField === "threadId" ? `Recorded ${definitionOf(peer)!.displayName} thread; leave the old native client before resuming this conversation.`
100
- : `Recorded ${definitionOf(peer)!.displayName} conversation; preserve this session when starting ${definitionOf(peer)!.displayName} again.`;
116
+ : adapter.session.idField === "threadId" ? `Recorded ${adapter.displayName} thread; leave the old native client before resuming this conversation.`
117
+ : `Recorded ${adapter.displayName} conversation; preserve this session when starting ${adapter.displayName} again.`;
101
118
  if (sessionFile && options.stopAndArchiveCommand && recordedFileState(sessionFile) === "missing") {
102
- note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand)}`;
119
+ note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand, peer)}`;
103
120
  }
104
121
  const reattachCommand = command ? options.reattachCommand?.(peer) : undefined;
105
122
  return { peer, ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}), ...(command ? { command } : {}), ...(reattachCommand ? { reattachCommand } : {}), note };