@staix/agent-hub 0.12.45 → 0.14.0

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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
@@ -1,3 +1,4 @@
1
+ import { describe } from "../compat.ts";
1
2
  import { join } from "node:path";
2
3
  import type { Launch } from "../../cli/launch.ts";
3
4
  import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
@@ -16,7 +17,7 @@ export function previewQwen(ctx: NativePreviewContext) {
16
17
  if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
17
18
  launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
18
19
  userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
19
- unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
20
+ unresolved.push({ field: "nativeIdentity", reason: `Qwen Code (${describe("qwen")}) identity and permission modes are checked at ACP initialization; preview starts nothing` });
20
21
 
21
22
  return finishPreview(ctx, launch, envNames, unresolved, undefined);
22
23
  }
@@ -1,16 +1,20 @@
1
+ import { assessVersion } from "../compat.ts";
1
2
  import type { PermissionMode } from "../../hub/permission-mode.ts";
2
3
  import type { Launch } from "../../cli/launch.ts";
3
4
 
4
5
  export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
5
6
 
6
7
  export class QwenIdentityError extends Error {
7
- constructor() { super("Qwen Code 0.25.0 native identity is not verified; check the executable and its advertised ACP version"); }
8
+ constructor(reason?: string) { super(reason ? `Qwen Code native identity is not verified: ${reason}` : "Qwen Code native identity is not verified; check the executable and its advertised ACP version"); }
8
9
  }
9
10
 
10
11
  export function qwenNativeIdentity(instance: string, production: boolean | undefined, init: unknown, kind?: string): boolean {
11
12
  const native = init as { agentInfo?: { name?: unknown; version?: unknown } } | null | undefined;
12
- const verified = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code" && native?.agentInfo?.version === "0.25.0";
13
- if (production && !verified) throw new QwenIdentityError();
13
+ const identity = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code";
14
+ // #691: a version refusal carries the compat reason (observed and verified versions, the trust command).
15
+ const version = identity ? assessVersion("qwen", native?.agentInfo?.version) : undefined;
16
+ if (production && !version?.ok) throw new QwenIdentityError(version && !version.ok ? version.reason : undefined);
17
+ const verified = !!version?.ok;
14
18
  return verified;
15
19
  }
16
20
 
@@ -1,3 +1,4 @@
1
+ import { describe } from "../compat.ts";
1
2
  import { runQwenEntry } from "./native-cli-entry-qwen.ts";
2
3
  import { previewQwen } from "./native-cli-preview-qwen.ts";
3
4
  import { QWEN_DISPLAY } from "./native-display.ts";
@@ -67,7 +68,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
67
68
  turnEnd: "adapter",
68
69
  budget: { account: "qwen" },
69
70
  capabilities: { watch: true },
70
- cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
71
+ cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off)`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
71
72
  display: QWEN_DISPLAY,
72
73
  hooks: { recallPlatform: "qwen" },
73
74
  };
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
2
3
  import { createConnection } from "node:net";
3
4
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
@@ -12,21 +13,25 @@ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expe
12
13
  command: string[];
13
14
  home: string;
14
15
  providerProfile?: string;
16
+ /** The hub peer id this Jcode acts for: "jcode" for the base peer, an instance id for an extra one (#685). */
17
+ peer?: string;
18
+ /** Initial native provider (`-p`), passed exactly as the person wrote it (#685). The hub never handles model credentials. */
19
+ provider?: string;
15
20
  /** How long a native steer may take to be acknowledged (#572); default 5 s. */
16
21
  steerTimeoutMs?: number;
17
22
  }
18
- export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string): string[] {
23
+ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string, provider?: string, peer = "jcode"): string[] {
19
24
  if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
20
- return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
21
- ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
25
+ return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []), ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
26
+ ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", `agent-hub-${peer}`] : ["--mcp-tools", "eager"])];
22
27
  }
23
28
  export interface JcodeTuiBinding { launchId: string; sessionId: string }
24
29
  export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
25
30
  { state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
26
31
 
27
- export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string): string[] {
32
+ export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string, provider?: string): string[] {
28
33
  if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
29
- return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev",
34
+ return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []),
30
35
  ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : [])];
31
36
  }
32
37
 
@@ -65,17 +70,8 @@ export class JcodeOptionMismatch extends Error {
65
70
  constructor(readonly option: "--session-id" | "--model" | "--provider-profile") { super("Jcode launch options differ from its running session"); }
66
71
  }
67
72
  const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
68
- /** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
69
- export const JCODE_MIN_VERSION = "0.98.0";
70
- /** The first native release assumed to break that contract: the next major. Lower it when a release is shown to break. */
71
- export const JCODE_BREAKING_VERSION = "1.0.0";
72
- const release = (version: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
73
- const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
74
- /** A plain release from JCODE_MIN_VERSION up to, not including, JCODE_BREAKING_VERSION; the minor is not pinned (#529). */
75
- export function jcodeVersionSupported(version: unknown): boolean {
76
- const v = typeof version === "string" ? release(version) : undefined;
77
- return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
78
- }
73
+ /** #691: the verdict lives in `src/adapters/compat.ts` (line, verified versions, the person's trust); never pin here. */
74
+ export const jcodeVersionSupported = (version: unknown): boolean => assessVersion("jcode", version).ok;
79
75
 
80
76
  /** A native steer the server did not take, with nothing sent: the message may safely stay queued. */
81
77
  class SteerRefused extends Error {}
@@ -137,20 +133,56 @@ function nativeSteer(socket: string, cwd: string, sessionId: string, text: strin
137
133
  }
138
134
 
139
135
  /** The wrapper contract preserves every configured prefix argument before the native probe. */
140
- export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
136
+ export function jcodeVersion(command: string[], cwd: string): CompatVerdict {
141
137
  const [bin, ...prefix] = command;
142
- if (!bin) return false;
138
+ if (!bin) return assessVersion("jcode", undefined);
143
139
  try {
144
140
  const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
145
- if (result.status !== 0) return false;
146
- const version = JSON.parse(result.stdout);
147
- return jcodeVersionSupported(version.semver);
148
- } catch { return false; }
141
+ return assessVersion("jcode", result.status === 0 ? COMPAT.jcode.parse(result.stdout) : undefined);
142
+ } catch { return assessVersion("jcode", undefined); }
143
+ }
144
+ export const verifiedJcodeCommand = (command: string[], cwd: string): boolean => jcodeVersion(command, cwd).ok;
145
+ /** Collects at most `cap` bytes; the chunk that would pass it, and every later one, is refused and not kept. */
146
+ export function boundedOutput(cap: number): { push(chunk: Buffer): boolean; text(): string; readonly size: number; readonly overflowed: boolean } {
147
+ const chunks: Buffer[] = [];
148
+ let size = 0, overflowed = false;
149
+ return {
150
+ push(chunk) { if (overflowed || size + chunk.length > cap) { overflowed = true; return false; } chunks.push(chunk); size += chunk.length; return true; },
151
+ text: () => Buffer.concat(chunks).toString("utf8"),
152
+ get size() { return size; },
153
+ get overflowed() { return overflowed; },
154
+ };
155
+ }
156
+ /**
157
+ * The daemon's preflight: the same probe without blocking the event loop (a slow wrapper must not stall peers). The probe owns its
158
+ * process group: at the deadline, or once stdout passes 64 KiB, the whole group is stopped, descendants included (kimi-review's pattern).
159
+ */
160
+ export function jcodeVersionAsync(command: string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
161
+ const [bin, ...prefix] = command;
162
+ if (!bin) return Promise.resolve(assessVersion("jcode", undefined));
163
+ return new Promise(resolve => {
164
+ let child: ChildProcess;
165
+ try { child = spawn(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
166
+ catch { resolve(assessVersion("jcode", undefined)); return; }
167
+ const output = boundedOutput(64 * 1024);
168
+ let done = false;
169
+ const finish = async (code: number | null) => {
170
+ if (done) return; done = true; clearTimeout(timer);
171
+ try { await stopOwnedProcess(child, { group: true }); } catch { resolve(assessVersion("jcode", undefined)); return; }
172
+ resolve(assessVersion("jcode", code === 0 && !output.overflowed ? COMPAT.jcode.parse(output.text()) : undefined));
173
+ };
174
+ const timer = setTimeout(() => { void finish(null); }, timeoutMs);
175
+ child.once("spawn", () => trackGroup(child));
176
+ // Chunks after the cap or after settling are drained and discarded, never buffered.
177
+ child.stdout!.on("data", (bytes: Buffer) => { if (!done && !output.push(bytes)) void finish(null); });
178
+ child.once("error", () => { void finish(null); });
179
+ child.once("close", code => { void finish(code); });
180
+ });
149
181
  }
150
182
 
151
183
  /** Both processes are ours. The user's global daemon and its sockets are never stopped. */
152
184
  export class JcodePeer extends AcpPeer {
153
- private readonly launchId = crypto.randomUUID();
185
+ private readonly launchId: string;
154
186
  private socketIdentity: { dev: number; ino: number } | undefined;
155
187
  private readonly root: string;
156
188
  private readonly socket: string;
@@ -161,21 +193,28 @@ export class JcodePeer extends AcpPeer {
161
193
 
162
194
  constructor(private readonly jcode: JcodeOptions) {
163
195
  if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
196
+ const peer = jcode.peer ?? "jcode";
164
197
  jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
165
198
  // The name must end in ".sock": jcode names its debug socket by replacing that suffix, so "s" binds one path twice.
166
199
  const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "j.sock");
167
- const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
168
- super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
169
- this.root = root; this.socket = socket;
200
+ const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile, jcode.provider, peer);
201
+ const launchId = crypto.randomUUID();
202
+ // The ACP child gets peerChildEnv(this.id): the instance id, so its hub tools act as this peer and never as the base Jcode. The launch id
203
+ // goes with it: the daemon admits a hub-tools hello for this peer only when it carries the id of this peer's current owned launch (#368).
204
+ super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
205
+ this.root = root; this.socket = socket; this.launchId = launchId;
170
206
  }
171
207
 
208
+ /** True only for the id of this peer's current owned launch, while it is not ending: the proof a hub-tools hello must carry (#368). */
209
+ ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
210
+
172
211
  override async start(): Promise<void> {
173
212
  // The native loader otherwise imports Codex MCP into the user's store on first use.
174
213
  if (!existsSync(join(this.jcode.home, "mcp.json"))) throw new Error("initialize Jcode's global MCP store with its native client before enabling this peer; the hub never initializes or copies it");
175
214
  if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
176
215
  this.ending = false;
177
- const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
178
- const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
216
+ const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider, this.id);
217
+ const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv(this.id), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
179
218
  this.server = server; trackGroup(server);
180
219
  try {
181
220
  await new Promise<void>((resolve, reject) => {
@@ -279,11 +318,13 @@ export class JcodePeer extends AcpPeer {
279
318
  : options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile ? "--provider-profile" : undefined;
280
319
  if (differs) throw new JcodeOptionMismatch(differs);
281
320
  if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
282
- const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
283
- return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, ...JCODE_OWNED_ENV } } };
321
+ const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider);
322
+ // The TUI is the person's own client of this peer's server. The shared project MCP entry names no peer (#685), so every launch
323
+ // path supplies its own id: the base Jcode's too, or its MCP child would fall back to the channel default.
324
+ return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, AGENTHUB_PEER_ID: this.id, AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV } } };
284
325
  }
285
326
  override recoveryMetadata(): Record<string, unknown> {
286
- return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
327
+ return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: this.id, mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
287
328
  }
288
329
  }
289
330
 
@@ -1,4 +1,5 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { assessVersion, COMPAT } from "./compat.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
5
  import { homedir, tmpdir } from "node:os";
@@ -7,7 +8,7 @@ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
7
8
  import { realPath } from "../hub/project.ts";
8
9
  import type { ReviewerBackend, ReviewerPrepareContext } from "./reviewer-print.ts";
9
10
 
10
- const VERSION = "2.2.0", MANAGED = "managed:kimi-code";
11
+ const MANAGED = "managed:kimi-code";
11
12
  const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
12
13
  const hash = (text: string) => createHash("sha256").update(text).digest("hex");
13
14
  /** Native discovery can walk above cwd; check lexical aliases as well as the physical temporary root. */
@@ -133,7 +134,9 @@ export const kimiReviewerBackend: ReviewerBackend = {
133
134
  const executable = realPath(Bun.which(native[0]!, { PATH: process.env.PATH }) ?? resolve(native[0]!));
134
135
  const command = [executable, ...native.slice(1)];
135
136
  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
+ // #691: the verified versions and the person's trust live in compat.ts.
138
+ const verdict = assessVersion("kimi-review", COMPAT["kimi-review"].parse(await version(command, cwd, env, context.signal)));
139
+ if (!verdict.ok) throw new Error(verdict.reason);
137
140
  const fingerprints = [configFile, mcpFile, agentFile].map(file => hash(read(file, 2_097_152)));
138
141
  let cleaned = false;
139
142
  const cleanup = () => { if (cleaned) return; rmSync(root, { recursive: true, force: true }); cleaned = true; };
@@ -149,7 +152,7 @@ export const kimiReviewerBackend: ReviewerBackend = {
149
152
  && ["credentials", "oauth", "device_id"].every((name, index) => realPath(join(source, name)) === sources[index] && realPath(join(tool, name)) === sources[index]);
150
153
  } catch { return false; }
151
154
  },
152
- isolation: { backend: "kimi", version: VERSION, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
155
+ isolation: { backend: "kimi", version: verdict.version, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
153
156
  enforcement: "native-policy", freshSession: true, hubToolsOnly: true,
154
157
  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
158
  };
@@ -202,7 +202,9 @@ export interface AdapterDefinition {
202
202
  };
203
203
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
204
204
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
205
- instances?: { mode: "external" | "headless"; roles?: readonly string[] };
205
+ instances?: { mode: "external" | "headless"; roles?: readonly string[];
206
+ /** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
207
+ nativeHome?: true };
206
208
  attach?: {
207
209
  /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
208
210
  externalHello?: boolean;
@@ -232,6 +232,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
232
232
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
233
233
  ["routing", definition.routing !== undefined],
234
234
  ["projection", definition.projection !== undefined],
235
+ ["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
235
236
  ["reviewerVariant", definition.reviewerVariant !== undefined],
236
237
  ["reviewer", definition.reviewer !== undefined],
237
238
  ["hooks.factsHook", declared(definition.hooks, "factsHook")],
@@ -6,6 +6,28 @@ import { BasePeer } from "../hub/peers.ts";
6
6
  import type { Envelope } from "../hub/envelope.ts";
7
7
  import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
8
8
 
9
+ /** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
10
+ export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
11
+ export type ReviewerCauseClass = typeof REVIEWER_CAUSE_CLASSES[number];
12
+ const REVIEWER_CAUSE_HINT: Record<ReviewerCauseClass, string> = {
13
+ version: "the CLI version is neither verified nor trusted; see ahub agents",
14
+ binary: "the CLI binary or a watched config file could not be bound, or changed during the proof; check the configured binary and retry",
15
+ mcp_inventory: "the MCP server inventory or the hub server entry did not match the proof",
16
+ plugins_hooks_apps: "plugins, hooks or apps could not be proven disabled",
17
+ sandbox_proof: "the native sandbox proof did not pass",
18
+ credential_root: "a host credential root is unusable; remove or repair it",
19
+ timeout: "a proof command timed out",
20
+ unknown: "unclassified; inspect hub.log",
21
+ };
22
+ /** The one wording for the unavailable reason, the log and `ahub status`. `detail` is only ever a credential-root path class. */
23
+ export function reviewerUnavailableReason(cause: ReviewerCauseClass, detail?: string): string {
24
+ return `reviewer isolation unavailable [cause: ${cause}]: ${REVIEWER_CAUSE_HINT[cause]}${detail ? ` (${detail})` : ""}`;
25
+ }
26
+ /** A prepare refusal that carries its class to the host; the message stays the backend's own text. */
27
+ export class ReviewerUnavailableError extends Error {
28
+ constructor(message: string, readonly causeClass: ReviewerCauseClass, readonly detail?: string) { super(message); }
29
+ }
30
+
9
31
  export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
10
32
  export interface ReviewerIsolationReceipt {
11
33
  backend: string;
@@ -51,7 +73,7 @@ export interface ReviewerBackend {
51
73
  id: string;
52
74
  taskMcp?: true;
53
75
  nativePolicy?: true;
54
- prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
76
+ prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }>;
55
77
  usage?(event: unknown): ReviewerPrintUsage | undefined;
56
78
  failure?(event: unknown): "quota" | undefined;
57
79
  }
@@ -60,6 +82,9 @@ export interface ReviewerFailure {
60
82
  head?: string;
61
83
  flowRevision?: number;
62
84
  cause: "start_failed" | "quota" | "no_verdict";
85
+ /** Only for `start_failed`: the leg that refused (#722). */
86
+ causeClass?: ReviewerCauseClass;
87
+ causeDetail?: string;
63
88
  }
64
89
 
65
90
  export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
@@ -93,8 +118,8 @@ const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
93
118
  const revision = Number(env.refs?.reviewRevision);
94
119
  return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
95
120
  };
96
- const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
97
- batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
121
+ const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"], why?: { causeClass: ReviewerCauseClass; causeDetail?: string }): ReviewerFailure[] =>
122
+ batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause, ...(why ? { causeClass: why.causeClass, ...(why.causeDetail ? { causeDetail: why.causeDetail } : {}) } : {}) }] : []);
98
123
  const reviewTask = (env: Envelope): number | undefined => {
99
124
  if (env.from !== HUB || env.kind !== "review") return undefined;
100
125
  const id = Number(env.refs?.task);
@@ -225,7 +250,9 @@ export class ReviewerPrintPeer extends BasePeer {
225
250
  }
226
251
  // Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
227
252
  failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
228
- reroute.push(...remainingFailures(batch, index, "start_failed"));
253
+ const why = error instanceof ReviewerUnavailableError ? { causeClass: error.causeClass, causeDetail: error.detail } : { causeClass: "unknown" as const };
254
+ this.opts.log?.(`[${this.id}] review of task #${task} could not start [cause: ${why.causeClass}]`);
255
+ reroute.push(...remainingFailures(batch, index, "start_failed", why));
229
256
  batch.next = envs.length;
230
257
  break;
231
258
  }
@@ -273,14 +300,14 @@ export class ReviewerPrintPeer extends BasePeer {
273
300
  mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
274
301
  systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
275
302
  ...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
276
- if ("unavailable" in launch) throw new Error(launch.unavailable);
303
+ if ("unavailable" in launch) throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
277
304
  const proof = launch.isolation;
278
305
  const policy = this.opts.backend.nativePolicy
279
306
  ? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
280
307
  : proof?.strictMcp === true && proof.ephemeral === true;
281
308
  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) {
282
309
  await launch.cleanup?.();
283
- throw new Error("reviewer isolation is unavailable or preparation was cancelled");
310
+ throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
284
311
  }
285
312
  return launch;
286
313
  })();
@@ -307,7 +334,7 @@ export class ReviewerPrintPeer extends BasePeer {
307
334
  const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
308
335
  try {
309
336
  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");
337
+ if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
311
338
  this.receipt = launch.isolation;
312
339
  const [cmd, ...prefix] = launch.command;
313
340
  let quota = false;
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writeTrust, type CompatId, type CompatVerdict } from "../adapters/compat.ts";
3
+
4
+ /** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
5
+ export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
6
+
7
+ /** `commands` carries the configured argv (codex_bin, jcode_cmd, qwen_cmd); the probe words follow it. */
8
+ export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
9
+ const trusted = readTrust();
10
+ return (Object.keys(COMPAT) as CompatId[]).map(id => {
11
+ const [defaultBin, ...words] = COMPAT[id].probe;
12
+ const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
+ const executable = Bun.which(bin) ?? undefined;
14
+ if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
15
+ const result = spawnSync(executable, [...prefix, ...words], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" } });
16
+ return { id, executable, verdict: assessVersion(id, result.status === 0 ? COMPAT[id].parse(result.stdout ?? "") : undefined, trusted), policy: describe(id) };
17
+ });
18
+ }
19
+
20
+ export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
21
+ const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
22
+ if (sub === "trust") {
23
+ const revoke = rest.includes("--revoke");
24
+ if (!isCompatId(id) || !version || rest.some(arg => arg !== "--revoke")) fail(`usage: ahub agents trust <${Object.keys(COMPAT).join("|")}> <version> [--revoke]`);
25
+ // The issue requires saying what trust covers before it is recorded.
26
+ if (!revoke) console.log(`${COMPAT[id].label} ${version}: ${COMPAT[id].trustNote}`);
27
+ let rows;
28
+ try { rows = writeTrust(id, version, revoke); } catch (error) { fail((error as Error).message); }
29
+ if (revoke) return console.log(`revoked: ${id} ${version} (${rows.length} trusted on this machine, ${trustFile()})`);
30
+ console.log(`trusted on this machine: ${COMPAT[id].label} ${version}\nUndo: ahub agents trust ${id} ${version} --revoke`);
31
+ return;
32
+ }
33
+ if (sub !== undefined) fail("usage: ahub agents [--json] | ahub agents trust <id> <version> [--revoke]");
34
+ const rows = agentVersions(commands);
35
+ if (args.includes("--json")) return console.log(JSON.stringify(rows.map(row => ({ id: row.id, executable: row.executable ?? null, ...row.verdict, policy: row.policy })), null, 2));
36
+ for (const row of rows) {
37
+ const v = row.verdict;
38
+ console.log(`${row.id.padEnd(13)} ${(v.version ?? "-").padEnd(10)} ${v.state.padEnd(9)} ${row.policy}${v.ok ? (v.note ? `\n${" ".repeat(14)}${v.note}` : "") : `\n${" ".repeat(14)}${v.reason}`}`);
39
+ }
40
+ }
@@ -0,0 +1,54 @@
1
+ // #711 / #380: the console's review guidance. Pure strings and decisions, no terminal code and no state import: console-state.ts renders them.
2
+ // Nothing here changes authority: the console runs `ahub review` as the person, and the hub decides.
3
+
4
+ export type Verdict = "approved" | "changes_requested";
5
+ /** The task a review choice was opened for, taken from the selected row or the open detail. */
6
+ export interface ReviewChoice { id: number; title: string; reviewerIsUser: boolean }
7
+
8
+ const MAX_TITLE = 40;
9
+ const clean = (text: string) => text.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
10
+ /** The title as the choice line shows it: quoted, one line, cut to the width; a private task is `[pii]`. */
11
+ export function choiceTitle(row: { title?: unknown; signals?: unknown }): string {
12
+ const priv = (Array.isArray(row.signals) && row.signals.some(s => s === "pii" || s === "pii_path")) || row.title === "[pii]";
13
+ if (priv) return "[pii]";
14
+ const text = clean(String(row.title ?? ""));
15
+ return JSON.stringify(text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE - 3)}...` : text);
16
+ }
17
+
18
+ /** The choice for a task row, or undefined when it is not one the person can review (not in review). */
19
+ export function reviewChoiceFor(row: any): ReviewChoice | undefined {
20
+ if (!row || row.state !== "in_review" || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0) return undefined;
21
+ return { id: Number(row.id), title: choiceTitle(row), reviewerIsUser: row.reviewer === "user" };
22
+ }
23
+
24
+ /** The prompt line while the choice is open (#711 change 1). The title is already quoted. */
25
+ export const choicePrompt = (c: ReviewChoice): string => `review #${c.id} ${c.title}: a approve c request changes Esc cancel`;
26
+ /** What `a` and `c` prefill on the input line. The trailing space is where the note goes. */
27
+ export const verdictInput = (id: number, verdict: Verdict): string => `review ${id} ${verdict} `;
28
+ /** The guide line shown above the input after a choice (#711 change 1). */
29
+ export const verdictGuide = (id: number, verdict: Verdict): string =>
30
+ verdict === "approved" ? `Enter approves #${id}. You can type a note first. Esc cancels.` : "Say what to fix, then Enter. Esc cancels.";
31
+
32
+ /** The footer hint for a task in review (#711 change 2): the verb, and whose review it is when it is the person's. */
33
+ export const reviewHint = (reviewer: unknown): string => reviewer === "user" ? "your review: r approve or request changes" : "r approve or request changes";
34
+
35
+ /**
36
+ * A `review <id> <verdict> [note]` line the person typed, checked before it is sent: a changes request must say what to fix (#711 change 1). Returns the refusal,
37
+ * or undefined when the line is fine or is not a review line at all (other commands are validated where they always were).
38
+ */
39
+ export function reviewRefusal(args: readonly string[]): string | undefined {
40
+ if (args[0] !== "review") return undefined;
41
+ const verdict = args[2];
42
+ if (verdict !== "changes_requested") return undefined;
43
+ const note = args.slice(3).filter(word => !word.startsWith("--")).join(" ").trim();
44
+ return note ? undefined : "say what to fix before requesting changes";
45
+ }
46
+
47
+ /** The notice after a review command finished: `#372 approved`, `#372 changes requested`, or the CLI's own refusal verbatim (#711 change 3). */
48
+ export function reviewResult(args: readonly string[], code: number, diagnostic: string): { text: string; tone: "success" | "failure" } | undefined {
49
+ if (args[0] !== "review" || !/^\d+$/.test(args[1] ?? "")) return undefined;
50
+ const verdict = args[2];
51
+ if (verdict !== "approved" && verdict !== "changes_requested") return undefined;
52
+ if (code) return { text: clean(diagnostic) || `command exited ${code}`, tone: "failure" };
53
+ return { text: `#${args[1]} ${verdict === "approved" ? "approved" : "changes requested"}`, tone: "success" };
54
+ }