@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.
- package/CHANGELOG.md +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- 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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
const
|
|
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
|
-
|
|
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
|
|
46
|
-
if (
|
|
47
|
-
configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
|
|
48
|
-
const res = await hub.request({ t: "start", peer
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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 ===
|
|
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 ===
|
|
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);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -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 }>>;
|
package/src/adapters/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|