@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
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,9 +14,9 @@ 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
|
-
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
19
|
+
export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
|
|
19
20
|
export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
|
|
20
21
|
|
|
21
22
|
|
|
@@ -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
|
}
|