@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/adapters.md +19 -8
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +6 -3
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +3 -3
  9. package/docs/operations.md +45 -17
  10. package/docs/quickstart.md +19 -5
  11. package/docs/security.md +7 -3
  12. package/docs/smoke.md +22 -2
  13. package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
  14. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  15. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  16. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  17. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  20. package/docs/verified.json +22 -14
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1759 -437
  24. package/src/adapters/acp.ts +20 -2
  25. package/src/adapters/agy/definition.ts +61 -0
  26. package/src/adapters/agy/entry.ts +76 -0
  27. package/src/adapters/agy/hooks.ts +81 -0
  28. package/src/adapters/claude-channel.ts +1 -0
  29. package/src/adapters/codex-review-isolation.ts +30 -16
  30. package/src/adapters/definitions/builtin-metadata.ts +8 -8
  31. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  32. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  33. package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
  34. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  35. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  37. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  38. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  39. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  41. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  42. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  43. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  44. package/src/adapters/definitions/qwen.ts +1 -1
  45. package/src/adapters/definitions/reviewer-variants.ts +4 -3
  46. package/src/adapters/jcode.ts +58 -1
  47. package/src/adapters/kimi-review.ts +161 -0
  48. package/src/adapters/registry-types.ts +29 -1
  49. package/src/adapters/registry.ts +16 -7
  50. package/src/adapters/reviewer-mcp.ts +85 -0
  51. package/src/adapters/reviewer-print.ts +83 -20
  52. package/src/cli/agy-mcp.ts +370 -0
  53. package/src/cli/console-state.ts +6 -0
  54. package/src/cli/console.ts +2 -0
  55. package/src/cli/facts-hook.ts +15 -2
  56. package/src/cli/init.ts +7 -0
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +5 -4
  59. package/src/cli/output.ts +5 -0
  60. package/src/cli/preview.ts +2 -2
  61. package/src/hub/bus.ts +17 -6
  62. package/src/hub/control-client.ts +7 -1
  63. package/src/hub/daemon.ts +182 -24
  64. package/src/hub/native-hook-launch.ts +37 -0
  65. package/src/hub/peer-catalog.ts +2 -3
  66. package/src/hub/peers.ts +2 -2
  67. package/src/hub/reviewer-pool.ts +12 -6
  68. package/src/hub/routing.ts +3 -0
  69. package/src/hub/start-mode.ts +1 -1
  70. package/src/hub/tasks.ts +7 -0
  71. package/src/ui/index.html +6 -1
  72. package/templates/AGENTS.block.md +1 -1
@@ -1,4 +1,5 @@
1
- import { AGY_DEFINITION } from "./definitions/reviewer-variants.ts";
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([AGY_DEFINITION]);
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 SupportedPeer { return typeof peer === "string" && ADAPTERS.some(definition => definition.kind === peer); }
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: true;
16
- ephemeral: true;
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 { return this.reviewing === task ? this.reviewSeq : 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
- const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId, mcpConfig: this.opts.mcpConfig,
237
- systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal });
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
- if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || proof.strictMcp !== true || proof.ephemeral !== true || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
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
- const launch = await this.prepare(randomUUID());
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
- if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before child spawn");
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.args], { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id, { ...process.env, ...launch.env }), stdio: ["pipe", "pipe", "ignore"] });
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
- child.once("error", error => { if (this.child === child) this.child = undefined; reject(error); });
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
- let facts: string | undefined;
270
- try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
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
- try { await launch.cleanup?.(); }
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
  }