@staix/agent-hub 0.12.36 → 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.
Files changed (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -11
  3. package/docs/agent-notes/adapters.md +19 -6
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/tasks.md +8 -4
  8. package/docs/agent-notes/tests.md +2 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +57 -10
  11. package/docs/quickstart.md +15 -7
  12. package/docs/sdlc.md +29 -3
  13. package/docs/security.md +10 -2
  14. package/docs/smoke.md +38 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
  16. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  17. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  18. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  19. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  20. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  21. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  22. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  23. package/docs/verified.json +27 -15
  24. package/package.json +2 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +5627 -3670
  27. package/src/adapters/acp.ts +3 -2
  28. package/src/adapters/agy/definition.ts +61 -0
  29. package/src/adapters/agy/entry.ts +76 -0
  30. package/src/adapters/agy/hooks.ts +81 -0
  31. package/src/adapters/claude-channel.ts +1 -0
  32. package/src/adapters/claude-print.ts +18 -156
  33. package/src/adapters/codex-review-isolation.ts +290 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +9 -6
  35. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  38. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  39. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  40. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  41. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  42. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  43. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  44. package/src/adapters/definitions/native-factories.ts +17 -0
  45. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  46. package/src/adapters/definitions/qwen.ts +1 -1
  47. package/src/adapters/definitions/reviewer-variants.ts +33 -0
  48. package/src/adapters/jcode.ts +20 -5
  49. package/src/adapters/kimi-review.ts +161 -0
  50. package/src/adapters/registry-types.ts +36 -4
  51. package/src/adapters/registry.ts +28 -7
  52. package/src/adapters/reviewer-mcp.ts +85 -0
  53. package/src/adapters/reviewer-print.ts +382 -0
  54. package/src/cli/agy-mcp.ts +370 -0
  55. package/src/cli/console-state.ts +6 -0
  56. package/src/cli/console.ts +2 -0
  57. package/src/cli/facts-hook.ts +15 -2
  58. package/src/cli/help.ts +2 -0
  59. package/src/cli/identity.ts +1 -1
  60. package/src/cli/init.ts +7 -0
  61. package/src/cli/launch.ts +3 -1
  62. package/src/cli/main.ts +20 -0
  63. package/src/cli/memory.ts +13 -12
  64. package/src/cli/output.ts +5 -0
  65. package/src/hub/board.ts +34 -6
  66. package/src/hub/bus.ts +17 -6
  67. package/src/hub/config-apply.ts +3 -2
  68. package/src/hub/control-client.ts +7 -1
  69. package/src/hub/daemon.ts +266 -46
  70. package/src/hub/envelope.ts +1 -1
  71. package/src/hub/host-executable.ts +1 -1
  72. package/src/hub/hub-tools.ts +5 -0
  73. package/src/hub/instances.ts +13 -3
  74. package/src/hub/memory-profile.ts +67 -11
  75. package/src/hub/native-hook-launch.ts +37 -0
  76. package/src/hub/peer-catalog.ts +2 -3
  77. package/src/hub/planning.ts +2 -0
  78. package/src/hub/release-admission.ts +64 -0
  79. package/src/hub/reviewer-pool-config.ts +11 -0
  80. package/src/hub/reviewer-pool.ts +76 -0
  81. package/src/hub/routing.ts +45 -3
  82. package/src/hub/sdlc-release.ts +113 -0
  83. package/src/hub/tasks.ts +261 -36
  84. package/src/ui/index.html +6 -1
  85. package/templates/AGENTS.block.md +1 -1
@@ -68,7 +68,7 @@ export interface AcpOptions {
68
68
  /** Production Qwen only; legacy benchmark adapters retain their own native policy. */
69
69
  qwen025?: boolean;
70
70
  /** A production adapter's source-pinned identity; no table is inferred from it. */
71
- expectedAgent?: { name: string; version: string };
71
+ expectedAgent?: { name: string; version: string | ((version: unknown) => boolean) };
72
72
  /** Vendors with uncertain load semantics must explicitly echo the restored identity. */
73
73
  requireLoadSessionEcho?: boolean;
74
74
  /** Bound for native mode acknowledgement; defaults to the handshake deadline. */
@@ -242,7 +242,8 @@ export class AcpPeer extends BasePeer {
242
242
  // The configured peer id and command are labels, not an agent-specific mode contract.
243
243
  // Installed Kimi Code CLI's ACP initialize advertises this exact name.
244
244
  this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
245
- if (this.opts.expectedAgent && (init?.agentInfo?.name !== this.opts.expectedAgent.name || init?.agentInfo?.version !== this.opts.expectedAgent.version)) throw new Error("native ACP identity is not verified for this production adapter");
245
+ const expected = this.opts.expectedAgent, version = init?.agentInfo?.version;
246
+ if (expected && (init?.agentInfo?.name !== expected.name || !(typeof expected.version === "function" ? expected.version(version) : version === expected.version))) throw new Error("native ACP identity is not verified for this production adapter");
246
247
  this.qwenAgent = qwenNativeIdentity(this.id, this.opts.qwen025, init, this.opts.kind);
247
248
  const resume = this.opts.resumeSessionId;
248
249
  if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
@@ -0,0 +1,61 @@
1
+ import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
2
+ import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
3
+ import { AGY_FACTS_HOOK } from "./hooks.ts";
4
+
5
+ const DISPLAY = { palette: { fg: "#6558a6", bg: "#f1effa" } };
6
+ const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
7
+
8
+ function agyLaunch(ctx: LaunchContext): LaunchPlan {
9
+ const argv = ["agy", ...ctx.userArgs], command = argv.map(shellQuote).join(" ");
10
+ const metadata = { packageEntrypoint: ctx.entrypoint, command, argv, env: {}, kind: "agy", peer: ctx.instance } as LaunchPlan["metadata"] & { kind: "agy"; peer: string };
11
+ return { cmd: "agy", args: ctx.userArgs, metadata };
12
+ }
13
+
14
+ /** Antigravity is an unmanaged, pull-only peer attached from its native project plugin. */
15
+ export const AGY_DEFINITION: AdapterDefinition = {
16
+ api: 1,
17
+ kind: "agy",
18
+ displayName: "Antigravity",
19
+ family: "agy",
20
+ transport: "ws-pull",
21
+ roles: ["implementer", "verifier"],
22
+ optional: true,
23
+ defaultOnRoster: false,
24
+ trust: { onPrem: false, hubSandboxed: false },
25
+ start: {
26
+ forms: ["tui"],
27
+ defaultForm: "tui",
28
+ ownsTerminal: form => form === "tui",
29
+ launch: agyLaunch,
30
+ conductorStartable: false,
31
+ hubSwitches: [],
32
+ ownedFlags: ["--new", "--continue", "--conversation", "--resume"],
33
+ conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub agy") : {},
34
+ },
35
+ identity: { mcp: "channel-plugin" },
36
+ attach: {
37
+ pullOnlyForms: ["tui"],
38
+ recoveryLaunch: instance => ({ kind: "agy", peer: instance }),
39
+ pullOnlyNotice: "pull-only: messages wait for hub_inbox; permission modes and session recovery are not managed",
40
+ },
41
+ permission: {
42
+ modes: {},
43
+ unmanaged: "Antigravity selects its own permission mode in its TUI; no hub permission mode",
44
+ unmanagedForms: ["tui"],
45
+ unmanagedNote: "Antigravity approves tools in its own TUI; no hub permission mode",
46
+ unmanagedStatusNote: "unmanaged native permission policy",
47
+ },
48
+ session: {
49
+ idField: "sessionId",
50
+ currentId: () => undefined,
51
+ freshOnRestore: true,
52
+ recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
53
+ },
54
+ turnEnd: "native-hook",
55
+ cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
56
+ display: DISPLAY,
57
+ routing: { allowedRoles: ["implementer", "verifier"] },
58
+ reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
59
+ reviewerVariant: AGY_REVIEWER_VARIANT,
60
+ hooks: { factsHook: AGY_FACTS_HOOK },
61
+ };
@@ -0,0 +1,76 @@
1
+ import { publicArg } from "../definitions/native-cli-preview-common.ts";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
5
+ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
6
+ import { ensureAgyMcp, cleanupAgyMcp, type AgyMcpOptions } from "../../cli/agy-mcp.ts";
7
+ import { peerChildEnv } from "../../hub/child-process.ts";
8
+
9
+ export function agyTuiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv) {
10
+ return { cwd, env, stdio: "inherit" as const };
11
+ }
12
+
13
+ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<number> {
14
+ const child = spawn("agy", argv, agyTuiSpawnOptions(cwd, env));
15
+ return new Promise((resolveExit, reject) => {
16
+ const forward = (signal: NodeJS.Signals) => {
17
+ if (child.exitCode !== null || child.signalCode !== null) return;
18
+ try { child.kill(signal); } catch { /* The native child may exit as the signal arrives. */ }
19
+ };
20
+ const onInterrupt = () => forward("SIGINT");
21
+ const onTerminate = () => forward("SIGTERM");
22
+ const onHangup = () => forward("SIGHUP");
23
+ const cleanup = () => {
24
+ process.off("SIGINT", onInterrupt);
25
+ process.off("SIGTERM", onTerminate);
26
+ process.off("SIGHUP", onHangup);
27
+ };
28
+ process.on("SIGINT", onInterrupt);
29
+ process.on("SIGTERM", onTerminate);
30
+ process.on("SIGHUP", onHangup);
31
+ child.once("error", error => { cleanup(); reject(error); });
32
+ child.once("close", code => { cleanup(); resolveExit(code ?? 1); });
33
+ });
34
+ }
35
+
36
+ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
37
+ const { args, cwd, stateDir, cliDir, connect, fail } = ctx;
38
+ if (ctx.instance) fail("extra Antigravity TUI instances are not verified in P1");
39
+ const preview = args.includes("--print-command") || args.includes("--dry-run");
40
+ const unattended = ctx.unattendedEnv || args.includes("--unattended");
41
+ const nativeArgs = args.filter(arg => !["--print-command", "--dry-run", "--unattended"].includes(arg));
42
+ // Headless, resume and native approval overrides are not silently passed through an unverified hub mapping.
43
+ if (nativeArgs.some(arg => ["--print", "-p", "--prompt", "--input-format", "--output-format", "--conversation", "--continue", "-c", "--new", "--mode", "--dangerously-skip-permissions"].some(flag => arg === flag || arg.startsWith(`${flag}=`)))) fail("agy P1 supports a fresh TUI only; headless, resume and permission flags need a verified mapping");
44
+ if (unattended) fail("agy P1 native permissions are unmanaged; unattended mode has no verified prompt mapping");
45
+ const argv = nativeArgs;
46
+ if (preview) { console.log(JSON.stringify({ command: "agy", args: argv.map(publicArg), mode: "tui", permission: "unmanaged", mcp: ".agents/plugins/agent-hub/", writes: false })); return; }
47
+ const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
48
+ if (version.status !== 0 || version.stdout.trim() !== "1.4.2") fail("agy P1 requires the verified Antigravity CLI 1.4.2");
49
+ const hub = await connect();
50
+ let options: AgyMcpOptions | undefined;
51
+ let pluginWritten = false;
52
+ try {
53
+ if (!hub.features.includes("native_hook_launch")) fail("running hub lacks the verified native hook launcher; upgrade or restart it before launching agy");
54
+ const status = (await hub.request({ t: "status" })).status;
55
+ if (rosterInStatus(status, "agy") !== true) fail("agy is off roster; add it with ahub roster add agy");
56
+ const admitted = await hub.request({ t: "native_hook_launch", op: "begin", peer: "agy" });
57
+ if (!admitted.ok || typeof admitted.launchId !== "string" || typeof admitted.instanceId !== "string") fail(admitted.error ?? "native hook launch was not admitted");
58
+ options = { projectRoot: cwd, stateDir, serverJs: bundleInStatus(status) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"),
59
+ hookScript: resolve(cliDir, "facts-hook.ts"), peer: "agy", launchId: admitted.launchId, instanceId: admitted.instanceId };
60
+ const written = ensureAgyMcp(options);
61
+ pluginWritten = written.changed;
62
+ if (written.tracked) console.error("The managed agy plugin is tracked; review its local machine paths before committing");
63
+ console.error("Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox");
64
+ const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
65
+ delete env.AGENTHUB_CHANNEL;
66
+ const code = await runAgyTui(argv, cwd, env);
67
+ if (code !== 0) process.exitCode = code;
68
+ } finally {
69
+ if (options) {
70
+ // Revoke launcher authority before cleanup; stale cleanup never removes a newer managed launch.
71
+ await hub.request({ t: "native_hook_launch", op: "end", peer: "agy", launchId: options.launchId }).catch(() => {});
72
+ if (pluginWritten) try { cleanupAgyMcp(options); } catch { console.error("Managed agy plugin cleanup refused: files changed or ownership could not be verified"); }
73
+ }
74
+ hub.close();
75
+ }
76
+ }
@@ -0,0 +1,81 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import type { NativeFactsInput, NativeFactsPort, NativeFactsTranscript } from "../registry-types.ts";
6
+
7
+ const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
10
+
11
+ /** The pinned 1.4.2 envelope has no event name: the managed command supplies it separately. */
12
+ export function decodeAgyHook(event: string, value: unknown): NativeFactsInput | undefined {
13
+ if (!(EVENTS as readonly string[]).includes(event) || !record(value) || typeof value.conversationId !== "string" || !UUID.test(value.conversationId)
14
+ || typeof value.transcriptPath !== "string" || !isAbsolute(value.transcriptPath) || value.transcriptPath.length > 4096
15
+ || !Array.isArray(value.workspacePaths) || !value.workspacePaths.length || value.workspacePaths.length > 32
16
+ || !value.workspacePaths.every(path => typeof path === "string" && path.length <= 4096 && isAbsolute(path))) return;
17
+ if (event === "Stop" && typeof value.fullyIdle !== "boolean") return;
18
+ const call = record(value.toolCall) ? value.toolCall : undefined;
19
+ if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record(call.args))) return;
20
+ const native = call?.args as Record<string, unknown> | undefined;
21
+ // Only the observed native read is classified. Unobserved edits never receive a scoped grant or effect attribution.
22
+ const observedRead = call?.name === "view_file" && typeof native?.AbsolutePath === "string";
23
+ const phase = ({ SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" } as const)[event as typeof EVENTS[number]];
24
+ return { event: event as NativeFactsInput["event"], phase, sessionId: value.conversationId, transcriptPath: value.transcriptPath,
25
+ workspacePaths: value.workspacePaths as string[], tool: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path: native!.AbsolutePath } : native ?? {},
26
+ ...(Number.isSafeInteger(value.stepIdx) && (value.stepIdx as number) >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {}),
27
+ ...(Number.isSafeInteger(value.invocationNum) && (value.invocationNum as number) >= 0 ? { invocation: value.invocationNum as number } : {}),
28
+ ...(event === "Stop" ? { fullyIdle: value.fullyIdle as boolean, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}) };
29
+ }
30
+
31
+ export function encodeAgyHook(event: string, text?: string): Record<string, unknown> | undefined {
32
+ // `allow` does not override native approval. Until a prompt mapping is proven, leave all native permissions alone.
33
+ if (text && (event === "SessionStart" || event === "PreInvocation")) return { injectSteps: [{ ephemeralMessage: text }] };
34
+ return undefined;
35
+ }
36
+
37
+ /** Only native-generated rows under the exact session cache path count as readback/completion evidence. */
38
+ export function readAgyTranscript(session: string, transcript: string): NativeFactsTranscript {
39
+ const empty: NativeFactsTranscript = { factIds: [] };
40
+ if (!UUID.test(session)) return empty;
41
+ const home = process.env.HOME ?? homedir();
42
+ const candidates = ["antigravity-cli", "antigravity", "antigravity-ide"].map(surface => join(home, ".gemini", surface, "brain", session, ".system_generated/logs/transcript_full.jsonl"));
43
+ if (!candidates.includes(transcript)) return empty;
44
+ let fd: number | undefined;
45
+ try {
46
+ let cursor = home;
47
+ for (const name of relative(home, transcript).split("/")) { cursor = join(cursor, name); if (lstatSync(cursor).isSymbolicLink()) return empty; }
48
+ fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ const stat = fstatSync(fd);
50
+ if (!stat.isFile() || stat.nlink !== 1) return empty;
51
+ // ponytail: completion lookback is capped at 4 MiB (as for Claude). A larger final row is unverified;
52
+ // upgrade to a native completion cursor if that ceiling becomes insufficient.
53
+ const cap = 4 * 1024 * 1024;
54
+ for (let window = 64 * 1024; ; window = Math.min(cap, window * 2)) {
55
+ const start = Math.max(0, stat.size - window), from = start > 0 ? start - 1 : 0;
56
+ const buffer = Buffer.alloc(stat.size - from);
57
+ let bytes = 0;
58
+ while (bytes < buffer.length) {
59
+ const count = readSync(fd, buffer, bytes, buffer.length - bytes, from + bytes);
60
+ if (!count) break;
61
+ bytes += count;
62
+ }
63
+ const lines = buffer.subarray(0, bytes).toString("utf8").split("\n");
64
+ // The preceding byte distinguishes a cut inside a row from a row beginning exactly at the window.
65
+ if (start) lines.shift();
66
+ const out: NativeFactsTranscript = { factIds: [] };
67
+ for (const line of lines) {
68
+ let row: unknown; try { row = JSON.parse(line); } catch { continue; }
69
+ if (!record(row) || row.status !== "DONE") continue;
70
+ if (row.source === "SYSTEM_SDK" && row.type === "EPHEMERAL_MESSAGE" && typeof row.content === "string") {
71
+ for (const match of row.content.matchAll(/agent-hub facts \[([a-zA-Z0-9-]{1,128})\]/g)) out.factIds.push(match[1]!);
72
+ }
73
+ if (row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index)
74
+ && typeof row.content === "string" && row.content.trim().length > 0 && (!Array.isArray(row.tool_calls) || row.tool_calls.length === 0)) out.completion = { id: createHash("sha256").update(`${session}:${row.step_index}:${line}`).digest("hex"), index: row.step_index as number };
75
+ }
76
+ if (out.completion || start === 0 || window === cap) return out;
77
+ }
78
+ } catch { return empty; } finally { if (fd !== undefined) closeSync(fd); }
79
+ }
80
+
81
+ export const AGY_FACTS_HOOK: NativeFactsPort = { decode: decodeAgyHook, encode: encodeAgyHook, read: readAgyTranscript };
@@ -368,6 +368,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
368
368
  const res = await hub.request({ t: "send", body, to, reply_to });
369
369
  if (!res.ok) return text(`not sent: ${res.error}`);
370
370
  if (res.recorded) return text("recorded only ([FYI]): it is on the hub console and log, and no peer spent a turn on it");
371
+ if (res.dropped === "hop") return text(res.notice);
371
372
  const sent = `sent to: ${res.targets.join(", ") || "(no other peers attached)"}`;
372
373
  return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
373
374
  }
@@ -1,10 +1,8 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { childEnv, peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
1
+ import { childEnv } from "../hub/child-process.ts";
4
2
  import { hubGitSync } from "../hub/git.ts";
5
3
  import { changedPathGroups } from "../hub/review-brief.ts";
6
- import { HUB, frame, type Envelope } from "../hub/envelope.ts";
7
- import { BasePeer } from "../hub/peers.ts";
4
+ import { type Envelope } from "../hub/envelope.ts";
5
+ import { ReviewerPrintPeer, type ReviewerBackend, type ReviewerPrintOptions } from "./reviewer-print.ts";
8
6
 
9
7
  export interface ClaudePrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
10
8
 
@@ -23,6 +21,7 @@ export interface ClaudePrintOptions {
23
21
  /** Whether this peer recorded a verdict on the task since `since` (ms). */
24
22
  verdictSince(task: number, since: number): boolean;
25
23
  onUsage?(usage: ClaudePrintUsage, task: number): void;
24
+ onUnavailable?: ReviewerPrintOptions["onUnavailable"];
26
25
  log?(line: string): void;
27
26
  }
28
27
 
@@ -74,157 +73,20 @@ export function reviewDiff(cwd: string, commit: unknown, policy: { privatePath(p
74
73
  return `Diff of ${commit} against its merge base with the checkout's HEAD:\n${body}${note}`;
75
74
  }
76
75
 
77
- /** The review's leader exited but its process group could not be confirmed gone; the child handle is kept. */
78
- class GroupCleanupError extends Error {}
79
-
80
- const reviewTask = (env: Envelope): number | undefined => {
81
- if (env.from !== HUB || env.kind !== "review") return undefined;
82
- const id = Number(env.refs?.task);
83
- return Number.isSafeInteger(id) && id > 0 ? id : undefined;
76
+ export const claudeReviewerBackend: ReviewerBackend = {
77
+ id: "claude",
78
+ prepare: async context => ({ command: context.command ?? ["claude"],
79
+ args: claudePrintArgs(context.sessionId, context.mcpConfig, context.systemPrompt, context.privatePaths),
80
+ isolation: { backend: "claude", version: "existing-223-contract", binary: (context.command ?? ["claude"])[0]!, profile: "strict-mcp-read-grep-glob",
81
+ readOnly: true, strictMcp: true, ephemeral: true, evidence: ["#223 fixed native allowlist, strict MCP config and unpersisted session"] } }),
82
+ usage: event => {
83
+ const row = event as { type?: string; usage?: ClaudePrintUsage } | null;
84
+ return row?.type === "result" ? row.usage : undefined;
85
+ },
84
86
  };
85
87
 
86
- /**
87
- * #223 R3: a hub-native Claude reviewer. Idle with no process; each hub review request runs one `claude -p` child in a
88
- * new session (never resumed), so no verdict sees an earlier round or builder chat. Its final text is not published:
89
- * the verdict is the child's own `hub_review` call.
90
- */
91
- export class ClaudePrintPeer extends BasePeer {
92
- readonly hubNative = true;
93
- private generation = 0;
94
- private child: ChildProcess | undefined;
95
- private activeDeliveryId: string | undefined;
96
- /** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
97
- reviewing: number | undefined;
98
- private reviewSeq = 0;
99
- /** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
100
- reviewToken(task: number): number | undefined { return this.reviewing === task ? this.reviewSeq : undefined; }
101
-
102
- constructor(id: string, private readonly opts: ClaudePrintOptions) { super(id, opts.watchdogMs); }
103
-
104
- recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-print", peer: this.id } }; }
105
-
106
- async start(): Promise<void> {
107
- // A retained child means a review's process group was never confirmed gone: no new review beside it.
108
- if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
109
- this.setState("idle");
110
- }
111
-
112
- async stop(reason?: string): Promise<void> {
113
- this.generation++; // fence before the first await: a late exit settles nothing
114
- this.settle("needs_review", reason ?? "review stopped before settlement");
115
- if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
116
- await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
117
- this.setState("offline");
118
- }
119
-
120
- protected override onWatchdog(): void {
121
- this.generation++;
122
- this.settle("needs_review", "review watchdog timeout");
123
- // Busy until the old child is gone: a new review never runs beside it.
124
- void this.kill().then(() => { if (this.state === "busy") super.onWatchdog(); }, (error: Error) => this.opts.log?.(`[${this.id}] watchdog stop failed, staying busy: ${error.message}`));
125
- }
126
-
127
- async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
128
- if (this.state !== "idle") {
129
- if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
130
- throw new Error(`${this.id} is ${this.state}`);
131
- }
132
- const generation = ++this.generation;
133
- this.activeDeliveryId = deliveryId;
134
- this.setState("busy");
135
- if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
136
- void this.run(envs, generation).catch((error: Error) => {
137
- if (generation !== this.generation) return;
138
- this.settle("needs_review", `claude reviewer failed: ${error.message}`);
139
- void this.kill().then(() => this.setState("idle"), () => undefined);
140
- });
141
- }
142
-
143
- private async run(envs: Envelope[], generation: number): Promise<void> {
144
- const missing: number[] = [];
145
- let started = false;
146
- let failure: { state: "failed_safe" | "needs_review"; reason: string } | undefined;
147
- for (const env of envs) {
148
- const task = reviewTask(env);
149
- if (task === undefined) continue; // a withdrawal or other notice: nothing to review
150
- if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
151
- const since = Date.now();
152
- let code: number | null;
153
- try { code = await this.review(env, task, generation); }
154
- catch (error) {
155
- if (generation !== this.generation) return;
156
- if (error instanceof GroupCleanupError) {
157
- // Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
158
- this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
159
- this.settle("needs_review", `review process group still running: ${error.message}`);
160
- return;
161
- }
162
- // Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
163
- failure = { state: started ? "needs_review" : "failed_safe", reason: `claude reviewer could not start: ${(error as Error).message}` }; break;
164
- }
165
- started = true;
166
- if (generation !== this.generation) return;
167
- let recorded = false;
168
- try { recorded = this.opts.verdictSince(task, since); } catch (error) { this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${(error as Error).message}`); }
169
- if (!recorded) missing.push(task);
170
- if (code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${code}`);
171
- }
172
- if (generation !== this.generation) return;
173
- this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
174
- this.setState("idle");
175
- }
176
-
177
- private review(env: Envelope, task: number, generation: number): Promise<number | null> {
178
- const [cmd = "claude", ...prefix] = this.opts.command ?? ["claude"];
179
- const args = [...prefix, ...claudePrintArgs(randomUUID(), this.opts.mcpConfig, this.opts.systemPrompt, this.opts.privatePaths)];
180
- return new Promise((resolve, reject) => {
181
- const child = spawn(cmd, args, { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id), stdio: ["pipe", "pipe", "ignore"] });
182
- // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
183
- this.child = child;
184
- child.once("error", error => { if (this.child === child) this.child = undefined; reject(error); });
185
- child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
186
- child.once("spawn", () => {
187
- trackGroup(child);
188
- if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
189
- this.reviewing = task; this.reviewSeq++;
190
- let facts: string | undefined;
191
- try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
192
- child.stdin!.end(`${frame(env)}${facts ? `\n\n${facts}` : ""}`);
193
- });
194
- let buffer = "";
195
- child.stdout!.on("data", (chunk: Buffer) => {
196
- if (generation !== this.generation) return;
197
- this.touch();
198
- buffer += chunk.toString("utf8");
199
- for (let i; (i = buffer.indexOf("\n")) >= 0;) {
200
- const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
201
- try {
202
- const event = JSON.parse(line) as { type?: string; usage?: ClaudePrintUsage };
203
- if (event.type === "result" && event.usage) this.opts.onUsage?.(event.usage, task);
204
- } catch { /* not a stream-json line */ }
205
- }
206
- });
207
- child.once("exit", code => {
208
- if (this.child === child) this.reviewing = undefined; // the verdict authority ends with the leader
209
- // The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
210
- stopOwnedProcess(child, { group: true }).then(() => { if (this.child === child) this.child = undefined; resolve(code); },
211
- (error: Error) => reject(new GroupCleanupError(error.message)));
212
- });
213
- });
214
- }
215
-
216
- private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
217
- const id = this.activeDeliveryId;
218
- this.activeDeliveryId = undefined;
219
- if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
220
- }
221
-
222
- /** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
223
- private async kill(): Promise<void> {
224
- this.reviewing = undefined;
225
- const child = this.child;
226
- if (!child) return;
227
- await stopOwnedProcess(child, { group: true });
228
- if (this.child === child) this.child = undefined;
229
- }
88
+ /** Compatibility wrapper for the released Claude transport and its existing tests. */
89
+ export class ClaudePrintPeer extends ReviewerPrintPeer {
90
+ constructor(id: string, options: ClaudePrintOptions) { super(id, { ...options, backend: claudeReviewerBackend }); }
91
+ override recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-print", peer: this.id } }; }
230
92
  }