@staix/agent-hub 0.12.34 → 0.12.36

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 (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. package/templates/AGENTS.block.md +2 -1
@@ -1,8 +1,10 @@
1
1
  import { resolve } from "node:path";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
4
+ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
4
5
 
5
6
  export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
7
+ const peer = ctx.instance ?? "kimi";
6
8
  const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, connect, fail, takeFlags } = ctx;
7
9
  const { launcherPreview, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
8
10
  if (args.includes("--print-command") || args.includes("--dry-run")) {
@@ -10,13 +12,14 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
10
12
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
11
13
  }
12
14
  const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
13
- const mode = one["--mode"] ?? startModeOf(projectConfig().peers, "kimi"), model = one["--model"];
15
+ const mode = one["--mode"] ?? (ctx.instance ? "headless" : startModeOf(projectConfig().peers, "kimi")), model = one["--model"];
16
+ if (ctx.instance && mode !== "headless") fail("extra Kimi instances require headless mode");
14
17
  if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
15
18
  if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
16
19
  if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
17
20
  const hub = await connect();
18
21
  try {
19
- const reply = await hub.request({ t: "peer_start", peer: "kimi" }, 35_000);
22
+ const reply = await hub.request({ t: "peer_start", peer }, 35_000);
20
23
  if (!reply.ok) fail(reply.error);
21
24
  return console.log(reply.already ? "kimi is already attached" : `kimi: opened a terminal running ${reply.command}; it attaches pull-only when its TUI is ready`);
22
25
  } finally { hub.close(); }
@@ -26,14 +29,17 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
26
29
  if (mode === "tui") {
27
30
  const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
28
31
  const snapshot = (await hub.request({ t: "status" })).status;
29
- if (snapshot?.roster?.find((row: any) => row.peer === "kimi")?.on === false) fail("kimi is off roster; add it with ahub roster add kimi");
30
- const peer = snapshot?.peers?.kimi;
31
- if (peer && !["offline"].includes(peer.state) && !peer.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
32
- const admission = await hub.request({ t: "start", peer: "kimi", args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
32
+ const rostered = rosterInStatus(snapshot, peer);
33
+ if (rostered === undefined) fail("cannot read kimi's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
34
+ if (!rostered) fail("kimi is off roster; add it with ahub roster add kimi");
35
+ const owner = snapshot?.peers?.[peer];
36
+ if (owner && !["offline"].includes(owner.state) && !owner.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
37
+ const admission = await hub.request({ t: "start", peer, args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
33
38
  if (!admission.ok) fail(admission.error);
34
39
  let result: ReturnType<typeof spawnSync>;
35
40
  try {
36
- const written = configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
41
+ // The daemon's own bundle, so the MCP server matches the hub it talks to (#502); this CLI's copy only for an older hub.
42
+ const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
37
43
  if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
38
44
  result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
39
45
  } finally {
@@ -42,11 +48,11 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
42
48
  if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
43
49
  process.exit(result.status ?? 1);
44
50
  }
45
- const peer = (await hub.request({ t: "status" })).status?.peers?.kimi;
46
- if (peer?.pullOnly && peer.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
47
- configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
48
- const res = await hub.request({ t: "start", peer: "kimi", args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
51
+ const owner = (await hub.request({ t: "status" })).status?.peers?.[peer];
52
+ if (owner?.pullOnly && owner.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
53
+ if (!ctx.instance) configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
54
+ const res = await hub.request({ t: "start", peer, args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
49
55
  if (!res.ok) fail(res.error);
50
- console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
56
+ console.log(res.already ? `${peer} is already attached` : `${peer} attached (headless). Talk to it with: ahub say @${peer} "..."`);
51
57
  } finally { hub.close(); }
52
58
  }
@@ -1,14 +1,15 @@
1
1
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
2
2
 
3
3
  export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
4
+ const peer = ctx.instance ?? "local";
4
5
  const { args, connect, fail } = ctx;
5
6
  const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
6
7
  for (const flag of ["--route", "--model"]) if (args.includes(flag) && (!opt(flag) || opt(flag)!.startsWith("--"))) fail(`${flag} needs a value`);
7
8
  if (args.includes("--route") && args.includes("--model")) fail("use --route or --model, not both");
8
9
  if (args.some((a, i) => i % 2 === 0 && !["--route", "--model"].includes(a))) fail("usage: ahub local [--route <id> | --model <id>]");
9
10
  const hub = await connect();
10
- const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
11
+ const res = await hub.request({ t: "start", peer, args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
11
12
  hub.close();
12
13
  if (!res.ok) fail(res.error);
13
- console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
14
+ console.log(res.already ? `${peer} is already attached` : `${peer} attached on ${res.model}. Give it work with: ahub say @${peer} "..."`);
14
15
  }
@@ -2,6 +2,7 @@ import { parsePiFlags } from "./native-cli-pi.ts";
2
2
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
3
3
 
4
4
  export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
5
+ const peer = ctx.instance ?? "pi";
5
6
  const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, execWithEnv } = ctx;
6
7
  const { launcherPreview, startModeOf, recoveryLock, readControl, recordTerminalLaunch } = ctx.helpers;
7
8
  // --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
@@ -12,7 +13,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
12
13
  }
13
14
  const flags = parsePiFlags(args, fail);
14
15
  // #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
15
- const options = { ...flags, mode: flags.mode ?? startModeOf(projectConfig().peers, "pi") };
16
+ const options = { ...flags, mode: flags.mode ?? (ctx.instance ? "headless" : startModeOf(projectConfig().peers, "pi")) };
17
+ if (ctx.instance && options.mode !== "headless") fail("extra Pi instances require headless mode");
16
18
  if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
17
19
  // The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
18
20
  // opens one, or says how to. It is never downgraded to headless. The fixed command carries no option, so one
@@ -20,7 +22,7 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
20
22
  if (Object.keys(flags).length) fail("Pi's TUI needs a terminal, and these options are not passed to one the hub opens: run this in a terminal, or add --mode headless");
21
23
  const hub = await connect();
22
24
  try {
23
- const reply = await hub.request({ t: "peer_start", peer: "pi" }, 35_000);
25
+ const reply = await hub.request({ t: "peer_start", peer }, 35_000);
24
26
  if (reply.ok !== true) {
25
27
  const error = String(reply.error ?? "pi start refused");
26
28
  fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_start"/i.test(error) ? `${error}; upgrade the running hub, or run ahub pi --mode headless` : error);
@@ -32,9 +34,9 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
32
34
  // trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
33
35
  const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
34
36
  const before = readControl(stateDir);
35
- if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
37
+ if (recovering && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
36
38
  const hub = await connect();
37
- const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
39
+ const res = await hub.request({ t: "start", peer, args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
38
40
  hub.close();
39
41
  if (!res.ok) fail(res.error);
40
42
  if (options.mode === "tui") {
@@ -44,8 +46,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
44
46
  delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
45
47
  delete launchEnv.AGENTHUB_UNATTENDED;
46
48
  const control = readControl(stateDir);
47
- if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
49
+ if (!recovering && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId);
48
50
  return execWithEnv(launch.cmd, launch.args, launchEnv);
49
51
  }
50
- console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
52
+ console.log(res.already ? `${peer} is already attached` : `${peer} attached (headless). Talk to it with: ahub say @${peer} "..."`);
51
53
  }
@@ -1,6 +1,7 @@
1
1
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
2
2
 
3
3
  export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
4
+ const peer = ctx.instance ?? "qwen";
4
5
  const { args, cwd, stateDir, unattendedEnv, connect, fail, takeFlags } = ctx;
5
6
  const { launcherPreview } = ctx.helpers;
6
7
  if (args.includes("--print-command") || args.includes("--dry-run")) {
@@ -13,8 +14,8 @@ export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
13
14
  if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
14
15
  const hub = await connect();
15
16
  try {
16
- const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
17
+ const reply = await hub.request({ t: "start", peer, args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
17
18
  if (!reply.ok) fail(reply.error);
18
- console.log(reply.already ? "qwen is already attached" : "qwen attached (headless); use ahub say @qwen and the console for approvals");
19
+ console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); use ahub say @${peer} and the console for approvals`);
19
20
  } finally { hub.close(); }
20
21
  }
@@ -4,6 +4,9 @@ import type { AdapterDefinition } from "../registry-types.ts";
4
4
 
5
5
  /** Private static builtin relocation ports. The CLI binds identity, project and authority before invocation. */
6
6
  export interface NativeCliEntryContext {
7
+ instance?: string; executionCwd?: string;
8
+ /** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
9
+ hubStarted?: true;
7
10
  args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
8
11
  projectConfig(): HubConfig;
9
12
  connect(): Promise<ControlClient>;
@@ -38,6 +38,12 @@ export function createLocal(ctx: CreateContext): PeerAdapter {
38
38
  return new LocalPeer(ctx.instance, ctx.daemon.local);
39
39
  }
40
40
 
41
+ export function createClaudeReviewer(ctx: CreateContext): PeerAdapter {
42
+ if (!("claudePrint" in ctx.daemon)) throw new Error("Claude reviewer requires its print transport options");
43
+ const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
44
+ return new ClaudePrintPeer(ctx.instance, ctx.daemon.claudePrint);
45
+ }
46
+
41
47
  /** Preserve existing transport option types and object identity; selection stays host-owned. */
42
48
  export const DAEMON_PORTS = {
43
49
  "codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
@@ -45,4 +51,5 @@ export const DAEMON_PORTS = {
45
51
  "owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
46
52
  "pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
47
53
  "local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
54
+ "claude-print": (options: Extract<DaemonPorts, { claudePrint: unknown }>["claudePrint"]) => ({ claudePrint: options }),
48
55
  } satisfies Record<DaemonEngine, unknown>;
@@ -33,10 +33,10 @@ export const CODEX_RECOVERY_SESSION: SessionPolicy = {
33
33
  if (phase !== "plan") return false;
34
34
  let current: string | undefined, started = false, turned = false;
35
35
  for (const e of readEvents(join(stateDir, "events.jsonl"))) {
36
- if (e.type === "native_thread" && e.peer === "codex") {
36
+ if (e.type === "native_thread" && e.peer === binding.peer) {
37
37
  current = e.thread;
38
38
  if (e.thread === binding.sessionId && e.fresh) started = true;
39
- } else if (e.type === "turn_start" && e.peer === "codex" && current === binding.sessionId) turned = true;
39
+ } else if (e.type === "turn_start" && e.peer === binding.peer && current === binding.sessionId) turned = true;
40
40
  }
41
41
  return started && !turned;
42
42
  },
@@ -7,9 +7,9 @@ export class QwenIdentityError extends Error {
7
7
  constructor() { super("Qwen Code 0.25.0 native identity is not verified; check the executable and its advertised ACP version"); }
8
8
  }
9
9
 
10
- export function qwenNativeIdentity(instance: string, production: boolean | undefined, init: unknown): boolean {
10
+ export function qwenNativeIdentity(instance: string, production: boolean | undefined, init: unknown, kind?: string): boolean {
11
11
  const native = init as { agentInfo?: { name?: unknown; version?: unknown } } | null | undefined;
12
- const verified = production === true && instance === "qwen" && native?.agentInfo?.name === "qwen-code" && native?.agentInfo?.version === "0.25.0";
12
+ const verified = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code" && native?.agentInfo?.version === "0.25.0";
13
13
  if (production && !verified) throw new QwenIdentityError();
14
14
  return verified;
15
15
  }
@@ -18,6 +18,7 @@ import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
18
18
  export interface LocalOptions {
19
19
  profile?: import("../hub/profiles.ts").ProfileSelection;
20
20
  sdlcStages?: readonly string[];
21
+ sdlcCriteria?: boolean;
21
22
  cwd: string;
22
23
  omni: OmniRoute;
23
24
  /** Absent = always the fixed model on OmniRoute. */
@@ -108,7 +109,7 @@ export class LocalPeer extends BasePeer {
108
109
  await this.requireBudget(envs, "model_calls");
109
110
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
110
111
  if (signal.aborted) throw new Error("turn cancelled before model request");
111
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
112
+ const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
112
113
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
113
114
  this.recordUsage(result, model, task);
114
115
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -378,7 +379,7 @@ export class LocalPeer extends BasePeer {
378
379
  const route = policy?.route ?? this.opts.route;
379
380
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
380
381
  const task = policy?.task === undefined ? undefined : Number(policy.task);
381
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
382
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
382
383
  const signal = this.abort!.signal;
383
384
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
384
385
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -33,6 +33,8 @@ class PiPromptNotStarted extends Error {
33
33
  }
34
34
  class PiPromptUncertain extends Error {}
35
35
  export interface PiOptions {
36
+ /** stateDir owns this native instance; hubStateDir retains the canonical daemon binding. */
37
+ hubStateDir?: string;
36
38
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
37
39
  model?: string;
38
40
  relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
@@ -385,7 +387,7 @@ export class PiPeer extends BasePeer {
385
387
  const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
386
388
  const inherited: NodeJS.ProcessEnv = {};
387
389
  for (const key of ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"]) if (process.env[key]) inherited[key] = process.env[key];
388
- this._tuiLaunch = buildPiLaunch(this.opts, inherited, extension, bridge.port!, token);
390
+ this._tuiLaunch = buildPiLaunch({ ...this.opts, instance: this.id }, inherited, extension, bridge.port!, token);
389
391
  const { cmd: bin, args, env } = this._tuiLaunch;
390
392
  if (this.opts.mode === "tui") { return; }
391
393
  // Its own process group, stopped as a whole (#115, as Codex's in #113).
@@ -13,9 +13,10 @@ import type { CodexOptions } from "./codex-appserver.ts";
13
13
  import type { JcodeOptions } from "./jcode.ts";
14
14
  import type { LocalOptions } from "./local-worker.ts";
15
15
  import type { PiOptions } from "./pi.ts";
16
+ import type { ClaudePrintOptions } from "./claude-print.ts";
16
17
 
17
18
  export type StartForm = "tui" | "headless";
18
- export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker";
19
+ export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print";
19
20
 
20
21
  /** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
21
22
  export interface LaunchContext {
@@ -53,7 +54,8 @@ export type DaemonPorts =
53
54
  | { acp: AcpOptions }
54
55
  | { jcode: JcodeOptions }
55
56
  | { pi: PiOptions }
56
- | { local: LocalOptions };
57
+ | { local: LocalOptions }
58
+ | { claudePrint: ClaudePrintOptions };
57
59
 
58
60
  export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
59
61
  instance: string;
@@ -223,6 +225,8 @@ export interface AdapterDefinition {
223
225
  reporting?: { tokensNotReported?: boolean };
224
226
  /** Legacy native routing policy; external definitions cannot claim these host-specific policies. */
225
227
  routing?: {
228
+ /** Takes reviews only: never chosen or nominated as a task owner, for any class. */
229
+ reviewOnly?: true;
226
230
  localAllowedRefusal?: string;
227
231
  longContext?: "backend-limit" | "skip-owner";
228
232
  preferredTier?: boolean;
@@ -258,4 +262,9 @@ export interface AdapterDefinition {
258
262
  };
259
263
  display: { palette: { fg: string; bg: string; tone?: string; ansi?: string } };
260
264
  hooks?: Partial<NativeHooks>;
265
+ /** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
266
+ reviewerVariant?: AdapterDefinition;
261
267
  }
268
+
269
+ /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
270
+ export type KindMap = Readonly<Record<string, { readonly kind: string }>>;
@@ -4,7 +4,8 @@ import { DAEMON_PORTS } from "./definitions/native-factories.ts";
4
4
  export { DAEMON_PORTS };
5
5
  import { NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN } from "./definitions/builtin-metadata.ts";
6
6
  import { QWEN_DEFINITION } from "./definitions/qwen.ts";
7
- import type { AdapterDefinition } from "./registry-types.ts";
7
+ import type { AdapterDefinition, KindMap } from "./registry-types.ts";
8
+ import { types } from "node:util";
8
9
 
9
10
  export type * from "./registry-types.ts";
10
11
  export const ADAPTER_API = 1;
@@ -25,6 +26,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
25
26
  }
26
27
 
27
28
  const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION]);
29
+ for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
28
30
 
29
31
  /** Keep the native catalog's order, independently of the legacy default-role object order. */
30
32
  export const ADAPTERS: readonly AdapterDefinition[] = immutable([
@@ -36,6 +38,7 @@ const definitions = new Map<string, AdapterDefinition>();
36
38
  for (const definition of ADAPTERS) {
37
39
  assertDaemonEngine(definition);
38
40
  assertNativeCapabilityScope(definition);
41
+ if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
39
42
  if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
40
43
  definitions.set(definition.kind, definition);
41
44
  }
@@ -45,15 +48,39 @@ export const QUOTA_HANDOFF_PLATFORMS: Readonly<Record<string, string>> = Object.
45
48
  ADAPTERS.flatMap(adapter => adapter.hooks?.recallPlatform ? [[adapter.kind, adapter.hooks.recallPlatform]] : []),
46
49
  ));
47
50
 
48
- /** A1 registers kinds only. Undeclared suffixes never acquire a native kind or admission. */
49
- export function kindOf(id: string): string | undefined {
50
- return definitions.has(id) ? id : undefined;
51
+ /** Own enumerable data field of a plain object, never a caller accessor or an inherited value. */
52
+ function ownField(value: unknown, key: string): unknown {
53
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
54
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return undefined;
55
+ const field = Object.getOwnPropertyDescriptor(value, key);
56
+ return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
51
57
  }
52
- export function definitionOf(id: string): AdapterDefinition | undefined {
53
- return definitions.get(id);
58
+ /** Declaration reads never execute caller accessors or inherit another project's mapping. */
59
+ function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
60
+ if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
61
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(instances))) return undefined;
62
+ const field = Object.getOwnPropertyDescriptor(instances, id);
63
+ if (!field || !Object.hasOwn(field, "value") || !field.enumerable) return undefined;
64
+ const entry: unknown = field.value;
65
+ if (!entry || typeof entry !== "object" || types.isProxy(entry) || Array.isArray(entry)
66
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return undefined;
67
+ const kind = Object.getOwnPropertyDescriptor(entry, "kind");
68
+ if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !definitions.has(kind.value)) return undefined;
69
+ const prefix = `${kind.value}-`;
70
+ return /^[a-z][a-z0-9-]{0,31}$/.test(id) && id.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)) ? kind.value : undefined;
54
71
  }
55
- export function familyOf(id: string): string | undefined {
56
- return definitionOf(id)?.family;
72
+ /** Legacy ids retain their identity. Extra instances resolve only through the explicit project map. */
73
+ export function kindOf(id: string, instances?: KindMap): string | undefined {
74
+ return definitions.has(id) ? id : typeof id === "string" ? declaredKind(id, instances) : undefined;
75
+ }
76
+ export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
77
+ const kind = kindOf(id, instances);
78
+ const definition = kind === undefined ? undefined : definitions.get(kind);
79
+ // #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
80
+ return definition?.reviewerVariant && kind !== id && ownField(ownField(instances, id), "start_mode") === "headless" ? definition.reviewerVariant : definition;
81
+ }
82
+ export function familyOf(id: string, instances?: KindMap): string | undefined {
83
+ return definitionOf(id, instances)?.family;
57
84
  }
58
85
 
59
86
  /** Compatibility projection order is observable in default role maps and planner selection. */
@@ -97,6 +124,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
97
124
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
98
125
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
99
126
  ["routing", definition.routing !== undefined],
127
+ ["reviewerVariant", definition.reviewerVariant !== undefined],
100
128
  ];
101
129
  const field = restricted.find(([, present]) => present)?.[0];
102
130
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
package/src/cli/help.ts CHANGED
@@ -23,6 +23,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
23
23
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
24
24
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
25
25
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
26
+ ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
27
+ ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
28
+ ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
29
+ ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
26
30
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
27
31
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
28
32
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
@@ -84,6 +88,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
84
88
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
85
89
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
86
90
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
91
+ ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
87
92
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
88
93
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
89
94
  ["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
@@ -1,5 +1,5 @@
1
1
  import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
2
- import { definitionOf } from "../adapters/registry.ts";
2
+ import { definitionOf, kindOf, type KindMap } from "../adapters/registry.ts";
3
3
 
4
4
  /** Recovery reads this declaration before delegating to another package's CLI. */
5
5
  export const LIVE_TEST_SUPPORTED = true;
@@ -13,13 +13,14 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
13
13
 
14
14
  /** Person verdicts and user-authored workflow content are never delegated. */
15
15
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
16
+ if (command === "sdlc" && args[0] === "plan-review") return true;
16
17
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
17
18
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
18
19
  return ["task", "role", "goal"].includes(command)
19
20
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
20
21
  || (command === "memory" && sub !== "graph")
21
22
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
22
- || (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
23
+ || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept"))
23
24
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
24
25
  }
25
26
 
@@ -29,7 +30,7 @@ export type CliIdentity =
29
30
  | { role: "invalid"; reason: string };
30
31
 
31
32
  /** Honest defaults for native shells, not authentication against an agent that can edit its environment. */
32
- export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>): CliIdentity {
33
+ export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>, instances?: KindMap): CliIdentity {
33
34
  const hub = env.AGENTHUB_PEER_ID;
34
35
  const claude = env.CLAUDECODE;
35
36
  const session = env.CLAUDE_CODE_SESSION_ID;
@@ -42,7 +43,7 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
42
43
  if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
43
44
  if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
44
45
  const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
45
- if (hub !== undefined && native !== undefined && hub !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
46
+ if (hub !== undefined && native !== undefined && (kindOf(hub, instances) ?? hub) !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
46
47
  const peer = hub ?? native;
47
48
  return peer === undefined ? { role: "console" } : { role: "tools", peer };
48
49
  }
package/src/cli/launch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
+ import { claudeStateFile } from "../hub/claude-state.ts";
2
3
  import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
3
4
  import { spawn } from "node:child_process";
4
5
  import { randomUUID, createHash } from "node:crypto";
@@ -7,15 +8,50 @@ import { processLiveness, processSignature } from "../pi/process-signature.ts";
7
8
  import { realPath } from "../hub/project.ts";
8
9
  import { peerChildEnv, processTable } from "../hub/child-process.ts";
9
10
  import { definitionOf } from "../adapters/registry.ts";
11
+ import type { InstancesConfig } from "../hub/instances.ts";
10
12
  import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
11
13
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
12
14
 
15
+ /** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
16
+ export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[]; hubStarted?: true } {
17
+ if (!definitionOf(command)) return { args: [...raw] };
18
+ const args: string[] = [];
19
+ let instance: string | undefined, hubStarted = false;
20
+ for (let index = 0; index < raw.length; index++) {
21
+ const arg = raw[index]!;
22
+ if (arg === "--") { args.push(...raw.slice(index)); break; }
23
+ if (arg !== "--as" && !arg.startsWith("--as=")) { args.push(arg); continue; }
24
+ if (instance !== undefined) throw new Error("--as may appear only once");
25
+ const id = arg === "--as" ? raw[++index] : arg.slice("--as=".length);
26
+ if (!id || id.startsWith("--")) throw new Error("--as needs a declared instance id");
27
+ const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
28
+ if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
29
+ // #223: a headless reviewer is started by the hub, never from this terminal.
30
+ hubStarted = definitionOf(id, instances) === definitionOf(command)?.reviewerVariant;
31
+ instance = id;
32
+ }
33
+ if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) throw new Error(`${instance} is a headless reviewer the hub runs per review: ahub ${command} --as ${instance} takes no launch arguments`);
34
+ return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
35
+ }
36
+
37
+ /** The running daemon bound this instance at startup; a declaration added later, or an older daemon, is not proof. */
38
+ export function requireActiveInstance(status: unknown, instance: string): void {
39
+ const active = (status as { activeInstances?: unknown } | undefined)?.activeInstances;
40
+ if (!Array.isArray(active)) throw new Error("running hub does not report its declared instances; upgrade it before starting an extra instance");
41
+ if (!active.includes(instance)) throw new Error(`running hub has not loaded the declaration of ${instance}; restart the hub (ahub restart) after declaring it`);
42
+ }
43
+
44
+ /** Only an authenticated daemon hello can authorize extra-instance effects. */
45
+ export function requirePeerInstances(features: unknown): void {
46
+ if (!Array.isArray(features) || !features.includes("peer_instances")) throw new Error("running hub lacks peer_instances; upgrade it before starting an extra instance");
47
+ }
48
+
13
49
  /** 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 {
50
+ export function nativeLaunchEnv(tool: string, source: NodeJS.ProcessEnv, instances?: InstancesConfig): NodeJS.ProcessEnv {
15
51
  const env = peerChildEnv(tool, source);
16
52
  delete env.AGENTHUB_UNATTENDED;
17
53
  // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
18
- const definition = definitionOf(tool);
54
+ const definition = definitionOf(tool, instances);
19
55
  if (definition?.identity.mcp === "channel-plugin") env.AGENTHUB_CHANNEL = "1";
20
56
  if (definition?.attach?.pullOnlyForms?.includes("tui")) delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
21
57
  return env;
@@ -67,6 +103,8 @@ export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown,
67
103
 
68
104
  export interface ClaudeLaunchRecord extends Record<string, unknown> {
69
105
  instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
106
+ /** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
107
+ peer?: string;
70
108
  }
71
109
 
72
110
  export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
@@ -119,7 +157,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
119
157
  }
120
158
 
121
159
  function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
122
- const file = join(stateDir, "claude-launch.json"), temp = `${file}.${randomUUID()}.tmp`;
160
+ const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : "claude")), temp = `${file}.${randomUUID()}.tmp`;
123
161
  try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
124
162
  finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
125
163
  }
@@ -162,7 +200,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
162
200
  const publication = await withClaudeLaunchRecord(stateDir, () => {
163
201
  let current: ClaudeLaunchRecord;
164
202
  try {
165
- const file = join(stateDir, "claude-launch.json"), stat = lstatSync(file);
203
+ const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), stat = lstatSync(file);
166
204
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
167
205
  current = JSON.parse(readFileSync(file, "utf8"));
168
206
  } catch { return; }
@@ -179,7 +217,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
179
217
  writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
180
218
  renameSync(temp, file);
181
219
  } finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
182
- try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
220
+ try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
183
221
  });
184
222
  if (publication.warning) report?.(publication.warning);
185
223
  return publication.recorded && recorded;
@@ -289,12 +327,12 @@ export function sessionSettings(tee?: StatusLineTee, facts?: FactsHook): string
289
327
  export function buildLaunch(
290
328
  tool: "claude" | "codex",
291
329
  userArgs: string[],
292
- ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
330
+ ctx: { instance?: string; unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
293
331
  ): Launch {
294
332
  const adapter = definitionOf(tool);
295
333
  if (!adapter) throw new Error("native launch definition unavailable");
296
334
  const { metadata: _metadata, ...launch } = adapter.start.launch({
297
- instance: tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
335
+ instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
298
336
  entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
299
337
  });
300
338
  return launch;