@staix/agent-hub 0.12.37 → 0.12.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +16 -7
  4. package/docs/agent-notes/bus.md +3 -2
  5. package/docs/agent-notes/daemon.md +5 -3
  6. package/docs/agent-notes/tasks.md +4 -3
  7. package/docs/agent-notes/tests.md +2 -2
  8. package/docs/operations.md +21 -6
  9. package/docs/quickstart.md +13 -5
  10. package/docs/security.md +5 -1
  11. package/docs/smoke.md +22 -2
  12. package/docs/specs/2026-09-19-agent-hub-design.md +29 -5
  13. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  14. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  15. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  16. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  17. package/docs/verified.json +22 -14
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +1507 -405
  21. package/src/adapters/agy/definition.ts +61 -0
  22. package/src/adapters/agy/entry.ts +76 -0
  23. package/src/adapters/agy/hooks.ts +81 -0
  24. package/src/adapters/claude-channel.ts +1 -0
  25. package/src/adapters/codex-review-isolation.ts +30 -16
  26. package/src/adapters/definitions/builtin-metadata.ts +5 -5
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  31. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  32. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  33. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  34. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  35. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  36. package/src/adapters/definitions/qwen.ts +1 -1
  37. package/src/adapters/definitions/reviewer-variants.ts +4 -3
  38. package/src/adapters/kimi-review.ts +161 -0
  39. package/src/adapters/registry-types.ts +29 -1
  40. package/src/adapters/registry.ts +15 -6
  41. package/src/adapters/reviewer-mcp.ts +85 -0
  42. package/src/adapters/reviewer-print.ts +83 -20
  43. package/src/cli/agy-mcp.ts +370 -0
  44. package/src/cli/console-state.ts +6 -0
  45. package/src/cli/console.ts +2 -0
  46. package/src/cli/facts-hook.ts +15 -2
  47. package/src/cli/init.ts +7 -0
  48. package/src/cli/launch.ts +3 -1
  49. package/src/cli/main.ts +1 -0
  50. package/src/cli/output.ts +5 -0
  51. package/src/hub/bus.ts +17 -6
  52. package/src/hub/control-client.ts +7 -1
  53. package/src/hub/daemon.ts +169 -23
  54. package/src/hub/native-hook-launch.ts +37 -0
  55. package/src/hub/peer-catalog.ts +2 -3
  56. package/src/hub/reviewer-pool.ts +12 -6
  57. package/src/hub/routing.ts +3 -0
  58. package/src/hub/tasks.ts +7 -0
  59. package/src/ui/index.html +6 -1
  60. package/templates/AGENTS.block.md +1 -1
@@ -0,0 +1,161 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
7
+ import { realPath } from "../hub/project.ts";
8
+ import type { ReviewerBackend, ReviewerPrepareContext } from "./reviewer-print.ts";
9
+
10
+ const VERSION = "2.2.0", MANAGED = "managed:kimi-code";
11
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
12
+ const hash = (text: string) => createHash("sha256").update(text).digest("hex");
13
+ /** Native discovery can walk above cwd; check lexical aliases as well as the physical temporary root. */
14
+ function assertCleanCwdAncestors(...candidates: readonly string[]): void {
15
+ const seen = new Set<string>();
16
+ for (const candidate of candidates) {
17
+ let ancestor = resolve(candidate);
18
+ for (;;) {
19
+ if (!seen.has(ancestor)) {
20
+ seen.add(ancestor);
21
+ for (const marker of [".git", ".kimi-code", "AGENTS.md"]) {
22
+ if (lstatSync(join(ancestor, marker), { throwIfNoEntry: false }) !== undefined) {
23
+ throw new Error("private cwd ancestor contains ambient configuration");
24
+ }
25
+ }
26
+ }
27
+ const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent;
28
+ }
29
+ }
30
+ }
31
+ const read = (file: string, limit = 262_144) => {
32
+ const stat = lstatSync(file); if (!stat.isFile() || stat.size > limit) throw new Error("unsupported reviewer file");
33
+ return readFileSync(file, "utf8");
34
+ };
35
+ const scalar = (value: unknown, depth = 0): string => {
36
+ if (depth > 4) throw new Error("managed model setting too deeply nested");
37
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
38
+ if (Array.isArray(value) && value.every(v => typeof v === "string")) return JSON.stringify(value);
39
+ if (object(value) && Object.keys(value).length <= 32) return `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)} = ${scalar(item, depth + 1)}`).join(", ")} }`;
40
+ throw new Error("unsupported managed model setting");
41
+ };
42
+ function managedConfig(raw: string): { config: string; lock: string } {
43
+ const config = Bun.TOML.parse(raw) as Record<string, unknown>;
44
+ if (!object(config.providers) || !object(config.models) || typeof config.default_model !== "string") throw new Error("managed Kimi configuration required");
45
+ const provider = config.providers[MANAGED], model = config.models[config.default_model];
46
+ if (!object(provider) || provider.type !== "kimi" || provider.api_key && provider.api_key !== "" || !object(provider.oauth) || provider.oauth.storage !== "file" || !object(model) || model.provider !== MANAGED) throw new Error("managed file-backed Kimi OAuth required");
47
+ const oauth = provider.oauth;
48
+ const key = oauth.key;
49
+ const lock = !key || key === "kimi-code" || key === "oauth/kimi-code" ? "kimi-code" : typeof key === "string" && key.startsWith("oauth/") ? key.slice(6) : key;
50
+ if (typeof lock !== "string" || !lock || basename(lock) !== lock || lock.startsWith(".")) throw new Error("unsupported OAuth slot");
51
+ if (typeof provider.base_url !== "string") throw new Error("managed Kimi endpoint required");
52
+ const endpoint = new URL(provider.base_url);
53
+ if (endpoint.protocol !== "https:" || !["api.kimi.ai", "api.kimi.com"].includes(endpoint.hostname) || endpoint.username || endpoint.password) throw new Error("unsupported managed Kimi endpoint");
54
+ if (oauth.oauth_host !== undefined) {
55
+ const auth = new URL(String(oauth.oauth_host));
56
+ if (auth.protocol !== "https:" || !["auth.kimi.ai", "auth.kimi.com"].includes(auth.hostname) || auth.username || auth.password) throw new Error("unsupported OAuth endpoint");
57
+ }
58
+ const lines = ["telemetry = false", "hooks = []", `default_model = ${scalar(config.default_model)}`,
59
+ ...(config.thinking === undefined ? [] : [`thinking = ${scalar(config.thinking)}`]),
60
+ `[providers.${scalar(MANAGED)}]`, 'type = "kimi"', `base_url = ${scalar(provider.base_url)}`, `[providers.${scalar(MANAGED)}.oauth]`];
61
+ for (const name of ["storage", "key", "oauth_host"]) if (oauth[name] !== undefined) lines.push(`${name} = ${scalar(oauth[name])}`);
62
+ lines.push(`[models.${scalar(config.default_model)}]`);
63
+ const modelFields = ["provider", "model", "protocol", "max_context_size", "max_input_size", "capabilities", "display_name", "support_efforts", "default_effort", "beta_api", "adaptive_thinking", "overrides"];
64
+ if (Object.keys(model).some(name => !modelFields.includes(name))) throw new Error("unsupported managed model setting");
65
+ if (model.overrides !== undefined && (!object(model.overrides) || Object.keys(model.overrides).some(name => !modelFields.includes(name) || ["provider", "overrides"].includes(name)))) throw new Error("unsupported managed model override");
66
+ for (const name of modelFields) if (model[name] !== undefined) lines.push(`${name} = ${scalar(model[name])}`);
67
+ return { config: lines.join("\n") + "\n", lock };
68
+ }
69
+ function mcpConfig(context: ReviewerPrepareContext): string {
70
+ const config: unknown = JSON.parse(context.mcpConfig);
71
+ if (!object(config) || !object(config.mcpServers)) throw new Error("review MCP unavailable");
72
+ if (context.task === undefined && Object.keys(config.mcpServers).length === 0) return context.mcpConfig;
73
+ if (Object.keys(config.mcpServers).join() !== "agent-hub") throw new Error("ambient MCP refused");
74
+ const server = config.mcpServers["agent-hub"];
75
+ if (!object(server) || Object.keys(server).some(k => !["transport", "url", "headers"].includes(k)) || server.transport !== "http" || typeof server.url !== "string" || !object(server.headers) || Object.keys(server.headers).join() !== "Authorization" || typeof server.headers.Authorization !== "string" || !server.headers.Authorization.startsWith("Bearer ")) throw new Error("task-bound HTTP MCP required");
76
+ const url = new URL(server.url);
77
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/mcp" || url.username || url.password || url.search || url.hash) throw new Error("local review MCP required");
78
+ return context.mcpConfig;
79
+ }
80
+ async function version(command: readonly string[], cwd: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<string> {
81
+ return await new Promise((accept, reject) => {
82
+ const child = spawn(command[0]!, [...command.slice(1), "--version"], { cwd, env, detached: true, stdio: ["ignore", "pipe", "ignore"] });
83
+ let text = "", done = false;
84
+ const finish = async (code: number | null) => {
85
+ if (done) return; done = true; clearTimeout(timer); signal.removeEventListener("abort", abort);
86
+ try { await stopOwnedProcess(child, { group: true }); } catch { reject(new Error("version probe cleanup failed")); return; }
87
+ if (code !== 0 || signal.aborted) reject(new Error("version probe failed")); else accept(text.trim());
88
+ };
89
+ const abort = () => { void finish(null); };
90
+ const timer = setTimeout(abort, 5_000);
91
+ signal.addEventListener("abort", abort, { once: true });
92
+ child.once("spawn", () => trackGroup(child));
93
+ child.stdout.on("data", (bytes: Buffer) => { text += bytes.toString(); if (text.length > 4096) abort(); });
94
+ child.once("error", () => { void finish(null); }); child.once("close", code => { void finish(code); });
95
+ if (signal.aborted) abort();
96
+ });
97
+ }
98
+ /** Do not SIGKILL a live native refresh holder. A bounded incomplete stop retains the peer handle. */
99
+ export async function stopKimiReview(child: ChildProcess, locks: readonly string[], graceMs = 10_000): Promise<void> {
100
+ const locked = () => locks.some(file => existsSync(file));
101
+ if (child.exitCode === null && child.signalCode === null && locked()) {
102
+ child.kill("SIGTERM");
103
+ const end = Date.now() + graceMs;
104
+ while (child.exitCode === null && child.signalCode === null && locked() && Date.now() < end) await Bun.sleep(50);
105
+ if (child.exitCode === null && child.signalCode === null && locked()) throw new Error("Kimi OAuth refresh still locked; graceful stop incomplete");
106
+ }
107
+ await stopOwnedProcess(child, { group: true });
108
+ }
109
+
110
+ export const kimiReviewerBackend: ReviewerBackend = {
111
+ id: "kimi", taskMcp: true, nativePolicy: true,
112
+ async prepare(context) {
113
+ let own: string | undefined;
114
+ try {
115
+ if (context.signal.aborted) throw new Error("cancelled");
116
+ const sourcePath = process.env.KIMI_CODE_HOME ?? join(process.env.HOME ?? homedir(), ".kimi-code"), source = realPath(sourcePath);
117
+ const sourceConfig = join(source, "config.toml"), original = read(sourceConfig), selected = managedConfig(original);
118
+ const sources = ["credentials", "oauth", "device_id"].map(name => realPath(join(source, name)));
119
+ if (!sources.every((file, index) => index < 2 ? lstatSync(file).isDirectory() : lstatSync(file).isFile())) throw new Error("native OAuth directories and device identity required");
120
+ const mcp = mcpConfig(context);
121
+ if (Buffer.byteLength(context.input ?? "") > 1_048_576) throw new Error("review facts exceed limit");
122
+ const lexicalRoot = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "ahub-kimi-review-"));
123
+ own = realPath(lexicalRoot);
124
+ assertCleanCwdAncestors(join(lexicalRoot, "workspace"), join(own, "workspace"));
125
+ const root = own, home = join(root, "home"), tool = join(root, "tool"), cwd = join(root, "workspace"), skills = join(root, "empty-skills"), temporary = join(root, "tmp");
126
+ for (const dir of [home, tool, cwd, skills, temporary]) mkdirSync(dir, { mode: 0o700 });
127
+ ["credentials", "oauth", "device_id"].forEach((name, index) => symlinkSync(sources[index]!, join(tool, name), index < 2 ? "dir" : "file"));
128
+ const agentFile = join(cwd, "reviewer.md"), configFile = join(tool, "config.toml"), mcpFile = join(tool, "mcp.json");
129
+ writeFileSync(configFile, selected.config, { mode: 0o600 }); writeFileSync(mcpFile, mcp, { mode: 0o600 });
130
+ writeFileSync(agentFile, `---\nname: hub-reviewer\ndescription: Fresh agent-hub reviewer\ntools:\n - mcp__agent-hub__hub_review\n - mcp__agent-hub__hub_task_show\nsubagents: []\n---\n\n${context.systemPrompt}\n\nUse only the supplied public facts and hub_task_show. Never claim to have read evidence that was not supplied.\n\n${context.input ?? "No review is active; startup self-check only."}\n`, { mode: 0o600 });
131
+ const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, KIMI_CODE_HOME: tool, TMPDIR: `${temporary}/`, XDG_CONFIG_HOME: join(home, ".config"), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...(process.env.USER ? { USER: process.env.USER } : {}) };
132
+ const native = context.command ?? [Bun.which("kimi", { PATH: process.env.PATH }) ?? "kimi"];
133
+ const executable = realPath(Bun.which(native[0]!, { PATH: process.env.PATH }) ?? resolve(native[0]!));
134
+ const command = [executable, ...native.slice(1)];
135
+ const executableStat = lstatSync(executable);
136
+ if (await version(command, cwd, env, context.signal) !== VERSION) throw new Error("unsupported Kimi version; receipt covers 2.2.0");
137
+ const fingerprints = [configFile, mcpFile, agentFile].map(file => hash(read(file, 2_097_152)));
138
+ let cleaned = false;
139
+ const cleanup = () => { if (cleaned) return; rmSync(root, { recursive: true, force: true }); cleaned = true; };
140
+ return { command, args: ["-p", "Review the supplied task facts and record the evidence-based verdict with hub_review.", "--output-format", "stream-json", "--agent-file", agentFile, "--skills-dir", skills],
141
+ cwd, env, envMode: "replace", cleanup, watchCurrent: true,
142
+ stop: child => stopKimiReview(child, [join(sources[1]!, "kimi-code.lock"), join(sources[1]!, `${selected.lock}.lock`)]),
143
+ current: () => {
144
+ try {
145
+ assertCleanCwdAncestors(join(lexicalRoot,"workspace"),cwd);
146
+ const now = lstatSync(executable);
147
+ return !cleaned && !context.signal.aborted && realPath(sourcePath) === source && now.dev === executableStat.dev && now.ino === executableStat.ino && now.size === executableStat.size && now.mtimeMs === executableStat.mtimeMs && now.ctimeMs === executableStat.ctimeMs && hash(read(sourceConfig)) === hash(original)
148
+ && [configFile, mcpFile, agentFile].every((file, index) => hash(read(file, 2_097_152)) === fingerprints[index])
149
+ && ["credentials", "oauth", "device_id"].every((name, index) => realPath(join(source, name)) === sources[index] && realPath(join(tool, name)) === sources[index]);
150
+ } catch { return false; }
151
+ },
152
+ isolation: { backend: "kimi", version: VERSION, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
153
+ enforcement: "native-policy", freshSession: true, hubToolsOnly: true,
154
+ evidence: ["#533 person ruling2026-10-19: native execution-time exact tool allowlist, no kernel sandbox", "private config/cwd/empty skills; shared OAuth directory symlinks and native lock/atomic rename", "task-bound in-process hub MCP; no full control token", "residual: interruption mid-refresh may require native re-login"] },
155
+ };
156
+ } catch (error) {
157
+ if (own) rmSync(own, { recursive: true, force: true });
158
+ return { unavailable: `Kimi reviewer unavailable: ${(error as Error).message}` };
159
+ }
160
+ },
161
+ };
@@ -117,12 +117,36 @@ export interface CapabilityManifest {
117
117
  }[];
118
118
  }
119
119
 
120
+ export interface NativeFactsInput {
121
+ event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop";
122
+ phase: "session" | "start" | "pre" | "post" | "stop";
123
+ sessionId: string;
124
+ transcriptPath: string;
125
+ workspacePaths: string[];
126
+ tool: string;
127
+ input: Record<string, unknown>;
128
+ toolUseId?: string;
129
+ invocation?: number;
130
+ fullyIdle?: boolean;
131
+ terminationReason?: string;
132
+ }
133
+ export interface NativeFactsTranscript {
134
+ completion?: { id: string; index: number };
135
+ factIds: string[];
136
+ }
137
+ /** Fixed native codec and bounded readback, never supplied by project configuration. */
138
+ export interface NativeFactsPort {
139
+ decode(event: string, input: unknown): NativeFactsInput | undefined;
140
+ encode(event: string, text?: string): Record<string, unknown> | undefined;
141
+ read(session: string, transcript: string): NativeFactsTranscript;
142
+ }
143
+
120
144
  /** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
121
145
  export interface NativeHooks {
122
146
  agentIdentity(init: unknown): void;
123
147
  onAttach(instance: string): void;
124
148
  onDetach(instance: string): void;
125
- factsHook: (msg: unknown) => unknown;
149
+ factsHook: NativeFactsPort;
126
150
  conversationRevert(turnId: string): Promise<void>;
127
151
  replaceSession(req: StartRequest): Promise<unknown>;
128
152
  pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
@@ -229,6 +253,8 @@ export interface AdapterDefinition {
229
253
  routing?: {
230
254
  /** Takes reviews only: never chosen or nominated as a task owner, for any class. */
231
255
  reviewOnly?: true;
256
+ /** Fixed role ceiling; project roles cannot turn an unmanaged implementer into a planner/reviewer. */
257
+ allowedRoles?: readonly string[];
232
258
  localAllowedRefusal?: string;
233
259
  longContext?: "backend-limit" | "skip-owner";
234
260
  preferredTier?: boolean;
@@ -253,6 +279,8 @@ export interface AdapterDefinition {
253
279
  };
254
280
  cli: {
255
281
  command: string;
282
+ /** Configuration leaves that select this peer's next native launch command or provider profile. */
283
+ launchConfigKeys?: readonly string[];
256
284
  /** Restrictions only; core identity remains authoritative. */
257
285
  authority?: "person" | "conductor";
258
286
  personOnlyFlags?: readonly string[];
@@ -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,7 +14,7 @@ export const ADAPTER_API = 1;
13
14
 
14
15
  /** Ordered compatibility projections stay typed independently of definition implementation inference. */
15
16
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
16
- export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
17
+ export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
17
18
  export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
18
19
  export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
19
20
  export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
@@ -26,7 +27,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
26
27
  return Object.freeze(value);
27
28
  }
28
29
 
29
- const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION]);
30
+ const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION, AGY_REVIEWER_VARIANT]);
30
31
  for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
31
32
 
32
33
  /** Keep the native catalog's order, independently of the legacy default-role object order. */
@@ -34,9 +35,10 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
34
35
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind !== "jcode"),
35
36
  QWEN_DEFINITION,
36
37
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
38
+ AGY_DEFINITION,
37
39
  ]);
38
40
  // Unavailable declarations are metadata, not supported native peers or launchers.
39
- const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([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
  }