@staix/agent-hub 0.16.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 +18 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +18 -5
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/tasks.md +2 -2
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +6 -3
- package/docs/operations.md +45 -18
- package/docs/quickstart.md +6 -2
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +15 -5
- package/docs/smoke.md +51 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +40 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- 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 +20 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1022 -160
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +7 -2
- 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/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/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +5 -1
- 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/upgrade.ts +4 -3
- package/src/hub/daemon.ts +134 -22
- package/src/hub/memory-plan-guard.ts +8 -33
- package/src/hub/tasks.ts +25 -12
- package/src/memory/brief.ts +45 -5
|
@@ -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
|
@@ -35,8 +35,8 @@ export const COMPAT = {
|
|
|
35
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] });
|
|
@@ -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
|
};
|