@staix/agent-hub 0.12.45 → 0.13.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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +83 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +6 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +6 -1
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +10 -0
  10. package/docs/agent-notes/tests.md +4 -2
  11. package/docs/dogfood.md +92 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +250 -19
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +8 -2
  16. package/docs/sdlc.md +36 -6
  17. package/docs/security.md +20 -11
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  26. package/docs/verified.json +14 -14
  27. package/package.json +1 -1
  28. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  29. package/plugins/agent-hub/server.js +613 -329
  30. package/src/adapters/agy/entry.ts +5 -4
  31. package/src/adapters/claude-channel.ts +20 -9
  32. package/src/adapters/codex-appserver.ts +18 -2
  33. package/src/adapters/codex-review-isolation.ts +12 -5
  34. package/src/adapters/compat.ts +140 -0
  35. package/src/adapters/definitions/builtin-metadata.ts +2 -1
  36. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  38. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  39. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  41. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  42. package/src/adapters/definitions/qwen-native.ts +7 -3
  43. package/src/adapters/definitions/qwen.ts +2 -1
  44. package/src/adapters/jcode.ts +72 -31
  45. package/src/adapters/kimi-review.ts +6 -3
  46. package/src/adapters/registry-types.ts +3 -1
  47. package/src/adapters/registry.ts +1 -0
  48. package/src/cli/agents.ts +40 -0
  49. package/src/cli/console-review.ts +54 -0
  50. package/src/cli/console-state.ts +124 -29
  51. package/src/cli/console-symbols.ts +74 -0
  52. package/src/cli/console-usage.ts +89 -0
  53. package/src/cli/console.ts +26 -1
  54. package/src/cli/help.ts +16 -6
  55. package/src/cli/identity.ts +29 -6
  56. package/src/cli/init.ts +109 -11
  57. package/src/cli/main.ts +122 -11
  58. package/src/cli/output.ts +44 -1
  59. package/src/cli/preview.ts +1 -1
  60. package/src/cli/sdlc-release-guard.ts +82 -0
  61. package/src/hub/action-class.ts +362 -0
  62. package/src/hub/action-policy.ts +106 -0
  63. package/src/hub/board.ts +43 -8
  64. package/src/hub/budget.ts +71 -2
  65. package/src/hub/bus.ts +43 -3
  66. package/src/hub/checks.ts +80 -31
  67. package/src/hub/code-facts.ts +223 -0
  68. package/src/hub/config-apply.ts +10 -1
  69. package/src/hub/config-trust.ts +7 -4
  70. package/src/hub/daemon.ts +237 -22
  71. package/src/hub/dogfood-read.ts +199 -0
  72. package/src/hub/dogfood.ts +163 -0
  73. package/src/hub/envelope.ts +1 -1
  74. package/src/hub/events.ts +11 -2
  75. package/src/hub/hub-tools.ts +24 -2
  76. package/src/hub/inbox-polling.ts +25 -0
  77. package/src/hub/instances.ts +73 -1
  78. package/src/hub/profiles.ts +7 -2
  79. package/src/hub/release-worktree.ts +56 -0
  80. package/src/hub/review-brief.ts +36 -0
  81. package/src/hub/sdlc-queue.ts +31 -9
  82. package/src/hub/sdlc-release-authorization.ts +173 -0
  83. package/src/hub/sdlc.ts +49 -11
  84. package/src/hub/settings.ts +5 -2
  85. package/src/hub/snapshots.ts +93 -8
  86. package/src/hub/symbol-normalizer.ts +26 -0
  87. package/src/hub/task-code-symbols.ts +90 -0
  88. package/src/hub/task-sweep.ts +3 -2
  89. package/src/hub/tasks.ts +292 -54
  90. package/src/hub/test-integrity.ts +162 -0
  91. package/src/hub/usage-rollup.ts +361 -0
  92. package/src/local/sandbox.ts +5 -3
  93. package/src/ui/index.html +1 -0
  94. package/templates/AGENTS.block.md +1 -1
  95. package/templates/profiles/dev.json +6 -1
  96. package/templates/profiles/general.json +6 -1
  97. package/templates/profiles/research.json +6 -1
  98. package/templates/profiles/writing.json +6 -1
  99. package/templates/release-guard.block.md +6 -0
@@ -1,4 +1,5 @@
1
1
  import { publicArg } from "../definitions/native-cli-preview-common.ts";
2
+ import { assessVersion, COMPAT } from "../compat.ts";
2
3
  import { spawn, spawnSync } from "node:child_process";
3
4
  import { resolve } from "node:path";
4
5
  import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
@@ -38,10 +39,10 @@ export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions
38
39
 
39
40
  /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
40
41
  export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
41
- const version = stdout.trim();
42
- if (version === "1.4.2") return { ok: true };
43
- if (/^1\.4\.\d+$/.test(version)) return { ok: true, message: `agy ${version}: only 1.4.2 is T0-verified; launching on the 1.4.x line unverified` };
44
- return { ok: false, message: "agy P1 requires the Antigravity CLI 1.4.x line (1.4.2 verified)" };
42
+ // #691: the verified versions, the compatible line and the person's trust live in compat.ts.
43
+ const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
44
+ if (!verdict.ok) return { ok: false, message: verdict.reason };
45
+ return verdict.note ? { ok: true, message: verdict.note } : { ok: true };
45
46
  }
46
47
 
47
48
  export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
@@ -22,9 +22,16 @@ import { renderHeldHint } from "../hub/delivery-hints.ts";
22
22
  // ControlClient verifies the manifest identity on every connection.
23
23
  const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
24
24
  const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
25
- const peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
26
- /** tools mode: the same server, run by Kimi (ACP mcpServers) or Codex (mcp_servers override). Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
25
+ /** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
27
26
  const toolsMode = process.env.AGENTHUB_MODE === "tools";
27
+ /**
28
+ * #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
29
+ * id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
30
+ * refuses every tool with a reason, and never defaults to "jcode" or any other id. Only a plain Claude session defaults to "claude".
31
+ */
32
+ const unidentified = toolsMode && !process.env.AGENTHUB_PEER_ID;
33
+ const peerId = process.env.AGENTHUB_PEER_ID ?? (toolsMode ? "" : "claude");
34
+ const UNIDENTIFIED = "agent-hub tools refused: this process was not started by the hub (no AGENTHUB_PEER_ID), so it has no peer identity. Start the agent with ahub jcode (or the hub's own start) to use hub tools.";
28
35
  // Exposure hint only; the daemon verifies the actual adapter before any retry mutation.
29
36
  const deliveryOnly = process.env.AGENTHUB_DELIVERY_ONLY === "true";
30
37
  /**
@@ -84,7 +91,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000;
84
91
  const TERMINAL_CLOSES: Record<number, string> = {
85
92
  4401: "the hub refused the control token",
86
93
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
87
- 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
94
+ 4403: `the hub refused "${peerId}": the id is reserved or malformed, or this process is not an owned launch of that peer (start it with ahub jcode)`,
88
95
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
89
96
  4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
90
97
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
@@ -251,7 +258,7 @@ async function connectLoop(): Promise<void> {
251
258
  let closeReason: string | undefined;
252
259
  try {
253
260
  const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
254
- ...(!toolsMode && process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
261
+ ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
255
262
  ...(!toolsMode && standingBy ? { standby: true } : {}),
256
263
  ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
257
264
  client.onPush = (msg) => {
@@ -375,6 +382,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
375
382
 
376
383
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
377
384
  const { name, arguments: args } = req.params;
385
+ if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
378
386
  if (name === "hub_delivery_done" && channel) {
379
387
  if (!hub) return text(offline());
380
388
  const a = args ?? {};
@@ -382,7 +390,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
382
390
  return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
383
391
  }
384
392
  if (name === "hub_inbox" && !toolsMode) {
385
- let remote: string[] = [], notice = "";
393
+ let remote: string[] = [], notice = "", polling = "", counted = false;
386
394
  if (hub) {
387
395
  try {
388
396
  const res = await hub.request({ t: "inbox" });
@@ -391,15 +399,18 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
391
399
  else {
392
400
  pendingQueued = { queued: typeof res.waiting === "number" ? res.waiting : 0, important: typeof res.waitingImportant === "number" ? res.waitingImportant : 0 };
393
401
  remote = (res.envs as Envelope[]).map(frame);
394
- if (res.waiting) notice = `(${res.waiting} queued hub message(s) remain unread)`;
402
+ if (Number.isSafeInteger(res.pollingAdvice?.count) && res.pollingAdvice.count >= 3 && Number.isSafeInteger(res.pollingAdvice?.minutes) && res.pollingAdvice.minutes >= 0 && res.pollingAdvice.minutes <= 10) polling = `[polling detected] ${res.pollingAdvice.count} empty reads in ${res.pollingAdvice.minutes} min with no assigned work: end your turn`;
403
+ // #713: a pull drains what fits one read; what is left is said plainly, with what to do about it. The count already says how many remain, so the pending hint adds only the important ones (#221).
404
+ if (res.waiting) { notice = `(${res.waiting} more waiting: call hub_inbox again before you start work)`; counted = true; }
395
405
  }
396
406
  } catch { notice = "not read: daemon inbox unavailable; inspect the connection before another read"; }
397
407
  } else notice = offline();
398
408
  // Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
399
409
  const out = [...inbox.splice(0), ...remote];
400
410
  if (notice) out.push(notice);
401
- const result = text(out.length ? out.join("\n\n") : "(no queued hub messages)");
402
- if (notice.endsWith("remain unread)")) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
411
+ const body = out.length ? out.join("\n\n") : "(no queued hub messages)\nNothing is waiting. Do not poll: if you have no assigned task, report and end your turn; new work waits here for your next boundary.";
412
+ const result = text(polling ? `${body}\n${polling}` : body);
413
+ if (counted) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
403
414
  return result;
404
415
  }
405
416
  if (name === "hub_send") {
@@ -427,4 +438,4 @@ await server.connect(new StdioServerTransport());
427
438
  // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
428
439
  process.stdin.on("end", () => process.exit(0));
429
440
  process.stdin.on("close", () => process.exit(0));
430
- void connectLoop();
441
+ if (unidentified) log(UNIDENTIFIED); else void connectLoop();
@@ -9,6 +9,8 @@ import type { ContextReader } from "./registry-types.ts";
9
9
 
10
10
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
11
11
 
12
+ /** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
13
+ const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
12
14
  export interface CodexOptions {
13
15
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
14
16
  proxyPort: number;
@@ -28,6 +30,11 @@ export interface CodexOptions {
28
30
  onUsage?: (rateLimits: unknown, hard: boolean) => void;
29
31
  /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
30
32
  onTokens?: (added: number) => void;
33
+ /**
34
+ * The same update as `onTokens`, split into the kinds Codex reports (#683 U1): the increase of each `total` counter since the previous
35
+ * update. Cached input is a part of `input`, and `reasoning` a part of `output` (Codex's own definition), so `total` is not their sum.
36
+ */
37
+ onTokenBreakdown?: (delta: { input: number; cachedInput: number; output: number; reasoning: number; total: number }) => void;
31
38
  onContext?: (reading: ContextReading) => void;
32
39
  /** The definition's native occupancy reader; standalone proxies retain the native default. */
33
40
  contextReader?: ContextReader;
@@ -89,6 +96,8 @@ export class CodexPeer extends BasePeer {
89
96
  * resumed thread's first update only sets the baseline: its total holds the history from before (issue #40).
90
97
  */
91
98
  private tokenTotal: number | undefined;
99
+ /** The per-kind counters of the last `total`, for the breakdown deltas (#683 U1). */
100
+ private tokenKinds: { input: number; cachedInput: number; output: number; reasoning: number } | undefined;
92
101
  private freshThread = false;
93
102
  private readonly activeTurns = new Set<string>();
94
103
  private deliveryEpoch = 0;
@@ -584,7 +593,7 @@ export class CodexPeer extends BasePeer {
584
593
  for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
585
594
  for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
586
595
  this.turnDeliveries.clear(); this.unboundDeliveries.clear();
587
- this.tokenTotal = undefined;
596
+ this.tokenTotal = undefined; this.tokenKinds = undefined;
588
597
  this.freshThread = fresh;
589
598
  this.link = link;
590
599
  this.threadId = threadId;
@@ -648,9 +657,16 @@ export class CodexPeer extends BasePeer {
648
657
  this.opts.onContext?.(reader ? reader.read(params.tokenUsage, this.threadId, Date.now()) : codexContext(params.tokenUsage, this.threadId, Date.now()));
649
658
  const total = Number(params.tokenUsage?.total?.totalTokens);
650
659
  if (!Number.isFinite(total)) return;
660
+ const previous = this.tokenKinds;
661
+ const kinds = { input: counter(params.tokenUsage?.total?.inputTokens), cachedInput: counter(params.tokenUsage?.total?.cachedInputTokens),
662
+ output: counter(params.tokenUsage?.total?.outputTokens), reasoning: counter(params.tokenUsage?.total?.reasoningOutputTokens) };
651
663
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
652
- this.tokenTotal = total;
664
+ // The same baseline rule as the total: a thread that was resumed counts only what it adds from now on; a new one counts from zero.
665
+ const base = previous ?? (this.freshThread && this.tokenTotal === undefined ? { input: 0, cachedInput: 0, output: 0, reasoning: 0 } : undefined);
666
+ this.tokenTotal = total; this.tokenKinds = kinds;
653
667
  if (added > 0) this.opts.onTokens?.(added);
668
+ if (added > 0 && base) this.opts.onTokenBreakdown?.({ input: Math.max(0, kinds.input - base.input), cachedInput: Math.max(0, kinds.cachedInput - base.cachedInput),
669
+ output: Math.max(0, kinds.output - base.output), reasoning: Math.max(0, kinds.reasoning - base.reasoning), total: added });
654
670
  return;
655
671
  }
656
672
  if (method === "turn/started") {
@@ -1,4 +1,5 @@
1
1
  import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
+ import { assessVersion, COMPAT } from "./compat.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { spawn } from "node:child_process";
4
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
@@ -9,7 +10,9 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
9
10
  import { realPath } from "../hub/project.ts";
10
11
  import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
11
12
 
12
- const VERSION = "0.146.0", TOOLS = ["hub_review", "hub_task_show"];
13
+ const TOOLS = ["hub_review", "hub_task_show"];
14
+ /** A version refusal names only versions and a command, so the daemon may show it (#691). */
15
+ class VersionRefusal extends Error {}
13
16
  const CAP = 128 * 1024, MAX_SERVERS = 64;
14
17
  const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
15
18
  /** Conservative translation of the portable core's regex sources; unfamiliar syntax refuses the receipt. */
@@ -207,11 +210,14 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
207
210
  child.once("error", () => { failed = true; void finish(null); });
208
211
  child.once("close", code => { void finish(code); });
209
212
  });
210
- if ((await run(["--version"])).trim() !== `codex-cli ${VERSION}`) throw new Error("native version has no isolation receipt");
213
+ // #691: the verified versions and the person's trust live in compat.ts; every proof below still runs after trust.
214
+ const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
215
+ if (!verdict.ok) throw new VersionRefusal(verdict.reason);
216
+ const version = verdict.version;
211
217
  if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
212
218
  const hub = ownServer(ctx.mcpConfig);
213
219
  // Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
214
- const policy = ["--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
220
+ const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
215
221
  proveDisabledFeatures(await run(["features", "list", ...policy]));
216
222
  const before = inventory(await run(["mcp", "list", "--json", ...policy]));
217
223
  if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
@@ -268,11 +274,12 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
268
274
  return { command: bound.command, args: ["exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
269
275
  current: () => { try { return current(); } catch { return false; } },
270
276
  cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
271
- isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
272
- evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
277
+ isolation: { backend: "codex", version, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
278
+ evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "legacy-notify-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
273
279
  } catch (error) {
274
280
  if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
275
281
  // Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
282
+ if (error instanceof VersionRefusal) return { unavailable: error.message };
276
283
  if (error instanceof CredentialRootError) {
277
284
  const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
278
285
  return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
@@ -0,0 +1,140 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { hubHome } from "../hub/project.ts";
5
+
6
+ /**
7
+ * #691: the one version policy for native agent CLIs. Every version gate asks `assessVersion`; no other file holds a
8
+ * version constant. `verified` lists versions with a receipt in docs/verification; `line` is a semver window assumed
9
+ * compatible (contract gates only); a person's trust lets an exact other version through on this machine. An isolation
10
+ * gate keeps every runtime proof after trust: trust only lifts the version pin.
11
+ */
12
+ export type CompatGate = "contract" | "isolation";
13
+ export interface CompatPolicy {
14
+ label: string;
15
+ verified: readonly string[];
16
+ line?: { min: string; below: string };
17
+ gate: CompatGate;
18
+ /** The default executable and the argv `ahub agents` uses to read the version. */
19
+ probe: readonly string[];
20
+ /** Turns the probe's stdout into a plain semver, or undefined. */
21
+ parse: (stdout: string) => string | undefined;
22
+ /** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
23
+ trustNote: string;
24
+ /** The config key that selects this kind's executable, when one exists. */
25
+ config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
26
+ }
27
+
28
+ const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
+ export const COMPAT = {
30
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
31
+ "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
32
+ parse: out => plain(out.trim().replace(/^codex-cli /, "")),
33
+ 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)." },
34
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
35
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0"], gate: "isolation", probe: ["kimi", "--version"],
36
+ parse: out => plain(out.trim()),
37
+ 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." },
38
+ 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",
39
+ parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
40
+ trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them." },
41
+ agy: { label: "agy", verified: ["1.4.2"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
42
+ parse: out => plain(out.trim()),
43
+ trustNote: "Only 1.4.2 is T0-verified; the plugin MCP and hook contract of another version is unverified." },
44
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
45
+ parse: out => plain(out.trim()),
46
+ 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." },
47
+ } as const satisfies Record<string, CompatPolicy>;
48
+ export type CompatId = keyof typeof COMPAT;
49
+ type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
50
+ /** The configured executables the native gates use; the rest probe their PATH default. */
51
+ export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
52
+ const out: Partial<Record<CompatId, readonly string[]>> = {};
53
+ for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
54
+ if (!policy.config) continue;
55
+ const value = config[policy.config];
56
+ out[id] = typeof value === "string" ? [value] : value;
57
+ }
58
+ return out;
59
+ }
60
+ export const isCompatId = (id: unknown): id is CompatId => typeof id === "string" && Object.hasOwn(COMPAT, id);
61
+
62
+ export type CompatVerdict =
63
+ | { ok: true; state: "verified" | "in-line" | "trusted"; version: string; note?: string }
64
+ | { ok: false; state: "refused" | "unknown"; version?: string; reason: string };
65
+
66
+ const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(v)?.slice(1).map(Number);
67
+ const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
68
+ function inLine(version: string, line?: { min: string; below: string }): boolean {
69
+ const v = parts(version);
70
+ return !!line && !!v && compare(v, parts(line.min)!) >= 0 && compare(v, parts(line.below)!) < 0;
71
+ }
72
+
73
+ /** A refusal the daemon may show as is: it names only versions and a command. */
74
+ export const VERSION_REFUSAL = "Version not verified: ";
75
+ /** A window of one minor (1.4.0 up to 1.5.0) reads as "the 1.4.x line". */
76
+ function lineText(line: { min: string; below: string }): string {
77
+ const [a, b] = [parts(line.min)!, parts(line.below)!];
78
+ return a[2] === 0 && b[2] === 0 && a[0] === b[0] && b[1] === a[1]! + 1 ? `the ${a[0]}.${a[1]}.x line` : `the line ${line.min} up to ${line.below}`;
79
+ }
80
+ export function describe(id: CompatId): string {
81
+ const p: CompatPolicy = COMPAT[id];
82
+ return `verified ${p.verified.join(", ")}${p.line ? `; compatible: ${lineText(p.line)}` : ""}`;
83
+ }
84
+
85
+ /** `trusted` defaults to this machine's record; tests and the CLI pass their own. */
86
+ export function assessVersion(id: CompatId, observed: unknown, trusted: readonly TrustEntry[] = readTrust()): CompatVerdict {
87
+ const policy: CompatPolicy = COMPAT[id];
88
+ const version = typeof observed === "string" ? plain(observed) : undefined;
89
+ if (!version) return { ok: false, state: "unknown", reason: `${VERSION_REFUSAL}${policy.label} version unknown (${describe(id)}); check that the executable runs` };
90
+ if (policy.verified.includes(version)) return { ok: true, state: "verified", version };
91
+ if (trusted.some(entry => entry.id === id && entry.version === version)) return { ok: true, state: "trusted", version, note: `${policy.label} ${version} is untested; trusted on this machine by the person` };
92
+ if (policy.gate === "contract" && inLine(version, policy.line)) return { ok: true, state: "in-line", version, note: `${policy.label} ${version} is untested (${describe(id)}); launching on the compatible line` };
93
+ return { ok: false, state: "refused", version, reason: `${VERSION_REFUSAL}${policy.label} ${version} (${describe(id)}). Install a verified version, or accept this one on this machine with: ahub agents trust ${id} ${version}` };
94
+ }
95
+
96
+ export interface TrustEntry { id: CompatId; version: string; at: number }
97
+ const MAX_TRUST = 64;
98
+ export const trustFile = (home = hubHome()) => join(home, "agent-compat.json");
99
+
100
+ const MAX_BYTES = 64 * 1024;
101
+ /** An unreadable, linked, oversized or invalid record trusts nothing. */
102
+ export function readTrust(file = trustFile()): TrustEntry[] {
103
+ let fd: number | undefined;
104
+ try {
105
+ // O_NONBLOCK: a FIFO planted at the record path must not stall a synchronous version gate; fstat refuses it.
106
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ const info = fstatSync(fd);
108
+ if (!info.isFile() || info.size > MAX_BYTES) return [];
109
+ const bytes = Buffer.alloc(info.size);
110
+ if (readSync(fd, bytes, 0, info.size, 0) !== info.size) return [];
111
+ const raw = JSON.parse(bytes.toString("utf8"));
112
+ if (raw?.schemaVersion !== 1 || !Array.isArray(raw.trusted) || raw.trusted.length > MAX_TRUST) return [];
113
+ const rows = raw.trusted.filter((row: unknown): row is TrustEntry => {
114
+ const r = row as TrustEntry;
115
+ return !!r && isCompatId(r.id) && typeof r.version === "string" && plain(r.version) === r.version && Number.isSafeInteger(r.at);
116
+ });
117
+ return rows.length === raw.trusted.length ? rows : [];
118
+ } catch { return []; }
119
+ finally { if (fd !== undefined) closeSync(fd); }
120
+ }
121
+
122
+ /** Person-only (the CLI classifier refuses agent shells). Writes atomically; returns the new record. */
123
+ export function writeTrust(id: CompatId, version: string, revoke: boolean, file = trustFile(), now = Date.now()): TrustEntry[] {
124
+ if (plain(version) !== version) throw new Error("trust needs an exact version such as 1.2.3");
125
+ const rows = readTrust(file).filter(row => !(row.id === id && row.version === version));
126
+ if (!revoke) rows.push({ id, version, at: now });
127
+ if (rows.length > MAX_TRUST) throw new Error(`at most ${MAX_TRUST} trusted versions; revoke some first`);
128
+ const parent = dirname(file);
129
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
130
+ const dir = lstatSync(parent);
131
+ if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error("the hub home is not a plain directory; trust not written");
132
+ const target = lstatSync(file, { throwIfNoEntry: false });
133
+ if (target && !target.isFile()) throw new Error("the trust record is not a regular file; trust not written");
134
+ // A unique, exclusively created temporary file: an existing name or a planted link fails instead of being followed.
135
+ const temporary = join(parent, `.agent-compat.${randomUUID()}.tmp`);
136
+ writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, trusted: rows }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
137
+ try { renameSync(temporary, file); }
138
+ catch (error) { unlinkSync(temporary); throw error; }
139
+ return rows;
140
+ }
@@ -159,7 +159,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
159
159
  api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
160
160
  roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
161
161
  start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, hubSwitches: [], ownedFlags: [] },
162
- identity: { mcp: "project-file" },
162
+ // #685: an extra Jcode is a headless instance with its own native home, socket and state (instances.<id>.jcode_home).
163
+ identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
163
164
  permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
164
165
  ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
165
166
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
@@ -1,3 +1,4 @@
1
+ import type { CompatId } from "../compat.ts";
1
2
  import type { DoctorCheck } from "../../cli/output.ts";
2
3
 
3
4
  // Fixed builtin doctor policy only. These projections neither execute probes nor extend AdapterDefinition.
@@ -33,17 +34,23 @@ export function nativeDoctorRecoveryIdentity(peer: { id: string; state: string;
33
34
  export type NativeDoctorOptionalProbe =
34
35
  | { kind: "source-identity"; name: string; command: string[]; detail: string }
35
36
  | { kind: "jcode-mcp"; name: string; home: string }
36
- | { kind: "pinned-version"; name: string; command: string[]; expected: string; confirmed: string; unconfirmed: string };
37
+ | { kind: "pinned-version"; name: string; command: string[]; compat: CompatId; confirmed: string; unconfirmed: string };
37
38
 
38
39
  /** The host runs these existing checks in order; no process, filesystem or caller callback enters this planner. */
39
- export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[]; jcode_home?: string }): NativeDoctorOptionalProbe[] {
40
+ export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[]; jcode_home?: string;
41
+ /** Declared extra instances (#685): a rostered Jcode instance's own home is inventoried like the base home. */
42
+ instances?: Readonly<Record<string, { kind: string; jcode_home?: string }>> }): NativeDoctorOptionalProbe[] {
40
43
  const probes: NativeDoctorOptionalProbe[] = [];
41
44
  if (config.roster.jcode === true) probes.push({ kind: "source-identity", name: "jcode", command: config.jcode_cmd,
42
45
  detail: "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged" });
43
46
  // #572 AC4: jcode cannot be told which MCP servers to load, so the host lists the ones its config files name.
44
47
  if (config.roster.jcode === true && config.jcode_home) probes.push({ kind: "jcode-mcp", name: "jcode MCP servers", home: config.jcode_home });
45
- if (config.roster.qwen === true) probes.push({ kind: "pinned-version", name: "qwen", command: config.qwen_cmd, expected: "0.25.0",
46
- confirmed: "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check",
47
- unconfirmed: "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown" });
48
+ // #685: each extra instance has its own home, hence its own inventory. Names and homes only: no model source, URL or token.
49
+ for (const [id, declaration] of Object.entries(config.instances ?? {})) {
50
+ if (declaration.kind === "jcode" && declaration.jcode_home && config.roster[id] === true) probes.push({ kind: "jcode-mcp", name: `${id} MCP servers`, home: declaration.jcode_home });
51
+ }
52
+ if (config.roster.qwen === true) probes.push({ kind: "pinned-version", name: "qwen", command: config.qwen_cmd, compat: "qwen",
53
+ confirmed: "CLI version verified; native ACP identity/modes and provider access require a separate live check",
54
+ unconfirmed: "Qwen Code executable not confirmed; provider and ACP readiness unknown" });
48
55
  return probes;
49
56
  }
@@ -51,7 +51,10 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
51
51
  if (rest.length || (one["--mode"] !== undefined && !["headless", "tui"].includes(one["--mode"]))) fail("usage: ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
52
52
  if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
53
53
  const control = readControl(stateDir);
54
+ // #685: `--as <id>` selects an extra Jcode instance; every request below names it, never the base peer.
55
+ const peer = ctx.instance ?? "jcode";
54
56
  const mode = one["--mode"] ?? (ctx.instance ? "headless" : control?.startModes?.jcode ?? startModeOf(ctx.projectConfig().peers, "jcode"));
57
+ if (ctx.instance && mode === "tui") fail(`${peer} is an extra Jcode instance: it runs headless on its own private server (ahub jcode --as ${peer}); the TUI belongs to the base jcode`);
55
58
  if (print && (mode === "headless" || !control?.features?.includes("native_tui_launch"))) {
56
59
  try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
57
60
  catch { return fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
@@ -66,10 +69,10 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
66
69
  hub.onClose = (code, reason) => { if (!abort.signal.aborted) abort.abort(); oldClose?.(code, reason); };
67
70
  try {
68
71
  const snapshot = (await hub.request({ t: "status" })).status;
69
- const rostered = rosterInStatus(snapshot, "jcode");
70
- if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
71
- if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
72
- const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
72
+ const rostered = rosterInStatus(snapshot, peer);
73
+ if (rostered === undefined) fail(`cannot read ${peer}'s roster membership from the running hub; upgrade or restart it, then retry (nothing was started)`);
74
+ if (!rostered) fail(`${peer} is off roster; add it with ahub roster add ${peer}`);
75
+ const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
73
76
  const options = { model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] };
74
77
  if (print) {
75
78
  if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
@@ -89,10 +92,10 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
89
92
  }
90
93
  const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
91
94
  if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
92
- const reply = await hub.request({ t: "start", peer: "jcode", args: { mode, ...options }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
95
+ const reply = await hub.request({ t: "start", peer, args: { mode, ...options }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
93
96
  if (interruptedWait()) return;
94
97
  if (!reply.ok) fail(reply.error);
95
- if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
98
+ if (mode === "headless") { console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); native policy unmanaged, NO hub sandbox`); return; }
96
99
  const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
97
100
  if (interruptedWait() || !ready) return;
98
101
  // No managed terminal record: Jcode's controlled recovery is refused at plan time, so, like Kimi's TUI, it has
@@ -18,10 +18,17 @@ function document(file: string): Document {
18
18
  return value;
19
19
  }
20
20
  const map = (doc: Document): Document => (doc.mcpServers ?? doc.servers ?? {}) as Document;
21
- const owned = (entry: unknown, cwd: string): entry is Document => object(entry) && object(entry.env) && entry.env.AGENTHUB_MODE === "tools" && entry.env.AGENTHUB_PEER_ID === "jcode" && entry.env.AGENTHUB_PROJECT_DIR === cwd;
21
+ /**
22
+ * The project's `.jcode/mcp.json` entry is shared by every Jcode in it, so it names no peer (#685): jcode gives an entry's own env
23
+ * precedence over the process env (probed on 0.99.0), so a hardcoded `AGENTHUB_PEER_ID: "jcode"` would make an extra instance's
24
+ * hub tools act as the base peer. Each owned launch path supplies its own id instead (peerChildEnv for serve and ACP, the TUI launch env).
25
+ * An entry written before this still hardcodes the id: `jcodeMcpReady` is false for it, and the launcher rewrites it.
26
+ */
27
+ const owned = (entry: unknown, cwd: string): entry is Document => object(entry) && object(entry.env) && entry.env.AGENTHUB_MODE === "tools"
28
+ && (entry.env.AGENTHUB_PEER_ID === undefined || entry.env.AGENTHUB_PEER_ID === "jcode") && entry.env.AGENTHUB_PROJECT_DIR === cwd;
22
29
 
23
30
  export function jcodeMcpEntry(cwd: string, stateDir: string, bundle: string) {
24
- return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_DELIVERY_ONLY: "true", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
31
+ return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_DELIVERY_ONLY: "true", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
25
32
  }
26
33
  /** The native loader reads later project files last. Never shadow a foreign definition. */
27
34
  function inspect(cwd: string): Document {
@@ -23,6 +23,8 @@ export interface NativePreviewHelpers {
23
23
  export interface NativePreviewContext {
24
24
  tool: string; args: string[]; cwd: string; stateDir: string; cliDir: string; unattended: boolean;
25
25
  config: ReturnType<typeof loadConfig>; definition: AdapterDefinition; baseEnvNames: string[]; helpers: NativePreviewHelpers;
26
+ /** The declared extra instance a `--as <id>` launch selected; its own model fields belong in the preview (#685). */
27
+ instance?: string;
26
28
  }
27
29
  export interface NativePreviewMetadata { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } }
28
30
  export type NativePreviewBuilder = (ctx: NativePreviewContext) => NativePreview;
@@ -2,7 +2,7 @@ import type { Launch } from "../../cli/launch.ts";
2
2
  import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext, type NativePreview } from "./native-cli-preview-common.ts";
3
3
 
4
4
  export function previewJcode(ctx: NativePreviewContext): NativePreview {
5
- const { args, config } = ctx;
5
+ const { args, config, instance } = ctx;
6
6
  const { jcodeCommand, jcodeTuiCommand, qwenSessionId, startModeOf } = ctx.helpers;
7
7
  const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
8
8
  let envNames = state.envNames;
@@ -11,7 +11,13 @@ export function previewJcode(ctx: NativePreviewContext): NativePreview {
11
11
  if (mode !== "headless" && mode !== "tui") throw new Error("invalid Jcode start mode");
12
12
  for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
13
13
  if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
14
- const cmd = mode === "tui" ? jcodeTuiCommand(nativeCommand(config.jcode_cmd, ["jcode"]), UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined) : jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
14
+ // The daemon starts an instance with its own declared provider, model and profile (a launch flag still wins, as in the start branch):
15
+ // preview the same flags. Values stay redacted, as the base Jcode's model and profile already are.
16
+ const declared = instance ? config.instances?.[instance] : undefined;
17
+ const provider = declared?.provider ? REDACTED : undefined, modelWord = model || declared?.model ? REDACTED : undefined;
18
+ const profileWord = profile || declared?.provider_profile || config.jcode_provider_profile ? REDACTED : undefined;
19
+ const cmd = mode === "tui" ? jcodeTuiCommand(nativeCommand(config.jcode_cmd, ["jcode"]), UNRESOLVED, UNRESOLVED, modelWord, profileWord, provider)
20
+ : jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, modelWord, profileWord, provider, instance ?? "jcode");
15
21
  launch = { cmd: cmd[0]!, args: cmd.slice(1) };
16
22
  unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
17
23
  unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
@@ -1,3 +1,4 @@
1
+ import { describe } from "../compat.ts";
1
2
  import { join } from "node:path";
2
3
  import type { Launch } from "../../cli/launch.ts";
3
4
  import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
@@ -16,7 +17,7 @@ export function previewQwen(ctx: NativePreviewContext) {
16
17
  if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
17
18
  launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
18
19
  userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
19
- unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
20
+ unresolved.push({ field: "nativeIdentity", reason: `Qwen Code (${describe("qwen")}) identity and permission modes are checked at ACP initialization; preview starts nothing` });
20
21
 
21
22
  return finishPreview(ctx, launch, envNames, unresolved, undefined);
22
23
  }
@@ -1,16 +1,20 @@
1
+ import { assessVersion } from "../compat.ts";
1
2
  import type { PermissionMode } from "../../hub/permission-mode.ts";
2
3
  import type { Launch } from "../../cli/launch.ts";
3
4
 
4
5
  export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
5
6
 
6
7
  export class QwenIdentityError extends Error {
7
- constructor() { super("Qwen Code 0.25.0 native identity is not verified; check the executable and its advertised ACP version"); }
8
+ constructor(reason?: string) { super(reason ? `Qwen Code native identity is not verified: ${reason}` : "Qwen Code native identity is not verified; check the executable and its advertised ACP version"); }
8
9
  }
9
10
 
10
11
  export function qwenNativeIdentity(instance: string, production: boolean | undefined, init: unknown, kind?: string): boolean {
11
12
  const native = init as { agentInfo?: { name?: unknown; version?: unknown } } | null | undefined;
12
- const verified = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code" && native?.agentInfo?.version === "0.25.0";
13
- if (production && !verified) throw new QwenIdentityError();
13
+ const identity = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code";
14
+ // #691: a version refusal carries the compat reason (observed and verified versions, the trust command).
15
+ const version = identity ? assessVersion("qwen", native?.agentInfo?.version) : undefined;
16
+ if (production && !version?.ok) throw new QwenIdentityError(version && !version.ok ? version.reason : undefined);
17
+ const verified = !!version?.ok;
14
18
  return verified;
15
19
  }
16
20
 
@@ -1,3 +1,4 @@
1
+ import { describe } from "../compat.ts";
1
2
  import { runQwenEntry } from "./native-cli-entry-qwen.ts";
2
3
  import { previewQwen } from "./native-cli-preview-qwen.ts";
3
4
  import { QWEN_DISPLAY } from "./native-display.ts";
@@ -67,7 +68,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
67
68
  turnEnd: "adapter",
68
69
  budget: { account: "qwen" },
69
70
  capabilities: { watch: true },
70
- 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 0.25.0 ACP (roster.qwen, default off)"]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
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"] },
71
72
  display: QWEN_DISPLAY,
72
73
  hooks: { recallPlatform: "qwen" },
73
74
  };