@staix/agent-hub 0.18.0 → 0.20.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 (90) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +13 -10
  5. package/docs/agent-notes/bus.md +1 -1
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +6 -1
  9. package/docs/agent-notes/tests.md +8 -4
  10. package/docs/dogfood.md +13 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +98 -28
  13. package/docs/quickstart.md +13 -5
  14. package/docs/sdlc.md +12 -0
  15. package/docs/security.md +24 -15
  16. package/docs/smoke.md +3 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
  18. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  19. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  20. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  21. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  22. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  23. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  24. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  25. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  26. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/claude-permission-transport-native.py +187 -0
  29. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  31. package/docs/verification/pi-140-model-free.ts +57 -0
  32. package/docs/verification/pi-140-native.mjs +21 -0
  33. package/docs/verified.json +25 -17
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +373 -72
  37. package/src/adapters/acp.ts +8 -0
  38. package/src/adapters/agy/entry.ts +1 -1
  39. package/src/adapters/channel-peer.ts +7 -5
  40. package/src/adapters/claude-channel.ts +7 -3
  41. package/src/adapters/claude-implementer.ts +6 -3
  42. package/src/adapters/codex-appserver.ts +11 -1
  43. package/src/adapters/compat.ts +32 -18
  44. package/src/adapters/conformance/enrollment.ts +4 -4
  45. package/src/adapters/conformance/factories.ts +2 -1
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/conformance/kit.ts +10 -0
  48. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  49. package/src/adapters/definitions/native-factories.ts +7 -2
  50. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  51. package/src/adapters/local-worker.ts +2 -0
  52. package/src/adapters/pi.ts +46 -3
  53. package/src/adapters/qwen-dual.ts +25 -0
  54. package/src/adapters/registry-types.ts +6 -2
  55. package/src/adapters/registry.ts +1 -0
  56. package/src/adapters/reviewer-print.ts +1 -0
  57. package/src/cli/agents.ts +69 -7
  58. package/src/cli/claude-shell-wrapper.ts +54 -0
  59. package/src/cli/main.ts +30 -24
  60. package/src/cli/output.ts +4 -0
  61. package/src/hub/agent-drift.ts +61 -0
  62. package/src/hub/board.ts +14 -2
  63. package/src/hub/check-runner.ts +148 -18
  64. package/src/hub/ci-watch.ts +140 -0
  65. package/src/hub/claude-launch-proof.ts +67 -2
  66. package/src/hub/claude-shell-wrapper.ts +349 -0
  67. package/src/hub/config-apply.ts +2 -2
  68. package/src/hub/config-edit.ts +2 -2
  69. package/src/hub/config-tier.ts +2 -2
  70. package/src/hub/config-trust.ts +1 -0
  71. package/src/hub/daemon.ts +282 -113
  72. package/src/hub/host-executable.ts +20 -1
  73. package/src/hub/instances.ts +18 -2
  74. package/src/hub/lifecycle.ts +1 -1
  75. package/src/hub/operational-authority.ts +7 -0
  76. package/src/hub/operational-view.ts +6 -10
  77. package/src/hub/peers.ts +34 -0
  78. package/src/hub/read-only-shell-proof.ts +7 -6
  79. package/src/hub/review-scope.ts +13 -0
  80. package/src/hub/sdlc-metrics.ts +6 -4
  81. package/src/hub/settings.ts +1 -0
  82. package/src/hub/task-shell-command.ts +85 -0
  83. package/src/hub/task-shell-default.ts +79 -0
  84. package/src/hub/task-shell-proof.ts +171 -0
  85. package/src/hub/task-shell-sandbox.ts +61 -0
  86. package/src/hub/tasks.ts +274 -16
  87. package/src/local/deny.ts +112 -11
  88. package/src/local/sandbox.ts +70 -29
  89. package/src/local/shell-environment.ts +10 -0
  90. package/src/ui/index.html +1 -0
@@ -10,6 +10,7 @@ export { QwenIdentityError } from "./definitions/qwen-native.ts";
10
10
  import { BasePeer } from "../hub/peers.ts";
11
11
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
12
12
  import { AcpOwnedGroup } from "./acp-owned-group.ts";
13
+ import type { StartForm } from "./registry-types.ts";
13
14
 
14
15
  export interface PermissionOption {
15
16
  optionId: string;
@@ -163,6 +164,9 @@ function jsonObject(text: string | undefined): object | undefined {
163
164
 
164
165
  /** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
165
166
  export class AcpPeer extends BasePeer {
167
+ readonly deliveryOnly: boolean = true;
168
+ readonly hubOwned: boolean = true;
169
+ readonly form: StartForm = "headless";
166
170
  private permissionMode: PermissionMode;
167
171
  private readonly nativePolicy: AcpOptions["nativePolicy"];
168
172
  private modeUnknown = false;
@@ -256,6 +260,10 @@ export class AcpPeer extends BasePeer {
256
260
  recoveryMetadata(): Record<string, unknown> {
257
261
  return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
258
262
  }
263
+ get nativeSession(): string | undefined {
264
+ const session = this.recoveryMetadata().sessionId;
265
+ return typeof session === "string" ? session : undefined;
266
+ }
259
267
 
260
268
  async start(): Promise<void> {
261
269
  if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
@@ -37,7 +37,7 @@ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise
37
37
  /** #607: agy 1.4.x attaches the plugin MCP server at the first prompt, so the hub tools are listed from the second turn. */
38
38
  export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox; hub tools appear from the second turn (send any first prompt, then ask for hub work)";
39
39
 
40
- /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
40
+ /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 and 1.4.7 have a T0 receipt and other patches warn (#589). */
41
41
  export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
42
42
  // #691: the verified versions, the compatible line and the person's trust live in compat.ts.
43
43
  const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
@@ -1,6 +1,7 @@
1
1
  import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
2
2
  import type { Envelope } from "../hub/envelope.ts";
3
3
  import { definitionOf } from "./registry.ts";
4
+ import type { StartForm } from "./registry-types.ts";
4
5
 
5
6
  /** Only the control socket operations the peer owns; authentication remains in the daemon. */
6
7
  export interface ChannelSocket {
@@ -12,6 +13,7 @@ export interface ChannelSocket {
12
13
 
13
14
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
14
15
  export class WsPeer extends BasePeer {
16
+ readonly form: StartForm = "tui";
15
17
  private generation = crypto.randomUUID();
16
18
  // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
17
19
  private claimGeneration = crypto.randomUUID();
@@ -23,7 +25,7 @@ export class WsPeer extends BasePeer {
23
25
  private delivered = new Set<string>();
24
26
  private sock: ChannelSocket | undefined;
25
27
  private claimed: ChannelSocket | undefined;
26
- private nativeSession: string | undefined;
28
+ private nativeTurnSession: string | undefined;
27
29
  private nativeActive = false;
28
30
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
29
31
  pullOnly = false;
@@ -66,7 +68,7 @@ export class WsPeer extends BasePeer {
66
68
  this.sock = sock;
67
69
  this.generation = crypto.randomUUID();
68
70
  this.delivered.clear();
69
- this.nativeSession = undefined;
71
+ this.nativeTurnSession = undefined;
70
72
  this.nativeActive = false;
71
73
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
72
74
  // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
@@ -112,13 +114,13 @@ export class WsPeer extends BasePeer {
112
114
  owns(sock: ChannelSocket): boolean { return this.sock === sock; }
113
115
  nativeStarted(session: string): void {
114
116
  if (!this.sock) return;
115
- this.nativeSession = session;
117
+ this.nativeTurnSession = session;
116
118
  this.nativeActive = true;
117
119
  this.setState("busy");
118
120
  }
119
121
  nativeStopped(session: string): boolean {
120
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
121
- this.nativeSession = session;
122
+ if (!this.sock || (this.nativeTurnSession !== undefined && this.nativeTurnSession !== session)) return false;
123
+ this.nativeTurnSession = session;
122
124
  this.nativeActive = false;
123
125
  this.setState("idle");
124
126
  return true;
@@ -400,9 +400,13 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
400
400
  || !args.input || typeof args.input !== "object" || Array.isArray(args.input)
401
401
  || typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return deny("invalid native permission request");
402
402
  try {
403
- // Keep original arguments only in this native request closure; the host returns a decision, not replacements.
404
- const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input }, 3_605_000);
405
- return res.ok === true && res.behavior === "allow" ? text(JSON.stringify({ behavior: "allow", updatedInput: args?.input })) : deny(typeof res.message === "string" ? res.message : "permission denied");
403
+ // Only this fixed client capability permits host wrapper grants; older clients keep person approval.
404
+ const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
405
+ if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
406
+ if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
407
+ const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
408
+ if (res.wrapperProtocol === 1 && (args.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
409
+ return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
406
410
  } catch { return deny("hub permission unavailable"); }
407
411
  }
408
412
  if (name === "hub_delivery_done" && channel) {
@@ -23,6 +23,8 @@ export interface ClaudeImplementerOptions {
23
23
  factsHook: string;
24
24
  systemPrompt: string;
25
25
  watchdogMs: number;
26
+ /** Declared `model` of this instance (#844): one validated word passed as `--model`; unset leaves the native default and the argv unchanged. */
27
+ model?: string;
26
28
  mode?: "ask" | "ask-when-needed";
27
29
  /** Exact host-reported transcript evidence only; unknown/read errors retain the recorded UUID. */
28
30
  transcriptPersisted?(task: number, sessionId: string): boolean | "unknown";
@@ -38,10 +40,10 @@ export interface ClaudeImplementerOptions {
38
40
  prove?(): Promise<() => boolean>;
39
41
  }
40
42
  export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
41
- export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string): string[] {
43
+ export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
42
44
  return ["-p", "--output-format", "stream-json", "--verbose", turn.resume ? "--resume" : "--session-id", turn.sessionId,
43
45
  ...(!turn.persistent ? ["--no-session-persistence"] : []), "--strict-mcp-config", "--mcp-config", mcp, "--setting-sources", "", "--settings", settings,
44
- "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
46
+ ...(model ? ["--model", model] : []), "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
45
47
  "--allowedTools", ...IMPLEMENTER_HUB_TOOL_NAMES.map(name => `mcp__agent-hub__${name}`), "--append-system-prompt", prompt];
46
48
  }
47
49
 
@@ -70,6 +72,7 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
70
72
  /** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
71
73
  export class ClaudeImplementerPeer extends BasePeer {
72
74
  readonly hubNative = true;
75
+ readonly hubOwned: boolean = true;
73
76
  private generation = 0;
74
77
  private owned: OwnedPrintChild | undefined;
75
78
  private running: Promise<void> | undefined;
@@ -184,7 +187,7 @@ export class ClaudeImplementerPeer extends BasePeer {
184
187
  AGENTHUB_INSTANCE_ID: this.options.instanceId, AGENTHUB_LAUNCH_ID: launchId, AGENTHUB_HOOK_PURPOSE: "permission", AGENTHUB_PERMISSION_TOOL: "1" };
185
188
  const mcp = JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", this.options.server], env } } });
186
189
  let result = false, failed = false;
187
- const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt), cwd: this.options.cwd,
190
+ const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt, this.options.model), cwd: this.options.cwd,
188
191
  input: group.envs.map(frame).join("\n\n"), env, current, stop: this.options.stopChild,
189
192
  onSpawn: () => {
190
193
  try {
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import type { PermissionMode } from "../hub/permission-mode.ts";
5
- import { BasePeer } from "../hub/peers.ts";
5
+ import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
6
6
  import { codexContext, type ContextReading } from "../hub/context-window.ts";
7
7
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
8
  import type { ContextReader } from "./registry-types.ts";
@@ -82,6 +82,7 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
82
82
  * TUI's, and their responses are swallowed.
83
83
  */
84
84
  export class CodexPeer extends BasePeer {
85
+ readonly frontendProxy: FrontendProxyPort;
85
86
  private permissionMode: PermissionMode;
86
87
  private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
87
88
  get actionGated(): boolean { return this.opts.actionGate !== undefined; }
@@ -136,6 +137,13 @@ export class CodexPeer extends BasePeer {
136
137
  ) {
137
138
  super(id, opts.watchdogMs);
138
139
  this.permissionMode = opts.permissionMode ?? "ask";
140
+ const owner = this;
141
+ this.frontendProxy = {
142
+ get url() { return owner.proxyUrl; },
143
+ get attached() { return owner.proxyAttached; },
144
+ setStartupPermissionMode: this.setStartupPermissionMode.bind(this),
145
+ clearPermissionMode: this.clearPermissionMode.bind(this),
146
+ };
139
147
  }
140
148
 
141
149
  recoveryMetadata(): Record<string, unknown> {
@@ -149,6 +157,7 @@ export class CodexPeer extends BasePeer {
149
157
  get thread(): string {
150
158
  return this.threadId;
151
159
  }
160
+ get nativeSession(): string | undefined { return this.threadId || undefined; }
152
161
 
153
162
  get proxyUrl(): string {
154
163
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
@@ -694,6 +703,7 @@ export class CodexPeer extends BasePeer {
694
703
  link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
695
704
  });
696
705
  }
706
+ revertTurn(nativeTurn: string): Promise<void> { return this.revert(nativeTurn); }
697
707
 
698
708
  /** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
699
709
  private readUsage(): void {
@@ -21,48 +21,62 @@ export interface CompatPolicy {
21
21
  parse: (stdout: string) => string | undefined;
22
22
  /** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
23
23
  trustNote: string;
24
+ /** The last receipt in docs/verification whose model-free procedure a new version repeats (#843). */
25
+ receipt: string;
24
26
  /** The config key that selects this kind's executable, when one exists. */
25
- config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
27
+ config?: "codex_bin" | "jcode_cmd" | "qwen_cmd" | "pi.cmd";
26
28
  }
27
29
 
28
30
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
31
  export const COMPAT = {
30
- // Receipt: docs/verification/2026-10-23-headless-claude-permissions.md
31
- "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
+ // Receipt: docs/verification/2026-10-26-pi-140-compat.md
33
+ pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
34
+ parse: out => plain(out.trim()),
35
+ trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
+ receipt: "docs/verification/2026-10-26-pi-140-compat.md" },
37
+ // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; host-wrapper receipt below repeats with them.
38
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310"], gate: "contract", probe: ["claude", "--version"],
32
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
- trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
40
+ trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
41
+ receipt: "docs/verification/2026-10-26-headless-claude-shell-context.md" },
34
42
  // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
35
43
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
36
44
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
37
- trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
38
- // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md
39
- "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1"], gate: "isolation", probe: ["kimi", "--version"],
45
+ trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
46
+ receipt: "docs/verification/2026-10-24-codex-0164-reviewer-isolation.md" },
47
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md
48
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2"], gate: "isolation", probe: ["kimi", "--version"],
40
49
  parse: out => plain(out.trim()),
41
- trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven." },
42
- jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
50
+ trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
51
+ receipt: "docs/verification/2026-10-26-kimi-232-reviewer-compat.md" },
52
+ jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
43
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
44
- trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them." },
45
- agy: { label: "agy", verified: ["1.4.2"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
54
+ trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
55
+ receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
56
+ agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
46
57
  parse: out => plain(out.trim()),
47
- trustNote: "Only 1.4.2 is T0-verified; the plugin MCP and hook contract of another version is unverified." },
48
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
58
+ trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
+ receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
49
61
  parse: out => plain(out.trim()),
50
- trustNote: "Native ACP identity and mode ids have dated receipts; 0.26.0 permission callbacks were checked without inference. Another version may change those contracts." },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.1; its two ACP fixes were judged not to change the hub contract. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-10-26-qwen-0261-acp.md" },
51
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
52
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
53
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
54
67
  parse: out => plain(out.trim()),
55
- trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either." },
68
+ trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either.",
69
+ receipt: "docs/verification/2026-10-25-qwen-dual-output-t0.md" },
56
70
  } as const satisfies Record<string, CompatPolicy>;
57
71
  export type CompatId = keyof typeof COMPAT;
58
- type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
72
+ type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[]; pi?: { cmd: string[] } };
59
73
  /** The configured executables the native gates use; the rest probe their PATH default. */
60
74
  export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
61
75
  const out: Partial<Record<CompatId, readonly string[]>> = {};
62
76
  for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
63
77
  if (!policy.config) continue;
64
- const value = config[policy.config];
65
- out[id] = typeof value === "string" ? [value] : value;
78
+ const value = policy.config === "pi.cmd" ? config.pi?.cmd : config[policy.config];
79
+ if (value !== undefined) out[id] = typeof value === "string" ? [value] : value;
66
80
  }
67
81
  return out;
68
82
  }
@@ -4,7 +4,7 @@ import { isFixedBuiltinDefinition } from "../registry.ts";
4
4
  /** Stable generic ACP legs; both actual test registration and enrollment validation consume this catalog. */
5
5
  export const ACP_LEGS = Object.freeze(["native-approval-rpc", "native-mode-ack", "native-quota", "watchdog-generation"] as const);
6
6
  export type AcpLeg = typeof ACP_LEGS[number];
7
- /** Reserved until 2b supplies the restrictive ACP product members; not an executable ACP leg yet. */
7
+ /** Mandatory product invariant, asserted for generic and owned ACP factories rather than skipped protocol legs. */
8
8
  export const ACP_PORTS_LEG = "restrictive-ports";
9
9
  export const NATIVE_PROOF_PORTS_LEG = "native-proof-ports";
10
10
  /** No unsupported key or non-generic ACP engine can silently escape an executable catalog leg. */
@@ -18,8 +18,9 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
18
18
  if (!row || !isFixedBuiltinDefinition(row.definition)) throw new Error(`${key}: only fixed builtin proof legs may be nonapplicable`);
19
19
  continue;
20
20
  }
21
- if (separator < 1 || !acp.has(kind) || !(ACP_LEGS as readonly string[]).includes(leg) && leg !== ACP_PORTS_LEG) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
22
- if (leg === ACP_PORTS_LEG && reason !== "ports added in 2b") throw new Error(`${key}: reserved ACP ports leg requires reason ports added in 2b`);
21
+ if (separator < 1 || !acp.has(kind)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
22
+ if (leg === ACP_PORTS_LEG) throw new Error(`${key}: mandatory ACP restrictive ports leg cannot be unsupported`);
23
+ if (!(ACP_LEGS as readonly string[]).includes(leg)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
23
24
  }
24
25
  for (const definition of acp.values()) if (definition.start.daemonPort !== "acp") {
25
26
  for (const leg of ACP_LEGS) {
@@ -50,7 +51,6 @@ export function assertEnrollment(definitions: readonly AdapterDefinition[], enro
50
51
  }
51
52
  for (const { id, definition } of rows) {
52
53
  if (isFixedBuiltinDefinition(definition) && !Object.hasOwn(unsupportedLegs, `${id}:${NATIVE_PROOF_PORTS_LEG}`)) throw new Error(`${id}: native proof ports need an explicit nonapplicable reason`);
53
- if (definition.transport === "acp" && !Object.hasOwn(unsupportedLegs, `${id}:${ACP_PORTS_LEG}`)) throw new Error(`${id}: ACP restrictive ports require the reserved 2b reason`);
54
54
  if (!enrolled.includes(id) && !Object.hasOwn(unsupported, id)) throw new Error(`${id}: no conformance fixture or unsupported reason`);
55
55
  if (definition.transport === "acp" && !enrolled.includes(id)) throw new Error(`${id}: every ACP definition must run the ACP legs`);
56
56
  if (id.endsWith(":reviewer") && !definition.reviewer) throw new Error(`${id}: reviewer variant lacks reviewer policy`);
@@ -1,7 +1,7 @@
1
1
  import type { AdapterDefinition, CreateContext, DaemonPorts } from "../registry-types.ts";
2
2
  import type { PeerAdapter } from "../../hub/peers.ts";
3
3
  import { join } from "node:path";
4
- import { assertNativeProofPorts } from "./kit.ts";
4
+ import { assertAcpProductPorts, assertNativeProofPorts } from "./kit.ts";
5
5
 
6
6
  /** The supplied definition creates its actual adapter, with only this fixture's transport options. */
7
7
  export function createFromDefinition<T extends PeerAdapter>(definition: AdapterDefinition, daemon: DaemonPorts): T {
@@ -10,6 +10,7 @@ export function createFromDefinition<T extends PeerAdapter>(definition: AdapterD
10
10
  const product = definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
11
11
  kindConfig: {}, permission: "ask", mcpServers: options.mcpServers ?? [], daemon }) as T;
12
12
  assertNativeProofPorts(definition, product);
13
+ assertAcpProductPorts(definition, product);
13
14
  return product;
14
15
  }
15
16
  /** Explicit watchdog seam on the factory-created implementation, replacing direct test subclasses. */
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ if (process.argv.includes("--version")) { console.log("1.4.0"); process.exit(0); }
4
5
  const option = (flag: string) => process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : undefined;
5
6
  const empty = process.argv.includes("--empty-session");
6
7
  const dir = option("--session-dir")!;
@@ -24,6 +24,15 @@ export interface AdapterConformance {
24
24
  create(): Promise<ConformanceFixture>;
25
25
  }
26
26
 
27
+ /** Every ACP factory product keeps the transport's restrictive delivery and ownership contract. */
28
+ export function assertAcpProductPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
+ if (definition?.transport !== "acp") return;
30
+ for (const port of ["deliveryOnly", "hubOwned"] as const) {
31
+ if (product[port] !== true) throw new Error(`${definition.kind}: ACP restrictive port ${port} must be true`);
32
+ }
33
+ if (product.form !== "headless") throw new Error(`${definition.kind}: ACP restrictive port form must be headless`);
34
+ }
35
+
27
36
  /** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
28
37
  export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
38
  if (isFixedBuiltinDefinition(definition)) return;
@@ -38,6 +47,7 @@ export function registerConformance(adapter: AdapterConformance): void {
38
47
  const f = await adapter.create();
39
48
  try {
40
49
  assertNativeProofPorts(adapter.definition, f.peer);
50
+ assertAcpProductPorts(adapter.definition, f.peer);
41
51
  expect(f.peer.id).toBe(adapter.kind);
42
52
  expect(f.peer.state).toBe("idle");
43
53
  const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
@@ -85,8 +85,8 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
85
85
  api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
86
86
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
87
87
  start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
88
- identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"] },
89
- permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools inside the owned worktree may run automatically; shell and protected paths ask" } },
88
+ identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
89
+ permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
90
90
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
91
91
  turnEnd: "adapter", budget: { account: "claude" },
92
92
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
@@ -34,8 +34,13 @@ export function createQwenDual(ctx: CreateContext): PeerAdapter {
34
34
  export function createPi(ctx: CreateContext): PeerAdapter {
35
35
  if (!("pi" in ctx.daemon)) throw new Error("Pi requires its Pi transport options");
36
36
  if (ctx.permission === "read-only") throw new Error("Pi has no verified read-only permission mapping");
37
- const { PiPeer } = require("../pi.ts") as typeof import("../pi.ts");
38
- return new PiPeer(ctx.instance, ctx.daemon.pi);
37
+ const { PiPeer, PiVersionRefusal, piVersionAsync } = require("../pi.ts") as typeof import("../pi.ts");
38
+ const options = ctx.daemon.pi;
39
+ return new PiPeer(ctx.instance, options, async () => {
40
+ const verdict = await piVersionAsync(options.cmd ?? ["pi"], options.cwd);
41
+ if (!verdict.ok) throw new PiVersionRefusal(verdict);
42
+ if (verdict.note) options.log?.(verdict.note);
43
+ });
39
44
  }
40
45
 
41
46
  export function createLocal(ctx: CreateContext): PeerAdapter {
@@ -19,9 +19,9 @@ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullabl
19
19
  reviewer, cli: { command: kind, startWords: () => [] }, display,
20
20
  };
21
21
  }
22
- const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
22
+ const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, CODEX_DISPLAY);
23
23
  export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
24
- export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit" }, KIMI_DISPLAY);
24
+ export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, KIMI_DISPLAY);
25
25
  /** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
26
26
  export const AGY_REVIEWER_VARIANT: AdapterDefinition = {
27
27
  ...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
@@ -77,6 +77,8 @@ const system = (cwd: string, preamble = "") =>
77
77
  /** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
78
78
  export class LocalPeer extends BasePeer {
79
79
  readonly hubNative = true;
80
+ readonly deliveryOnly: boolean = true;
81
+ readonly hubOwned: boolean = true;
80
82
  private readonly history: ChatMessage[] = [];
81
83
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
82
84
  private readonly routes: HubRouteRuntime;
@@ -2,15 +2,17 @@ import { buildPiLaunch } from "../pi/launch.ts";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
6
6
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
7
7
  import { BasePeer } from "../hub/peers.ts";
8
- import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
+ import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
9
10
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
10
11
  import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
11
12
  import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
12
13
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
13
14
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
15
+ import type { StartForm } from "./registry-types.ts";
14
16
 
15
17
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
16
18
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
@@ -56,6 +58,39 @@ export interface PiOptions {
56
58
  signature?: typeof processSignature;
57
59
  }
58
60
  export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
61
+
62
+ /** Only the host's parsed version verdict may cross the generic startup-error boundary. */
63
+ export class PiVersionRefusal extends Error {
64
+ constructor(verdict: Extract<CompatVerdict, { ok: false }>) { super(verdict.reason); }
65
+ }
66
+
67
+ /** Bounded, non-blocking probe of the exact configured native command, including wrapper arguments. */
68
+ export function piVersionAsync(command: readonly string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
69
+ const [bin, ...prefix] = command;
70
+ if (!bin) return Promise.resolve(assessVersion("pi", undefined));
71
+ return new Promise(resolve => {
72
+ let proc: ChildProcess;
73
+ try { proc = spawn(bin, [...prefix, "--version"], { cwd, env: peerChildEnv("pi"), detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
74
+ catch { resolve(assessVersion("pi", undefined)); return; }
75
+ let output = "", bytes = 0, done = false;
76
+ const finish = async (code: number | null) => {
77
+ if (done) return; done = true; clearTimeout(timer);
78
+ try { await stopOwnedProcess(proc, { group: true }); }
79
+ catch { resolve(assessVersion("pi", undefined)); return; }
80
+ resolve(assessVersion("pi", code === 0 && bytes <= 64 * 1024 ? COMPAT.pi.parse(output) : undefined));
81
+ };
82
+ const timer = setTimeout(() => { void finish(null); }, timeoutMs);
83
+ proc.once("spawn", () => trackGroup(proc));
84
+ proc.stdout!.on("data", (chunk: Buffer) => {
85
+ if (done) return;
86
+ bytes += chunk.length;
87
+ if (bytes > 64 * 1024) { void finish(null); return; }
88
+ output += chunk.toString("utf8");
89
+ });
90
+ proc.once("error", () => { void finish(null); });
91
+ proc.once("close", code => { void finish(code); });
92
+ });
93
+ }
59
94
  type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
60
95
  type VerifiedEmptyResume = { sessionId: string };
61
96
  type TerminalOwnerObservation = { pid: number; signature: string; sessionId: string; sessionFile: string; ownerToken: string; checkedAt: number };
@@ -65,6 +100,7 @@ function ownerStillAlive(pid: number, signature: string | undefined): boolean {
65
100
 
66
101
  export class PiPeer extends BasePeer {
67
102
  readonly hubNative = true;
103
+ readonly hubOwned: boolean = true;
68
104
  private proc?: ChildProcessWithoutNullStreams;
69
105
  private server?: ReturnType<typeof Bun.serve>;
70
106
  private buffer = "";
@@ -132,7 +168,8 @@ export class PiPeer extends BasePeer {
132
168
  private tuiQueue: Record<string, unknown>[] = [];
133
169
  private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
134
170
 
135
- constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
171
+ /** The fixed production factory supplies preflight separately, preserving transport option identity. */
172
+ constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); }
136
173
  get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
137
174
  get pendingResume(): { sessionId?: string; sessionFile?: string } {
138
175
  if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
@@ -167,6 +204,10 @@ export class PiPeer extends BasePeer {
167
204
  const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
168
205
  return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(file ? { sessionFile: file } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(file ? { sessionFile: file } : {}) };
169
206
  }
207
+ get nativeSession(): string | undefined {
208
+ const session = this.recoveryMetadata().sessionId;
209
+ return typeof session === "string" ? session : undefined;
210
+ }
170
211
 
171
212
  /** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
172
213
  async captureResume(): Promise<Record<string, unknown>> {
@@ -344,6 +385,7 @@ export class PiPeer extends BasePeer {
344
385
  }
345
386
 
346
387
  private async startImpl(): Promise<void> {
388
+ if (this.preflight) await this.preflight();
347
389
  this.terminalOwnerObservation = undefined;
348
390
  this.stopping = false;
349
391
  this.rosterDetached = false; this.rosterUnownedRun = false;
@@ -466,6 +508,7 @@ export class PiPeer extends BasePeer {
466
508
  return pending.map((call) => call.settled);
467
509
  }
468
510
  get mode(): "headless" | "tui" { return this.opts.mode; }
511
+ get form(): StartForm { return this.opts.mode; }
469
512
 
470
513
  /** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
471
514
  async detachForRoster(): Promise<void> {
@@ -82,6 +82,17 @@ export function qwenDualNamesStateDir(input: unknown, stateDir: string): boolean
82
82
  return seen(input, 0);
83
83
  }
84
84
 
85
+ /**
86
+ * #517: Qwen tells the model that one of its background shells finished with a `user` event of its own, which is neither the hub's submit nor the person typing. Qwen may fold
87
+ * several finished shells into one event, each its own envelope, separated by one blank line (8 of the 48 in the live event files do). The shape is exact: the text is wholly
88
+ * one or more complete envelopes and nothing else, so a person's text that merely contains or resembles one is still a person's prompt. Nothing inside an envelope is read
89
+ * beyond its header lines, so nothing in it can steer the adapter.
90
+ */
91
+ const NOTIFICATION = String.raw`<task-notification>\n<task-id>bg_[0-9a-f]{4,32}<\/task-id>\n<kind>[a-z_-]{1,32}<\/kind>\n<status>[a-z_-]{1,32}<\/status>\n(?:(?!<task-notification>|<\/task-notification>)[\s\S])*<\/task-notification>`;
92
+ const QWEN_TASK_NOTIFICATIONS = new RegExp(`^${NOTIFICATION}(?:\\n\\n${NOTIFICATION})*$`);
93
+ const NOTIFICATION_CAP = 64 * 1024;
94
+ export function isQwenTaskNotification(text: string): boolean { return text.length <= NOTIFICATION_CAP && QWEN_TASK_NOTIFICATIONS.test(text); }
95
+
85
96
  /** `qwen --version` against the dual-output compat entry. Never throws: a refusal is a reason. */
86
97
  export async function qwenDualVersionAsync(cmd: readonly string[], cwd: string): Promise<{ ok: true; version: string; note?: string } | { ok: false; reason: string }> {
87
98
  const [bin, ...prefix] = cmd.filter(word => word !== "--acp");
@@ -416,6 +427,17 @@ export class QwenDualPeer extends BasePeer {
416
427
  if (!this.stopping && !this.detached && this.state === "offline") this.setState("idle");
417
428
  }
418
429
 
430
+ /**
431
+ * Judged on the raw event, not on `promptText`, which joins every text block and drops the rest: an event is Qwen's own notice only when its content is exactly one text block
432
+ * and that block is wholly the strict envelope. A text split across blocks, or a block with anything else beside it (an image, a second text), is a person's prompt.
433
+ */
434
+ private isNotificationEvent(event: Json): boolean {
435
+ const content = event.message?.content;
436
+ if (!Array.isArray(content) || content.length !== 1) return false;
437
+ const block = content[0];
438
+ return !!block && typeof block === "object" && block.type === "text" && typeof block.text === "string" && isQwenTaskNotification(block.text);
439
+ }
440
+
419
441
  private promptText(event: Json): string | undefined {
420
442
  const content = event.message?.content;
421
443
  if (typeof content === "string") return content;
@@ -425,6 +447,9 @@ export class QwenDualPeer extends BasePeer {
425
447
  }
426
448
 
427
449
  private onUser(event: Json): void {
450
+ // Qwen's own background-shell notice (#517) is part of whatever turn is running and never the person typing, so it ends no hub turn and holds no queue. It changes nothing else:
451
+ // an idle peer stays idle (Qwen may answer it or not, and a peer that waited for an answer that never comes would block its queue), and a running turn keeps its owner.
452
+ if (this.isNotificationEvent(event)) return;
428
453
  const text = this.promptText(event);
429
454
  if (text === undefined) return; // a tool result echoed back, not a prompt
430
455
  const turn = this.turn;
@@ -210,7 +210,9 @@ export interface AdapterDefinition {
210
210
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
211
211
  instances?: { mode: "external" | "headless"; roles?: readonly string[];
212
212
  /** 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. */
213
- nativeHome?: true };
213
+ nativeHome?: true;
214
+ /** A declared headless instance of this definition may pass one validated `model` word to its native child (#844). Fixed builtins only. */
215
+ modelSelect?: true };
214
216
  attach?: {
215
217
  /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
216
218
  externalHello?: boolean;
@@ -309,7 +311,9 @@ export interface AdapterDefinition {
309
311
  /** #525: fixed builtin variant for declared headless implementer instances. */
310
312
  headlessVariant?: AdapterDefinition;
311
313
  /** Fixed host backend; runtime isolation proof is required before bus admission. */
312
- reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
314
+ reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit";
315
+ /** #852: what a reviewer of this kind reads. `code`: the code only, its sandbox cannot run tests or read CI. Unset means `full`. Fixed builtins only. */
316
+ scope?: "full" | "code" };
313
317
  }
314
318
 
315
319
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
@@ -259,6 +259,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
259
259
  ["cli.entry", declared(definition.cli, "entry")],
260
260
  ["cli.preview", declared(definition.cli, "preview")],
261
261
  ["cli.usage", declared(definition.cli, "usage")],
262
+ ["instances.modelSelect", declared(definition.instances, "modelSelect")], // #844: a fixed-builtin assumption, one validated model word per declared headless instance
262
263
  ];
263
264
  const field = restricted.find(([, present]) => present)?.[0];
264
265
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
@@ -156,6 +156,7 @@ const reviewTask = (env: Envelope): number | undefined => {
156
156
 
157
157
  export class ReviewerPrintPeer extends BasePeer {
158
158
  readonly hubNative = true;
159
+ readonly hubOwned: boolean = true;
159
160
  private generation = 0;
160
161
  private child: OwnedPrintChild | undefined;
161
162
  private childCleanup: (() => Promise<void>) | undefined;