@staix/agent-hub 0.12.36 → 0.12.37

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -8
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +5 -1
  6. package/docs/agent-notes/tasks.md +5 -2
  7. package/docs/agent-notes/tests.md +2 -2
  8. package/docs/memory.md +3 -1
  9. package/docs/operations.md +42 -10
  10. package/docs/quickstart.md +7 -7
  11. package/docs/sdlc.md +29 -3
  12. package/docs/security.md +6 -2
  13. package/docs/smoke.md +18 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +34 -4
  15. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  16. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  17. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  18. package/docs/verified.json +18 -14
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3091 -2236
  22. package/src/adapters/acp.ts +3 -2
  23. package/src/adapters/claude-print.ts +18 -156
  24. package/src/adapters/codex-review-isolation.ts +276 -0
  25. package/src/adapters/definitions/builtin-metadata.ts +4 -1
  26. package/src/adapters/definitions/native-factories.ts +17 -0
  27. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  28. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  29. package/src/adapters/jcode.ts +20 -5
  30. package/src/adapters/registry-types.ts +7 -3
  31. package/src/adapters/registry.ts +17 -5
  32. package/src/adapters/reviewer-print.ts +319 -0
  33. package/src/cli/help.ts +2 -0
  34. package/src/cli/identity.ts +1 -1
  35. package/src/cli/main.ts +19 -0
  36. package/src/cli/memory.ts +13 -12
  37. package/src/hub/board.ts +34 -6
  38. package/src/hub/config-apply.ts +3 -2
  39. package/src/hub/control-client.ts +1 -1
  40. package/src/hub/daemon.ts +101 -27
  41. package/src/hub/envelope.ts +1 -1
  42. package/src/hub/host-executable.ts +1 -1
  43. package/src/hub/hub-tools.ts +5 -0
  44. package/src/hub/instances.ts +13 -3
  45. package/src/hub/memory-profile.ts +67 -11
  46. package/src/hub/planning.ts +2 -0
  47. package/src/hub/release-admission.ts +64 -0
  48. package/src/hub/reviewer-pool-config.ts +11 -0
  49. package/src/hub/reviewer-pool.ts +70 -0
  50. package/src/hub/routing.ts +42 -3
  51. package/src/hub/sdlc-release.ts +113 -0
  52. package/src/hub/tasks.ts +254 -36
@@ -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 ?? [] });
@@ -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
  }
@@ -0,0 +1,276 @@
1
+ import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
+ import { createHash } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
+ import { realPath } from "../hub/project.ts";
10
+ import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
11
+
12
+ const VERSION = "0.146.0", TOOLS = ["hub_review", "hub_task_show"];
13
+ const CAP = 128 * 1024, MAX_SERVERS = 64;
14
+ const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
15
+ /** Conservative translation of the portable core's regex sources; unfamiliar syntax refuses the receipt. */
16
+ export function codexCoreDenyGlobs(names: readonly string[] = DENY_NAMES, segments: readonly string[] = DENY_SEGMENTS): string[] {
17
+ if (names.length > 64 || segments.length > 64) throw new Error("core deny policy bound exceeded");
18
+ const globs: string[] = [];
19
+ for (const source of names) {
20
+ if (typeof source !== "string" || source.length > 256) throw new Error("unverified core deny pattern");
21
+ const anchored = source.startsWith("^");
22
+ let body = anchored ? source.slice(1) : source;
23
+ const branch = "(\\.|$)", branchSuffix = anchored && body.endsWith(branch);
24
+ const end = !branchSuffix && body.endsWith("$");
25
+ if (!anchored && !end) throw new Error("unverified core deny pattern");
26
+ if (branchSuffix) body = body.slice(0, -branch.length);
27
+ else if (end) body = body.slice(0, -1);
28
+ let literal = "";
29
+ for (let i = 0; i < body.length; i++) {
30
+ const char = body[i]!;
31
+ if (char === "\\" && body[i + 1] === ".") { literal += "."; i++; }
32
+ else if (/^[A-Za-z0-9_-]$/.test(char)) literal += char;
33
+ else throw new Error("unverified core deny pattern");
34
+ }
35
+ if (!literal) throw new Error("empty core deny pattern");
36
+ const glob = `/**/${anchored ? "" : "*"}${literal}${end ? "" : "*"}`;
37
+ globs.push(glob, `${glob}/**`);
38
+ }
39
+ for (const segment of segments) {
40
+ if (typeof segment !== "string" || segment.length > 256 || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(segment) || segment.split("/").some(part => part === "." || part === "..")) throw new Error("unverified core deny segment");
41
+ globs.push(`/**/${segment}`, `/**/${segment}/**`);
42
+ }
43
+ return [...new Set(globs)];
44
+ }
45
+
46
+ const inside = (file: string, root: string) => { const part = relative(root, file); return part === "" || part !== ".." && !part.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(part); };
47
+ const object = (value: unknown): Record<string, unknown> => {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid metadata");
49
+ return value as Record<string, unknown>;
50
+ };
51
+ function toml(value: unknown): string {
52
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
53
+ if (Array.isArray(value)) return `[${value.map(toml).join(",")}]`;
54
+ return `{${Object.entries(object(value)).map(([key, item]) => `${JSON.stringify(key)}=${toml(item)}`).join(",")}}`;
55
+ }
56
+ function metadata(file: string): string {
57
+ try { const link = lstatSync(file), stat = statSync(file); return [link.dev, link.ino, link.mtimeMs, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); }
58
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent"; throw new Error("config metadata unavailable"); }
59
+ }
60
+ function configFiles(cwd: string, env: NodeJS.ProcessEnv): string[] {
61
+ const home = resolve(env.CODEX_HOME ?? join(homedir(), ".codex"));
62
+ const files = [join(home, "config.toml"), join(home, "cloud-config-bundle-cache.json"), "/etc/codex/config.toml", "/etc/codex/requirements.toml", "/etc/codex/managed_config.toml"];
63
+ // Include ancestors even outside the git root: no trust/layout assumption can hide a changed layer.
64
+ for (let dir = cwd; ; dir = dirname(dir)) { files.push(join(dir, ".codex", "config.toml")); if (dirname(dir) === dir) break; }
65
+ return files;
66
+ }
67
+ // Bounded host credential roots, not a universal private-home classifier. Never inspect their file contents.
68
+ const HOST_CREDENTIAL_PATHS = [".config", ".ssh", ".aws", ".azure", ".kube", ".docker", ".gnupg", ".codex", ".claude", ".kimi", ".kimi-code", ".gemini", ".git-credentials", ".vault-token", "Library/Keychains", ".local/share/keyrings", ".password-store"] as const;
69
+ const HOST_CREDENTIAL_ENV = ["XDG_CONFIG_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "KIMI_CODE_HOME", "KIMI_HOME", "GEMINI_CLI_HOME", "GNUPGHOME", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DOCKER_CONFIG", "GOOGLE_APPLICATION_CREDENTIALS"] as const;
70
+ function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): string[] {
71
+ const home = resolve(cwd, env.HOME || homedir());
72
+ const homes = [...new Set([home, credentialCanonicalPath(home)])];
73
+ const roots: string[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => join(base, entry)));
74
+ const configured = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [env[key]!] : []);
75
+ if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean));
76
+ if (configured.length > 64) throw new Error("credential root bound exceeded");
77
+ for (const entry of configured) {
78
+ if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid credential root");
79
+ roots.push(entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry));
80
+ }
81
+ return [...new Set(roots)];
82
+ }
83
+ /** Resolve absent descendants through the deepest existing ancestor; dangling links refuse admission. */
84
+ function credentialCanonicalPath(lexical: string): string {
85
+ // Same strict ancestor algorithm as policyRealPath; importing path-policy here would cycle through the registry.
86
+ let ancestor = lexical;
87
+ for (;;) {
88
+ try { lstatSync(ancestor); break; }
89
+ catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error;
91
+ ancestor = dirname(ancestor);
92
+ }
93
+ }
94
+ return join(realPath(ancestor), relative(ancestor, lexical));
95
+ }
96
+ function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly string[], cwd: string): void {
97
+ for (const lexical of roots) {
98
+ // Native profile keys with glob metacharacters cannot represent these literal host locations safely.
99
+ if (/[?*\[\]{}]/.test(lexical)) throw new Error("unverified credential root syntax");
100
+ const targets = new Set([lexical, credentialCanonicalPath(lexical)]);
101
+ for (const target of targets) {
102
+ if (/[?*\[\]{}]/.test(target)) throw new Error("unverified credential root syntax");
103
+ if (inside(cwd, target)) throw new Error("credential root overlaps checkout");
104
+ filesystem[target] = "deny"; filesystem[`${target.replace(/\/$/, "")}/**`] = "deny";
105
+ }
106
+ }
107
+ }
108
+
109
+ function resolveCommand(ctx: ReviewerPrepareContext, cwd: string): { command: string[]; files: string[] } {
110
+ const [name = "codex", ...prefix] = ctx.command ?? [];
111
+ const found = isAbsolute(name) ? name : Bun.which(name);
112
+ if (!found) throw new Error("host executable unavailable");
113
+ let binary = realPath(found);
114
+ // Bind the actual native binary, not the mutable npm launcher/interpreter selection.
115
+ if (basename(binary) === "codex.js") {
116
+ const target = process.platform === "darwin" ? process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
117
+ : process.platform === "linux" ? process.arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl" : undefined;
118
+ if (!target || !["arm64", "x64"].includes(process.arch)) throw new Error("unverified native platform");
119
+ const suffix = `${process.platform}-${process.arch === "x64" ? "x64" : "arm64"}`;
120
+ const manifest = createRequire(binary).resolve(`@openai/codex-${suffix}/package.json`);
121
+ binary = realPath(join(dirname(manifest), "vendor", target, "bin", "codex"));
122
+ }
123
+ const files = [binary];
124
+ if (inside(binary, cwd)) throw new Error("project executable refused");
125
+ for (const [index, arg] of prefix.entries()) {
126
+ // Privileged host argv may include a fixture/launcher script, but never a project-owned one.
127
+ if (isAbsolute(arg) && (index === 0 || /\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) && existsSync(arg) && statSync(arg).isFile()) {
128
+ const file = realPath(arg); if (inside(file, cwd)) throw new Error("project command prefix refused"); files.push(file);
129
+ } else if (/\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) throw new Error("unbound command prefix refused");
130
+ }
131
+ return { command: [binary, ...prefix], files };
132
+ }
133
+ interface Server { name: string; enabled: boolean; type: "stdio" | "http"; transport: Record<string, unknown> }
134
+ function inventory(text: string): Server[] {
135
+ const raw: unknown = JSON.parse(text);
136
+ if (!Array.isArray(raw) || raw.length > MAX_SERVERS) throw new Error("inventory bound exceeded");
137
+ const seen = new Set<string>();
138
+ return raw.map(value => {
139
+ const row = object(value), transport = object(row.transport);
140
+ if (typeof row.name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(row.name) || seen.has(row.name) || typeof row.enabled !== "boolean") throw new Error("invalid inventory identity");
141
+ seen.add(row.name);
142
+ const type = transport.type;
143
+ if (type !== "stdio" && type !== "http" && type !== "streamable_http") throw new Error("unverified MCP transport");
144
+ return { name: row.name, enabled: row.enabled, type: type === "stdio" ? "stdio" : "http", transport };
145
+ });
146
+ }
147
+ /** The configured MCP list omits runtime-synthesized Apps; verify the fixed feature gate separately. */
148
+ function proveDisabledFeatures(text: string): void {
149
+ const rows = text.trim().split(/\r?\n/).map(line => line.trim());
150
+ if (rows.length > 512) throw new Error("feature inventory bound exceeded");
151
+ for (const name of ["apps", "plugins", "hooks"]) {
152
+ const matches = rows.filter(line => line.split(/\s+/)[0] === name);
153
+ if (matches.length !== 1 || !new RegExp(`^${name}\\s+stable\\s+false$`).test(matches[0]!)) throw new Error("effective feature isolation unverified");
154
+ }
155
+ }
156
+ function ownServer(text: string): Record<string, unknown> {
157
+ const config = object(JSON.parse(text)), servers = object(config.mcpServers);
158
+ if (Object.keys(servers).length !== 1 || !Object.hasOwn(servers, "agent-hub")) throw new Error("fixed hub MCP required");
159
+ const hub = object(servers["agent-hub"]);
160
+ if (Object.keys(hub).some(key => !["command", "args", "env"].includes(key)) || typeof hub.command !== "string" || !hub.command || hub.args !== undefined && (!Array.isArray(hub.args) || hub.args.some(arg => typeof arg !== "string"))) throw new Error("invalid hub transport");
161
+ const env = hub.env === undefined ? {} : object(hub.env);
162
+ if (Object.values(env).some(value => typeof value !== "string")) throw new Error("invalid hub environment");
163
+ return { command: hub.command, args: hub.args ?? [], env, enabled: true, required: true, enabled_tools: TOOLS };
164
+ }
165
+
166
+ /** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
167
+ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }> {
168
+ let root: string | undefined;
169
+ try {
170
+ const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
171
+ const initialBinaryMetadata = bound.files.map(metadata).join(";");
172
+ // Hash content once per receipt, then fence the identity with dev/ino/size/mtime/ctime metadata.
173
+ const binaryHash = digest(bound.files.map(file => digest(readFileSync(file))).join(";"));
174
+ if (bound.files.map(metadata).join(";") !== initialBinaryMetadata) throw new Error("binary changed while hashing");
175
+ const watched = configFiles(cwd, env), initialConfig = watched.map(metadata).join(";");
176
+ const deadline = performance.now() + 12_000;
177
+ const current = () => {
178
+ if (ctx.signal.aborted || watched.map(metadata).join(";") !== initialConfig) return false;
179
+ return bound.files.map(metadata).join(";") === initialBinaryMetadata;
180
+ };
181
+ const run = (args: readonly string[], capture = true, maxBytes = CAP) => new Promise<string>((resolveOutput, reject) => {
182
+ if (ctx.signal.aborted || performance.now() >= deadline) { reject(new Error("metadata deadline")); return; }
183
+ const [cmd, ...prefix] = bound.command;
184
+ const child = spawn(cmd!, [...prefix, ...args], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
185
+ let output = "", bytes = 0, failed = false, settled = false;
186
+ const stop = () => { failed = true; void finish(null); };
187
+ const timer = setTimeout(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
188
+ ctx.signal.addEventListener("abort", stop, { once: true });
189
+ const finish = async (code: number | null) => {
190
+ if (settled) return; settled = true; clearTimeout(timer); ctx.signal.removeEventListener("abort", stop);
191
+ try { await stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 }); }
192
+ catch { reject(new Error("metadata cleanup unconfirmed")); return; }
193
+ if (failed || ctx.signal.aborted || code !== 0) reject(new Error("metadata proof refused")); else resolveOutput(output);
194
+ };
195
+ child.once("spawn", () => { trackGroup(child); if (ctx.signal.aborted) stop(); });
196
+ child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); else if (capture) output += chunk.toString("utf8"); });
197
+ child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); });
198
+ child.once("error", () => { failed = true; void finish(null); });
199
+ child.once("close", code => { void finish(code); });
200
+ });
201
+ if ((await run(["--version"])).trim() !== `codex-cli ${VERSION}`) throw new Error("native version has no isolation receipt");
202
+ if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
203
+ const hub = ownServer(ctx.mcpConfig);
204
+ // Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
205
+ const policy = ["--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
206
+ proveDisabledFeatures(await run(["features", "list", ...policy]));
207
+ const before = inventory(await run(["mcp", "list", "--json", ...policy]));
208
+ if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
209
+ const servers: Record<string, unknown> = Object.assign(Object.create(null), { "agent-hub": hub });
210
+ for (const server of before) servers[server.name] = server.type === "stdio" ? { command: "/usr/bin/false", enabled: false } : { url: "http://127.0.0.1:1", enabled: false };
211
+ const mcp = ["-c", `mcp_servers=${toml(servers)}`];
212
+ const after = inventory(await run(["mcp", "list", "--json", ...policy, ...mcp]));
213
+ if (after.length !== before.length + 1 || after.some(row => row.name !== "agent-hub" && !before.some(old => old.name === row.name)) || after.filter(row => row.enabled).map(row => row.name).join(",") !== "agent-hub") throw new Error("MCP inventory changed or is not strict");
214
+ const observed = object(JSON.parse(await run(["mcp", "get", "agent-hub", "--json", ...policy, ...mcp])));
215
+ if (observed.name !== "agent-hub" || observed.enabled !== true || !Array.isArray(observed.enabled_tools) || JSON.stringify(observed.enabled_tools) !== JSON.stringify(TOOLS)) throw new Error("hub tool allowlist unverified");
216
+ const observedTransport = object(observed.transport);
217
+ const observedEnv = object(observedTransport.env ?? {}), intendedEnv = object(hub.env);
218
+ if (observedTransport.type !== "stdio" || observedTransport.command !== hub.command || JSON.stringify(observedTransport.args ?? []) !== JSON.stringify(hub.args) || Object.keys(observedEnv).length !== Object.keys(intendedEnv).length || Object.entries(intendedEnv).some(([key, value]) => observedEnv[key] !== value)) throw new Error("hub transport changed");
219
+ // A real native sandbox probes a fresh private fixture: the model never sees these files.
220
+ root = realPath(mkdtempSync(join(tmpdir(), "ahub-codex-review-isolation-")));
221
+ const privateFile = join(root, "private.env"), publicFile = join(root, "public.txt"), writeFile = join(root, "write-proof");
222
+ const coreName = join(root, ".env"), coreStateDir = join(root, ".agenthub", "state"), coreState = join(coreStateDir, "private.txt");
223
+ const fixtureHome = join(root, "host-home"), hostConfig = join(fixtureHome, ".config", "gh", "hosts.yml"), hostKube = join(fixtureHome, ".kube", "config"), hostDocker = join(fixtureHome, ".docker", "config.json"), hostAlias = join(root, "host-alias.yml");
224
+ for (const file of [hostConfig, hostKube, hostDocker]) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, "synthetic host credential fixture"); }
225
+ symlinkSync(hostConfig, hostAlias);
226
+ mkdirSync(coreStateDir, { recursive: true });
227
+ writeFileSync(coreName, "core name fixture"); writeFileSync(coreState, "core state fixture");
228
+ const globRoot = join(root, "glob"), nested = join(globRoot, "nested"), globFile = join(nested, "private.env"), alias = join(root, "alias.env");
229
+ mkdirSync(globRoot); mkdirSync(nested);
230
+ writeFileSync(privateFile, "private fixture"); writeFileSync(publicFile, "public fixture"); writeFileSync(globFile, "glob private fixture");
231
+ symlinkSync(globFile, alias);
232
+ const filesystem: Record<string, string> = { "/": "read", [privateFile]: "deny", [join(globRoot, "**", "*.env")]: "deny" };
233
+ for (const glob of codexCoreDenyGlobs()) filesystem[glob] = "deny";
234
+ denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
235
+ for (const entry of ctx.privatePaths) {
236
+ if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid private path policy");
237
+ // Native Seatbelt supports *, ?, and closed character classes, not brace alternatives.
238
+ if (/[{}]/.test(entry)) throw new Error("unverified private path pattern");
239
+ const homePath = entry === "~" || entry.startsWith("~/");
240
+ const absolute = homePath ? resolve(homedir(), entry.slice(2)) : isAbsolute(entry) ? entry : resolve(cwd, entry);
241
+ const patterns = new Set([absolute]);
242
+ if (!/[?*\[\]]/.test(absolute) && existsSync(absolute)) patterns.add(realPath(absolute));
243
+ if (!isAbsolute(entry) && !homePath && entry !== "." && entry !== "..") {
244
+ patterns.add(join(cwd, "**", entry));
245
+ // ctx combines PII path patterns and local substring denies: broadening is safer than omission.
246
+ if (!/[?*\[\]]/.test(entry)) patterns.add(join(cwd, "**", `*${entry.replace(/^\.\//, "").replace(/\/$/, "")}*`));
247
+ }
248
+ for (const pattern of patterns) { filesystem[pattern] = "deny"; filesystem[`${pattern.replace(/\/$/, "")}/**`] = "deny"; }
249
+ }
250
+ const profile = { filesystem, network: { enabled: false } }, permissions = ["-c", `permissions=${toml({ "ahub-review": profile })}`, "-c", 'default_permissions="ahub-review"'];
251
+ // ponytail: drain the pinned catalog without retaining it, capped at 4 MiB; replace with quiet config validation when the native CLI provides it.
252
+ await run(["debug", "models", "--bundled", ...policy, ...permissions], false, 4 * 1024 * 1024);
253
+ const script = 'if /bin/cat "$1" >/dev/null 2>&1; then echo AHUB_CODEX_PUBLIC_READ; fi; if /bin/cat "$2" >/dev/null 2>&1; then exit 12; else echo AHUB_CODEX_PRIVATE_DENIED; fi; if (echo no > "$3") 2>/dev/null; then exit 13; else echo AHUB_CODEX_WRITE_DENIED; fi; if /bin/cat "$4" >/dev/null 2>&1; then exit 14; else echo AHUB_CODEX_GLOB_PRIVATE_DENIED; fi; if /bin/cat "$5" >/dev/null 2>&1; then exit 15; else echo AHUB_CODEX_ALIAS_PRIVATE_DENIED; fi; if /bin/cat "$6" >/dev/null 2>&1; then exit 16; else echo AHUB_CODEX_CORE_NAME_DENIED; fi; if /bin/cat "$7" >/dev/null 2>&1; then exit 17; else echo AHUB_CODEX_CORE_STATE_DENIED; fi; if /bin/cat "$8" >/dev/null 2>&1; then exit 18; else echo AHUB_CODEX_HOST_CONFIG_DENIED; fi; if /bin/cat "$9" >/dev/null 2>&1; then exit 19; else echo AHUB_CODEX_HOST_KUBE_DENIED; fi; if /bin/cat "${10}" >/dev/null 2>&1; then exit 20; else echo AHUB_CODEX_HOST_DOCKER_DENIED; fi; if /bin/cat "${11}" >/dev/null 2>&1; then exit 21; else echo AHUB_CODEX_HOST_ALIAS_DENIED; fi';
254
+ const proof = await run(["sandbox", "-P", "ahub-review", "-C", cwd, ...policy, ...permissions, "/bin/sh", "-c", script, "proof", publicFile, privateFile, writeFile, globFile, alias, coreName, coreState, hostConfig, hostKube, hostDocker, hostAlias]);
255
+ if (proof.trim() !== "AHUB_CODEX_PUBLIC_READ\nAHUB_CODEX_PRIVATE_DENIED\nAHUB_CODEX_WRITE_DENIED\nAHUB_CODEX_GLOB_PRIVATE_DENIED\nAHUB_CODEX_ALIAS_PRIVATE_DENIED\nAHUB_CODEX_CORE_NAME_DENIED\nAHUB_CODEX_CORE_STATE_DENIED\nAHUB_CODEX_HOST_CONFIG_DENIED\nAHUB_CODEX_HOST_KUBE_DENIED\nAHUB_CODEX_HOST_DOCKER_DENIED\nAHUB_CODEX_HOST_ALIAS_DENIED" || existsSync(writeFile)) throw new Error("native sandbox isolation unverified");
256
+ if (!current()) throw new Error("binary or config changed during proof");
257
+ const cleanupRoot = root;
258
+ const launchProfile = digest(toml({ profile, servers, policy }));
259
+ return { command: bound.command, args: ["exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
260
+ current: () => { try { return current(); } catch { return false; } },
261
+ cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
262
+ isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
263
+ evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
264
+ } catch {
265
+ if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
266
+ return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
267
+ }
268
+ }
269
+
270
+ export const codexReviewerBackend: ReviewerBackend = {
271
+ id: "codex", prepare,
272
+ usage(event) {
273
+ const row = event as { type?: string; usage?: { input_tokens?: number; output_tokens?: number; cached_input_tokens?: number } };
274
+ return row?.type === "turn.completed" && row.usage ? { input_tokens: row.usage.input_tokens, output_tokens: row.usage.output_tokens, cache_read_input_tokens: row.usage.cached_input_tokens } : undefined;
275
+ },
276
+ };
@@ -1,3 +1,4 @@
1
+ import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
1
2
  import { kimiAcpCommand } from "./native-daemon-kimi.ts";
2
3
  import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
3
4
  import { piConfigSchema, localConfigSchema, piRoutingSchema, localRoutingSchema, PI_SETTINGS, PI_RESTART_REASONS, LOCAL_RESTART_REASONS, piEffectiveConfig } from "./native-config-policy.ts";
@@ -65,7 +66,7 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
65
66
  identity: { mcp: "env" },
66
67
  permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
67
68
  session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
68
- turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true },
69
+ turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true }, reviewer: { backend: claudeReviewerBackend, defaultPool: true, declaration: "automatic" },
69
70
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
70
71
  };
71
72
 
@@ -88,6 +89,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
88
89
  reviewerVariant: CLAUDE_REVIEWER_VARIANT,
89
90
  },
90
91
  {
92
+ reviewerVariant: CODEX_REVIEWER_VARIANT,
91
93
  api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
92
94
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
93
95
  start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
@@ -101,6 +103,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
101
103
  cli: { command: "codex", retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
102
104
  },
103
105
  {
106
+ reviewerVariant: KIMI_REVIEWER_VARIANT,
104
107
  api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
105
108
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
106
109
  start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
@@ -39,11 +39,27 @@ export function createLocal(ctx: CreateContext): PeerAdapter {
39
39
  }
40
40
 
41
41
  export function createClaudeReviewer(ctx: CreateContext): PeerAdapter {
42
+ if ("reviewerPrint" in ctx.daemon) {
43
+ const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
44
+ return new ClaudePrintPeer(ctx.instance, ctx.daemon.reviewerPrint);
45
+ }
42
46
  if (!("claudePrint" in ctx.daemon)) throw new Error("Claude reviewer requires its print transport options");
43
47
  const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
44
48
  return new ClaudePrintPeer(ctx.instance, ctx.daemon.claudePrint);
45
49
  }
46
50
 
51
+ export function createCodexReviewer(ctx: CreateContext): PeerAdapter {
52
+ if (!("reviewerPrint" in ctx.daemon)) throw new Error("Codex reviewer requires its fresh print transport options");
53
+ const config = ctx.kindConfig as { codex_bin?: string };
54
+ return createReviewer({ ...ctx, daemon: { reviewerPrint: { ...ctx.daemon.reviewerPrint, command: ctx.daemon.reviewerPrint.command ?? [config.codex_bin ?? "codex"] } } });
55
+ }
56
+
57
+ export function createReviewer(ctx: CreateContext): PeerAdapter {
58
+ if (!("reviewerPrint" in ctx.daemon)) throw new Error("Reviewer requires its fresh print transport options");
59
+ const { ReviewerPrintPeer } = require("../reviewer-print.ts") as typeof import("../reviewer-print.ts");
60
+ return new ReviewerPrintPeer(ctx.instance, ctx.daemon.reviewerPrint);
61
+ }
62
+
47
63
  /** Preserve existing transport option types and object identity; selection stays host-owned. */
48
64
  export const DAEMON_PORTS = {
49
65
  "codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
@@ -51,5 +67,6 @@ export const DAEMON_PORTS = {
51
67
  "owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
52
68
  "pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
53
69
  "local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
70
+ "reviewer-print": (options: Extract<DaemonPorts, { reviewerPrint: unknown }>["reviewerPrint"]) => ({ reviewerPrint: options }),
54
71
  "claude-print": (options: Extract<DaemonPorts, { claudePrint: unknown }>["claudePrint"]) => ({ claudePrint: options }),
55
72
  } satisfies Record<DaemonEngine, unknown>;
@@ -15,7 +15,7 @@ export function codexCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
15
15
  }
16
16
 
17
17
  export function jcodeCrashPlan(_meta: Record<string, unknown>): NativeCrashPlan {
18
- return { how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
18
+ return { how: "jcode: native ACP load (verified on 0.94.0 and 0.98.1) does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
19
19
  }
20
20
 
21
21
  export function kimiCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
@@ -0,0 +1,32 @@
1
+ import type { AdapterDefinition } from "../registry-types.ts";
2
+ import { claudeReviewerBackend } from "../claude-print.ts";
3
+ import { codexReviewerBackend } from "../codex-review-isolation.ts";
4
+ import { createReviewer, createCodexReviewer } from "./native-factories.ts";
5
+ import { CODEX_DISPLAY, KIMI_DISPLAY } from "./native-display.ts";
6
+
7
+ export { claudeReviewerBackend };
8
+ /** No launch path is exposed by metadata-only placeholders. */
9
+ function unavailableLaunch(): never { throw new Error("Reviewer unavailable: verified runtime support is deferred to #533"); }
10
+
11
+ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullable<AdapterDefinition["reviewer"]>, display: AdapterDefinition["display"]): AdapterDefinition {
12
+ return {
13
+ api: 1, kind, family: kind, displayName, transport: "hub-native", roles: ["reviewer"],
14
+ defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
15
+ start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless", ownsTerminal: () => false,
16
+ launch: unavailableLaunch,
17
+ conductorStartable: true, hubSwitches: [], ownedFlags: [] },
18
+ identity: { mcp: "env" },
19
+ permission: { modes: {}, readOnly: { kind: "hub-gate", allow: ["read", "search", "think"] }, unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
20
+ session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "reviewer-print", crashPlan: () => ({ resume: {}, how: "starts a fresh isolated review session" }) },
21
+ turnEnd: "adapter", budget: { account: kind }, routing: { reviewOnly: true },
22
+ reviewer, cli: { command: kind, startWords: () => [] }, display,
23
+ };
24
+ }
25
+ const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
26
+ export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
27
+ export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { defaultPool: false, declaration: "explicit", unavailable: "Kimi reviewer unavailable: runtime and isolation verification deferred to #533" }, KIMI_DISPLAY);
28
+ /** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
29
+ export const AGY_DEFINITION: AdapterDefinition = {
30
+ ...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy reviewer unavailable: native adapter and isolation verification deferred to #533" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
31
+ optional: true,
32
+ };