@staix/agent-hub 0.16.0 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +20 -5
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +5 -3
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +9 -3
  9. package/docs/agent-notes/tests.md +6 -3
  10. package/docs/dogfood.md +21 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +150 -34
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +14 -4
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +39 -13
  17. package/docs/security.md +41 -9
  18. package/docs/smoke.md +51 -3
  19. package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  22. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  23. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  24. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  25. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  26. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  27. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  28. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  29. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  30. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  31. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  32. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  33. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  34. package/docs/verified.json +28 -19
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +1115 -192
  38. package/src/adapters/codex-review-isolation.ts +10 -1
  39. package/src/adapters/compat.ts +7 -2
  40. package/src/adapters/conformance/enrollment.ts +18 -4
  41. package/src/adapters/conformance/factories.ts +4 -1
  42. package/src/adapters/conformance/kit.ts +11 -0
  43. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  46. package/src/adapters/definitions/native-factories.ts +8 -0
  47. package/src/adapters/definitions/native-recovery.ts +8 -0
  48. package/src/adapters/definitions/qwen.ts +7 -4
  49. package/src/adapters/local-worker.ts +5 -3
  50. package/src/adapters/qwen-dual.ts +578 -0
  51. package/src/adapters/registry-types.ts +5 -1
  52. package/src/adapters/registry.ts +9 -1
  53. package/src/adapters/reviewer-mcp.ts +12 -6
  54. package/src/adapters/reviewer-print.ts +100 -17
  55. package/src/cli/console-state.ts +9 -2
  56. package/src/cli/help.ts +29 -28
  57. package/src/cli/identity.ts +87 -6
  58. package/src/cli/main.ts +190 -69
  59. package/src/cli/output.ts +7 -2
  60. package/src/cli/sdlc-output.ts +242 -0
  61. package/src/cli/task-output.ts +212 -0
  62. package/src/cli/upgrade-output.ts +57 -0
  63. package/src/cli/upgrade.ts +4 -3
  64. package/src/hub/board.ts +17 -0
  65. package/src/hub/check-runner.ts +252 -22
  66. package/src/hub/conductor.ts +5 -0
  67. package/src/hub/config-apply.ts +16 -7
  68. package/src/hub/config-edit-host.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -1
  70. package/src/hub/daemon.ts +487 -94
  71. package/src/hub/dogfood-profile.ts +248 -0
  72. package/src/hub/dogfood-read.ts +77 -14
  73. package/src/hub/dogfood.ts +5 -1
  74. package/src/hub/events.ts +1 -1
  75. package/src/hub/host-executable.ts +2 -1
  76. package/src/hub/hub-tools.ts +2 -2
  77. package/src/hub/memory-plan-guard.ts +8 -33
  78. package/src/hub/operational-authority.ts +60 -0
  79. package/src/hub/operational-view.ts +141 -0
  80. package/src/hub/orphan-process.ts +69 -0
  81. package/src/hub/permission-mode.ts +1 -1
  82. package/src/hub/read-only-shell-proof.ts +80 -0
  83. package/src/hub/read-only-shell.ts +162 -0
  84. package/src/hub/reviewer-pool-config.ts +3 -2
  85. package/src/hub/reviewer-pool.ts +3 -0
  86. package/src/hub/routing.ts +23 -6
  87. package/src/hub/sdlc-merge.ts +299 -0
  88. package/src/hub/sdlc-mirror.ts +129 -0
  89. package/src/hub/sdlc.ts +58 -12
  90. package/src/hub/settings.ts +11 -5
  91. package/src/hub/snapshots.ts +60 -0
  92. package/src/hub/task-repository.ts +31 -5
  93. package/src/hub/tasks.ts +266 -28
  94. package/src/local/tools.ts +17 -4
  95. package/src/memory/brief.ts +45 -5
  96. package/src/ui/index.html +2 -1
@@ -0,0 +1,578 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, writeFileSync, writeSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId } from "../hub/envelope.ts";
4
+ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
5
+ import { peerChildEnv, processTable, type ProcRow } from "../hub/child-process.ts";
6
+ import { processSignature } from "../pi/process-signature.ts";
7
+ import { assessVersion } from "./compat.ts";
8
+ import { qwenSessionId } from "./definitions/qwen-native.ts";
9
+ import type { PermissionRequest } from "./acp.ts";
10
+
11
+ /**
12
+ * #286 Part 3: a Qwen Code TUI the hub drives through its dual-output files (`--input-file`, `--json-file`), verified on 0.26.1
13
+ * (docs/verification/2026-10-25-qwen-dual-output-t0.md). The person runs Qwen in their own terminal; the hub only appends
14
+ * `submit` and `confirmation_response` lines to the input file and tails the event file.
15
+ *
16
+ * Two limits, by design (spec: "Qwen dual-output TUI amendment"):
17
+ * 1. A hub deny cancels Qwen's whole turn and Qwen emits nothing for it: after sending `allowed: false` this adapter ends the turn itself.
18
+ * 2. The input protocol cannot stop a turn and SIGINT does not either: the watchdog never cancels. It marks the turn stalled, and the
19
+ * person presses Esc in the Qwen terminal. The hub never types into the terminal and never signals the process to stop a turn.
20
+ */
21
+ export interface QwenDualMcpServer { name: string; command: string; args: string[]; env: Record<string, string> }
22
+ export interface QwenDualOptions {
23
+ cwd: string;
24
+ /** Where the files live: `<stateDir>/qwen-dual/`. */
25
+ stateDir: string;
26
+ /** The hub's whole state dir. A Qwen write or command that names it is refused: the input file is a prompt channel. Defaults to `stateDir`. */
27
+ guardDir?: string;
28
+ /** The configured executable argv (`qwen_cmd`); a trailing `--acp` is dropped, this is the TUI. */
29
+ cmd: readonly string[];
30
+ mcpServers: readonly QwenDualMcpServer[];
31
+ model?: string;
32
+ /** Recovery: resume this native session (`qwen -r`). */
33
+ sessionId?: string;
34
+ watchdogMs?: number;
35
+ /** How often the event file is read (ms). */
36
+ pollMs?: number;
37
+ /** How often the owner process is checked (ms). */
38
+ ownerMs?: number;
39
+ preamble?: string;
40
+ /** The hub's own tools by exact `mcp__<server>__<tool>` name, never by prefix. */
41
+ autoApprove?: (toolName: string) => boolean;
42
+ onPermission?: (req: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
43
+ actionGate?: (call: { names: readonly string[]; input: unknown }, session: string) => { title: string; denyOnly?: true } | undefined;
44
+ /** Called when the turn becomes stalled (true) or moves again (false). */
45
+ onStalled?: (stalled: boolean) => void;
46
+ /** A line for the person (console notice). Never carries model or message text. */
47
+ onNotice?: (text: string) => void;
48
+ /** Once, after the claimed TUI owner is gone. */
49
+ onExit?: () => void;
50
+ log?: (line: string) => void;
51
+ /** Host process-identity reader; never supplied by event data. */
52
+ signature?: typeof processSignature;
53
+ /** The process table, to find the Qwen this adapter launched. Tests supply their own. */
54
+ table?: () => ProcRow[] | undefined;
55
+ }
56
+
57
+ const SUBMIT_CAP = 256 * 1024;
58
+ const READ_CAP = 1024 * 1024;
59
+ const ANSWERED_CAP = 256;
60
+ /** What the console, status and dashboard say while a turn is stalled. */
61
+ export const QWEN_STALLED_NOTE = "stalled: Qwen has said nothing for a while; the hub cannot stop a Qwen turn, press Esc in the Qwen terminal";
62
+ /** Shown while the hub cannot read the event file: the Qwen TUI itself keeps working, so this is never idle. */
63
+ export const QWEN_DETACHED_NOTE = "detached: the hub cannot read Qwen's event file; Qwen keeps working in its terminal and the hub reattaches when the file is readable again";
64
+ const DIR = "qwen-dual";
65
+
66
+ type StateFile = { v: 1; sessionId?: string; offset: number; generation: number; ownerPid?: number; ownerSignature?: string };
67
+ interface Turn { envs: Envelope[]; deliveryId?: string; text: string; started: boolean; accepted: boolean }
68
+ type Json = Record<string, any>;
69
+
70
+ /** Whether a Qwen tool request names the hub's state dir: the input file is a prompt channel, so a write or command that reaches it is refused. */
71
+ export function qwenDualNamesStateDir(input: unknown, stateDir: string): boolean {
72
+ // The directory itself, and the names of the files in it: a command can reach `input.jsonl` through a relative path or a variable the directory test would miss.
73
+ const needles = new Set<string>([".agenthub/state", ".agenthub\\state", "qwen-dual", "input.jsonl", stateDir]);
74
+ try { needles.add(realpathSync(stateDir)); } catch { /* an absent dir has no real path */ }
75
+ const seen = (value: unknown, depth: number): boolean => {
76
+ if (depth > 8) return true; // too deep to read: refuse rather than guess
77
+ if (typeof value === "string") { const flat = value.replaceAll("\\", "/"); return [...needles].some(n => n && (value.includes(n) || flat.includes(n.replaceAll("\\", "/")))); }
78
+ if (Array.isArray(value)) return value.some(item => seen(item, depth + 1));
79
+ if (value && typeof value === "object") return Object.entries(value).some(([key, item]) => seen(key, depth + 1) || seen(item, depth + 1));
80
+ return false;
81
+ };
82
+ return seen(input, 0);
83
+ }
84
+
85
+ /** `qwen --version` against the dual-output compat entry. Never throws: a refusal is a reason. */
86
+ export async function qwenDualVersionAsync(cmd: readonly string[], cwd: string): Promise<{ ok: true; version: string; note?: string } | { ok: false; reason: string }> {
87
+ const [bin, ...prefix] = cmd.filter(word => word !== "--acp");
88
+ try {
89
+ const child = Bun.spawn([bin ?? "qwen", ...prefix, "--version"], { cwd, stdout: "pipe", stderr: "ignore", stdin: "ignore", env: { ...process.env } });
90
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
91
+ const out = await new Response(child.stdout).text(); await child.exited; clearTimeout(timer);
92
+ const verdict = assessVersion("qwen-dual", out.trim());
93
+ return verdict.ok ? { ok: true, version: verdict.version, ...(verdict.note ? { note: verdict.note } : {}) } : { ok: false, reason: verdict.reason };
94
+ } catch { return { ok: false, reason: "Version not verified: Qwen Code version unknown; check that the executable runs" }; }
95
+ }
96
+
97
+ export class QwenDualPeer extends BasePeer {
98
+ readonly permissionModeState = "unmanaged" as const;
99
+ private readonly dir: string;
100
+ private readonly inputFile: string;
101
+ private readonly eventFile: string;
102
+ private readonly stateFile: string;
103
+ private readonly mcpFile: string;
104
+ private readonly signature: typeof processSignature;
105
+ private poller?: ReturnType<typeof setInterval>;
106
+ private monitor?: ReturnType<typeof setInterval>;
107
+ private state_: StateFile = { v: 1, offset: 0, generation: 0 };
108
+ private resume = false;
109
+ private _launch?: { cmd: string; args: string[]; env: NodeJS.ProcessEnv };
110
+ private stopping = true;
111
+ private requestedStopReason: string | undefined;
112
+ private detached = false;
113
+ private sessionSeen = false;
114
+ private primed = false;
115
+ private turn?: Turn;
116
+ /** Who owns the turn Qwen is running: the hub's submit, or the person typing in the TUI. */
117
+ private owner: "hub" | "personal" | undefined;
118
+ private lastAssistant?: { text: string; toolUse: boolean };
119
+ private readonly pending = new Map<string, { controller: AbortController; turn: Turn }>();
120
+ private readonly answered = new Set<string>();
121
+ private stalled_ = false;
122
+ private exited = false;
123
+ private pollFailures = 0;
124
+ /** Inside an event line longer than READ_CAP: its rest is dropped through the next newline. */
125
+ private discarding = false;
126
+
127
+ constructor(id: PeerId, private readonly opts: QwenDualOptions) {
128
+ super(id, opts.watchdogMs);
129
+ this.dir = join(opts.stateDir, DIR);
130
+ this.inputFile = join(this.dir, "input.jsonl"); this.eventFile = join(this.dir, "events.jsonl");
131
+ this.stateFile = join(this.dir, "state.json"); this.mcpFile = join(this.dir, "mcp.json");
132
+ this.signature = opts.signature ?? processSignature;
133
+ }
134
+
135
+ get launch(): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } | undefined { return this._launch; }
136
+ /** True when this start adopted the running TUI of an earlier hub run: nothing is launched. */
137
+ get adopted(): boolean { return this.adopting; }
138
+ private adopting = false;
139
+ get stalled(): boolean { return this.stalled_; }
140
+ get statusNotice(): { code: "qwen-stalled" | "qwen-detached"; message: string } | undefined {
141
+ if (this.detached) return { code: "qwen-detached", message: QWEN_DETACHED_NOTE };
142
+ return this.stalled_ ? { code: "qwen-stalled", message: QWEN_STALLED_NOTE } : undefined;
143
+ }
144
+ get isDetached(): boolean { return this.detached; }
145
+ /**
146
+ * Whether this adapter holds a person's running Qwen, whatever its state says: a detached peer reads offline while Qwen keeps working, so state is no proof. True while the bound
147
+ * owner is verified alive, or a session was seen and no owner could be bound; false once the owner exited, the adapter stopped, or a launch never attached anything.
148
+ */
149
+ holdsTerminal(): boolean {
150
+ if (this.stopping || this.exited) return false;
151
+ if (this.state_.ownerPid !== undefined) return this.ownerAlive();
152
+ return this.sessionSeen;
153
+ }
154
+ get sessionId(): string | undefined { return this.state_.sessionId; }
155
+ get offset(): number { return this.state_.offset; }
156
+ recoveryMetadata(): Record<string, unknown> { return { ...(this.state_.sessionId ? { sessionId: this.state_.sessionId } : {}), launch: { mode: "tui" } }; }
157
+
158
+ // ---- files -------------------------------------------------------------------------------------------------------
159
+
160
+ private ensureFile(path: string, create: boolean): void {
161
+ const found = lstatSync(path, { throwIfNoEntry: false });
162
+ if (!found) { if (!create) throw new Error("Qwen dual-output file is missing"); writeFileSync(path, "", { mode: 0o600, flag: "wx" }); return; }
163
+ if (!found.isFile() || found.isSymbolicLink()) throw new Error("Qwen dual-output path is not a plain file");
164
+ if (typeof process.getuid === "function" && found.uid !== process.getuid()) throw new Error("Qwen dual-output file is not owned by the person running the hub");
165
+ if ((found.mode & 0o077) !== 0) throw new Error("Qwen dual-output file is readable or writable by others");
166
+ }
167
+
168
+ private loadState(): void {
169
+ let fd: number | undefined;
170
+ try {
171
+ fd = openSync(this.stateFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
172
+ const info = fstatSync(fd);
173
+ if (!info.isFile() || info.size > 4096) return;
174
+ const buffer = Buffer.alloc(info.size); readSync(fd, buffer, 0, info.size, 0);
175
+ const raw = JSON.parse(buffer.toString("utf8")) as Partial<StateFile>;
176
+ if (raw.v !== 1 || !Number.isSafeInteger(raw.offset) || raw.offset! < 0 || !Number.isSafeInteger(raw.generation)) return;
177
+ this.state_ = { v: 1, offset: raw.offset!, generation: raw.generation!,
178
+ ...(qwenSessionId(raw.sessionId) ? { sessionId: raw.sessionId } : {}),
179
+ ...(Number.isSafeInteger(raw.ownerPid) && typeof raw.ownerSignature === "string" ? { ownerPid: raw.ownerPid, ownerSignature: raw.ownerSignature } : {}) };
180
+ } catch { /* no usable record: a fresh launch */ }
181
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
182
+ }
183
+
184
+ private saveState(): void {
185
+ const temp = `${this.stateFile}.${process.pid}.tmp`;
186
+ try { writeFileSync(temp, `${JSON.stringify(this.state_)}\n`, { mode: 0o600 }); renameSync(temp, this.stateFile); }
187
+ catch { this.opts.log?.(`[${this.id}] could not record its Qwen read position`); }
188
+ }
189
+
190
+ private ownerAlive(): boolean {
191
+ const { ownerPid, ownerSignature } = this.state_;
192
+ if (ownerPid === undefined || ownerSignature === undefined) return false;
193
+ return this.signature(ownerPid) === ownerSignature;
194
+ }
195
+
196
+ async start(): Promise<void> {
197
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
198
+ const dir = lstatSync(this.dir);
199
+ if (!dir.isDirectory() || dir.isSymbolicLink() || (typeof process.getuid === "function" && dir.uid !== process.getuid()) || (dir.mode & 0o077) !== 0) throw new Error("Qwen dual-output directory is not private to the person running the hub");
200
+ this.loadState();
201
+ for (const path of [this.inputFile, this.eventFile]) this.ensureFile(path, true);
202
+ this.stopping = false; this.exited = false;
203
+ if (this.ownerAlive()) {
204
+ // The hub restarted while the person's Qwen kept running: read on from the saved offset. No event is processed twice and no launch happens.
205
+ this.adopting = true;
206
+ const probe = openSync(this.eventFile, constants.O_RDONLY | constants.O_NOFOLLOW);
207
+ let size = 0; try { size = fstatSync(probe).size; } finally { closeSync(probe); }
208
+ if (size < this.state_.offset) { this.state_.offset = 0; this.state_.generation++; }
209
+ // ponytail: a Qwen turn that was running when the hub went away is not known here, so the adopted peer reads idle until the next event says otherwise.
210
+ // Upgrade: record the open hub turn in state.json and settle it on adoption.
211
+ this.sessionSeen = this.state_.sessionId !== undefined; this.primed = true;
212
+ this.saveState();
213
+ } else {
214
+ // A fresh or resumed launch. Qwen opens an existing event file without truncating or appending, so it would overwrite it from byte 0: start from empty files.
215
+ this.adopting = false;
216
+ this.resume = this.opts.sessionId !== undefined || this.state_.sessionId !== undefined;
217
+ const resumeId = this.opts.sessionId ?? this.state_.sessionId;
218
+ const previous = lstatSync(this.eventFile);
219
+ if (previous.size > 0) { try { renameSync(this.eventFile, join(this.dir, "events.prev.jsonl")); } catch { /* a stale copy is only evidence */ } this.ensureFile(this.eventFile, true); }
220
+ closeSync(openSync(this.inputFile, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_TRUNC)); // Qwen starts reading at the file's current end, so earlier submits are never replayed; empty is the clearest record
221
+ this.state_ = { v: 1, offset: 0, generation: this.state_.generation + 1, ...(resumeId ? { sessionId: resumeId } : {}) };
222
+ this.sessionSeen = false;
223
+ this.saveState();
224
+ this.writeLaunch(resumeId);
225
+ }
226
+ this.poller = setInterval(() => this.poll(), this.opts.pollMs ?? 100); this.poller.unref?.();
227
+ if (!this.monitor) { this.monitor = setInterval(() => this.checkOwner(), this.opts.ownerMs ?? 1_000); this.monitor.unref?.(); }
228
+ this.poll();
229
+ if (this.adopting && !this.detached && this.sessionSeen) this.setState("idle");
230
+ // A TUI that has not announced itself yet is not attached: the hub treats the peer as offline until session_start.
231
+ }
232
+
233
+ private writeLaunch(resumeId: string | undefined): void {
234
+ const [bin, ...rest] = this.opts.cmd.filter(word => word !== "--acp");
235
+ if (!bin) throw new Error("qwen_cmd must name an executable");
236
+ const servers = Object.fromEntries(this.opts.mcpServers.map(server => [server.name, { command: server.command, args: server.args, env: server.env }]));
237
+ writeFileSync(this.mcpFile, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
238
+ const args = [...rest, "--input-file", this.inputFile, "--json-file", this.eventFile, "--mcp-config", this.mcpFile, "--approval-mode", "default",
239
+ ...(this.opts.model ? ["--model", this.opts.model] : []), ...(resumeId ? ["-r", resumeId] : [])];
240
+ this._launch = { cmd: bin, args, env: peerChildEnv(this.id, {}) };
241
+ }
242
+
243
+ /**
244
+ * Bind the Qwen this adapter launched: the one process whose argv names this adapter's own `--input-file`. No launcher message is trusted for it; the pid and its start-time
245
+ * signature come from the host's own process table, so a process that merely claims to be Qwen, or a reused pid, never becomes the owner. Two matches is ambiguous: no owner.
246
+ */
247
+ private discoverOwner(): void {
248
+ if (this.stopping || this.state_.ownerPid !== undefined) return;
249
+ const rows = (this.opts.table ?? processTable)();
250
+ if (!rows) return;
251
+ const flag = `--input-file ${this.inputFile}`;
252
+ const matches = rows.filter(row => row.pid !== process.pid && row.command.includes(flag));
253
+ // The real Qwen is a small tree, every member of which names the file: the person's `script` or shell wrapper, Qwen's own node launcher and its `cli.js` child (measured on
254
+ // 0.26.1). The owner is the root of that tree, the one match whose parent is not also a match. Two separate roots (another Qwen on the same file) is ambiguous: no owner.
255
+ const pids = new Set(matches.map(row => row.pid));
256
+ const roots = matches.filter(row => !pids.has(row.ppid));
257
+ if (roots.length !== 1) return;
258
+ const pid = roots[0]!.pid, signature = this.signature(pid);
259
+ if (!signature) return;
260
+ this.state_.ownerPid = pid; this.state_.ownerSignature = signature; this.saveState();
261
+ }
262
+
263
+ private checkOwner(): void {
264
+ if (this.stopping || this.exited) return;
265
+ if (this.state_.ownerPid === undefined) { if (this.sessionSeen) this.discoverOwner(); return; }
266
+ if (this.ownerAlive()) return;
267
+ if (this.state === "offline" && !this.sessionSeen) return;
268
+ this.exited = true;
269
+ this.opts.log?.(`[${this.id}] the Qwen TUI exited`);
270
+ this.failTurn("the Qwen terminal ended before the turn settled; inspect it before retrying");
271
+ this.abortApprovals();
272
+ delete this.state_.ownerPid; delete this.state_.ownerSignature; this.saveState();
273
+ this.setState("offline");
274
+ this.opts.onExit?.();
275
+ }
276
+
277
+ // ---- delivery ----------------------------------------------------------------------------------------------------
278
+
279
+ private appendInput(record: Json): void {
280
+ const line = `${JSON.stringify(record)}\n`;
281
+ if (Buffer.byteLength(line) > SUBMIT_CAP) throw new Error("Qwen submit exceeds its size bound");
282
+ const fd = openSync(this.inputFile, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
283
+ try {
284
+ const info = fstatSync(fd);
285
+ if (!info.isFile() || (info.mode & 0o077) !== 0 || (typeof process.getuid === "function" && info.uid !== process.getuid())) throw new Error("Qwen input file is not private");
286
+ writeSync(fd, line);
287
+ } finally { closeSync(fd); }
288
+ }
289
+
290
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
291
+ if (this.state !== "idle" || this.stopping || this.detached) {
292
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
293
+ throw new Error(`${this.id} is ${this.state}`);
294
+ }
295
+ let text = this.primed || !this.opts.preamble ? renderDigest(envs, this.primed) : `${this.opts.preamble}\n\n${renderDigest(envs, false)}`;
296
+ // Qwen reads a submit like typed input: a leading `/` runs a slash command, `!` a shell line and `@` includes a file. Another agent's words must start with none of them.
297
+ if (/^\s*[\/!@?]/.test(text)) text = `[agent-hub]\n${text}`;
298
+ const turn: Turn = { envs, ...(deliveryId ? { deliveryId } : {}), text, started: false, accepted: false };
299
+ this.turn = turn; this.owner = undefined; this.lastAssistant = undefined;
300
+ this.setState("busy"); // synchronously: the bus must not drain the next envelope into this turn
301
+ try { this.appendInput({ type: "submit", text }); }
302
+ catch (error) {
303
+ this.turn = undefined;
304
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Qwen input file could not be written" });
305
+ this.setState("idle");
306
+ throw error instanceof Error ? error : new Error("Qwen input file could not be written");
307
+ }
308
+ this.primed = true;
309
+ }
310
+
311
+ // ---- watchdog: report, never cancel -----------------------------------------------------------------------------------
312
+
313
+ protected override onWatchdog(): void {
314
+ if (this.state !== "busy" || this.stalled_) return;
315
+ if (this.pending.size > 0) { this.touch(); return; } // a person deciding is not Qwen going silent
316
+ this.stalled_ = true;
317
+ this.opts.log?.(`[${this.id}] turn stalled; the hub cannot stop it (press Esc in the Qwen terminal)`);
318
+ this.opts.onStalled?.(true);
319
+ try { this.opts.onNotice?.(`${this.id}: ${QWEN_STALLED_NOTE}`); } catch { /* a notice failure changes nothing */ }
320
+ // Still busy, still watching: the next event clears the stall and re-arms.
321
+ }
322
+
323
+ private alive(): void {
324
+ if (this.stalled_) { this.stalled_ = false; this.opts.onStalled?.(false); }
325
+ if (this.state === "busy") this.touch();
326
+ }
327
+
328
+ // ---- reading the event file ----------------------------------------------------------------------------------------------
329
+
330
+ private detach(reason: string): void {
331
+ if (this.detached) return;
332
+ this.detached = true;
333
+ this.opts.log?.(`[${this.id}] event file ${reason}: detached`);
334
+ this.failTurn("the Qwen event file became unreadable before the turn settled; inspect the Qwen terminal before retrying");
335
+ this.abortApprovals();
336
+ if (this.state !== "offline") this.setState("offline"); // detached is never idle: nothing can be delivered to a TUI nobody can read
337
+ }
338
+
339
+ private poll(): void {
340
+ if (this.stopping) return;
341
+ let fd: number | undefined;
342
+ try {
343
+ fd = openSync(this.eventFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
344
+ const info = fstatSync(fd);
345
+ if (!info.isFile()) throw new Error("not a regular file");
346
+ if (this.detached) { this.detached = false; this.pollFailures = 0; this.opts.log?.(`[${this.id}] event file readable again: reattached`); if (this.sessionSeen && this.state === "offline" && !this.exited) this.setState("idle"); }
347
+ let offset = this.state_.offset;
348
+ if (info.size < offset) { offset = 0; this.discarding = false; this.state_.generation++; this.opts.log?.(`[${this.id}] event file was replaced; reading from its start`); }
349
+ while (offset < info.size) {
350
+ const want = Math.min(info.size - offset, READ_CAP), buffer = Buffer.alloc(want);
351
+ const got = readSync(fd, buffer, 0, want, offset);
352
+ if (got <= 0) break;
353
+ if (this.discarding) {
354
+ // Inside a line longer than READ_CAP: drop it through its newline, and keep the position, so the events after it are read and a restart does not reread it.
355
+ const newline = buffer.subarray(0, got).indexOf(10);
356
+ offset += newline < 0 ? got : newline + 1;
357
+ this.state_.offset = offset;
358
+ if (newline >= 0) this.discarding = false;
359
+ continue;
360
+ }
361
+ const end = buffer.subarray(0, got).lastIndexOf(10) + 1;
362
+ if (end === 0) {
363
+ if (got < READ_CAP) break; // a partial line waits for its newline
364
+ this.opts.log?.(`[${this.id}] dropped an event line over ${READ_CAP} bytes`);
365
+ this.discarding = true; offset += got; this.state_.offset = offset; continue;
366
+ }
367
+ offset += end;
368
+ this.state_.offset = offset;
369
+ for (const line of buffer.subarray(0, end).toString("utf8").split("\n")) if (line.trim()) this.parse(line);
370
+ if (this.stopping) break;
371
+ }
372
+ this.saveState();
373
+ } catch (error) {
374
+ this.pollFailures++;
375
+ if (!this.detached) this.detach(`could not be read (${(error as NodeJS.ErrnoException).code ?? "error"})`);
376
+ } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
377
+ }
378
+
379
+ private parse(line: string): void {
380
+ let event: Json;
381
+ try { event = JSON.parse(line) as Json; } catch { this.opts.log?.(`[${this.id}] skipped an unreadable event line`); return; }
382
+ if (!event || typeof event !== "object" || typeof event.type !== "string") return;
383
+ try { this.handle(event); } catch (error) { this.opts.log?.(`[${this.id}] event handling failed: ${sanitize((error as Error).message)}`); }
384
+ }
385
+
386
+ private handle(event: Json): void {
387
+ this.alive();
388
+ switch (event.type) {
389
+ case "system": return this.onSystem(event);
390
+ case "user": return this.onUser(event);
391
+ case "assistant": return this.onAssistant(event);
392
+ case "stream_event": if (event.event?.type === "message_stop") this.onMessageStop(); return;
393
+ case "result": return this.onResult(event);
394
+ case "control_request": return this.onControlRequest(event);
395
+ case "control_response": return this.onControlResponse(event);
396
+ default: return;
397
+ }
398
+ }
399
+
400
+ private onSystem(event: Json): void {
401
+ if (event.subtype !== "session_start") return;
402
+ const data = event.data ?? {};
403
+ const id = event.session_id ?? data.session_id;
404
+ if (!qwenSessionId(id)) return;
405
+ // The TUI states its own contract: a version, protocol or event set the hub did not verify is refused, however it was launched.
406
+ const verdict = assessVersion("qwen-dual", data.version);
407
+ const supported: unknown = data.supported_events;
408
+ if (!verdict.ok || data.protocol_version !== 2 || !Array.isArray(supported) || !["user", "assistant", "stream_event", "control_request", "control_response"].every(name => supported.includes(name))) {
409
+ this.opts.log?.(`[${this.id}] the Qwen TUI's dual-output contract is not verified; not attached`);
410
+ try { this.opts.onNotice?.(`${this.id}: this Qwen's dual-output contract is not verified (${verdict.ok ? "protocol or event set" : verdict.reason}); the hub did not attach it`); } catch { /* notice only */ }
411
+ return;
412
+ }
413
+ this.state_.sessionId = id; this.sessionSeen = true; this.saveState();
414
+ // Bind the owner now, not on the next tick: a hub that stops in between would record no owner and relaunch over a Qwen that is still running.
415
+ this.discoverOwner();
416
+ if (!this.stopping && !this.detached && this.state === "offline") this.setState("idle");
417
+ }
418
+
419
+ private promptText(event: Json): string | undefined {
420
+ const content = event.message?.content;
421
+ if (typeof content === "string") return content;
422
+ if (!Array.isArray(content)) return undefined;
423
+ const blocks = content.filter((block: Json) => block?.type === "text" && typeof block.text === "string");
424
+ return blocks.length ? blocks.map((block: Json) => block.text).join("") : undefined;
425
+ }
426
+
427
+ private onUser(event: Json): void {
428
+ const text = this.promptText(event);
429
+ if (text === undefined) return; // a tool result echoed back, not a prompt
430
+ const turn = this.turn;
431
+ if (turn && !turn.started && (text.trim() === turn.text.trim() || text.trim().startsWith(turn.text.trim().slice(0, 200)))) {
432
+ turn.started = true; this.owner = "hub"; this.lastAssistant = undefined;
433
+ if (turn.deliveryId && !turn.accepted) { turn.accepted = true; this.delivery({ id: turn.deliveryId, state: "accepted" }); }
434
+ return;
435
+ }
436
+ // A prompt that is not the hub's: the person typed in the TUI. If a hub turn was running, Qwen ended it without a word (Esc): settle it as uncertain.
437
+ if (turn?.started) this.failTurn("the Qwen turn ended without a result (it was likely stopped in the terminal); inspect it before retrying", true);
438
+ this.owner = "personal"; this.lastAssistant = undefined;
439
+ if (this.state === "idle") this.setState("busy");
440
+ }
441
+
442
+ private onAssistant(event: Json): void {
443
+ const content = event.message?.content;
444
+ const blocks: Json[] = Array.isArray(content) ? content : [];
445
+ const text = blocks.filter(block => block?.type === "text" && typeof block.text === "string").map(block => block.text as string).join("");
446
+ const toolUse = event.message?.stop_reason === "tool_use" || blocks.some(block => block?.type === "tool_use");
447
+ this.lastAssistant = { text, toolUse };
448
+ }
449
+
450
+ private onMessageStop(): void {
451
+ const last = this.lastAssistant; this.lastAssistant = undefined;
452
+ if (!last || last.toolUse) return; // a model call that asked for a tool: the turn goes on
453
+ this.endTurn(last.text);
454
+ }
455
+
456
+ private onResult(event: Json): void {
457
+ const text = typeof event.result === "string" ? event.result : this.lastAssistant?.text ?? "";
458
+ this.endTurn(text);
459
+ }
460
+
461
+ private endTurn(text: string): void {
462
+ const turn = this.turn, owner = this.owner;
463
+ this.owner = undefined; this.lastAssistant = undefined;
464
+ this.abortApprovals();
465
+ if (owner === "hub" && turn?.started) {
466
+ this.turn = undefined;
467
+ const body = text.trim();
468
+ if (body) this.onMessage?.(body, { inReplyTo: replyParent(turn.envs), to: replyAudience(turn.envs) });
469
+ if (turn.deliveryId) this.delivery({ id: turn.deliveryId, state: "completed" });
470
+ this.stalled_ = false; this.opts.onStalled?.(false);
471
+ this.setState("idle");
472
+ return;
473
+ }
474
+ if (owner === "personal") { this.stalled_ = false; this.opts.onStalled?.(false); if (!this.turn) this.setState("idle"); }
475
+ }
476
+
477
+ /**
478
+ * Settle the current hub turn without an answer. `notIdle`: the caller goes on to mark the peer busy itself. `known`: the outcome is certain (a person's deny, a refused request),
479
+ * so the delivery completes. Anything uncertain stays `needs_review`, which holds the peer's whole queue until a person resolves it: a deny must not wedge Qwen that way.
480
+ */
481
+ private failTurn(reason: string, notIdle = false, known = false): void {
482
+ const turn = this.turn;
483
+ if (!turn) return;
484
+ this.turn = undefined; this.owner = undefined;
485
+ if (turn.deliveryId) { const receipt: DeliveryReceipt = { id: turn.deliveryId, state: known ? "completed" : "needs_review", reason }; this.delivery(receipt); }
486
+ try { this.opts.onNotice?.(`${this.id}: ${reason}`); } catch { /* notice only */ }
487
+ if (this.stalled_) { this.stalled_ = false; this.opts.onStalled?.(false); }
488
+ if (!notIdle && this.state === "busy") this.setState("idle");
489
+ }
490
+
491
+ // ---- approvals ------------------------------------------------------------------------------------------------------------
492
+
493
+ private abortApprovals(): void {
494
+ for (const { controller } of this.pending.values()) controller.abort();
495
+ this.pending.clear();
496
+ }
497
+
498
+ private onControlRequest(event: Json): void {
499
+ const requestId = event.request_id, request = event.request;
500
+ if (typeof requestId !== "string" || !request || request.subtype !== "can_use_tool") return;
501
+ const turn = this.turn;
502
+ // Only a hub turn asks the hub. During the person's own turn the TUI prompt is theirs; asking the console too would be two prompts for one decision.
503
+ if (!turn?.started || this.owner !== "hub" || this.stopping || this.state !== "busy") return;
504
+ const name = typeof request.tool_name === "string" ? request.tool_name : "";
505
+ const input = request.input ?? {};
506
+ const hubTool = name !== "" && this.opts.autoApprove?.(name) === true;
507
+ if (!hubTool && (qwenDualNamesStateDir({ input, path: request.blocked_path }, this.opts.guardDir ?? this.opts.stateDir))) {
508
+ this.opts.log?.(`[${this.id}] approval refused: the call names the hub state dir`);
509
+ return this.answer(requestId, false, turn, "a Qwen write or command that names the hub state dir was refused: the input file is a prompt channel");
510
+ }
511
+ let gate: { title: string; denyOnly?: true } | undefined;
512
+ try { gate = this.state_.sessionId ? this.opts.actionGate?.({ names: [name], input }, this.state_.sessionId) : undefined; }
513
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
514
+ if (hubTool && !gate) { this.opts.log?.(`permission auto-approved for ${this.id}: ${name}`); return this.answer(requestId, true, turn); }
515
+ const full = JSON.stringify(input), cut = full.length > 600;
516
+ const title = gate ? gate.title : `${sanitize(name || "tool")}: ${cut ? `${full.slice(0, 600)} [cut, ${full.length} chars]` : full}`;
517
+ const options = [...(gate?.denyOnly ? [] : [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }]), { optionId: "deny", name: "Deny", kind: "reject_once" as const }];
518
+ const controller = new AbortController();
519
+ this.pending.set(requestId, { controller, turn });
520
+ const tool = /^[\w.:@-]{1,80}$/.test(name) ? name : undefined;
521
+ void (async () => {
522
+ let picked: string | undefined;
523
+ try { picked = await this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}), ...(gate ? { classed: true as const } : {}) }, controller.signal); }
524
+ catch { picked = undefined; }
525
+ const still = this.pending.get(requestId);
526
+ if (!still || still.controller !== controller || controller.signal.aborted || this.turn !== turn || this.stopping) return; // answered in the TUI, or the turn is gone
527
+ this.pending.delete(requestId);
528
+ this.answer(requestId, picked === "allow" && options.some(option => option.optionId === "allow"), turn);
529
+ })();
530
+ }
531
+
532
+ private answer(requestId: string, allowed: boolean, turn: Turn, why?: string): void {
533
+ try { this.appendInput({ type: "confirmation_response", request_id: requestId, allowed }); }
534
+ catch {
535
+ // Qwen never received it: it is still blocked on the approval. Settling the turn (a deny) or reporting it answered would be a claim the hub cannot back, so the turn stays busy
536
+ // (the watchdog reports it if nothing moves) and the person is told to answer in the Qwen terminal, where the prompt is still open.
537
+ this.opts.log?.(`[${this.id}] could not write an approval answer`);
538
+ try { this.opts.onNotice?.(`${this.id}: the hub could not write its approval answer (the input file is unwritable); answer the prompt in the Qwen terminal`); } catch { /* notice only */ }
539
+ return;
540
+ }
541
+ this.answered.add(requestId); while (this.answered.size > ANSWERED_CAP) this.answered.delete(this.answered.values().next().value as string);
542
+ if (allowed || this.turn !== turn) return;
543
+ // Limit 1: a deny cancels Qwen's whole turn and Qwen emits nothing for it. The turn ends here, with no answer shared.
544
+ this.owner = undefined;
545
+ this.abortApprovals();
546
+ this.failTurn(why ?? "a hub deny cancelled the Qwen turn (Qwen emits nothing for it); the turn ended without an answer", false, true);
547
+ }
548
+
549
+ private onControlResponse(event: Json): void {
550
+ const response = event.response;
551
+ const id = response?.request_id;
552
+ if (typeof id !== "string") return;
553
+ if (response.subtype === "error") { this.opts.log?.(`[${this.id}] Qwen refused an approval answer (stale or unknown request); not retried`); return; }
554
+ const waiting = this.pending.get(id);
555
+ if (waiting && !this.answered.has(id)) {
556
+ // The person answered in the TUI before the hub did: withdraw the hub's approval, whose late answer must find nothing to grant.
557
+ this.pending.delete(id); waiting.controller.abort();
558
+ this.opts.log?.(`[${this.id}] an approval was answered in the Qwen terminal; the hub's was withdrawn`);
559
+ }
560
+ }
561
+
562
+ // ---- teardown ------------------------------------------------------------------------------------------------------------------
563
+
564
+ async stop(reason?: string): Promise<void> {
565
+ if (reason !== undefined) this.requestedStopReason = reason;
566
+ this.stopping = true;
567
+ if (this.poller) clearInterval(this.poller); this.poller = undefined;
568
+ if (this.monitor) clearInterval(this.monitor); this.monitor = undefined;
569
+ this.abortApprovals();
570
+ const turn = this.turn; this.turn = undefined; this.owner = undefined;
571
+ if (turn?.deliveryId) this.delivery({ id: turn.deliveryId, state: "needs_review", reason: reason ?? "Qwen session stopped before settlement" });
572
+ this.saveState();
573
+ this.setState("offline");
574
+ // The person's Qwen is never signalled: it belongs to their terminal.
575
+ }
576
+ }
577
+
578
+ export function isQwenDualPeer(value: unknown): value is QwenDualPeer { return value instanceof QwenDualPeer; }
@@ -15,13 +15,14 @@ import type { CodexOptions } from "./codex-appserver.ts";
15
15
  import type { JcodeOptions } from "./jcode.ts";
16
16
  import type { LocalOptions } from "./local-worker.ts";
17
17
  import type { PiOptions } from "./pi.ts";
18
+ import type { QwenDualOptions } from "./qwen-dual.ts";
18
19
  import type { ClaudePrintOptions } from "./claude-print.ts";
19
20
 
20
21
  import type { BuiltinCliEntry } from "./definitions/native-cli-entry-types.ts";
21
22
  import type { NativePreviewBuilder } from "./definitions/native-cli-preview-common.ts";
22
23
 
23
24
  export type StartForm = "tui" | "headless";
24
- export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print" | "claude-implementer";
25
+ export type DaemonEngine = "codex-appserver" | "acp" | "qwen-dual" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print" | "claude-implementer";
25
26
 
26
27
  /** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
27
28
  export interface LaunchContext {
@@ -59,6 +60,7 @@ export type LaunchPlan = Launch & { env?: NodeJS.ProcessEnv; metadata: LaunchMet
59
60
  export type DaemonPorts =
60
61
  | { codex: CodexOptions }
61
62
  | { acp: AcpOptions }
63
+ | { qwenDual: QwenDualOptions }
62
64
  | { jcode: JcodeOptions }
63
65
  | { pi: PiOptions }
64
66
  | { local: LocalOptions }
@@ -185,6 +187,8 @@ export interface AdapterDefinition {
185
187
  /** A real native argv planner; absent when startup belongs to a separate verified host port. */
186
188
  launch?(ctx: LaunchContext): LaunchPlan;
187
189
  create?(ctx: CreateContext): PeerAdapter;
190
+ /** A second, explicit-only TUI driver for a kind whose default form stays headless (Qwen's dual-output TUI, #286 Part 3). Fixed builtins only; never an extra start mode. */
191
+ createTui?(ctx: CreateContext): PeerAdapter;
188
192
  daemonPort?: DaemonEngine;
189
193
  preflight?(ctx: StartRequest): string | undefined;
190
194
  version?(ctx: VersionContext): string | undefined;
@@ -29,6 +29,11 @@ for (const definition of [...fixedBuiltinDefinitions]) {
29
29
  if (definition.headlessVariant) fixedBuiltinDefinitions.add(definition.headlessVariant);
30
30
  }
31
31
 
32
+ /** Native proof authority belongs only to the original fixed definition objects, variants included. */
33
+ export function isFixedBuiltinDefinition(definition: AdapterDefinition | undefined): boolean {
34
+ return definition !== undefined && fixedBuiltinDefinitions.has(definition);
35
+ }
36
+
32
37
  /** Keep the native catalog's order, independently of the legacy default-role object order. */
33
38
  export const ADAPTERS: readonly AdapterDefinition[] = immutable([
34
39
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind !== "jcode"),
@@ -231,14 +236,17 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
231
236
 
232
237
  /** Host-global native quirks are not capabilities a config-only or external definition may opt into. */
233
238
  export function assertNativeCapabilityScope(definition: AdapterDefinition): void {
234
- if (fixedBuiltinDefinitions.has(definition)) return;
239
+ if (isFixedBuiltinDefinition(definition)) return;
235
240
  const declared = (block: object | undefined, key: string): boolean => block !== undefined && key in block;
236
241
  const restricted: [string, boolean][] = [
237
242
  ["start.acpCommand", declared(definition.start, "acpCommand")],
238
243
  ["attach.admission", declared(definition.attach, "admission")],
239
244
  ["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
240
245
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
246
+ ["session.nativeRevert", declared(definition.session, "nativeRevert")],
241
247
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
248
+ ["start.daemonPort qwen-dual", definition.start.daemonPort === "qwen-dual"],
249
+ ["start.createTui", declared(definition.start, "createTui")],
242
250
  ["routing", definition.routing !== undefined],
243
251
  ["projection", definition.projection !== undefined],
244
252
  ["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance