@staix/agent-hub 0.15.0 → 0.17.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 (104) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +24 -8
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +15 -3
  9. package/docs/agent-notes/tests.md +8 -3
  10. package/docs/dogfood.md +18 -6
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +160 -47
  14. package/docs/quickstart.md +13 -5
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +11 -11
  17. package/docs/security.md +94 -22
  18. package/docs/smoke.md +58 -6
  19. package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  22. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  23. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  24. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  25. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  26. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  27. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  28. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  29. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  30. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  31. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  32. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  33. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  34. package/docs/verified.json +30 -18
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +3421 -2176
  38. package/src/adapters/acp.ts +5 -2
  39. package/src/adapters/codex-appserver.ts +69 -1
  40. package/src/adapters/codex-review-isolation.ts +10 -1
  41. package/src/adapters/compat.ts +9 -4
  42. package/src/adapters/conformance/enrollment.ts +18 -4
  43. package/src/adapters/conformance/factories.ts +4 -1
  44. package/src/adapters/conformance/kit.ts +11 -0
  45. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  46. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  47. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/definitions/native-recovery.ts +8 -0
  50. package/src/adapters/definitions/qwen.ts +7 -4
  51. package/src/adapters/local-worker.ts +5 -4
  52. package/src/adapters/qwen-dual.ts +578 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +9 -1
  55. package/src/adapters/reviewer-mcp.ts +12 -6
  56. package/src/adapters/reviewer-print.ts +100 -17
  57. package/src/cli/console-state.ts +20 -2
  58. package/src/cli/console-symbols.ts +3 -2
  59. package/src/cli/console-usage.ts +5 -2
  60. package/src/cli/console.ts +5 -1
  61. package/src/cli/help.ts +7 -5
  62. package/src/cli/identity.ts +3 -1
  63. package/src/cli/main.ts +44 -10
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +12 -4
  66. package/src/cli/upgrade.ts +4 -3
  67. package/src/hub/action-class.ts +211 -17
  68. package/src/hub/action-enforce.ts +13 -5
  69. package/src/hub/action-policy.ts +19 -8
  70. package/src/hub/ask.ts +16 -4
  71. package/src/hub/board.ts +86 -31
  72. package/src/hub/budget-notes.ts +65 -0
  73. package/src/hub/budget.ts +42 -10
  74. package/src/hub/bus.ts +1 -1
  75. package/src/hub/code-facts.ts +56 -16
  76. package/src/hub/conductor.ts +5 -2
  77. package/src/hub/config-apply.ts +7 -0
  78. package/src/hub/control-client.ts +1 -1
  79. package/src/hub/daemon.ts +384 -71
  80. package/src/hub/envelope.ts +1 -1
  81. package/src/hub/events.ts +2 -2
  82. package/src/hub/hub-tools.ts +3 -3
  83. package/src/hub/kimi-usage.ts +12 -0
  84. package/src/hub/lifecycle.ts +10 -5
  85. package/src/hub/memory-code-head.ts +44 -0
  86. package/src/hub/memory-code-overlay.ts +119 -0
  87. package/src/hub/memory-plan-guard.ts +11 -27
  88. package/src/hub/memory-stale-anchors.ts +159 -0
  89. package/src/hub/memory-symbols.ts +74 -0
  90. package/src/hub/planning.ts +10 -4
  91. package/src/hub/read-tools.ts +21 -3
  92. package/src/hub/sdlc.ts +34 -0
  93. package/src/hub/settings.ts +17 -2
  94. package/src/hub/settled-roles.ts +15 -3
  95. package/src/hub/task-sweep.ts +25 -3
  96. package/src/hub/tasks.ts +378 -76
  97. package/src/hub/token-window.ts +15 -2
  98. package/src/hub/window-kind.ts +13 -0
  99. package/src/local/tools.ts +5 -5
  100. package/src/memory/brief.ts +66 -16
  101. package/src/memory/capture.ts +26 -40
  102. package/src/memory/client.ts +137 -65
  103. package/src/memory/recall.ts +13 -22
  104. package/src/ui/index.html +100 -18
@@ -31,6 +31,8 @@ export interface PermissionRequest {
31
31
  /** What the host's always-ask gate says about one request (#636): the approval text it must show, and a class-budget choice when one applies. */
32
32
  export interface ActionGateVerdict {
33
33
  title: string;
34
+ /** The person cannot see the complete command: no allow option or class-budget grant is valid. */
35
+ denyOnly?: true;
34
36
  /** A person's explicit choice to approve the classes for the rest of the session's budget (`label` names them); never an ordinary allow. */
35
37
  budget?: { label: string; approve(): boolean };
36
38
  }
@@ -687,7 +689,7 @@ export class AcpPeer extends BasePeer {
687
689
  let gate: ActionGateVerdict | undefined;
688
690
  try {
689
691
  gate = rawInput === undefined || !this.sessionId ? undefined : this.opts.actionGate?.({ names: [call.title, announced, canonical].filter((name): name is string => typeof name === "string"), ...(kind ? { kind } : {}), input: rawInput }, this.sessionId);
690
- } catch { gate = { title: "Action approval: the always-ask check failed; approve only if you know what runs" }; } // a failing gate asks, never allows
692
+ } catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; } // a failing gate asks, never allows
691
693
  if (once && identity && !gate) {
692
694
  this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
693
695
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
@@ -708,8 +710,9 @@ export class AcpPeer extends BasePeer {
708
710
  const title: string = gate ? gate.title : raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
709
711
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
710
712
  let options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !gate && !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
713
+ if (gate?.denyOnly) options = options.filter(o => o.kind === "reject_once" || o.kind === "reject_always");
711
714
  // The class-budget choice is the hub's own option: answered to the agent as its allow_once once the ledger has recorded the grant.
712
- const budgetOption: PermissionOption | undefined = gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
715
+ const budgetOption: PermissionOption | undefined = !gate?.denyOnly && gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
713
716
  if (budgetOption) options = [...options, budgetOption];
714
717
  // Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
715
718
  const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
@@ -6,6 +6,7 @@ import { BasePeer } 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";
9
+ import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
9
10
 
10
11
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
11
12
 
@@ -15,6 +16,9 @@ export interface CodexOptions {
15
16
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
16
17
  proxyPort: number;
17
18
  permissionMode?: PermissionMode;
19
+ /** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
20
+ actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
21
+ onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
18
22
  /** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
19
23
  appPort: number;
20
24
  /** Attach to an already running app-server instead of spawning one (tests). */
@@ -65,6 +69,7 @@ interface Link {
65
69
  tui: ServerWebSocket<Link>;
66
70
  up: WebSocket;
67
71
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
72
+ approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
68
73
  tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
69
74
  }
70
75
 
@@ -78,6 +83,8 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
78
83
  */
79
84
  export class CodexPeer extends BasePeer {
80
85
  private permissionMode: PermissionMode;
86
+ private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
87
+ get actionGated(): boolean { return this.opts.actionGate !== undefined; }
81
88
  private readonly nativeApprovalPolicies = new Map<string, unknown>();
82
89
  private readonly overriddenApprovals = new Set<string>();
83
90
  private readonly restoringApprovals = new Map<string | number, string>();
@@ -200,6 +207,7 @@ export class CodexPeer extends BasePeer {
200
207
  }
201
208
 
202
209
  async stop(): Promise<void> {
210
+ this.abortActionWaits();
203
211
  this.server?.stop(true);
204
212
  this.claimedTui?.tui.close(1001, "hub shutting down");
205
213
  const proc = this.proc;
@@ -353,6 +361,7 @@ export class CodexPeer extends BasePeer {
353
361
 
354
362
  /** A silent turn is interrupted before the peer is declared idle, or the next turn/start would land inside it. */
355
363
  protected override onWatchdog(): void {
364
+ this.abortActionWaits();
356
365
  this.deliveryEpoch++;
357
366
  const known = new Set([...this.activeTurns, ...this.turnDeliveries.keys()].filter(id => !id.startsWith("unknown:")));
358
367
  const pendingStarts = [...this.pending.values()].filter(pending => pending.kind === "deliver");
@@ -379,6 +388,7 @@ export class CodexPeer extends BasePeer {
379
388
  }
380
389
 
381
390
  private retireTurn(turnId: string): void {
391
+ this.abortActionWaits(undefined, turnId);
382
392
  this.retiredTurns.add(turnId);
383
393
  if (this.link?.up.readyState === WebSocket.OPEN) this.link.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
384
394
  }
@@ -445,7 +455,7 @@ export class CodexPeer extends BasePeer {
445
455
 
446
456
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
447
457
  const up = new WebSocket(upstream);
448
- const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
458
+ const link: Link = { tui, up, backlog: [], tracked: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
449
459
  this.claimedTui = link;
450
460
  tui.data = link;
451
461
  up.onopen = () => {
@@ -456,6 +466,7 @@ export class CodexPeer extends BasePeer {
456
466
  }
457
467
 
458
468
  private detach(link: Link): void {
469
+ this.abortActionWaits(link);
459
470
  link.up.close();
460
471
  if (this.claimedTui === link) this.claimedTui = undefined;
461
472
  if (this.link !== link) return;
@@ -539,6 +550,8 @@ export class CodexPeer extends BasePeer {
539
550
 
540
551
  private fromTui(link: Link, raw: string): void {
541
552
  const msg = parse(raw);
553
+ if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
554
+ if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
542
555
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
543
556
  const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
544
557
  // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
@@ -561,6 +574,7 @@ export class CodexPeer extends BasePeer {
561
574
  if (this.state === "busy" && !(typeof nativeId === "string" && this.retiredTurns.has(nativeId))) this.touch();
562
575
  if (!msg) return void link.tui.send(raw);
563
576
 
577
+ if (this.interceptActionApproval(link, msg)) return;
564
578
  if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
565
579
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
566
580
  const p = this.pending.get(msg.id);
@@ -583,8 +597,61 @@ export class CodexPeer extends BasePeer {
583
597
  link.tui.send(raw);
584
598
  }
585
599
 
600
+ private abortActionWaits(link?: Link, turn?: string): void {
601
+ for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
602
+ }
603
+
604
+ /** Only the two native command schemas verified on 0.163.0. Missing payloads keep their native policy. */
605
+ private interceptActionApproval(link: Link, msg: any): boolean {
606
+ if (!this.opts.actionGate || !(typeof msg.id === "string" || Number.isSafeInteger(msg.id))) return false;
607
+ const legacy = msg.method === "execCommandApproval", params = msg.params;
608
+ if (!legacy && msg.method !== "item/commandExecution/requestApproval") return false;
609
+ if (!params || typeof params !== "object") return false;
610
+ if (link.approvalIds.has(msg.id)) return true; // one intercepted callback has exactly one host answer
611
+ if (legacy ? !Array.isArray(params.command) || !params.command.length || !params.command.every((word: unknown) => typeof word === "string")
612
+ : typeof params.command !== "string" || !params.command.length || params.kind != null && params.kind !== "command") return false;
613
+ const thread = legacy ? params.conversationId : params.threadId;
614
+ let gate: ActionGateVerdict | undefined;
615
+ try { gate = this.opts.actionGate({ names: ["shell"], kind: "execute", input: { command: params.command, cwd: params.cwd } }, typeof thread === "string" ? thread : ""); }
616
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
617
+ if (!gate) return false;
618
+ const verdict = gate;
619
+ // ponytail: retain at most 1024 intercepted ids per socket, never evict a tombstone and accept a late native answer.
620
+ // Reattach is the upgrade path after this finite lifetime cap; closing both sides fails closed.
621
+ if (link.approvalIds.size >= 1024) { this.abortActionWaits(link); link.up.close(); link.tui.close(1008, "Codex action approval id capacity reached; reattach"); return true; }
622
+ link.approvalIds.add(msg.id);
623
+ const named = [...this.activeTurns].filter(turn => !turn.startsWith("unknown:") && !this.retiredTurns.has(turn));
624
+ const turn = legacy ? named.length === 1 ? named[0] : undefined : typeof params.turnId === "string" ? params.turnId : undefined;
625
+ const epoch = this.contextEpoch, controller = new AbortController();
626
+ const live = () => !controller.signal.aborted && link === this.link && epoch === this.contextEpoch && thread === this.threadId &&
627
+ typeof turn === "string" && this.activeTurns.has(turn) && !this.retiredTurns.has(turn) && !this.unidentifiedCancellation && this.state === "busy" &&
628
+ link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN;
629
+ const answer = (allow: boolean) => { if (link.up.readyState === WebSocket.OPEN) link.up.send(JSON.stringify({ id: msg.id, result: { decision: legacy ? allow ? "approved" : "abort" : allow ? "accept" : "decline" } })); };
630
+ if (!live()) { answer(false); return true; } // no invented native turn binding, even when the command is classed
631
+ this.actionWaits.set(controller, { link, turn });
632
+ const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
633
+ if (verdict.denyOnly) options.splice(0, 1); // keep only denial, even if a broken callback later returns allow
634
+ if (!verdict.denyOnly && verdict.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${verdict.budget.label} for this session`, kind: "allow_once" });
635
+ const alive = setInterval(() => { if (live()) this.touch(); }, Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
636
+ void (async () => {
637
+ let picked: string | undefined;
638
+ try {
639
+ const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
640
+ picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title: verdict.title, tool: "shell", kind: "execute", classed: true, options }, controller.signal), cancelled]);
641
+ } catch { /* a broken or missing approval handler denies */ }
642
+ finally { clearInterval(alive); this.actionWaits.delete(controller); }
643
+ let allow = !verdict.denyOnly && live() && picked === "allow";
644
+ if (!verdict.denyOnly && live() && verdict.budget && picked === ACTION_BUDGET_OPTION) {
645
+ try { allow = verdict.budget.approve() && live(); } catch { allow = false; }
646
+ }
647
+ answer(allow);
648
+ })();
649
+ return true;
650
+ }
651
+
586
652
  private adopt(link: Link, threadId: unknown, fresh = false): void {
587
653
  if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
654
+ this.abortActionWaits();
588
655
  this.abandonSteers("thread changed");
589
656
  for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
590
657
  pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
@@ -699,6 +766,7 @@ export class CodexPeer extends BasePeer {
699
766
  if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
700
767
  } else if (method === "turn/completed") {
701
768
  const nativeTurn = params.turn?.id;
769
+ this.abortActionWaits(link, typeof nativeTurn === "string" ? nativeTurn : undefined);
702
770
  // A watchdog or a newer turn has invalidated this completion. Unknown-id legacy starts retain their fallback.
703
771
  if (!this.activeTurns.size || typeof nativeTurn === "string" && !this.activeTurns.has(nativeTurn) && ![...this.activeTurns].some(id => id.startsWith("unknown:"))) return;
704
772
  this.activeTurns.delete(nativeTurn);
@@ -172,7 +172,8 @@ function ownServer(text: string): Record<string, unknown> {
172
172
  if (Object.keys(hub).some(key => !["command", "args", "env"].includes(key)) || typeof hub.command !== "string" || !hub.command || hub.args !== undefined && (!Array.isArray(hub.args) || hub.args.some(arg => typeof arg !== "string"))) throw new Error("invalid hub transport");
173
173
  const env = hub.env === undefined ? {} : object(hub.env);
174
174
  if (Object.values(env).some(value => typeof value !== "string")) throw new Error("invalid hub environment");
175
- return { command: hub.command, args: hub.args ?? [], env, enabled: true, required: true, enabled_tools: TOOLS };
175
+ return { command: hub.command, args: hub.args ?? [], env, enabled: true, required: true, enabled_tools: TOOLS,
176
+ tools: Object.fromEntries(TOOLS.map(name => [name, { approval_mode: "approve" }])) };
176
177
  }
177
178
 
178
179
  /** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
@@ -301,6 +302,14 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
301
302
 
302
303
  export const codexReviewerBackend: ReviewerBackend = {
303
304
  id: "codex", prepare,
305
+ toolTrace(event) {
306
+ const row = event as { type?: string; item?: { type?: string; server?: string; tool?: string; error?: { message?: unknown } | string; result?: { isError?: boolean; content?: { type?: string; text?: unknown }[] } } };
307
+ const item = row?.item;
308
+ if (row?.type !== "item.completed" || item?.type !== "mcp_tool_call" || item.server !== "agent-hub" || (item.tool !== "hub_review" && item.tool !== "hub_task_show")) return undefined;
309
+ const failure = typeof item.error === "string" ? item.error : item.error?.message;
310
+ const refused = item.result?.isError === true && Array.isArray(item.result.content) ? item.result.content.filter(value => value?.type === "text" && typeof value.text === "string").map(value => value.text).join("\n") : undefined;
311
+ return { tool: item.tool, ...(typeof failure === "string" ? { refusal: failure } : typeof refused === "string" ? { refusal: refused } : {}) };
312
+ },
304
313
  usage(event) {
305
314
  const row = event as { type?: string; usage?: { input_tokens?: number; output_tokens?: number; cached_input_tokens?: number } };
306
315
  return row?.type === "turn.completed" && row.usage ? { input_tokens: row.usage.input_tokens, output_tokens: row.usage.output_tokens, cache_read_input_tokens: row.usage.cached_input_tokens } : undefined;
@@ -31,12 +31,12 @@ export const COMPAT = {
31
31
  "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
32
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
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." },
34
- // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
35
- "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
34
+ // 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
+ "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
36
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
37
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
39
- "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0"], gate: "isolation", probe: ["kimi", "--version"],
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"],
40
40
  parse: out => plain(out.trim()),
41
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
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",
@@ -48,6 +48,11 @@ export const COMPAT = {
48
48
  qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
49
49
  parse: out => plain(out.trim()),
50
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." },
51
+ // 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
+ // 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
+ "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
54
+ 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." },
51
56
  } as const satisfies Record<string, CompatPolicy>;
52
57
  export type CompatId = keyof typeof COMPAT;
53
58
  type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
@@ -1,15 +1,25 @@
1
1
  import type { AdapterDefinition } from "../registry-types.ts";
2
+ import { isFixedBuiltinDefinition } from "../registry.ts";
2
3
 
3
4
  /** Stable generic ACP legs; both actual test registration and enrollment validation consume this catalog. */
4
5
  export const ACP_LEGS = Object.freeze(["native-approval-rpc", "native-mode-ack", "native-quota", "watchdog-generation"] as const);
5
6
  export type AcpLeg = typeof ACP_LEGS[number];
7
+ /** Reserved until 2b supplies the restrictive ACP product members; not an executable ACP leg yet. */
8
+ export const ACP_PORTS_LEG = "restrictive-ports";
9
+ export const NATIVE_PROOF_PORTS_LEG = "native-proof-ports";
6
10
  /** No unsupported key or non-generic ACP engine can silently escape an executable catalog leg. */
7
11
  export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[], unsupportedLegs: Readonly<Record<string, string>> = {}): void {
8
12
  const acp = new Map(definitions.filter(definition => definition.transport === "acp").map(definition => [definition.kind, definition]));
9
13
  for (const [key, reason] of Object.entries(unsupportedLegs)) {
10
14
  const separator = key.lastIndexOf(":"), kind = key.slice(0, separator), leg = key.slice(separator + 1);
11
- if (separator < 1 || !acp.has(kind) || !(ACP_LEGS as readonly string[]).includes(leg)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
12
15
  if (typeof reason !== "string" || !reason.trim()) throw new Error(`${key}: unsupported ACP leg requires a concrete reason`);
16
+ if (leg === NATIVE_PROOF_PORTS_LEG) {
17
+ const row = definitionIdentities(definitions).find(row => row.id === kind);
18
+ if (!row || !isFixedBuiltinDefinition(row.definition)) throw new Error(`${key}: only fixed builtin proof legs may be nonapplicable`);
19
+ continue;
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`);
13
23
  }
14
24
  for (const definition of acp.values()) if (definition.start.daemonPort !== "acp") {
15
25
  for (const leg of ACP_LEGS) {
@@ -24,8 +34,12 @@ export function definitionIdentities(definitions: readonly AdapterDefinition[]):
24
34
  ...(definition.headlessVariant ? [{ id: `${definition.kind}:implementer`, definition: definition.headlessVariant }] : [])]);
25
35
  }
26
36
  export function assertEnrollment(definitions: readonly AdapterDefinition[], enrolled: readonly string[], unsupported: Readonly<Record<string, string>>, unsupportedLegs: Readonly<Record<string, string>> = {}, coherence?: (definition: AdapterDefinition) => void): void {
27
- assertAcpLegEnrollment(definitions, unsupportedLegs);
28
37
  const rows = definitionIdentities(definitions), identities = new Set(rows.map(row => row.id));
38
+ for (const { id, definition } of rows) {
39
+ try { coherence?.(definition); }
40
+ catch (error) { throw new Error(`${id}: ${(error as Error).message}`); }
41
+ }
42
+ assertAcpLegEnrollment(definitions, unsupportedLegs);
29
43
  if (identities.size !== rows.length) throw new Error("duplicate conformance definition identity");
30
44
  if (new Set(enrolled).size !== enrolled.length) throw new Error("duplicate conformance enrollment");
31
45
  for (const id of enrolled) if (!identities.has(id)) throw new Error(`${id}: enrolled fixture has no registry definition`);
@@ -35,8 +49,8 @@ export function assertEnrollment(definitions: readonly AdapterDefinition[], enro
35
49
  if (enrolled.includes(id)) throw new Error(`${id}: cannot be both enrolled and unsupported`);
36
50
  }
37
51
  for (const { id, definition } of rows) {
38
- try { coherence?.(definition); }
39
- catch (error) { throw new Error(`${id}: ${(error as Error).message}`); }
52
+ 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`);
40
54
  if (!enrolled.includes(id) && !Object.hasOwn(unsupported, id)) throw new Error(`${id}: no conformance fixture or unsupported reason`);
41
55
  if (definition.transport === "acp" && !enrolled.includes(id)) throw new Error(`${id}: every ACP definition must run the ACP legs`);
42
56
  if (id.endsWith(":reviewer") && !definition.reviewer) throw new Error(`${id}: reviewer variant lacks reviewer policy`);
@@ -1,13 +1,16 @@
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
5
 
5
6
  /** The supplied definition creates its actual adapter, with only this fixture's transport options. */
6
7
  export function createFromDefinition<T extends PeerAdapter>(definition: AdapterDefinition, daemon: DaemonPorts): T {
7
8
  if (!definition.start.create) throw new Error(`${definition.kind}: no driven native factory; use its external-hello fixture`);
8
9
  const options = Object.values(daemon)[0] as { cwd: string; stateDir?: string; mcpServers?: CreateContext["mcpServers"] };
9
- return definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
10
+ const product = definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
10
11
  kindConfig: {}, permission: "ask", mcpServers: options.mcpServers ?? [], daemon }) as T;
12
+ assertNativeProofPorts(definition, product);
13
+ return product;
11
14
  }
12
15
  /** Explicit watchdog seam on the factory-created implementation, replacing direct test subclasses. */
13
16
  export function expire(peer: PeerAdapter): void {
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import type { Envelope } from "../../hub/envelope.ts";
3
3
  import { newEnvelope } from "../../hub/envelope.ts";
4
4
  import type { PeerAdapter } from "../../hub/peers.ts";
5
+ import { isFixedBuiltinDefinition, type AdapterDefinition } from "../registry.ts";
5
6
 
6
7
  export async function until(condition: () => boolean, label: string): Promise<void> {
7
8
  const deadline = Date.now() + 5000;
@@ -19,14 +20,24 @@ export interface ConformanceFixture {
19
20
  }
20
21
  export interface AdapterConformance {
21
22
  kind: string;
23
+ definition?: AdapterDefinition;
22
24
  create(): Promise<ConformanceFixture>;
23
25
  }
24
26
 
27
+ /** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
28
+ export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
+ if (isFixedBuiltinDefinition(definition)) return;
30
+ for (const port of ["frontendProxy", "statusNotice", "revertTurn"] as const) {
31
+ if (port in product) throw new Error(`${definition?.kind ?? product.id}: ${port} is restricted to fixed builtin adapter products`);
32
+ }
33
+ }
34
+
25
35
  /** Shared observable contract; every invocation uses the real adapter and its fake native transport. */
26
36
  export function registerConformance(adapter: AdapterConformance): void {
27
37
  test(`P4 ${adapter.kind}: transport delivery, synchronous occupancy and owned teardown`, async () => {
28
38
  const f = await adapter.create();
29
39
  try {
40
+ assertNativeProofPorts(adapter.definition, f.peer);
30
41
  expect(f.peer.id).toBe(adapter.kind);
31
42
  expect(f.peer.state).toBe("idle");
32
43
  const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
@@ -149,7 +149,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
149
149
  // Approval enforcement belongs to the verified native transport and host permission gate.
150
150
  permission: { grants: { ...hubGrants } },
151
151
  session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
152
- turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
152
+ turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
153
153
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
154
154
  cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
155
155
  },
@@ -162,7 +162,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
162
162
  // Approval enforcement belongs to the verified native transport and host permission gate.
163
163
  permission: { grants: { ...hubGrants } },
164
164
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
165
- turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
165
+ turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
166
166
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
167
167
  cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
168
168
  },
@@ -243,7 +243,7 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[
243
243
  identity: { mcp: "env" },
244
244
  permission: { modes: {}, grants: { ...hubGrants } },
245
245
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
246
- turnEnd: "adapter", budget: { account: "pi" },
246
+ turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
247
247
  cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
248
248
  },
249
249
  {
@@ -1,9 +1,16 @@
1
+ import { peerChildEnv } from "../../hub/child-process.ts";
1
2
  import { retainedLaunchWarning } from "./native-cli-retained.ts";
2
3
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
3
4
 
5
+ function verifiedLaunch(value: unknown): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } | undefined {
6
+ const launch = value as { cmd?: unknown; args?: unknown; env?: unknown } | undefined;
7
+ if (!launch || typeof launch.cmd !== "string" || !launch.cmd || !Array.isArray(launch.args) || !launch.args.every((word: unknown) => typeof word === "string") || !launch.env || typeof launch.env !== "object" || Array.isArray(launch.env)) return undefined;
8
+ return { cmd: launch.cmd, args: launch.args as string[], env: launch.env as NodeJS.ProcessEnv };
9
+ }
10
+
4
11
  export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
5
12
  const peer = ctx.instance ?? "qwen";
6
- const { args, cwd, stateDir, unattendedEnv, connect, fail, takeFlags } = ctx;
13
+ const { args, cwd, stateDir, unattendedEnv, connect, fail, takeFlags, execWithEnv } = ctx;
7
14
  const { launcherPreview } = ctx.helpers;
8
15
  if (args.includes("--print-command") || args.includes("--dry-run")) {
9
16
  try { return console.log(JSON.stringify(launcherPreview("qwen", args, cwd, stateDir, unattendedEnv), null, 2)); }
@@ -11,14 +18,35 @@ export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
11
18
  }
12
19
  const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
13
20
  const adapter = ctx.definition;
21
+ const usage = "usage: ahub qwen [--mode headless|tui] [--model <alias>] [--session-id <id>]";
14
22
  if (one["--session-id"] !== undefined && !adapter.session.crashIdentity?.({ sessionId: one["--session-id"] }).sessionId) fail("invalid Qwen session identity");
15
- if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
23
+ if (rest.length || (one["--mode"] !== undefined && !["headless", "tui"].includes(one["--mode"]))) fail(usage);
24
+ const mode = one["--mode"] === "tui" ? "tui" : "headless";
25
+ if (mode === "tui" && ctx.instance) fail("extra Qwen instances require headless mode");
26
+ if (mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) fail("Qwen's TUI needs a terminal: run ahub qwen --mode tui in one, or use --mode headless");
16
27
  const hub = await connect();
28
+ let launch: unknown;
17
29
  try {
18
30
  const snapshot = (await hub.request({ t: "status" })).status;
19
31
  const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
20
- const reply = await hub.request({ t: "start", peer, args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
32
+ const reply = await hub.request({ t: "start", peer, args: { mode, ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
21
33
  if (!reply.ok) fail(reply.error);
22
- console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); use ahub say @${peer} and the console for approvals`);
34
+ if (mode === "tui") {
35
+ if (reply.already) return console.log("qwen is already attached (its TUI is running); use that terminal");
36
+ if (reply.adopted) return console.log("qwen: the hub read on from its saved position; the Qwen terminal it was driving is still running");
37
+ launch = reply.launch;
38
+ } else console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); use ahub say @${peer} and the console for approvals`);
23
39
  } finally { hub.close(); }
40
+ if (mode !== "tui") return;
41
+ const verified = verifiedLaunch(launch);
42
+ if (!verified) return fail("Qwen TUI launch metadata was not verified");
43
+ // The daemon's launch env carries only the peer marker: Qwen runs in the person's own terminal and needs their PATH, HOME (its settings and sessions) and the key its provider
44
+ // names, none of which the daemon may copy. So the person's environment is the base and the daemon's marker goes over it. (Measured: without this the exec found no `qwen`.)
45
+ // That terminal may itself be inside another agent (a Claude or Codex shell): its vendor markers and every hub marker but AGENTHUB_HOME are dropped, so Qwen and its hub MCP
46
+ // server never carry another peer's identity, launch id or mode.
47
+ const person = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("AGENTHUB_") || name === "AGENTHUB_HOME"));
48
+ const env = { ...peerChildEnv(peer, person), ...verified.env };
49
+ delete env.AGENTHUB_RECOVERY_OPERATION; delete env.AGENTHUB_UNATTENDED;
50
+ console.error("Qwen runs in this terminal, driven by the hub through its dual-output files. Limits: a hub deny ends the turn without an answer, and a stalled turn is stopped only by pressing Esc here.");
51
+ return execWithEnv(verified.cmd, verified.args, env);
24
52
  }
@@ -10,11 +10,19 @@ export function previewQwen(ctx: NativePreviewContext) {
10
10
  let launch: Launch;
11
11
  const mode = value("--mode") ?? "headless", model = value("--model");
12
12
  const adapter = ctx.definition;
13
- if (!(adapter.start.forms as readonly string[]).includes(mode)) throw new Error("Qwen TUI is not offered before verification");
13
+ if (!["headless", "tui"].includes(mode)) throw new Error("unknown Qwen mode");
14
14
  for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
15
15
  const sessionId = value("--session-id");
16
16
  if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
17
17
  if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
18
+ if (mode === "tui") {
19
+ // The TUI's argv is built by the daemon from the files it prepares; the preview names the shape and the compat entry, never a path or the MCP config contents.
20
+ const [bin = "qwen", ...prefix] = nativeCommand(config.qwen_cmd, ["qwen"]).filter(word => word !== "--acp");
21
+ launch = { cmd: bin, args: [...prefix, "--input-file", "<state dir>/qwen/qwen-dual/input.jsonl", "--json-file", "<state dir>/qwen/qwen-dual/events.jsonl", "--mcp-config", "<state dir>/qwen/qwen-dual/mcp.json", "--approval-mode", "default", ...(model ? ["--model", REDACTED] : []), ...(sessionId ? ["-r", REDACTED] : [])] };
22
+ unresolved.push({ field: "files", reason: "the daemon prepares the input and event files (0600) when the start is admitted; preview creates nothing" });
23
+ unresolved.push({ field: "nativeIdentity", reason: `Qwen Code (${describe("qwen-dual")}) is checked before the start and again from its session_start event; preview starts nothing` });
24
+ return finishPreview(ctx, launch, envNames, unresolved, undefined);
25
+ }
18
26
  launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
19
27
  userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
20
28
  unresolved.push({ field: "nativeIdentity", reason: `Qwen Code (${describe("qwen")}) identity and permission modes are checked at ACP initialization; preview starts nothing` });
@@ -24,6 +24,13 @@ export function createJcode(ctx: CreateContext): PeerAdapter {
24
24
  return new JcodePeer(ctx.daemon.jcode);
25
25
  }
26
26
 
27
+ export function createQwenDual(ctx: CreateContext): PeerAdapter {
28
+ if (!("qwenDual" in ctx.daemon)) throw new Error("Qwen TUI requires its dual-output transport options");
29
+ if (ctx.permission === "read-only") throw new Error("Qwen has no verified read-only permission mapping");
30
+ const { QwenDualPeer } = require("../qwen-dual.ts") as typeof import("../qwen-dual.ts");
31
+ return new QwenDualPeer(ctx.instance, ctx.daemon.qwenDual);
32
+ }
33
+
27
34
  export function createPi(ctx: CreateContext): PeerAdapter {
28
35
  if (!("pi" in ctx.daemon)) throw new Error("Pi requires its Pi transport options");
29
36
  if (ctx.permission === "read-only") throw new Error("Pi has no verified read-only permission mapping");
@@ -71,6 +78,7 @@ export function createClaudeImplementer(ctx: CreateContext): PeerAdapter {
71
78
  export const DAEMON_PORTS = {
72
79
  "codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
73
80
  acp: (options: Extract<DaemonPorts, { acp: unknown }>["acp"]) => ({ acp: options }),
81
+ "qwen-dual": (options: Extract<DaemonPorts, { qwenDual: unknown }>["qwenDual"]) => ({ qwenDual: options }),
74
82
  "owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
75
83
  "pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
76
84
  "local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
@@ -82,6 +82,14 @@ export const JCODE_RECOVERY_SESSION: SessionPolicy = {
82
82
  export const QWEN_RECOVERY_SESSION: SessionPolicy = {
83
83
  legacyTargetSupport: { file: "src/hub/peer-catalog.ts", exportName: "OPTIONAL_PEERS", refusal: "target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged" },
84
84
  restoreArgs: peer => ({ ...startupArgs(peer), ...(peer.sessionId ? { "session-id": peer.sessionId } : {}) }),
85
+ // #286 Part 3: the Qwen TUI belongs to the person's terminal. It is never restored as a headless worker; `ahub qwen --mode tui` resumes it (qwen -r <session>) from its saved offset.
86
+ // Worded and shaped unlike Kimi's refusal on purpose: the `kimi-recovery-tui-fence` seed anchors on that exact first line and must still match once.
87
+ recoveryRefusal: (peer, phase = "plan") => {
88
+ if (peer.args?.mode !== "tui") return undefined;
89
+ return phase === "restore"
90
+ ? "Qwen's own terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan"
91
+ : "qwen: its own terminal has no automatic restore; end it in that terminal, then make a new recovery plan (restart it with ahub qwen --mode tui, which resumes its session)";
92
+ },
85
93
  };
86
94
 
87
95
  /** Fixed legacy native probes: callers retain their existing source-text capability checks. */
@@ -7,6 +7,7 @@ import { AcpPeer } from "../acp.ts";
7
7
  import type { AdapterDefinition } from "../registry-types.ts";
8
8
  import { buildQwenLaunch, qwenSessionId } from "./qwen-native.ts";
9
9
  import { shellQuote } from "../../cli/shell-quote.ts";
10
+ import { createQwenDual } from "./native-factories.ts";
10
11
 
11
12
  /** Production Qwen policy. Legacy AcpPeer callers opt in separately through qwen025. */
12
13
  export const QWEN_DEFINITION: AdapterDefinition = {
@@ -27,10 +28,12 @@ export const QWEN_DEFINITION: AdapterDefinition = {
27
28
  conductorStartable: true,
28
29
  hubSwitches: [],
29
30
  ownedFlags: [],
30
- preflight: request => request.form !== undefined && request.form !== "headless"
31
- ? "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" : undefined,
31
+ // The TUI (#286 Part 3) is explicit only: forms stays headless, so Qwen never becomes a start-mode peer and the hub never opens it by itself.
32
+ preflight: request => request.form !== undefined && request.form !== "headless" && request.form !== "tui"
33
+ ? "unknown Qwen mode; usage: ahub qwen [--mode headless|tui]" : undefined,
34
+ createTui: createQwenDual,
32
35
  launch: context => {
33
- if (context.form !== "headless") throw new Error("Qwen TUI is not offered before its native verification; use ahub qwen --mode headless");
36
+ if (context.form !== "headless") throw new Error("the Qwen TUI is launched from the files the daemon prepares; use ahub qwen --mode tui");
34
37
  const launch = buildQwenLaunch(context.nativeCommand ?? ["qwen", "--acp"], context.model);
35
38
  const argv = [launch.cmd, ...launch.args];
36
39
  return { ...launch, metadata: { packageEntrypoint: context.entrypoint, argv, command: argv.map(shellQuote).join(" "), env: {} } };
@@ -68,7 +71,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
68
71
  turnEnd: "adapter",
69
72
  budget: { account: "qwen" },
70
73
  capabilities: { watch: true },
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"] },
74
+ cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless|tui] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off); --mode tui runs Qwen's own TUI in this terminal, driven through its dual-output files (${describe("qwen-dual")}); a hub deny ends the turn without an answer and a stalled turn is stopped with Esc in the Qwen terminal`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
72
75
  display: QWEN_DISPLAY,
73
76
  hooks: { recallPlatform: "qwen" },
74
77
  };
@@ -235,7 +235,7 @@ export class LocalPeer extends BasePeer {
235
235
  ): Promise<string> {
236
236
  const { maxSteps = 30 } = this.opts;
237
237
  const turnSignal = this.abort!.signal;
238
- // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
238
+ // Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
239
239
  const capture = policy?.pii ? undefined : this.opts.capture;
240
240
  // Unknown gateway state is never permission to send PII.
241
241
  if (policy?.pii && !(await this.opts.omni.onCampus())) {
@@ -245,6 +245,7 @@ export class LocalPeer extends BasePeer {
245
245
  this.routePii = !!policy?.pii;
246
246
  const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
247
247
  let usedTools = false;
248
+ let captureInvocation = 0;
248
249
  for (let step = 0; step < maxSteps; step++) {
249
250
  this.elide(msgs);
250
251
  const res = await this.call(msgs, policy, envs);
@@ -280,7 +281,7 @@ export class LocalPeer extends BasePeer {
280
281
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
281
282
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
282
283
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
283
- if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
284
+ if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
284
285
  }
285
286
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
286
287
  }
@@ -293,7 +294,7 @@ export class LocalPeer extends BasePeer {
293
294
  cwd: this.opts.cwd,
294
295
  deny: this.opts.tools.deny,
295
296
  readOnly: this.opts.tools.readOnly,
296
- permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath) => {
297
+ permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input) => {
297
298
  if (turnSignal.aborted) return "aborted";
298
299
  const generation = this.turn;
299
300
  let observed = false;
@@ -313,7 +314,7 @@ export class LocalPeer extends BasePeer {
313
314
  const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
314
315
  const onAbort = () => finish("aborted");
315
316
  turnSignal.addEventListener("abort", onAbort, { once: true });
316
- this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
317
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
317
318
  });
318
319
  if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
319
320
  // Legacy permit callbacks can represent a person's denial without provenance. A true