@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.
- package/CHANGELOG.md +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +384 -71
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +11 -27
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +378 -76
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +66 -16
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +100 -18
package/src/adapters/acp.ts
CHANGED
|
@@ -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;
|
|
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;
|
package/src/adapters/compat.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|