@staix/agent-hub 0.12.37 → 0.12.39
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 +19 -0
- package/README.md +12 -6
- package/docs/agent-notes/adapters.md +19 -8
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +6 -3
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +3 -3
- package/docs/operations.md +45 -17
- package/docs/quickstart.md +19 -5
- package/docs/security.md +7 -3
- package/docs/smoke.md +22 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
- 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/verification/2026-10-20-0.12.39.md +55 -0
- package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -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 +1759 -437
- package/src/adapters/acp.ts +20 -2
- 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 +8 -8
- 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 +82 -11
- 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-types.ts +1 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-daemon-policy.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +4 -3
- package/src/adapters/jcode.ts +58 -1
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +29 -1
- package/src/adapters/registry.ts +16 -7
- 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 +5 -4
- package/src/cli/output.ts +5 -0
- package/src/cli/preview.ts +2 -2
- package/src/hub/bus.ts +17 -6
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +182 -24
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/peers.ts +2 -2
- package/src/hub/reviewer-pool.ts +12 -6
- package/src/hub/routing.ts +3 -0
- package/src/hub/start-mode.ts +1 -1
- package/src/hub/tasks.ts +7 -0
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
|
@@ -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`);
|
|
@@ -7,6 +7,7 @@ export interface NativeCliEntryContext {
|
|
|
7
7
|
instance?: string; executionCwd?: string;
|
|
8
8
|
/** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
|
|
9
9
|
hubStarted?: true;
|
|
10
|
+
person?: boolean;
|
|
10
11
|
args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
|
|
11
12
|
projectConfig(): HubConfig;
|
|
12
13
|
connect(): Promise<ControlClient>;
|
|
@@ -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 },
|
|
@@ -14,6 +14,7 @@ export interface NativePreviewHelpers {
|
|
|
14
14
|
childEnv: typeof import("../../hub/child-process.ts").childEnv;
|
|
15
15
|
startModeOf: typeof import("../../hub/start-mode.ts").startModeOf;
|
|
16
16
|
kimiMcpPlan: typeof import("../../cli/kimi-mcp.ts").kimiMcpPlan;
|
|
17
|
+
jcodeTuiCommand: typeof import("../jcode.ts").jcodeTuiCommand;
|
|
17
18
|
jcodeCommand: typeof import("../jcode.ts").jcodeCommand;
|
|
18
19
|
qwenSessionId: typeof import("../../hub/crash.ts").qwenSessionId;
|
|
19
20
|
relayModelIds: typeof import("../../models/relay.ts").relayModelIds;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { Launch } from "../../cli/launch.ts";
|
|
2
|
-
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
2
|
+
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext, type NativePreview } from "./native-cli-preview-common.ts";
|
|
3
3
|
|
|
4
|
-
export function previewJcode(ctx: NativePreviewContext) {
|
|
4
|
+
export function previewJcode(ctx: NativePreviewContext): NativePreview {
|
|
5
5
|
const { args, config } = ctx;
|
|
6
|
-
const { jcodeCommand, qwenSessionId } = ctx.helpers;
|
|
6
|
+
const { jcodeCommand, jcodeTuiCommand, qwenSessionId, startModeOf } = ctx.helpers;
|
|
7
7
|
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
8
8
|
let envNames = state.envNames;
|
|
9
9
|
let launch: Launch;
|
|
10
|
-
const mode = value("--mode") ?? "
|
|
11
|
-
if (mode !== "headless") throw new Error("Jcode
|
|
10
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "jcode"), model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
|
|
11
|
+
if (mode !== "headless" && mode !== "tui") throw new Error("invalid Jcode start mode");
|
|
12
12
|
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
|
|
13
13
|
if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
|
|
14
|
-
const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
14
|
+
const cmd = mode === "tui" ? jcodeTuiCommand(nativeCommand(config.jcode_cmd, ["jcode"]), UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined) : jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
15
15
|
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
16
16
|
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
17
17
|
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
18
18
|
|
|
19
|
-
return finishPreview(ctx, launch, envNames, unresolved,
|
|
19
|
+
return { ...finishPreview(ctx, launch, envNames, unresolved), mode };
|
|
20
20
|
}
|
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,7 @@ export const codexRetainedTerminalNotice = (_instance: string, _form: StartForm
|
|
|
12
12
|
export const codexRosterReturnNotice = (resumeCommand: string): string =>
|
|
13
13
|
`Codex 0.146.0 needs a manual return: resume the previous native session with ${resumeCommand}.`;
|
|
14
14
|
export const jcodeStartPreflight = (request: StartRequest): string | undefined =>
|
|
15
|
-
request.form !== undefined && request.form !== "headless"
|
|
15
|
+
request.form !== undefined && request.form !== "headless" && request.form !== "tui" ? "invalid Jcode start mode" : undefined;
|
|
16
16
|
export const claudeObservedVersion = (context: VersionContext): string | undefined => context.observedVersion;
|
|
17
17
|
export const codexObservedVersion = (context: VersionContext): string | undefined =>
|
|
18
18
|
(context.peer as { version?: string } | undefined)?.version ?? context.observedVersion;
|
|
@@ -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
|
};
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { isAbsolute, join } from "node:path";
|
|
5
5
|
import type { Readable } from "node:stream";
|
|
@@ -17,6 +17,45 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
|
|
|
17
17
|
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
18
18
|
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
|
|
19
19
|
}
|
|
20
|
+
export interface JcodeTuiBinding { launchId: string; sessionId: string }
|
|
21
|
+
export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
|
|
22
|
+
{ state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
|
|
23
|
+
|
|
24
|
+
export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string): string[] {
|
|
25
|
+
if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
|
|
26
|
+
return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev",
|
|
27
|
+
...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : [])];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read native persistence only. Never synthesize a native session or send a save-inducing prompt. */
|
|
31
|
+
function savedSession(home: string, id: string): boolean {
|
|
32
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error("invalid native session identity");
|
|
33
|
+
const directory = join(home, "sessions"), info = lstatSync(directory, { throwIfNoEntry: false });
|
|
34
|
+
if (!info) return false;
|
|
35
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsafe native session directory");
|
|
36
|
+
const file = join(directory, `${id}.json`), entry = lstatSync(file, { throwIfNoEntry: false });
|
|
37
|
+
if (!entry) return false;
|
|
38
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("unsafe native session proof");
|
|
39
|
+
let fd: number;
|
|
40
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
41
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
42
|
+
try {
|
|
43
|
+
const before = fstatSync(fd);
|
|
44
|
+
// ponytail: bound native persistence proof to 32 MiB; larger conversations need a native saved-session API.
|
|
45
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 32 * 1024 * 1024) throw new Error("unsafe native session proof");
|
|
46
|
+
if (!before.size) return false;
|
|
47
|
+
const bytes = Buffer.alloc(before.size);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < bytes.length) { const n = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!n) return false; offset += n; }
|
|
50
|
+
const after = fstatSync(fd);
|
|
51
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) return false;
|
|
52
|
+
let data: unknown;
|
|
53
|
+
try { data = JSON.parse(bytes.toString("utf8")); } catch { return false; }
|
|
54
|
+
if (!data || typeof data !== "object" || (data as { id?: unknown }).id !== id || !Array.isArray((data as { messages?: unknown }).messages)) throw new Error("native saved session identity is unproven");
|
|
55
|
+
return true;
|
|
56
|
+
} finally { closeSync(fd); }
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/** Set on every owned jcode process: no Claude MCP import, and no native telemetry (jcode-telemetry-core is_enabled, #529). */
|
|
21
60
|
const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
|
|
22
61
|
/** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
|
|
@@ -45,6 +84,8 @@ export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
|
|
|
45
84
|
|
|
46
85
|
/** Both processes are ours. The user's global daemon and its sockets are never stopped. */
|
|
47
86
|
export class JcodePeer extends AcpPeer {
|
|
87
|
+
private readonly launchId = crypto.randomUUID();
|
|
88
|
+
private socketIdentity: { dev: number; ino: number } | undefined;
|
|
48
89
|
private readonly root: string;
|
|
49
90
|
private readonly socket: string;
|
|
50
91
|
private server: ChildProcess | undefined;
|
|
@@ -79,6 +120,8 @@ export class JcodePeer extends AcpPeer {
|
|
|
79
120
|
ready.once("data", () => { clearTimeout(timer); server.off("exit", fail); if (lstatSync(this.socket, { throwIfNoEntry: false })?.isSocket()) resolve(); else reject(new Error("Jcode readiness has no private socket")); });
|
|
80
121
|
});
|
|
81
122
|
server.once("exit", () => { if (!this.ending && this.server === server) void this.stop("Jcode owned daemon disconnected").catch(() => {}); });
|
|
123
|
+
const identity = lstatSync(this.socket);
|
|
124
|
+
this.socketIdentity = { dev: identity.dev, ino: identity.ino };
|
|
82
125
|
await super.start();
|
|
83
126
|
} catch (error) { await this.stop().catch(() => {}); throw error; }
|
|
84
127
|
}
|
|
@@ -119,6 +162,20 @@ export class JcodePeer extends AcpPeer {
|
|
|
119
162
|
}
|
|
120
163
|
if (failure) throw failure;
|
|
121
164
|
}
|
|
165
|
+
tuiLaunch(expected?: JcodeTuiBinding, options: { model?: string; providerProfile?: string; sessionId?: string } = {}): JcodeTuiState {
|
|
166
|
+
const sessionId = super.recoveryMetadata().sessionId;
|
|
167
|
+
const socket = lstatSync(this.socket, { throwIfNoEntry: false });
|
|
168
|
+
if (this.ending || !this.server || this.server.exitCode !== null || this.server.signalCode !== null || this.state === "offline" ||
|
|
169
|
+
!socket?.isSocket() || socket.dev !== this.socketIdentity?.dev || socket.ino !== this.socketIdentity.ino || typeof sessionId !== "string") throw new Error("owned Jcode launch is unavailable");
|
|
170
|
+
const binding = { launchId: this.launchId, sessionId };
|
|
171
|
+
if (expected && (expected.launchId !== binding.launchId || expected.sessionId !== binding.sessionId)) throw new Error("Jcode launch changed");
|
|
172
|
+
if ((options.sessionId !== undefined && options.sessionId !== sessionId) ||
|
|
173
|
+
(options.model !== undefined && options.model !== this.jcode.launchModel) ||
|
|
174
|
+
(options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile)) throw new Error("Jcode launch options differ from its running session");
|
|
175
|
+
if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
|
|
176
|
+
const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
|
|
177
|
+
return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, ...JCODE_OWNED_ENV } } };
|
|
178
|
+
}
|
|
122
179
|
override recoveryMetadata(): Record<string, unknown> {
|
|
123
180
|
return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
|
|
124
181
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
7
|
+
import { realPath } from "../hub/project.ts";
|
|
8
|
+
import type { ReviewerBackend, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
9
|
+
|
|
10
|
+
const VERSION = "2.2.0", MANAGED = "managed:kimi-code";
|
|
11
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
13
|
+
/** Native discovery can walk above cwd; check lexical aliases as well as the physical temporary root. */
|
|
14
|
+
function assertCleanCwdAncestors(...candidates: readonly string[]): void {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
for (const candidate of candidates) {
|
|
17
|
+
let ancestor = resolve(candidate);
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (!seen.has(ancestor)) {
|
|
20
|
+
seen.add(ancestor);
|
|
21
|
+
for (const marker of [".git", ".kimi-code", "AGENTS.md"]) {
|
|
22
|
+
if (lstatSync(join(ancestor, marker), { throwIfNoEntry: false }) !== undefined) {
|
|
23
|
+
throw new Error("private cwd ancestor contains ambient configuration");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const read = (file: string, limit = 262_144) => {
|
|
32
|
+
const stat = lstatSync(file); if (!stat.isFile() || stat.size > limit) throw new Error("unsupported reviewer file");
|
|
33
|
+
return readFileSync(file, "utf8");
|
|
34
|
+
};
|
|
35
|
+
const scalar = (value: unknown, depth = 0): string => {
|
|
36
|
+
if (depth > 4) throw new Error("managed model setting too deeply nested");
|
|
37
|
+
if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
38
|
+
if (Array.isArray(value) && value.every(v => typeof v === "string")) return JSON.stringify(value);
|
|
39
|
+
if (object(value) && Object.keys(value).length <= 32) return `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)} = ${scalar(item, depth + 1)}`).join(", ")} }`;
|
|
40
|
+
throw new Error("unsupported managed model setting");
|
|
41
|
+
};
|
|
42
|
+
function managedConfig(raw: string): { config: string; lock: string } {
|
|
43
|
+
const config = Bun.TOML.parse(raw) as Record<string, unknown>;
|
|
44
|
+
if (!object(config.providers) || !object(config.models) || typeof config.default_model !== "string") throw new Error("managed Kimi configuration required");
|
|
45
|
+
const provider = config.providers[MANAGED], model = config.models[config.default_model];
|
|
46
|
+
if (!object(provider) || provider.type !== "kimi" || provider.api_key && provider.api_key !== "" || !object(provider.oauth) || provider.oauth.storage !== "file" || !object(model) || model.provider !== MANAGED) throw new Error("managed file-backed Kimi OAuth required");
|
|
47
|
+
const oauth = provider.oauth;
|
|
48
|
+
const key = oauth.key;
|
|
49
|
+
const lock = !key || key === "kimi-code" || key === "oauth/kimi-code" ? "kimi-code" : typeof key === "string" && key.startsWith("oauth/") ? key.slice(6) : key;
|
|
50
|
+
if (typeof lock !== "string" || !lock || basename(lock) !== lock || lock.startsWith(".")) throw new Error("unsupported OAuth slot");
|
|
51
|
+
if (typeof provider.base_url !== "string") throw new Error("managed Kimi endpoint required");
|
|
52
|
+
const endpoint = new URL(provider.base_url);
|
|
53
|
+
if (endpoint.protocol !== "https:" || !["api.kimi.ai", "api.kimi.com"].includes(endpoint.hostname) || endpoint.username || endpoint.password) throw new Error("unsupported managed Kimi endpoint");
|
|
54
|
+
if (oauth.oauth_host !== undefined) {
|
|
55
|
+
const auth = new URL(String(oauth.oauth_host));
|
|
56
|
+
if (auth.protocol !== "https:" || !["auth.kimi.ai", "auth.kimi.com"].includes(auth.hostname) || auth.username || auth.password) throw new Error("unsupported OAuth endpoint");
|
|
57
|
+
}
|
|
58
|
+
const lines = ["telemetry = false", "hooks = []", `default_model = ${scalar(config.default_model)}`,
|
|
59
|
+
...(config.thinking === undefined ? [] : [`thinking = ${scalar(config.thinking)}`]),
|
|
60
|
+
`[providers.${scalar(MANAGED)}]`, 'type = "kimi"', `base_url = ${scalar(provider.base_url)}`, `[providers.${scalar(MANAGED)}.oauth]`];
|
|
61
|
+
for (const name of ["storage", "key", "oauth_host"]) if (oauth[name] !== undefined) lines.push(`${name} = ${scalar(oauth[name])}`);
|
|
62
|
+
lines.push(`[models.${scalar(config.default_model)}]`);
|
|
63
|
+
const modelFields = ["provider", "model", "protocol", "max_context_size", "max_input_size", "capabilities", "display_name", "support_efforts", "default_effort", "beta_api", "adaptive_thinking", "overrides"];
|
|
64
|
+
if (Object.keys(model).some(name => !modelFields.includes(name))) throw new Error("unsupported managed model setting");
|
|
65
|
+
if (model.overrides !== undefined && (!object(model.overrides) || Object.keys(model.overrides).some(name => !modelFields.includes(name) || ["provider", "overrides"].includes(name)))) throw new Error("unsupported managed model override");
|
|
66
|
+
for (const name of modelFields) if (model[name] !== undefined) lines.push(`${name} = ${scalar(model[name])}`);
|
|
67
|
+
return { config: lines.join("\n") + "\n", lock };
|
|
68
|
+
}
|
|
69
|
+
function mcpConfig(context: ReviewerPrepareContext): string {
|
|
70
|
+
const config: unknown = JSON.parse(context.mcpConfig);
|
|
71
|
+
if (!object(config) || !object(config.mcpServers)) throw new Error("review MCP unavailable");
|
|
72
|
+
if (context.task === undefined && Object.keys(config.mcpServers).length === 0) return context.mcpConfig;
|
|
73
|
+
if (Object.keys(config.mcpServers).join() !== "agent-hub") throw new Error("ambient MCP refused");
|
|
74
|
+
const server = config.mcpServers["agent-hub"];
|
|
75
|
+
if (!object(server) || Object.keys(server).some(k => !["transport", "url", "headers"].includes(k)) || server.transport !== "http" || typeof server.url !== "string" || !object(server.headers) || Object.keys(server.headers).join() !== "Authorization" || typeof server.headers.Authorization !== "string" || !server.headers.Authorization.startsWith("Bearer ")) throw new Error("task-bound HTTP MCP required");
|
|
76
|
+
const url = new URL(server.url);
|
|
77
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/mcp" || url.username || url.password || url.search || url.hash) throw new Error("local review MCP required");
|
|
78
|
+
return context.mcpConfig;
|
|
79
|
+
}
|
|
80
|
+
async function version(command: readonly string[], cwd: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<string> {
|
|
81
|
+
return await new Promise((accept, reject) => {
|
|
82
|
+
const child = spawn(command[0]!, [...command.slice(1), "--version"], { cwd, env, detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
83
|
+
let text = "", done = false;
|
|
84
|
+
const finish = async (code: number | null) => {
|
|
85
|
+
if (done) return; done = true; clearTimeout(timer); signal.removeEventListener("abort", abort);
|
|
86
|
+
try { await stopOwnedProcess(child, { group: true }); } catch { reject(new Error("version probe cleanup failed")); return; }
|
|
87
|
+
if (code !== 0 || signal.aborted) reject(new Error("version probe failed")); else accept(text.trim());
|
|
88
|
+
};
|
|
89
|
+
const abort = () => { void finish(null); };
|
|
90
|
+
const timer = setTimeout(abort, 5_000);
|
|
91
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
92
|
+
child.once("spawn", () => trackGroup(child));
|
|
93
|
+
child.stdout.on("data", (bytes: Buffer) => { text += bytes.toString(); if (text.length > 4096) abort(); });
|
|
94
|
+
child.once("error", () => { void finish(null); }); child.once("close", code => { void finish(code); });
|
|
95
|
+
if (signal.aborted) abort();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Do not SIGKILL a live native refresh holder. A bounded incomplete stop retains the peer handle. */
|
|
99
|
+
export async function stopKimiReview(child: ChildProcess, locks: readonly string[], graceMs = 10_000): Promise<void> {
|
|
100
|
+
const locked = () => locks.some(file => existsSync(file));
|
|
101
|
+
if (child.exitCode === null && child.signalCode === null && locked()) {
|
|
102
|
+
child.kill("SIGTERM");
|
|
103
|
+
const end = Date.now() + graceMs;
|
|
104
|
+
while (child.exitCode === null && child.signalCode === null && locked() && Date.now() < end) await Bun.sleep(50);
|
|
105
|
+
if (child.exitCode === null && child.signalCode === null && locked()) throw new Error("Kimi OAuth refresh still locked; graceful stop incomplete");
|
|
106
|
+
}
|
|
107
|
+
await stopOwnedProcess(child, { group: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const kimiReviewerBackend: ReviewerBackend = {
|
|
111
|
+
id: "kimi", taskMcp: true, nativePolicy: true,
|
|
112
|
+
async prepare(context) {
|
|
113
|
+
let own: string | undefined;
|
|
114
|
+
try {
|
|
115
|
+
if (context.signal.aborted) throw new Error("cancelled");
|
|
116
|
+
const sourcePath = process.env.KIMI_CODE_HOME ?? join(process.env.HOME ?? homedir(), ".kimi-code"), source = realPath(sourcePath);
|
|
117
|
+
const sourceConfig = join(source, "config.toml"), original = read(sourceConfig), selected = managedConfig(original);
|
|
118
|
+
const sources = ["credentials", "oauth", "device_id"].map(name => realPath(join(source, name)));
|
|
119
|
+
if (!sources.every((file, index) => index < 2 ? lstatSync(file).isDirectory() : lstatSync(file).isFile())) throw new Error("native OAuth directories and device identity required");
|
|
120
|
+
const mcp = mcpConfig(context);
|
|
121
|
+
if (Buffer.byteLength(context.input ?? "") > 1_048_576) throw new Error("review facts exceed limit");
|
|
122
|
+
const lexicalRoot = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "ahub-kimi-review-"));
|
|
123
|
+
own = realPath(lexicalRoot);
|
|
124
|
+
assertCleanCwdAncestors(join(lexicalRoot, "workspace"), join(own, "workspace"));
|
|
125
|
+
const root = own, home = join(root, "home"), tool = join(root, "tool"), cwd = join(root, "workspace"), skills = join(root, "empty-skills"), temporary = join(root, "tmp");
|
|
126
|
+
for (const dir of [home, tool, cwd, skills, temporary]) mkdirSync(dir, { mode: 0o700 });
|
|
127
|
+
["credentials", "oauth", "device_id"].forEach((name, index) => symlinkSync(sources[index]!, join(tool, name), index < 2 ? "dir" : "file"));
|
|
128
|
+
const agentFile = join(cwd, "reviewer.md"), configFile = join(tool, "config.toml"), mcpFile = join(tool, "mcp.json");
|
|
129
|
+
writeFileSync(configFile, selected.config, { mode: 0o600 }); writeFileSync(mcpFile, mcp, { mode: 0o600 });
|
|
130
|
+
writeFileSync(agentFile, `---\nname: hub-reviewer\ndescription: Fresh agent-hub reviewer\ntools:\n - mcp__agent-hub__hub_review\n - mcp__agent-hub__hub_task_show\nsubagents: []\n---\n\n${context.systemPrompt}\n\nUse only the supplied public facts and hub_task_show. Never claim to have read evidence that was not supplied.\n\n${context.input ?? "No review is active; startup self-check only."}\n`, { mode: 0o600 });
|
|
131
|
+
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, KIMI_CODE_HOME: tool, TMPDIR: `${temporary}/`, XDG_CONFIG_HOME: join(home, ".config"), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...(process.env.USER ? { USER: process.env.USER } : {}) };
|
|
132
|
+
const native = context.command ?? [Bun.which("kimi", { PATH: process.env.PATH }) ?? "kimi"];
|
|
133
|
+
const executable = realPath(Bun.which(native[0]!, { PATH: process.env.PATH }) ?? resolve(native[0]!));
|
|
134
|
+
const command = [executable, ...native.slice(1)];
|
|
135
|
+
const executableStat = lstatSync(executable);
|
|
136
|
+
if (await version(command, cwd, env, context.signal) !== VERSION) throw new Error("unsupported Kimi version; receipt covers 2.2.0");
|
|
137
|
+
const fingerprints = [configFile, mcpFile, agentFile].map(file => hash(read(file, 2_097_152)));
|
|
138
|
+
let cleaned = false;
|
|
139
|
+
const cleanup = () => { if (cleaned) return; rmSync(root, { recursive: true, force: true }); cleaned = true; };
|
|
140
|
+
return { command, args: ["-p", "Review the supplied task facts and record the evidence-based verdict with hub_review.", "--output-format", "stream-json", "--agent-file", agentFile, "--skills-dir", skills],
|
|
141
|
+
cwd, env, envMode: "replace", cleanup, watchCurrent: true,
|
|
142
|
+
stop: child => stopKimiReview(child, [join(sources[1]!, "kimi-code.lock"), join(sources[1]!, `${selected.lock}.lock`)]),
|
|
143
|
+
current: () => {
|
|
144
|
+
try {
|
|
145
|
+
assertCleanCwdAncestors(join(lexicalRoot,"workspace"),cwd);
|
|
146
|
+
const now = lstatSync(executable);
|
|
147
|
+
return !cleaned && !context.signal.aborted && realPath(sourcePath) === source && now.dev === executableStat.dev && now.ino === executableStat.ino && now.size === executableStat.size && now.mtimeMs === executableStat.mtimeMs && now.ctimeMs === executableStat.ctimeMs && hash(read(sourceConfig)) === hash(original)
|
|
148
|
+
&& [configFile, mcpFile, agentFile].every((file, index) => hash(read(file, 2_097_152)) === fingerprints[index])
|
|
149
|
+
&& ["credentials", "oauth", "device_id"].every((name, index) => realPath(join(source, name)) === sources[index] && realPath(join(tool, name)) === sources[index]);
|
|
150
|
+
} catch { return false; }
|
|
151
|
+
},
|
|
152
|
+
isolation: { backend: "kimi", version: VERSION, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
|
|
153
|
+
enforcement: "native-policy", freshSession: true, hubToolsOnly: true,
|
|
154
|
+
evidence: ["#533 person ruling2026-10-19: native execution-time exact tool allowlist, no kernel sandbox", "private config/cwd/empty skills; shared OAuth directory symlinks and native lock/atomic rename", "task-bound in-process hub MCP; no full control token", "residual: interruption mid-refresh may require native re-login"] },
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (own) rmSync(own, { recursive: true, force: true });
|
|
158
|
+
return { unavailable: `Kimi reviewer unavailable: ${(error as Error).message}` };
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
@@ -117,12 +117,36 @@ export interface CapabilityManifest {
|
|
|
117
117
|
}[];
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
export interface NativeFactsInput {
|
|
121
|
+
event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop";
|
|
122
|
+
phase: "session" | "start" | "pre" | "post" | "stop";
|
|
123
|
+
sessionId: string;
|
|
124
|
+
transcriptPath: string;
|
|
125
|
+
workspacePaths: string[];
|
|
126
|
+
tool: string;
|
|
127
|
+
input: Record<string, unknown>;
|
|
128
|
+
toolUseId?: string;
|
|
129
|
+
invocation?: number;
|
|
130
|
+
fullyIdle?: boolean;
|
|
131
|
+
terminationReason?: string;
|
|
132
|
+
}
|
|
133
|
+
export interface NativeFactsTranscript {
|
|
134
|
+
completion?: { id: string; index: number };
|
|
135
|
+
factIds: string[];
|
|
136
|
+
}
|
|
137
|
+
/** Fixed native codec and bounded readback, never supplied by project configuration. */
|
|
138
|
+
export interface NativeFactsPort {
|
|
139
|
+
decode(event: string, input: unknown): NativeFactsInput | undefined;
|
|
140
|
+
encode(event: string, text?: string): Record<string, unknown> | undefined;
|
|
141
|
+
read(session: string, transcript: string): NativeFactsTranscript;
|
|
142
|
+
}
|
|
143
|
+
|
|
120
144
|
/** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
|
|
121
145
|
export interface NativeHooks {
|
|
122
146
|
agentIdentity(init: unknown): void;
|
|
123
147
|
onAttach(instance: string): void;
|
|
124
148
|
onDetach(instance: string): void;
|
|
125
|
-
factsHook:
|
|
149
|
+
factsHook: NativeFactsPort;
|
|
126
150
|
conversationRevert(turnId: string): Promise<void>;
|
|
127
151
|
replaceSession(req: StartRequest): Promise<unknown>;
|
|
128
152
|
pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
|
|
@@ -229,6 +253,8 @@ export interface AdapterDefinition {
|
|
|
229
253
|
routing?: {
|
|
230
254
|
/** Takes reviews only: never chosen or nominated as a task owner, for any class. */
|
|
231
255
|
reviewOnly?: true;
|
|
256
|
+
/** Fixed role ceiling; project roles cannot turn an unmanaged implementer into a planner/reviewer. */
|
|
257
|
+
allowedRoles?: readonly string[];
|
|
232
258
|
localAllowedRefusal?: string;
|
|
233
259
|
longContext?: "backend-limit" | "skip-owner";
|
|
234
260
|
preferredTier?: boolean;
|
|
@@ -253,6 +279,8 @@ export interface AdapterDefinition {
|
|
|
253
279
|
};
|
|
254
280
|
cli: {
|
|
255
281
|
command: string;
|
|
282
|
+
/** Configuration leaves that select this peer's next native launch command or provider profile. */
|
|
283
|
+
launchConfigKeys?: readonly string[];
|
|
256
284
|
/** Restrictions only; core identity remains authoritative. */
|
|
257
285
|
authority?: "person" | "conductor";
|
|
258
286
|
personOnlyFlags?: readonly string[];
|