@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,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[];
|
package/src/adapters/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AGY_DEFINITION } from "./
|
|
1
|
+
import { AGY_DEFINITION } from "./agy/definition.ts";
|
|
2
|
+
import { AGY_REVIEWER_VARIANT } from "./definitions/reviewer-variants.ts";
|
|
2
3
|
import { builtinCliEntries } from "./definitions/native-cli-entry.ts";
|
|
3
4
|
import { builtinPreview } from "./definitions/native-cli-preview.ts";
|
|
4
5
|
import { DAEMON_PORTS } from "./definitions/native-factories.ts";
|
|
@@ -13,7 +14,7 @@ export const ADAPTER_API = 1;
|
|
|
13
14
|
|
|
14
15
|
/** Ordered compatibility projections stay typed independently of definition implementation inference. */
|
|
15
16
|
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
16
|
-
export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
|
|
17
|
+
export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
|
|
17
18
|
export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
|
|
18
19
|
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
19
20
|
export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
|
|
@@ -26,7 +27,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
26
27
|
return Object.freeze(value);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION]);
|
|
30
|
+
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION, AGY_REVIEWER_VARIANT]);
|
|
30
31
|
for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
31
32
|
|
|
32
33
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
@@ -34,9 +35,10 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
|
34
35
|
...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind !== "jcode"),
|
|
35
36
|
QWEN_DEFINITION,
|
|
36
37
|
...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
|
|
38
|
+
AGY_DEFINITION,
|
|
37
39
|
]);
|
|
38
40
|
// Unavailable declarations are metadata, not supported native peers or launchers.
|
|
39
|
-
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([
|
|
41
|
+
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
|
|
40
42
|
const definitions = new Map<string, AdapterDefinition>();
|
|
41
43
|
for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
42
44
|
assertDaemonEngine(definition);
|
|
@@ -95,7 +97,7 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/** Compatibility projection order is observable in default role maps and planner selection. */
|
|
98
|
-
const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"] as const;
|
|
100
|
+
const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
|
|
99
101
|
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
|
100
102
|
|
|
101
103
|
/** Callers own their role arrays, independent of immutable native policy. */
|
|
@@ -137,6 +139,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
137
139
|
["routing", definition.routing !== undefined],
|
|
138
140
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
139
141
|
["reviewer", definition.reviewer !== undefined],
|
|
142
|
+
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
140
143
|
];
|
|
141
144
|
const field = restricted.find(([, present]) => present)?.[0];
|
|
142
145
|
if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
|
|
@@ -145,7 +148,13 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
145
148
|
/** Ordered catalog projections consumed by generic core hosts. */
|
|
146
149
|
export const STARTABLE_PEERS = SUPPORTED_PEERS;
|
|
147
150
|
export type SupportedPeer = typeof SUPPORTED_PEERS[number];
|
|
148
|
-
export function isSupportedPeer(peer: unknown): peer is
|
|
151
|
+
export function isSupportedPeer(peer: unknown, instances?: KindMap): peer is string {
|
|
152
|
+
if (typeof peer !== "string") return false;
|
|
153
|
+
if (ADAPTERS.some(definition => definition.kind === peer)) return true;
|
|
154
|
+
const kind = declaredKind(peer, instances);
|
|
155
|
+
const runtime = ADAPTERS.find(definition => definition.kind === kind), resolved = definitionOf(peer, instances);
|
|
156
|
+
return runtime !== undefined && resolved !== undefined && (resolved === runtime || resolved.reviewer?.unavailable === undefined);
|
|
157
|
+
}
|
|
149
158
|
export function isStartablePeer(peer: unknown): peer is SupportedPeer { return isSupportedPeer(peer) && definitionOf(peer)?.start.conductorStartable === true; }
|
|
150
159
|
|
|
151
160
|
/** Body-free browser metadata, derived from the same native participation and managed-tool contracts. */
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { HubTool } from "../hub/hub-tools.ts";
|
|
3
|
+
import { sanitize } from "../hub/envelope.ts";
|
|
4
|
+
|
|
5
|
+
export type ReviewerToolName = "hub_review" | "hub_task_show";
|
|
6
|
+
export interface ReviewerMcpSession { config: string; close(): void }
|
|
7
|
+
export interface ReviewerMcpOptions {
|
|
8
|
+
task: number;
|
|
9
|
+
current(): boolean;
|
|
10
|
+
tools: readonly HubTool[];
|
|
11
|
+
/** Fixed host port; the host still applies Tasks' role, privacy, head and running-child fences. */
|
|
12
|
+
call(name: ReviewerToolName, args: Record<string, unknown>): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
const NAMES: readonly ReviewerToolName[] = ["hub_review", "hub_task_show"];
|
|
15
|
+
const BODY_CAP = 256 * 1024, RESULT_CAP = 4 * 1024 * 1024;
|
|
16
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
/** One revocable review capability. This server never reads a daemon control token or accepts a peer/role. */
|
|
19
|
+
export function createReviewerMcpSession(options: ReviewerMcpOptions): ReviewerMcpSession {
|
|
20
|
+
if (!Number.isSafeInteger(options.task) || options.task < 1) throw new Error("reviewer MCP needs a task");
|
|
21
|
+
const tools = NAMES.map(name => {
|
|
22
|
+
const tool = options.tools.find(candidate => candidate.name === name);
|
|
23
|
+
if (!tool) throw new Error(`reviewer MCP tool missing: ${name}`);
|
|
24
|
+
return structuredClone(tool);
|
|
25
|
+
});
|
|
26
|
+
const authority = Buffer.from(`Bearer ${randomBytes(32).toString("hex")}`);
|
|
27
|
+
let closed = false, calls = 0;
|
|
28
|
+
const current = () => { try { return !closed && options.current(); } catch { return false; } };
|
|
29
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: BODY_CAP,
|
|
30
|
+
async fetch(request) {
|
|
31
|
+
if (request.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
32
|
+
const supplied = Buffer.from(request.headers.get("authorization") ?? "");
|
|
33
|
+
if (closed || supplied.length !== authority.length || !timingSafeEqual(supplied, authority)) return new Response("unauthorized", { status: 401 });
|
|
34
|
+
if (new URL(request.url).pathname !== "/mcp") return new Response("not found", { status: 404 });
|
|
35
|
+
if (request.method !== "POST") return new Response("method not allowed", { status: 405, headers: { Allow: "POST" } });
|
|
36
|
+
if (Number(request.headers.get("content-length")) > BODY_CAP) return new Response("body too large", { status: 413 });
|
|
37
|
+
let body: unknown;
|
|
38
|
+
try {
|
|
39
|
+
const reader = request.body?.getReader();
|
|
40
|
+
let size = 0; const parts: Uint8Array[] = [];
|
|
41
|
+
if (reader) for (;;) {
|
|
42
|
+
const part = await reader.read();
|
|
43
|
+
if (part.done) break;
|
|
44
|
+
size += part.value.byteLength;
|
|
45
|
+
if (size > BODY_CAP) { await reader.cancel(); return new Response("body too large", { status: 413 }); }
|
|
46
|
+
parts.push(part.value);
|
|
47
|
+
}
|
|
48
|
+
body = JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
49
|
+
} catch { return new Response("invalid request", { status: 400 }); }
|
|
50
|
+
if (!record(body) || body.jsonrpc !== "2.0" || typeof body.method !== "string") return new Response("invalid request", { status: 400 });
|
|
51
|
+
const id = body.id;
|
|
52
|
+
const reply = (result: unknown) => Response.json({ jsonrpc: "2.0", id, result });
|
|
53
|
+
const error = (message: string, code = -32602) => Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
54
|
+
if (!current()) return error("review authority ended", -32001);
|
|
55
|
+
if (body.method === "notifications/initialized" && id === undefined) return new Response(null, { status: 202 });
|
|
56
|
+
if (typeof id !== "string" && typeof id !== "number") return error("request id required");
|
|
57
|
+
if (body.method === "initialize") return reply({ protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "agent-hub-review", version: "1" } });
|
|
58
|
+
if (body.method === "tools/list") return reply({ tools });
|
|
59
|
+
if (body.method !== "tools/call") return error("method unavailable", -32601);
|
|
60
|
+
if (!record(body.params)) return error("tool parameters required");
|
|
61
|
+
const name = body.params.name;
|
|
62
|
+
if (name !== "hub_review" && name !== "hub_task_show") return error("tool unavailable");
|
|
63
|
+
let args = body.params.arguments;
|
|
64
|
+
if (typeof args === "string") { try { args = JSON.parse(args); } catch { return error("invalid tool arguments"); } }
|
|
65
|
+
if (!record(args) || args.id !== options.task || !Number.isSafeInteger(args.id)) return error("tool is bound to its review task");
|
|
66
|
+
if (++calls > 64) return error("review tool call limit", -32002);
|
|
67
|
+
if (!current()) return error("review authority ended", -32001);
|
|
68
|
+
try {
|
|
69
|
+
const result = await options.call(name, args);
|
|
70
|
+
// Tasks.review rechecks its captured authority through its final write. A successful verdict
|
|
71
|
+
// ends that in_review binding itself; acknowledge its committed result, never fresh reads.
|
|
72
|
+
if (name !== "hub_review" && !current()) return error("review authority ended", -32001);
|
|
73
|
+
if (Buffer.byteLength(result) > RESULT_CAP) return error("tool result exceeds review limit");
|
|
74
|
+
return reply({ content: [{ type: "text", text: result }] });
|
|
75
|
+
} catch (failure) {
|
|
76
|
+
// The same host tool boundary supplies refusals to the released MCP transport.
|
|
77
|
+
const message = failure instanceof Error ? sanitize(failure.message).slice(0, 4000) : "review tool refused";
|
|
78
|
+
return reply({ isError: true, content: [{ type: "text", text: message }] });
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return { config: JSON.stringify({ mcpServers: { "agent-hub": { transport: "http", url: `http://127.0.0.1:${server.port}/mcp`, headers: { Authorization: authority.toString() } } } }),
|
|
83
|
+
close() { if (closed) return; closed = true; authority.fill(0); void server.stop(true); },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -4,6 +4,7 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
|
|
|
4
4
|
import { HUB, frame } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
|
+
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
7
8
|
|
|
8
9
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
9
10
|
export interface ReviewerIsolationReceipt {
|
|
@@ -12,8 +13,11 @@ export interface ReviewerIsolationReceipt {
|
|
|
12
13
|
binary: string;
|
|
13
14
|
profile: string;
|
|
14
15
|
readOnly: true;
|
|
15
|
-
strictMcp:
|
|
16
|
-
ephemeral:
|
|
16
|
+
strictMcp: boolean;
|
|
17
|
+
ephemeral: boolean;
|
|
18
|
+
enforcement?: "kernel" | "native-policy";
|
|
19
|
+
freshSession?: true;
|
|
20
|
+
hubToolsOnly?: true;
|
|
17
21
|
evidence: readonly string[];
|
|
18
22
|
}
|
|
19
23
|
export interface ReviewerPrepareContext {
|
|
@@ -25,6 +29,9 @@ export interface ReviewerPrepareContext {
|
|
|
25
29
|
privatePaths: readonly string[];
|
|
26
30
|
command?: readonly string[];
|
|
27
31
|
signal: AbortSignal;
|
|
32
|
+
/** Host-framed public review evidence, never configuration-selected argv. */
|
|
33
|
+
input?: string;
|
|
34
|
+
task?: number;
|
|
28
35
|
}
|
|
29
36
|
export interface ReviewerLaunch {
|
|
30
37
|
command: readonly string[];
|
|
@@ -34,9 +41,16 @@ export interface ReviewerLaunch {
|
|
|
34
41
|
current?: () => boolean;
|
|
35
42
|
env?: NodeJS.ProcessEnv;
|
|
36
43
|
cleanup?: () => void | Promise<void>;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
envMode?: "replace";
|
|
46
|
+
stop?: (child: ChildProcess) => Promise<void>;
|
|
47
|
+
/** Latch a change in native policy for the running review, not just at spawn. */
|
|
48
|
+
watchCurrent?: true;
|
|
37
49
|
}
|
|
38
50
|
export interface ReviewerBackend {
|
|
39
51
|
id: string;
|
|
52
|
+
taskMcp?: true;
|
|
53
|
+
nativePolicy?: true;
|
|
40
54
|
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
41
55
|
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
42
56
|
failure?(event: unknown): "quota" | undefined;
|
|
@@ -48,6 +62,7 @@ export interface ReviewerFailure {
|
|
|
48
62
|
cause: "start_failed" | "quota" | "no_verdict";
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
|
|
51
66
|
export interface ReviewerPrintOptions {
|
|
52
67
|
cwd: string;
|
|
53
68
|
command?: readonly string[];
|
|
@@ -61,6 +76,10 @@ export interface ReviewerPrintOptions {
|
|
|
61
76
|
onUsage?(usage: ReviewerPrintUsage, task: number): void;
|
|
62
77
|
onUnavailable?(failure: ReviewerFailure, current: () => boolean): void | Promise<void>;
|
|
63
78
|
log?(line: string): void;
|
|
79
|
+
taskMcp?(task: number, current: () => boolean): ReviewerMcpSession;
|
|
80
|
+
/** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
|
|
81
|
+
admit?(env: Envelope): boolean;
|
|
82
|
+
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
// ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
|
|
@@ -86,6 +105,10 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
86
105
|
readonly hubNative = true;
|
|
87
106
|
private generation = 0;
|
|
88
107
|
private child: ChildProcess | undefined;
|
|
108
|
+
private childStop: ((child: ChildProcess) => Promise<void>) | undefined;
|
|
109
|
+
private childCleanup: (() => Promise<void>) | undefined;
|
|
110
|
+
private childCurrent: (() => boolean) | undefined;
|
|
111
|
+
private reviewMcpClose: (() => void) | undefined;
|
|
89
112
|
private activeDeliveryId: string | undefined;
|
|
90
113
|
private batch: ReviewBatch | undefined;
|
|
91
114
|
private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
|
|
@@ -94,8 +117,18 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
94
117
|
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
95
118
|
reviewing: number | undefined;
|
|
96
119
|
private reviewSeq = 0;
|
|
120
|
+
private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
|
|
121
|
+
/** Immutable envelope identity of this running child, never the board's later head. */
|
|
122
|
+
reviewBinding(task: number): ReviewerReviewBinding | undefined {
|
|
123
|
+
return this.reviewing === task && this.runningBinding?.task === task ? this.runningBinding.binding : undefined;
|
|
124
|
+
}
|
|
97
125
|
/** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
|
|
98
|
-
reviewToken(task: number): number | undefined {
|
|
126
|
+
reviewToken(task: number): number | undefined {
|
|
127
|
+
if (this.reviewing !== task) return undefined;
|
|
128
|
+
try { if (this.childCurrent?.() === false) { this.abortReview("review policy changed"); return undefined; } }
|
|
129
|
+
catch { this.abortReview("review policy changed"); return undefined; }
|
|
130
|
+
return this.reviewSeq;
|
|
131
|
+
}
|
|
99
132
|
|
|
100
133
|
constructor(id: string, private readonly opts: ReviewerPrintOptions) { super(id, opts.watchdogMs); }
|
|
101
134
|
|
|
@@ -119,6 +152,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
119
152
|
|
|
120
153
|
async stop(reason?: string): Promise<void> {
|
|
121
154
|
this.generation++; // fence before the first await: a late exit settles nothing
|
|
155
|
+
this.reviewMcpClose?.();
|
|
122
156
|
this.receipt = undefined;
|
|
123
157
|
this.batch = undefined;
|
|
124
158
|
this.preparation?.abort.abort();
|
|
@@ -131,9 +165,10 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
131
165
|
|
|
132
166
|
protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
|
|
133
167
|
|
|
134
|
-
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded"): void {
|
|
168
|
+
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed"): void {
|
|
135
169
|
const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
|
|
136
170
|
this.reviewing = undefined;
|
|
171
|
+
this.reviewMcpClose?.();
|
|
137
172
|
this.receipt = undefined;
|
|
138
173
|
this.clearWatchdog();
|
|
139
174
|
preparation?.abort.abort();
|
|
@@ -230,14 +265,20 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
230
265
|
}
|
|
231
266
|
}
|
|
232
267
|
|
|
233
|
-
private prepare(sessionId: string): Promise<ReviewerLaunch> {
|
|
268
|
+
private prepare(sessionId: string, input?: string, task?: number, mcpConfig?: string): Promise<ReviewerLaunch> {
|
|
234
269
|
const abort = new AbortController();
|
|
235
270
|
const done = (async () => {
|
|
236
|
-
|
|
237
|
-
|
|
271
|
+
if (this.opts.backend.taskMcp && !this.opts.taskMcp) throw new Error("task-bound reviewer MCP is unavailable");
|
|
272
|
+
const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId,
|
|
273
|
+
mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
|
|
274
|
+
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
|
|
275
|
+
...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
|
|
238
276
|
if ("unavailable" in launch) throw new Error(launch.unavailable);
|
|
239
277
|
const proof = launch.isolation;
|
|
240
|
-
|
|
278
|
+
const policy = this.opts.backend.nativePolicy
|
|
279
|
+
? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
|
|
280
|
+
: proof?.strictMcp === true && proof.ephemeral === true;
|
|
281
|
+
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
241
282
|
await launch.cleanup?.();
|
|
242
283
|
throw new Error("reviewer isolation is unavailable or preparation was cancelled");
|
|
243
284
|
}
|
|
@@ -250,25 +291,44 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
250
291
|
}
|
|
251
292
|
|
|
252
293
|
private async review(env: Envelope, task: number, generation: number): Promise<{ code: number | null; quota: boolean }> {
|
|
253
|
-
|
|
294
|
+
if (this.opts.admit?.(env) === false) throw new Error("review admission changed");
|
|
295
|
+
let facts: string | undefined;
|
|
296
|
+
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
297
|
+
const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
|
|
298
|
+
let launch: ReviewerLaunch | undefined;
|
|
299
|
+
const revision=Number(env.refs?.reviewRevision);
|
|
300
|
+
const binding: ReviewerReviewBinding=Object.freeze({head:env.refs?.commit,flowRevision:Number.isSafeInteger(revision)&&revision>0?revision:undefined});
|
|
301
|
+
const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
|
|
302
|
+
let mcpClosed=false;
|
|
303
|
+
const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
|
|
304
|
+
this.reviewMcpClose=closeMcp;
|
|
305
|
+
let ownedChild: ChildProcess | undefined, releasing: Promise<void> | undefined;
|
|
306
|
+
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
307
|
+
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
254
308
|
try {
|
|
255
|
-
|
|
309
|
+
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
310
|
+
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new Error("reviewer isolation or admission changed before child spawn");
|
|
256
311
|
this.receipt = launch.isolation;
|
|
257
312
|
const [cmd, ...prefix] = launch.command;
|
|
258
313
|
let quota = false;
|
|
259
314
|
return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
|
|
260
|
-
const child = spawn(cmd!, [...prefix, ...launch
|
|
315
|
+
const child = spawn(cmd!, [...prefix, ...launch!.args], { cwd: launch!.cwd ?? this.opts.cwd, detached: true,
|
|
316
|
+
env: peerChildEnv(this.id, launch!.envMode === "replace" ? launch!.env : { ...process.env, ...launch!.env }), stdio: ["pipe", "pipe", "ignore"] });
|
|
261
317
|
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
262
318
|
this.child = child;
|
|
263
|
-
|
|
319
|
+
ownedChild = child;
|
|
320
|
+
this.childStop = launch!.stop;
|
|
321
|
+
this.childCleanup = release;
|
|
322
|
+
this.childCurrent = launch!.watchCurrent ? launch!.current : undefined;
|
|
323
|
+
child.once("error", error => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } reject(error); });
|
|
264
324
|
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
265
325
|
child.once("spawn", () => {
|
|
266
326
|
trackGroup(child);
|
|
267
327
|
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
328
|
+
this.runningBinding={task,binding};
|
|
268
329
|
this.reviewing = task; this.reviewSeq++;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
child.stdin!.end(`${frame(env)}${facts ? `\n\n${facts}` : ""}`);
|
|
330
|
+
if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
|
|
331
|
+
child.stdin!.end(input);
|
|
272
332
|
});
|
|
273
333
|
let buffer = "", outputBytes = 0;
|
|
274
334
|
child.stdout!.on("data", (chunk: Buffer) => {
|
|
@@ -290,14 +350,16 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
290
350
|
this.touch();
|
|
291
351
|
});
|
|
292
352
|
child.once("exit", code => {
|
|
293
|
-
if (this.child === child) this.reviewing = undefined; // the verdict authority ends with the leader
|
|
353
|
+
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; } // the verdict authority ends with the leader
|
|
294
354
|
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
295
|
-
stopOwnedProcess(child, { group: true }).then(() => { if (this.child === child) this.child = undefined; resolve({ code, quota }); },
|
|
355
|
+
(launch!.stop ? launch!.stop(child) : stopOwnedProcess(child, { group: true })).then(() => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } resolve({ code, quota }); },
|
|
296
356
|
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
297
357
|
});
|
|
298
358
|
});
|
|
299
359
|
} finally {
|
|
300
|
-
|
|
360
|
+
closeMcp();
|
|
361
|
+
if (policyTimer) clearInterval(policyTimer);
|
|
362
|
+
try { if (!ownedChild || this.child !== ownedChild) { await release(); if (this.childCleanup === release) this.childCleanup = undefined; } }
|
|
301
363
|
catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
|
|
302
364
|
}
|
|
303
365
|
}
|
|
@@ -311,9 +373,10 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
311
373
|
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
|
312
374
|
private async kill(): Promise<void> {
|
|
313
375
|
this.reviewing = undefined;
|
|
376
|
+
this.runningBinding=undefined;
|
|
314
377
|
const child = this.child;
|
|
315
378
|
if (!child) return;
|
|
316
|
-
await stopOwnedProcess(child, { group: true });
|
|
317
|
-
if (this.child === child) this.child = undefined;
|
|
379
|
+
await (this.childStop ? this.childStop(child) : stopOwnedProcess(child, { group: true }));
|
|
380
|
+
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
318
381
|
}
|
|
319
382
|
}
|