@staix/agent-hub 0.12.37 → 0.12.38
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 +11 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +16 -7
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +5 -3
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +2 -2
- package/docs/operations.md +21 -6
- package/docs/quickstart.md +13 -5
- package/docs/security.md +5 -1
- package/docs/smoke.md +22 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +29 -5
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +22 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1507 -405
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/codex-review-isolation.ts +30 -16
- package/src/adapters/definitions/builtin-metadata.ts +5 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +4 -3
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +29 -1
- package/src/adapters/registry.ts +15 -6
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +83 -20
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +1 -0
- package/src/cli/output.ts +5 -0
- package/src/hub/bus.ts +17 -6
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +169 -23
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/reviewer-pool.ts +12 -6
- package/src/hub/routing.ts +3 -0
- package/src/hub/tasks.ts +7 -0
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
2
|
+
import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
|
|
3
|
+
import { AGY_FACTS_HOOK } from "./hooks.ts";
|
|
4
|
+
|
|
5
|
+
const DISPLAY = { palette: { fg: "#6558a6", bg: "#f1effa" } };
|
|
6
|
+
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
|
|
8
|
+
function agyLaunch(ctx: LaunchContext): LaunchPlan {
|
|
9
|
+
const argv = ["agy", ...ctx.userArgs], command = argv.map(shellQuote).join(" ");
|
|
10
|
+
const metadata = { packageEntrypoint: ctx.entrypoint, command, argv, env: {}, kind: "agy", peer: ctx.instance } as LaunchPlan["metadata"] & { kind: "agy"; peer: string };
|
|
11
|
+
return { cmd: "agy", args: ctx.userArgs, metadata };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Antigravity is an unmanaged, pull-only peer attached from its native project plugin. */
|
|
15
|
+
export const AGY_DEFINITION: AdapterDefinition = {
|
|
16
|
+
api: 1,
|
|
17
|
+
kind: "agy",
|
|
18
|
+
displayName: "Antigravity",
|
|
19
|
+
family: "agy",
|
|
20
|
+
transport: "ws-pull",
|
|
21
|
+
roles: ["implementer", "verifier"],
|
|
22
|
+
optional: true,
|
|
23
|
+
defaultOnRoster: false,
|
|
24
|
+
trust: { onPrem: false, hubSandboxed: false },
|
|
25
|
+
start: {
|
|
26
|
+
forms: ["tui"],
|
|
27
|
+
defaultForm: "tui",
|
|
28
|
+
ownsTerminal: form => form === "tui",
|
|
29
|
+
launch: agyLaunch,
|
|
30
|
+
conductorStartable: false,
|
|
31
|
+
hubSwitches: [],
|
|
32
|
+
ownedFlags: ["--new", "--continue", "--conversation", "--resume"],
|
|
33
|
+
conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub agy") : {},
|
|
34
|
+
},
|
|
35
|
+
identity: { mcp: "channel-plugin" },
|
|
36
|
+
attach: {
|
|
37
|
+
pullOnlyForms: ["tui"],
|
|
38
|
+
recoveryLaunch: instance => ({ kind: "agy", peer: instance }),
|
|
39
|
+
pullOnlyNotice: "pull-only: messages wait for hub_inbox; permission modes and session recovery are not managed",
|
|
40
|
+
},
|
|
41
|
+
permission: {
|
|
42
|
+
modes: {},
|
|
43
|
+
unmanaged: "Antigravity selects its own permission mode in its TUI; no hub permission mode",
|
|
44
|
+
unmanagedForms: ["tui"],
|
|
45
|
+
unmanagedNote: "Antigravity approves tools in its own TUI; no hub permission mode",
|
|
46
|
+
unmanagedStatusNote: "unmanaged native permission policy",
|
|
47
|
+
},
|
|
48
|
+
session: {
|
|
49
|
+
idField: "sessionId",
|
|
50
|
+
currentId: () => undefined,
|
|
51
|
+
freshOnRestore: true,
|
|
52
|
+
recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
|
|
53
|
+
},
|
|
54
|
+
turnEnd: "native-hook",
|
|
55
|
+
cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
|
|
56
|
+
display: DISPLAY,
|
|
57
|
+
routing: { allowedRoles: ["implementer", "verifier"] },
|
|
58
|
+
reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
|
|
59
|
+
reviewerVariant: AGY_REVIEWER_VARIANT,
|
|
60
|
+
hooks: { factsHook: AGY_FACTS_HOOK },
|
|
61
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { publicArg } from "../definitions/native-cli-preview-common.ts";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
|
|
5
|
+
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
6
|
+
import { ensureAgyMcp, cleanupAgyMcp, type AgyMcpOptions } from "../../cli/agy-mcp.ts";
|
|
7
|
+
import { peerChildEnv } from "../../hub/child-process.ts";
|
|
8
|
+
|
|
9
|
+
export function agyTuiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv) {
|
|
10
|
+
return { cwd, env, stdio: "inherit" as const };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<number> {
|
|
14
|
+
const child = spawn("agy", argv, agyTuiSpawnOptions(cwd, env));
|
|
15
|
+
return new Promise((resolveExit, reject) => {
|
|
16
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
17
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
18
|
+
try { child.kill(signal); } catch { /* The native child may exit as the signal arrives. */ }
|
|
19
|
+
};
|
|
20
|
+
const onInterrupt = () => forward("SIGINT");
|
|
21
|
+
const onTerminate = () => forward("SIGTERM");
|
|
22
|
+
const onHangup = () => forward("SIGHUP");
|
|
23
|
+
const cleanup = () => {
|
|
24
|
+
process.off("SIGINT", onInterrupt);
|
|
25
|
+
process.off("SIGTERM", onTerminate);
|
|
26
|
+
process.off("SIGHUP", onHangup);
|
|
27
|
+
};
|
|
28
|
+
process.on("SIGINT", onInterrupt);
|
|
29
|
+
process.on("SIGTERM", onTerminate);
|
|
30
|
+
process.on("SIGHUP", onHangup);
|
|
31
|
+
child.once("error", error => { cleanup(); reject(error); });
|
|
32
|
+
child.once("close", code => { cleanup(); resolveExit(code ?? 1); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
37
|
+
const { args, cwd, stateDir, cliDir, connect, fail } = ctx;
|
|
38
|
+
if (ctx.instance) fail("extra Antigravity TUI instances are not verified in P1");
|
|
39
|
+
const preview = args.includes("--print-command") || args.includes("--dry-run");
|
|
40
|
+
const unattended = ctx.unattendedEnv || args.includes("--unattended");
|
|
41
|
+
const nativeArgs = args.filter(arg => !["--print-command", "--dry-run", "--unattended"].includes(arg));
|
|
42
|
+
// Headless, resume and native approval overrides are not silently passed through an unverified hub mapping.
|
|
43
|
+
if (nativeArgs.some(arg => ["--print", "-p", "--prompt", "--input-format", "--output-format", "--conversation", "--continue", "-c", "--new", "--mode", "--dangerously-skip-permissions"].some(flag => arg === flag || arg.startsWith(`${flag}=`)))) fail("agy P1 supports a fresh TUI only; headless, resume and permission flags need a verified mapping");
|
|
44
|
+
if (unattended) fail("agy P1 native permissions are unmanaged; unattended mode has no verified prompt mapping");
|
|
45
|
+
const argv = nativeArgs;
|
|
46
|
+
if (preview) { console.log(JSON.stringify({ command: "agy", args: argv.map(publicArg), mode: "tui", permission: "unmanaged", mcp: ".agents/plugins/agent-hub/", writes: false })); return; }
|
|
47
|
+
const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
|
|
48
|
+
if (version.status !== 0 || version.stdout.trim() !== "1.4.2") fail("agy P1 requires the verified Antigravity CLI 1.4.2");
|
|
49
|
+
const hub = await connect();
|
|
50
|
+
let options: AgyMcpOptions | undefined;
|
|
51
|
+
let pluginWritten = false;
|
|
52
|
+
try {
|
|
53
|
+
if (!hub.features.includes("native_hook_launch")) fail("running hub lacks the verified native hook launcher; upgrade or restart it before launching agy");
|
|
54
|
+
const status = (await hub.request({ t: "status" })).status;
|
|
55
|
+
if (rosterInStatus(status, "agy") !== true) fail("agy is off roster; add it with ahub roster add agy");
|
|
56
|
+
const admitted = await hub.request({ t: "native_hook_launch", op: "begin", peer: "agy" });
|
|
57
|
+
if (!admitted.ok || typeof admitted.launchId !== "string" || typeof admitted.instanceId !== "string") fail(admitted.error ?? "native hook launch was not admitted");
|
|
58
|
+
options = { projectRoot: cwd, stateDir, serverJs: bundleInStatus(status) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"),
|
|
59
|
+
hookScript: resolve(cliDir, "facts-hook.ts"), peer: "agy", launchId: admitted.launchId, instanceId: admitted.instanceId };
|
|
60
|
+
const written = ensureAgyMcp(options);
|
|
61
|
+
pluginWritten = written.changed;
|
|
62
|
+
if (written.tracked) console.error("The managed agy plugin is tracked; review its local machine paths before committing");
|
|
63
|
+
console.error("Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox");
|
|
64
|
+
const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
65
|
+
delete env.AGENTHUB_CHANNEL;
|
|
66
|
+
const code = await runAgyTui(argv, cwd, env);
|
|
67
|
+
if (code !== 0) process.exitCode = code;
|
|
68
|
+
} finally {
|
|
69
|
+
if (options) {
|
|
70
|
+
// Revoke launcher authority before cleanup; stale cleanup never removes a newer managed launch.
|
|
71
|
+
await hub.request({ t: "native_hook_launch", op: "end", peer: "agy", launchId: options.launchId }).catch(() => {});
|
|
72
|
+
if (pluginWritten) try { cleanupAgyMcp(options); } catch { console.error("Managed agy plugin cleanup refused: files changed or ownership could not be verified"); }
|
|
73
|
+
}
|
|
74
|
+
hub.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import type { NativeFactsInput, NativeFactsPort, NativeFactsTranscript } from "../registry-types.ts";
|
|
6
|
+
|
|
7
|
+
const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
|
|
11
|
+
/** The pinned 1.4.2 envelope has no event name: the managed command supplies it separately. */
|
|
12
|
+
export function decodeAgyHook(event: string, value: unknown): NativeFactsInput | undefined {
|
|
13
|
+
if (!(EVENTS as readonly string[]).includes(event) || !record(value) || typeof value.conversationId !== "string" || !UUID.test(value.conversationId)
|
|
14
|
+
|| typeof value.transcriptPath !== "string" || !isAbsolute(value.transcriptPath) || value.transcriptPath.length > 4096
|
|
15
|
+
|| !Array.isArray(value.workspacePaths) || !value.workspacePaths.length || value.workspacePaths.length > 32
|
|
16
|
+
|| !value.workspacePaths.every(path => typeof path === "string" && path.length <= 4096 && isAbsolute(path))) return;
|
|
17
|
+
if (event === "Stop" && typeof value.fullyIdle !== "boolean") return;
|
|
18
|
+
const call = record(value.toolCall) ? value.toolCall : undefined;
|
|
19
|
+
if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record(call.args))) return;
|
|
20
|
+
const native = call?.args as Record<string, unknown> | undefined;
|
|
21
|
+
// Only the observed native read is classified. Unobserved edits never receive a scoped grant or effect attribution.
|
|
22
|
+
const observedRead = call?.name === "view_file" && typeof native?.AbsolutePath === "string";
|
|
23
|
+
const phase = ({ SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" } as const)[event as typeof EVENTS[number]];
|
|
24
|
+
return { event: event as NativeFactsInput["event"], phase, sessionId: value.conversationId, transcriptPath: value.transcriptPath,
|
|
25
|
+
workspacePaths: value.workspacePaths as string[], tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path: native!.AbsolutePath } : native ?? {},
|
|
26
|
+
...(Number.isSafeInteger(value.stepIdx) && (value.stepIdx as number) >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {}),
|
|
27
|
+
...(Number.isSafeInteger(value.invocationNum) && (value.invocationNum as number) >= 0 ? { invocation: value.invocationNum as number } : {}),
|
|
28
|
+
...(event === "Stop" ? { fullyIdle: value.fullyIdle as boolean, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function encodeAgyHook(event: string, text?: string): Record<string, unknown> | undefined {
|
|
32
|
+
// `allow` does not override native approval. Until a prompt mapping is proven, leave all native permissions alone.
|
|
33
|
+
if (text && (event === "SessionStart" || event === "PreInvocation")) return { injectSteps: [{ ephemeralMessage: text }] };
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only native-generated rows under the exact session cache path count as readback/completion evidence. */
|
|
38
|
+
export function readAgyTranscript(session: string, transcript: string): NativeFactsTranscript {
|
|
39
|
+
const empty: NativeFactsTranscript = { factIds: [] };
|
|
40
|
+
if (!UUID.test(session)) return empty;
|
|
41
|
+
const home = process.env.HOME ?? homedir();
|
|
42
|
+
const candidates = ["antigravity-cli", "antigravity", "antigravity-ide"].map(surface => join(home, ".gemini", surface, "brain", session, ".system_generated/logs/transcript_full.jsonl"));
|
|
43
|
+
if (!candidates.includes(transcript)) return empty;
|
|
44
|
+
let fd: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
let cursor = home;
|
|
47
|
+
for (const name of relative(home, transcript).split("/")) { cursor = join(cursor, name); if (lstatSync(cursor).isSymbolicLink()) return empty; }
|
|
48
|
+
fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
49
|
+
const stat = fstatSync(fd);
|
|
50
|
+
if (!stat.isFile() || stat.nlink !== 1) return empty;
|
|
51
|
+
// ponytail: completion lookback is capped at 4 MiB (as for Claude). A larger final row is unverified;
|
|
52
|
+
// upgrade to a native completion cursor if that ceiling becomes insufficient.
|
|
53
|
+
const cap = 4 * 1024 * 1024;
|
|
54
|
+
for (let window = 64 * 1024; ; window = Math.min(cap, window * 2)) {
|
|
55
|
+
const start = Math.max(0, stat.size - window), from = start > 0 ? start - 1 : 0;
|
|
56
|
+
const buffer = Buffer.alloc(stat.size - from);
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
while (bytes < buffer.length) {
|
|
59
|
+
const count = readSync(fd, buffer, bytes, buffer.length - bytes, from + bytes);
|
|
60
|
+
if (!count) break;
|
|
61
|
+
bytes += count;
|
|
62
|
+
}
|
|
63
|
+
const lines = buffer.subarray(0, bytes).toString("utf8").split("\n");
|
|
64
|
+
// The preceding byte distinguishes a cut inside a row from a row beginning exactly at the window.
|
|
65
|
+
if (start) lines.shift();
|
|
66
|
+
const out: NativeFactsTranscript = { factIds: [] };
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
let row: unknown; try { row = JSON.parse(line); } catch { continue; }
|
|
69
|
+
if (!record(row) || row.status !== "DONE") continue;
|
|
70
|
+
if (row.source === "SYSTEM_SDK" && row.type === "EPHEMERAL_MESSAGE" && typeof row.content === "string") {
|
|
71
|
+
for (const match of row.content.matchAll(/agent-hub facts \[([a-zA-Z0-9-]{1,128})\]/g)) out.factIds.push(match[1]!);
|
|
72
|
+
}
|
|
73
|
+
if (row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index)
|
|
74
|
+
&& typeof row.content === "string" && row.content.trim().length > 0 && (!Array.isArray(row.tool_calls) || row.tool_calls.length === 0)) out.completion = { id: createHash("sha256").update(`${session}:${row.step_index}:${line}`).digest("hex"), index: row.step_index as number };
|
|
75
|
+
}
|
|
76
|
+
if (out.completion || start === 0 || window === cap) return out;
|
|
77
|
+
}
|
|
78
|
+
} catch { return empty; } finally { if (fd !== undefined) closeSync(fd); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const AGY_FACTS_HOOK: NativeFactsPort = { decode: decodeAgyHook, encode: encodeAgyHook, read: readAgyTranscript };
|
|
@@ -368,6 +368,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
368
368
|
const res = await hub.request({ t: "send", body, to, reply_to });
|
|
369
369
|
if (!res.ok) return text(`not sent: ${res.error}`);
|
|
370
370
|
if (res.recorded) return text("recorded only ([FYI]): it is on the hub console and log, and no peer spent a turn on it");
|
|
371
|
+
if (res.dropped === "hop") return text(res.notice);
|
|
371
372
|
const sent = `sent to: ${res.targets.join(", ") || "(no other peers attached)"}`;
|
|
372
373
|
return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
|
|
373
374
|
}
|
|
@@ -67,18 +67,28 @@ function configFiles(cwd: string, env: NodeJS.ProcessEnv): string[] {
|
|
|
67
67
|
// Bounded host credential roots, not a universal private-home classifier. Never inspect their file contents.
|
|
68
68
|
const HOST_CREDENTIAL_PATHS = [".config", ".ssh", ".aws", ".azure", ".kube", ".docker", ".gnupg", ".codex", ".claude", ".kimi", ".kimi-code", ".gemini", ".git-credentials", ".vault-token", "Library/Keychains", ".local/share/keyrings", ".password-store"] as const;
|
|
69
69
|
const HOST_CREDENTIAL_ENV = ["XDG_CONFIG_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "KIMI_CODE_HOME", "KIMI_HOME", "GEMINI_CLI_HOME", "GNUPGHOME", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DOCKER_CONFIG", "GOOGLE_APPLICATION_CREDENTIALS"] as const;
|
|
70
|
-
|
|
70
|
+
/** A credential root that cannot be resolved. `label` is a path class (a standard name or an env var name), never a path or contents. */
|
|
71
|
+
class CredentialRootError extends Error {
|
|
72
|
+
constructor(readonly label: string, readonly kind: "dangling" | "unreadable" | "unsupported") { super("credential root refused"); }
|
|
73
|
+
}
|
|
74
|
+
interface CredentialRoot { path: string; label: string }
|
|
75
|
+
function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): CredentialRoot[] {
|
|
71
76
|
const home = resolve(cwd, env.HOME || homedir());
|
|
72
|
-
const homes = [...new Set([home,
|
|
73
|
-
const roots:
|
|
74
|
-
const configured = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [env[key]
|
|
75
|
-
if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean));
|
|
77
|
+
const homes = [...new Set([home, canonicalLabelled(home, "HOME")])];
|
|
78
|
+
const roots: CredentialRoot[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => ({ path: join(base, entry), label: `~/${entry}` })));
|
|
79
|
+
const configured: CredentialRoot[] = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [{ path: env[key]!, label: `$${key}` }] : []);
|
|
80
|
+
if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean).map(path => ({ path, label: "$KUBECONFIG" })));
|
|
76
81
|
if (configured.length > 64) throw new Error("credential root bound exceeded");
|
|
77
|
-
for (const entry of configured) {
|
|
78
|
-
if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new
|
|
79
|
-
roots.push(entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry));
|
|
82
|
+
for (const { path: entry, label } of configured) {
|
|
83
|
+
if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new CredentialRootError(label, "unsupported");
|
|
84
|
+
roots.push({ path: entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry), label });
|
|
80
85
|
}
|
|
81
|
-
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
return roots.filter(root => !seen.has(root.path) && seen.add(root.path));
|
|
88
|
+
}
|
|
89
|
+
function canonicalLabelled(lexical: string, label: string): string {
|
|
90
|
+
try { return credentialCanonicalPath(lexical); }
|
|
91
|
+
catch (error) { throw new CredentialRootError(label, (error as NodeJS.ErrnoException).code === "ENOENT" ? "dangling" : "unreadable"); }
|
|
82
92
|
}
|
|
83
93
|
/** Resolve absent descendants through the deepest existing ancestor; dangling links refuse admission. */
|
|
84
94
|
function credentialCanonicalPath(lexical: string): string {
|
|
@@ -93,14 +103,13 @@ function credentialCanonicalPath(lexical: string): string {
|
|
|
93
103
|
}
|
|
94
104
|
return join(realPath(ancestor), relative(ancestor, lexical));
|
|
95
105
|
}
|
|
96
|
-
function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly
|
|
97
|
-
for (const lexical of roots) {
|
|
106
|
+
function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly CredentialRoot[], cwd: string): void {
|
|
107
|
+
for (const { path: lexical, label } of roots) {
|
|
98
108
|
// Native profile keys with glob metacharacters cannot represent these literal host locations safely.
|
|
99
|
-
if (/[?*\[\]{}]/.test(lexical)) throw new
|
|
100
|
-
const targets = new Set([lexical,
|
|
109
|
+
if (/[?*\[\]{}]/.test(lexical)) throw new CredentialRootError(label, "unsupported");
|
|
110
|
+
const targets = new Set([lexical, canonicalLabelled(lexical, label)]);
|
|
101
111
|
for (const target of targets) {
|
|
102
|
-
if (/[?*\[\]{}]/.test(target)) throw new
|
|
103
|
-
if (inside(cwd, target)) throw new Error("credential root overlaps checkout");
|
|
112
|
+
if (/[?*\[\]{}]/.test(target) || inside(cwd, target)) throw new CredentialRootError(label, "unsupported");
|
|
104
113
|
filesystem[target] = "deny"; filesystem[`${target.replace(/\/$/, "")}/**`] = "deny";
|
|
105
114
|
}
|
|
106
115
|
}
|
|
@@ -261,8 +270,13 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
261
270
|
cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
|
|
262
271
|
isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
|
|
263
272
|
evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
|
|
264
|
-
} catch {
|
|
273
|
+
} catch (error) {
|
|
265
274
|
if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
|
|
275
|
+
// Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
|
|
276
|
+
if (error instanceof CredentialRootError) {
|
|
277
|
+
const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
|
|
278
|
+
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
|
|
279
|
+
}
|
|
266
280
|
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
|
|
267
281
|
}
|
|
268
282
|
}
|
|
@@ -100,7 +100,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
100
100
|
grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
101
101
|
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
102
102
|
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
103
|
-
cli: { command: "codex", retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
103
|
+
cli: { command: "codex", launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
@@ -116,7 +116,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
116
116
|
unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
|
|
117
117
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
118
118
|
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
119
|
-
cli: { command: "kimi", stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
119
|
+
cli: { command: "kimi", launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
@@ -128,7 +128,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
128
128
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
129
129
|
turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
130
130
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
131
|
-
cli: { command: "pi", previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
131
|
+
cli: { command: "pi", launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
@@ -140,7 +140,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
140
140
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
141
141
|
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
142
142
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
143
|
-
cli: { command: "local", startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
143
|
+
cli: { command: "local", launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
@@ -151,7 +151,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
151
151
|
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
152
152
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
153
153
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
154
|
-
cli: { command: "jcode", authority: "person", startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
154
|
+
cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
155
155
|
},
|
|
156
156
|
] satisfies AdapterDefinition[]);
|
|
157
157
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -14,6 +15,8 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
14
15
|
if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
|
|
15
16
|
const hub = await ctx.connect();
|
|
16
17
|
try {
|
|
18
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
17
20
|
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
18
21
|
if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
|
|
19
22
|
} finally { hub.close(); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -19,6 +20,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
19
20
|
// only when a future recovery receipt supplies independently verified exact-instance execution-root evidence.
|
|
20
21
|
if (recovering && !ctx.instance && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
21
22
|
const hub = await connect();
|
|
23
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
24
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
22
25
|
const res = await hub.request({ t: "start", peer, args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
23
26
|
hub.close();
|
|
24
27
|
if (!res.ok) fail(res.error);
|
|
@@ -28,7 +31,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
28
31
|
}
|
|
29
32
|
const control = readControl(stateDir);
|
|
30
33
|
if ((!recovering || ctx.instance) && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
|
|
31
|
-
const
|
|
34
|
+
const runningCodexBin = res.retainedLaunchConfig?.codex_bin;
|
|
35
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
|
|
32
36
|
if (launch0.warning) console.error(launch0.warning);
|
|
33
37
|
exec(launch.cmd, launch.args, "codex");
|
|
34
38
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
4
|
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
@@ -18,6 +19,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
18
19
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
19
20
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
20
21
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
22
|
+
const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
|
|
21
23
|
// The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
|
|
22
24
|
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
23
25
|
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
@@ -6,19 +7,22 @@ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
|
6
7
|
export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
8
|
const peer = ctx.instance ?? "kimi";
|
|
8
9
|
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, connect, fail, takeFlags } = ctx;
|
|
9
|
-
const { launcherPreview, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
|
|
10
|
+
const { launcherPreview, readControl, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
|
|
10
11
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
11
12
|
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
12
13
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
13
14
|
}
|
|
14
15
|
const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
|
|
15
|
-
const
|
|
16
|
+
const acceptedMode = readControl(stateDir)?.startModes?.[peer];
|
|
17
|
+
const mode = one["--mode"] ?? (ctx.instance ? "headless" : acceptedMode ?? startModeOf(projectConfig().peers, "kimi")), model = one["--model"];
|
|
16
18
|
if (ctx.instance && mode !== "headless") fail("extra Kimi instances require headless mode");
|
|
17
19
|
if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
|
|
18
20
|
if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
19
21
|
if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
|
|
20
22
|
const hub = await connect();
|
|
21
23
|
try {
|
|
24
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
25
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
22
26
|
const reply = await hub.request({ t: "peer_start", peer }, 35_000);
|
|
23
27
|
if (!reply.ok) fail(reply.error);
|
|
24
28
|
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`);
|
|
@@ -27,15 +31,17 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
27
31
|
const hub = await connect();
|
|
28
32
|
try {
|
|
29
33
|
if (mode === "tui") {
|
|
30
|
-
const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
|
|
31
34
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
32
35
|
const rostered = rosterInStatus(snapshot, peer);
|
|
33
36
|
if (rostered === undefined) fail("cannot read kimi's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
34
37
|
if (!rostered) fail("kimi is off roster; add it with ahub roster add kimi");
|
|
38
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
35
39
|
const owner = snapshot?.peers?.[peer];
|
|
36
40
|
if (owner && !["offline"].includes(owner.state) && !owner.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
|
|
37
41
|
const admission = await hub.request({ t: "start", peer, args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
38
42
|
if (!admission.ok) fail(admission.error);
|
|
43
|
+
const runningCommand = admission.retainedLaunchConfig?.kimi_cmd;
|
|
44
|
+
const launch = buildKimiTuiLaunch(Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string") ? runningCommand : projectConfig().kimi_cmd, model);
|
|
39
45
|
let result: ReturnType<typeof spawnSync>;
|
|
40
46
|
try {
|
|
41
47
|
// 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.
|
|
@@ -48,7 +54,9 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
48
54
|
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
49
55
|
process.exit(result.status ?? 1);
|
|
50
56
|
}
|
|
51
|
-
const
|
|
57
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
58
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
59
|
+
const owner = snapshot?.peers?.[peer];
|
|
52
60
|
if (owner?.pullOnly && owner.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
|
|
53
61
|
if (!ctx.instance) configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
|
|
54
62
|
const res = await hub.request({ t: "start", peer, args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -8,6 +9,8 @@ export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
8
9
|
if (args.includes("--route") && args.includes("--model")) fail("use --route or --model, not both");
|
|
9
10
|
if (args.some((a, i) => i % 2 === 0 && !["--route", "--model"].includes(a))) fail("usage: ahub local [--route <id> | --model <id>]");
|
|
10
11
|
const hub = await connect();
|
|
12
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
13
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
11
14
|
const res = await hub.request({ t: "start", peer, args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
12
15
|
hub.close();
|
|
13
16
|
if (!res.ok) fail(res.error);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { parsePiFlags } from "./native-cli-pi.ts";
|
|
2
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
4
|
|
|
@@ -12,8 +13,9 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
12
13
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
13
14
|
}
|
|
14
15
|
const flags = parsePiFlags(args, fail);
|
|
16
|
+
const acceptedMode = ctx.helpers.readControl(stateDir)?.startModes?.[peer];
|
|
15
17
|
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
16
|
-
const options = { ...flags, mode: flags.mode ?? (ctx.instance ? "headless" : startModeOf(projectConfig().peers, "pi")) };
|
|
18
|
+
const options = { ...flags, mode: flags.mode ?? (ctx.instance ? "headless" : acceptedMode ?? startModeOf(projectConfig().peers, "pi")) };
|
|
17
19
|
if (ctx.instance && options.mode !== "headless") fail("extra Pi instances require headless mode");
|
|
18
20
|
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
19
21
|
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
@@ -22,6 +24,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
22
24
|
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");
|
|
23
25
|
const hub = await connect();
|
|
24
26
|
try {
|
|
27
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
28
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
25
29
|
const reply = await hub.request({ t: "peer_start", peer }, 35_000);
|
|
26
30
|
if (reply.ok !== true) {
|
|
27
31
|
const error = String(reply.error ?? "pi start refused");
|
|
@@ -36,6 +40,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
36
40
|
const before = readControl(stateDir);
|
|
37
41
|
if (recovering && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
38
42
|
const hub = await connect();
|
|
43
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
44
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
39
45
|
const res = await hub.request({ t: "start", peer, args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
40
46
|
hub.close();
|
|
41
47
|
if (!res.ok) fail(res.error);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -14,6 +15,8 @@ export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
14
15
|
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>]");
|
|
15
16
|
const hub = await connect();
|
|
16
17
|
try {
|
|
18
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
17
20
|
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);
|
|
18
21
|
if (!reply.ok) fail(reply.error);
|
|
19
22
|
console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); use ahub say @${peer} and the console for approvals`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAgyEntry } from "../agy/entry.ts";
|
|
1
2
|
import type { BuiltinCliEntry } from "./native-cli-entry-types.ts";
|
|
2
3
|
import { runClaudeEntry } from "./native-cli-entry-claude.ts";
|
|
3
4
|
import { runCodexEntry } from "./native-cli-entry-codex.ts";
|
|
@@ -9,6 +10,7 @@ import { runLocalEntry } from "./native-cli-entry-local.ts";
|
|
|
9
10
|
|
|
10
11
|
// Fixed trusted builtins only. No project configuration, caller payload or external module can register an entry.
|
|
11
12
|
export const builtinCliEntries: readonly BuiltinCliEntry[] = Object.freeze([
|
|
13
|
+
{ command: "agy", lifecycleAdmission: true, execute: runAgyEntry },
|
|
12
14
|
{ command: "claude", lifecycleAdmission: true, execute: runClaudeEntry },
|
|
13
15
|
{ command: "codex", lifecycleAdmission: true, execute: runCodexEntry },
|
|
14
16
|
{ command: "jcode", lifecycleAdmission: true, execute: runJcodeEntry },
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
|
+
|
|
3
|
+
/** Explain a retained launch setting using only metadata from the selected adapter definition. */
|
|
4
|
+
export function retainedLaunchWarning(status: unknown, peer: string, definition: AdapterDefinition): string | undefined {
|
|
5
|
+
const keys = definition.cli.launchConfigKeys;
|
|
6
|
+
const retained = (status as { retainedConfig?: unknown } | undefined)?.retainedConfig;
|
|
7
|
+
if (!keys?.length || !Array.isArray(retained)) return;
|
|
8
|
+
const pending = retained.flatMap(row => {
|
|
9
|
+
if (!row || typeof row !== "object") return [];
|
|
10
|
+
const key = (row as { key?: unknown }).key;
|
|
11
|
+
return typeof key === "string" && keys.some(candidate => key === candidate || key.startsWith(`${candidate}.`)) ? [key] : [];
|
|
12
|
+
});
|
|
13
|
+
const unique = [...new Set(pending)];
|
|
14
|
+
return unique.length ? `ahub: config retained: ${unique.join(", ")}; starting ${peer} with the running value; run ahub settings reload to apply the file's value` : undefined;
|
|
15
|
+
}
|
|
@@ -69,7 +69,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
69
69
|
turnEnd: "adapter",
|
|
70
70
|
budget: { account: "qwen" },
|
|
71
71
|
capabilities: { watch: true },
|
|
72
|
-
cli: { command: "qwen", startWords: () => ["qwen"] },
|
|
72
|
+
cli: { command: "qwen", launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
73
73
|
display: QWEN_DISPLAY,
|
|
74
74
|
hooks: { agentIdentity: init => { qwenNativeIdentity("qwen", true, init); }, recallPlatform: "qwen" },
|
|
75
75
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
2
|
import { claudeReviewerBackend } from "../claude-print.ts";
|
|
3
3
|
import { codexReviewerBackend } from "../codex-review-isolation.ts";
|
|
4
|
+
import { kimiReviewerBackend } from "../kimi-review.ts";
|
|
4
5
|
import { createReviewer, createCodexReviewer } from "./native-factories.ts";
|
|
5
6
|
import { CODEX_DISPLAY, KIMI_DISPLAY } from "./native-display.ts";
|
|
6
7
|
|
|
@@ -24,9 +25,9 @@ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullabl
|
|
|
24
25
|
}
|
|
25
26
|
const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
|
|
26
27
|
export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
|
|
27
|
-
export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", {
|
|
28
|
+
export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit" }, KIMI_DISPLAY);
|
|
28
29
|
/** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
|
|
29
|
-
export const
|
|
30
|
-
...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy
|
|
30
|
+
export const AGY_REVIEWER_VARIANT: AdapterDefinition = {
|
|
31
|
+
...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
|
|
31
32
|
optional: true,
|
|
32
33
|
};
|