@staix/agent-hub 0.12.39 → 0.12.41

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 (81) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -10
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +2 -0
  5. package/docs/agent-notes/bus.md +2 -2
  6. package/docs/agent-notes/daemon.md +10 -6
  7. package/docs/agent-notes/models.md +4 -4
  8. package/docs/agent-notes/tasks.md +3 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +152 -28
  12. package/docs/quickstart.md +23 -5
  13. package/docs/security.md +20 -8
  14. package/docs/smoke.md +2 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  17. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  18. package/docs/verification/2026-10-20-0.12.41.md +44 -0
  19. package/docs/verified.json +38 -19
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +415 -151
  23. package/src/adapters/acp.ts +34 -1
  24. package/src/adapters/claude-channel.ts +35 -4
  25. package/src/adapters/codex-appserver.ts +12 -3
  26. package/src/adapters/definitions/builtin-metadata.ts +4 -2
  27. package/src/adapters/definitions/native-cli-codex-launch.ts +2 -2
  28. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  29. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  30. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  31. package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
  32. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  33. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  34. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  35. package/src/adapters/jcode.ts +110 -4
  36. package/src/adapters/local-worker.ts +21 -37
  37. package/src/adapters/pi.ts +7 -0
  38. package/src/adapters/registry-types.ts +2 -0
  39. package/src/cli/console-state.ts +1 -0
  40. package/src/cli/console.ts +11 -1
  41. package/src/cli/help.ts +13 -8
  42. package/src/cli/identity-audit.ts +33 -7
  43. package/src/cli/jcode-mcp.ts +1 -1
  44. package/src/cli/launch.ts +1 -1
  45. package/src/cli/main.ts +134 -24
  46. package/src/cli/output.ts +5 -2
  47. package/src/cli/status-lines.ts +1 -1
  48. package/src/cli/terminal-recovery.ts +54 -4
  49. package/src/cli/upgrade-interactive.ts +5 -0
  50. package/src/cli/upgrade-runtime.ts +13 -1
  51. package/src/cli/upgrade.ts +67 -1
  52. package/src/hub/board.ts +4 -2
  53. package/src/hub/bus.ts +8 -3
  54. package/src/hub/checks.ts +15 -5
  55. package/src/hub/conductor.ts +2 -1
  56. package/src/hub/config-apply.ts +20 -2
  57. package/src/hub/config-reload.ts +2 -8
  58. package/src/hub/control-client.ts +26 -3
  59. package/src/hub/daemon.ts +155 -68
  60. package/src/hub/delivery-superseded.ts +19 -2
  61. package/src/hub/events.ts +4 -1
  62. package/src/hub/hub-tools.ts +4 -4
  63. package/src/hub/inference.ts +4 -8
  64. package/src/hub/lifecycle.ts +3 -3
  65. package/src/hub/main-checkout.ts +36 -0
  66. package/src/hub/memory-profile-check.ts +6 -2
  67. package/src/hub/memory-profile.ts +18 -1
  68. package/src/hub/peers.ts +2 -0
  69. package/src/hub/ports.ts +1 -0
  70. package/src/hub/routing.ts +24 -14
  71. package/src/hub/settings.ts +1 -1
  72. package/src/hub/task-repository.ts +136 -0
  73. package/src/hub/tasks.ts +101 -21
  74. package/src/hub/terminal-launch.ts +130 -0
  75. package/src/hub/usage.ts +44 -18
  76. package/src/models/route/config.ts +1 -1
  77. package/src/omniroute/client.ts +7 -19
  78. package/templates/AGENTS.block.md +1 -0
  79. package/templates/routing.toml +10 -44
  80. package/src/switchyard/config.ts +0 -45
  81. package/src/switchyard/sidecar.ts +0 -136
@@ -159,6 +159,9 @@ export class AcpPeer extends BasePeer {
159
159
  private activeDeliveryId: string | undefined;
160
160
  private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
161
161
  private deliveryAccepted = false;
162
+ /** What the running turn answers: its delivery plus whatever the transport took into it mid-turn (#572). */
163
+ private turnEnvs: Envelope[] = [];
164
+ private steerIds: string[] = [];
162
165
  private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout>; reported: boolean; released: Promise<void>; release: () => void } | undefined;
163
166
  private promptActivity = false;
164
167
  private activityOpen = false;
@@ -324,6 +327,7 @@ export class AcpPeer extends BasePeer {
324
327
  if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
325
328
  if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
326
329
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
330
+ this.settleSteers("needs_review", reason ?? "ACP session stopped before settlement");
327
331
  this.activeDeliveryId = undefined;
328
332
  const proc = this.proc;
329
333
  if (!proc) { this.setState("offline"); return; }
@@ -341,6 +345,7 @@ export class AcpPeer extends BasePeer {
341
345
  const turn = ++this.turn;
342
346
  this.activeDeliveryId = deliveryId;
343
347
  this.activeLegacy = deliveryId ? undefined : { turn, envs };
348
+ this.turnEnvs = envs; this.steerIds = [];
344
349
  this.deliveryAccepted = false;
345
350
  this.promptActivity = false;
346
351
  this.chunks = [];
@@ -366,13 +371,16 @@ export class AcpPeer extends BasePeer {
366
371
  this.observeUsage(result, "prompt_result");
367
372
  this.primed = true;
368
373
  const body = this.chunks.join("").trim();
369
- if (body) this.onMessage?.(body, { inReplyTo: replyParent(envs), to: replyAudience(envs) });
374
+ // What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
375
+ const answering = this.turnEnvs.length ? this.turnEnvs : envs;
376
+ if (body) this.onMessage?.(body, { inReplyTo: replyParent(answering), to: replyAudience(answering) });
370
377
  // Partial output cannot certify normal completion (#160).
371
378
  if (result?.stopReason !== "end_turn") this.reportTurnFailure(envs, `ACP prompt ended without normal completion (${String(result?.stopReason ?? "unknown")})`);
372
379
  if (deliveryId && this.activeDeliveryId === deliveryId) {
373
380
  this.acceptDelivery();
374
381
  this.delivery({ id: deliveryId, state: result?.stopReason === "end_turn" ? "completed" : "needs_review", ...(result?.stopReason === "end_turn" ? {} : { reason: "ACP prompt ended without normal completion" }) });
375
382
  }
383
+ this.settleSteers(result?.stopReason === "end_turn" ? "completed" : "needs_review", "ACP prompt ended without normal completion");
376
384
  })
377
385
  .catch((e: Error) => {
378
386
  if (turn !== this.turn) {
@@ -381,6 +389,7 @@ export class AcpPeer extends BasePeer {
381
389
  }
382
390
  this.endActivity("failed", true);
383
391
  const reason = sanitize(e.message);
392
+ this.settleSteers("needs_review", reason);
384
393
  this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
385
394
  const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
386
395
  if (quota) {
@@ -437,6 +446,7 @@ export class AcpPeer extends BasePeer {
437
446
  this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
438
447
  const durable = !!this.activeDeliveryId;
439
448
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
449
+ this.settleSteers("needs_review", "ACP turn watchdog timeout");
440
450
  this.activeDeliveryId = undefined;
441
451
  this.notify("session/cancel", { sessionId: this.sessionId });
442
452
  this.turn++; // whatever the cancelled prompt still reports is stale
@@ -474,6 +484,28 @@ export class AcpPeer extends BasePeer {
474
484
  if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
475
485
  }
476
486
 
487
+ /** The running turn's generation, for a transport that must adopt into exactly this turn after an async step (#572). */
488
+ protected currentTurn(): number { return this.turn; }
489
+
490
+ /**
491
+ * A transport that took envelopes into the running turn (#572) reports them here, once it has acknowledged them. The
492
+ * turn now answers their senders too, and their receipts settle with the turn: completed on a normal end, held for
493
+ * review on anything else. Only the busy turn the caller named (`currentTurn()` before sending) can adopt.
494
+ */
495
+ protected adoptIntoTurn(turn: number, envs: Envelope[], deliveryId?: string): void {
496
+ // Fenced to the exact turn the caller saw before it sent anything: a later turn on the same session is never adopted into.
497
+ if (turn !== this.turn || this.state !== "busy" || this.cancelWait || this.stopping) throw new Error(`${this.id} is ${this.state}, or its turn changed`);
498
+ this.turnEnvs = [...this.turnEnvs, ...envs];
499
+ this.activityPrivate ||= envs.some(env => env.private === true);
500
+ this.primed = true;
501
+ if (deliveryId) { this.steerIds.push(deliveryId); this.delivery({ id: deliveryId, state: "accepted" }); }
502
+ }
503
+
504
+ private settleSteers(state: "completed" | "needs_review", reason?: string): void {
505
+ const ids = this.steerIds; this.steerIds = [];
506
+ for (const id of ids) this.delivery({ id, state, ...(state === "needs_review" ? { reason: reason ?? "steered turn did not complete normally" } : {}) });
507
+ }
508
+
477
509
  private acceptDelivery(): void {
478
510
  if (!this.activeDeliveryId || this.deliveryAccepted) return;
479
511
  this.deliveryAccepted = true;
@@ -486,6 +518,7 @@ export class AcpPeer extends BasePeer {
486
518
  for (const p of this.pending.values()) p.reject(new Error(reason));
487
519
  this.pending.clear();
488
520
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason });
521
+ this.settleSteers("needs_review", reason);
489
522
  this.activeDeliveryId = undefined;
490
523
  this.setState("offline");
491
524
  }
@@ -86,6 +86,7 @@ const TERMINAL_CLOSES: Record<number, string> = {
86
86
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
87
87
  4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
88
88
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
89
+ 4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
89
90
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
90
91
  };
91
92
  /** Another session attached under this peer id. Unlike the other closes this one ends on its own, so the
@@ -97,7 +98,7 @@ const INBOX_CAP = 200;
97
98
  /**
98
99
  * Whether a live session holds this peer id, per the daemon's own status file. The hub gives the id to
99
100
  * whoever said hello last, so reconnecting blind would evict the session that just took it and the two
100
- * would trade the peer forever. No readable status means no live hub holds it, and a connect can only fail.
101
+ * would trade the peer forever. This file is advisory: a standby hello also checks live ownership at the daemon.
101
102
  */
102
103
  function peerHeld(): boolean {
103
104
  try {
@@ -144,7 +145,7 @@ const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });
144
145
 
145
146
  const server = new Server(
146
147
  { name: "agent-hub", version: VERSION },
147
- toolsMode ? { capabilities: { tools: {} }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: channel ? { experimental: { "claude/channel": {} }, tools: {} } : { tools: {} }, instructions: INSTRUCTIONS },
148
+ toolsMode ? { capabilities: { tools: { listChanged: true } }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: channel ? { experimental: { "claude/channel": {} }, tools: { listChanged: true } } : { tools: { listChanged: true } }, instructions: INSTRUCTIONS },
148
149
  );
149
150
 
150
151
  const inbox: string[] = []; // pushes that failed; drained by hub_inbox
@@ -243,6 +244,8 @@ async function connectLoop(): Promise<void> {
243
244
  let closeReason: string | undefined;
244
245
  try {
245
246
  const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
247
+ ...(!toolsMode && process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
248
+ ...(!toolsMode && standingBy ? { standby: true } : {}),
246
249
  ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
247
250
  client.onPush = (msg) => {
248
251
  if (msg.t === "queued") {
@@ -267,6 +270,9 @@ async function connectLoop(): Promise<void> {
267
270
  standingBy = false;
268
271
  detached = undefined;
269
272
  log(`connected to hub as "${peerId}"`);
273
+ // The tool list depends on the connected hub (its lifecycle): when it differs from what the host last listed,
274
+ // ask the host to list it again (#490).
275
+ if (listedTools !== undefined) void reannounce(client).catch(() => {});
270
276
  code = await new Promise<number>((r) => { client.onClose = (value, reason) => { closeReason = reason; r(value); }; });
271
277
  hub = undefined;
272
278
  log("hub connection lost");
@@ -290,8 +296,27 @@ async function connectLoop(): Promise<void> {
290
296
  }
291
297
  }
292
298
 
293
- server.setRequestHandler(ListToolsRequestSchema, async () => {
294
- const lifecycle = await activeLifecycle(stateDir, peerId, projectRoot, hub);
299
+ /** The tool definitions the host was last given; a reconnect that changes any of them asks the host to list again (#490).
300
+ * Whole definitions, not names: a lifecycle change alters schemas (stage enums, required fields) under the same names. */
301
+ let listedTools: string | undefined;
302
+ const toolKey = (list: { tools: unknown[] }) => JSON.stringify(list.tools);
303
+
304
+ /** After a reconnect, re-announce the tools when they differ from what the host listed. The status probe can time out
305
+ * right after a restart; retry it, and when it stays unavailable announce anyway: a needless re-list costs less than a
306
+ * session that keeps a short list until the next disconnect (#490). */
307
+ async function reannounce(client: ControlClient) {
308
+ for (let tries = 0; ; tries++) {
309
+ if (hub !== client) return;
310
+ const lifecycle = await activeLifecycle(stateDir, peerId, projectRoot, client);
311
+ if (lifecycle === undefined && tries < 2) { await new Promise(r => setTimeout(r, 1000)); continue; }
312
+ if (lifecycle === undefined || toolKey(toolListFor(lifecycle)) !== listedTools) await server.sendToolListChanged();
313
+ return;
314
+ }
315
+ }
316
+
317
+ async function buildToolList() { return toolListFor(await activeLifecycle(stateDir, peerId, projectRoot, hub)); }
318
+
319
+ function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
295
320
  return {
296
321
  tools: [
297
322
  {
@@ -333,6 +358,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
333
358
  ...CONDUCTOR_TOOLS,
334
359
  ],
335
360
  };
361
+ }
362
+
363
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
364
+ const list = await buildToolList();
365
+ listedTools = toolKey(list);
366
+ return list;
336
367
  });
337
368
 
338
369
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
@@ -49,6 +49,7 @@ export interface CodexOptions {
49
49
  }
50
50
 
51
51
  interface Link {
52
+ launchId?: string;
52
53
  tui: ServerWebSocket<Link>;
53
54
  up: WebSocket;
54
55
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
@@ -128,6 +129,13 @@ export class CodexPeer extends BasePeer {
128
129
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
129
130
  }
130
131
 
132
+ terminalOwner(launchId: string): { generation: string } | undefined {
133
+ const link = this.link;
134
+ return this.threadId && link && link === this.claimedTui && link.launchId === launchId &&
135
+ link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN
136
+ ? { generation: String(this.sessionGeneration) } : undefined;
137
+ }
138
+
131
139
  async start(): Promise<void> {
132
140
  const upstream = this.opts.upstreamUrl ?? (await this.spawnAppServer());
133
141
  try {
@@ -137,7 +145,8 @@ export class CodexPeer extends BasePeer {
137
145
  fetch: (req, server) => {
138
146
  // Browsers always send Origin on WebSocket upgrades; the TUI never does.
139
147
  if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
140
- if (server.upgrade(req, { data: {} as Link })) return undefined;
148
+ const launchId = /^Bearer (\S+)$/.exec(req.headers.get("authorization") ?? "")?.[1]; // codex --remote-auth-token-env (#586)
149
+ if (server.upgrade(req, { data: { ...(launchId && launchId.length <= 128 ? { launchId } : {}) } as Link })) return undefined;
141
150
  return new Response("agent-hub codex proxy");
142
151
  },
143
152
  websocket: {
@@ -418,7 +427,7 @@ export class CodexPeer extends BasePeer {
418
427
 
419
428
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
420
429
  const up = new WebSocket(upstream);
421
- const link: Link = { tui, up, backlog: [], tracked: new Map() };
430
+ const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
422
431
  this.claimedTui = link;
423
432
  tui.data = link;
424
433
  up.onopen = () => {
@@ -556,7 +565,7 @@ export class CodexPeer extends BasePeer {
556
565
  }
557
566
 
558
567
  private adopt(link: Link, threadId: unknown, fresh = false): void {
559
- if (typeof threadId !== "string" || !threadId) return;
568
+ if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
560
569
  this.abandonSteers("thread changed");
561
570
  for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
562
571
  pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
@@ -93,7 +93,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
93
93
  api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
94
94
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
95
95
  start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
96
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
96
+ unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
97
97
  identity: { mcp: "env" },
98
98
  permission: { trackUnattended: true, modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
99
99
  "ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
@@ -158,6 +158,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
158
158
  /**
159
159
  * ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
160
160
  * Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
161
+ * The frozen jcode entry stays byte-identical: its headless `launch` emits `jcode` without `--mode`, which the CLI now
162
+ * resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
161
163
  */
162
164
  const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
163
165
  export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
@@ -178,7 +180,7 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
178
180
  api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
179
181
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
180
182
  start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("codex", ctx), conductorStartable: true,
181
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
183
+ unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
182
184
  identity: { mcp: "env" },
183
185
  permission: { modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
184
186
  "ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
@@ -1,13 +1,13 @@
1
1
  import type { LaunchContext, LaunchPlan } from "../registry-types.ts";
2
2
  import { nativeLaunchArguments, nativeLaunchPlan } from "./native-cli-launch-common.ts";
3
3
 
4
- export const CODEX_LAUNCH_POLICY = Object.freeze({ hubSwitches: Object.freeze(["--unattended", "--safe", "--new"]), ownedFlags: Object.freeze(["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]), unattended: true, passthrough: true });
4
+ export const CODEX_LAUNCH_POLICY = Object.freeze({ hubSwitches: Object.freeze(["--unattended", "--safe", "--new"]), ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]), unattended: true, passthrough: true });
5
5
  export function codexNativeLaunch(ctx: LaunchContext): LaunchPlan {
6
6
  const { passthrough, unattended, warning } = nativeLaunchArguments("codex", ctx, CODEX_LAUNCH_POLICY);
7
7
  if (!ctx.proxyUrl) throw new Error("codex proxy url is missing");
8
8
  return nativeLaunchPlan({
9
9
  cmd: ctx.codexBin ?? "codex",
10
- args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
10
+ args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(ctx.launchTokenEnv ? ["--remote-auth-token-env", ctx.launchTokenEnv] : []), ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
11
11
  unattended,
12
12
  ...warning,
13
13
  }, ctx);
@@ -13,11 +13,11 @@ export const DOCTOR_PLUGIN_PROBE = Object.freeze({ command: "claude", argv: Obje
13
13
  export function nativeDoctorCheck(ok: boolean | undefined, name: string, detail: string): DoctorCheck {
14
14
  const nativeTool = nativeTools.some(tool => tool.id === name);
15
15
  const section: DoctorCheck["section"] = name === "bun" || nativeTool ? "Tools"
16
- : name === "config" || name === "retired setting" ? "Config"
16
+ : name === "config" || name === "retired setting" || name === "routing" ? "Config"
17
17
  : name.startsWith("memory") ? "Memory"
18
- : ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
19
- const optional = nativeTool || ["ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
20
- const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
18
+ : ["omniroute", "omniroute key", "routing model", "routing models", "pi mlx"].includes(name) ? "Models" : "Hub";
19
+ const optional = nativeTool || ["ahub daemon", "pi mlx"].includes(name) || section === "Memory";
20
+ const level: DoctorCheck["level"] = ok === undefined ? (name === "omniroute" ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
21
21
  return { section, level, name, detail };
22
22
  }
23
23
 
@@ -32,13 +32,16 @@ export function nativeDoctorRecoveryIdentity(peer: { id: string; state: string;
32
32
 
33
33
  export type NativeDoctorOptionalProbe =
34
34
  | { kind: "source-identity"; name: string; command: string[]; detail: string }
35
+ | { kind: "jcode-mcp"; name: string; home: string }
35
36
  | { kind: "pinned-version"; name: string; command: string[]; expected: string; confirmed: string; unconfirmed: string };
36
37
 
37
38
  /** The host runs these existing checks in order; no process, filesystem or caller callback enters this planner. */
38
- export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[] }): NativeDoctorOptionalProbe[] {
39
+ export function nativeDoctorOptionalProbes(config: { roster: Readonly<Record<string, boolean | undefined>>; jcode_cmd: string[]; qwen_cmd: string[]; jcode_home?: string }): NativeDoctorOptionalProbe[] {
39
40
  const probes: NativeDoctorOptionalProbe[] = [];
40
41
  if (config.roster.jcode === true) probes.push({ kind: "source-identity", name: "jcode", command: config.jcode_cmd,
41
42
  detail: "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged" });
43
+ // #572 AC4: jcode cannot be told which MCP servers to load, so the host lists the ones its config files name.
44
+ if (config.roster.jcode === true && config.jcode_home) probes.push({ kind: "jcode-mcp", name: "jcode MCP servers", home: config.jcode_home });
42
45
  if (config.roster.qwen === true) probes.push({ kind: "pinned-version", name: "qwen", command: config.qwen_cmd, expected: "0.25.0",
43
46
  confirmed: "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check",
44
47
  unconfirmed: "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown" });
@@ -22,7 +22,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
22
22
  } finally { hub.close(); }
23
23
  return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
24
24
  }
25
- const { launcherPreview, readControl, recordTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
25
+ const { launcherPreview, readControl, prepareTerminalLaunch, registerTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
26
26
  if (args.includes("--print-command") || args.includes("--dry-run")) {
27
27
  try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
28
28
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
@@ -30,8 +30,13 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
30
30
  const control = readControl(stateDir);
31
31
  if (control?.instanceId) {
32
32
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
33
- const terminal = await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
33
+ const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
34
34
  if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
35
+ else {
36
+ const hub = await ctx.connect();
37
+ try { await registerTerminalLaunch(hub, terminal, process.env.AGENTHUB_RECOVERY_OPERATION); }
38
+ finally { hub.close(); }
39
+ }
35
40
  }
36
41
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
37
42
  // project local, then project, then user.
@@ -4,35 +4,34 @@ import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
4
4
  export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
5
5
  const peer = ctx.instance ?? "codex";
6
6
  const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
7
- const { launcherPreview, buildLaunch, recoveryLock, readControl, recordTerminalLaunch } = ctx.helpers;
7
+ const { launcherPreview, buildLaunch, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
8
8
  if (args.includes("--print-command") || args.includes("--dry-run")) {
9
9
  try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
10
10
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
11
11
  }
12
12
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: "pending" }); // refuse bad flags before starting anything
13
- // #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
14
- // a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
15
- // refused start must not replace the record of a Codex already running here (one row per peer and instance).
16
- const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
17
- const before = readControl(stateDir);
18
- // ponytail: recovering extras have no authenticated execution-root binding before start replies, so their row
19
- // is recorded afterward. The coordinator retains its pending creation/wait evidence; add pre-start recording
20
- // only when a future recovery receipt supplies independently verified exact-instance execution-root evidence.
21
- if (recovering && !ctx.instance && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
22
13
  const hub = await connect();
23
14
  const snapshot = (await hub.request({ t: "status" })).status;
24
15
  const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
25
16
  const res = await hub.request({ t: "start", peer, args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
26
- hub.close();
27
- if (!res.ok) fail(res.error);
17
+ if (!res.ok) { hub.close(); fail(res.error); }
28
18
  if (ctx.instance) {
29
19
  if (typeof res.executionCwd !== "string" || !res.executionCwd.startsWith("/")) fail("instance execution directory was not verified");
30
20
  ctx.executionCwd = res.executionCwd;
31
21
  }
32
22
  const control = readControl(stateDir);
33
- if ((!recovering || ctx.instance) && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
23
+ let launchTokenEnv: string | undefined;
24
+ try {
25
+ if (control?.instanceId) {
26
+ const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
27
+ if (terminal) {
28
+ await registerTerminalLaunch(hub, terminal, process.env.AGENTHUB_RECOVERY_OPERATION);
29
+ launchTokenEnv = "AGENTHUB_LAUNCH_ID"; // codex --remote takes only ws://host:port, so the id rides the bearer header (#586)
30
+ }
31
+ }
32
+ } finally { hub.close(); }
34
33
  const runningCodexBin = res.retainedLaunchConfig?.codex_bin;
35
- const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
34
+ const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, launchTokenEnv, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
36
35
  if (launch0.warning) console.error(launch0.warning);
37
36
  exec(launch.cmd, launch.args, "codex");
38
37
  }
@@ -7,6 +7,11 @@ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
7
7
 
8
8
  const WAITING = "jcode's TUI opens after its first hub turn; send it work, or Ctrl-C";
9
9
 
10
+ /** The daemon names the one session option that differs from the attached saved session (#559 N2, #574). */
11
+ function refuseNamedOption(reply: { ok?: unknown; option?: unknown }, fail: NativeCliEntryContext["fail"]): void {
12
+ if (!reply.ok && typeof reply.option === "string" && ["--session-id", "--model", "--provider-profile"].includes(reply.option)) fail(`Jcode is already running with a different ${reply.option}; run ahub jcode without it, or stop jcode first`);
13
+ }
14
+
10
15
  export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryContext["fail"], signal: AbortSignal,
11
16
  options: Record<string, string | undefined>, first?: JcodeTuiState): Promise<Extract<JcodeTuiState, { state: "ready" }> | undefined> {
12
17
  let binding: JcodeTuiBinding | undefined = first?.binding, announced = false, current = first;
@@ -14,6 +19,7 @@ export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryCo
14
19
  if (!current) {
15
20
  const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
16
21
  if (signal.aborted) return;
22
+ refuseNamedOption(reply, fail);
17
23
  if (!reply.ok || !reply.binding || !["waiting", "ready"].includes(reply.state)) fail("Jcode launch changed or its native saved session is unavailable; retry from a terminal");
18
24
  current = reply as JcodeTuiState;
19
25
  }
@@ -68,6 +74,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
68
74
  if (print) {
69
75
  if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
70
76
  const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
77
+ refuseNamedOption(reply, fail);
71
78
  if (!reply.ok || reply.state !== "ready") fail("Jcode's selected native session is not saved and ready; run ahub jcode to wait for its first hub turn");
72
79
  return console.log(JSON.stringify(reply.launch, null, 2));
73
80
  }
@@ -88,12 +95,15 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
88
95
  if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
89
96
  const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
90
97
  if (interruptedWait() || !ready) return;
91
- const instanceId = typeof snapshot?.instanceId === "string" ? snapshot.instanceId : undefined;
92
- if (!instanceId) return fail("cannot bind Jcode's terminal to the running hub");
93
- await ctx.helpers.recordTerminalLaunch("jcode", cwd, stateDir, instanceId);
98
+ // No managed terminal record: Jcode's controlled recovery is refused at plan time, so, like Kimi's TUI, it has
99
+ // no terminal recovery adapter (#564).
94
100
  const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
95
101
  if (interruptedWait()) return;
96
102
  if (!confirmed.ok || confirmed.state !== "ready") fail("Jcode launch changed before its terminal opened");
103
+ // ponytail: jcode 0.98.1's top-level TUI spawns a shared server (no --temporary-server or --owner-pid) when nothing
104
+ // answers on the dialed socket at startup, and on its reload-handoff reconnect. A hub stop or an owned-server crash
105
+ // between the confirm reply above and that check can leave an unowned jcode server on the private socket under the
106
+ // person's JCODE_HOME. No native flag prevents it; revisit if upstream adds an owner flag (#559 N2).
97
107
  ctx.execWithEnv(confirmed.launch.cmd, confirmed.launch.args, { ...ctx.helpers.childEnv(), ...confirmed.launch.env });
98
108
  } finally { process.removeListener("SIGINT", interrupt); hub.onClose = oldClose; hub.close(); }
99
109
  }
@@ -5,7 +5,7 @@ import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
5
5
  export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
6
6
  const peer = ctx.instance ?? "pi";
7
7
  const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, execWithEnv } = ctx;
8
- const { launcherPreview, startModeOf, recoveryLock, readControl, recordTerminalLaunch } = ctx.helpers;
8
+ const { launcherPreview, startModeOf, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
9
9
  // --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
10
10
  if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
11
11
  if (args.includes("--print-command") || args.includes("--dry-run")) {
@@ -34,11 +34,6 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
34
34
  return console.log(reply.already ? "pi is already attached" : reply.opened ? `pi: opened a terminal running ${reply.command}; it attaches when its TUI is ready` : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
35
35
  } finally { hub.close(); }
36
36
  }
37
- // #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
38
- // trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
39
- const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
40
- const before = readControl(stateDir);
41
- if (recovering && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
42
37
  const hub = await connect();
43
38
  const snapshot = (await hub.request({ t: "status" })).status;
44
39
  const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
@@ -52,7 +47,14 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
52
47
  delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
53
48
  delete launchEnv.AGENTHUB_UNATTENDED;
54
49
  const control = readControl(stateDir);
55
- if (!recovering && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId);
50
+ if (control?.instanceId) {
51
+ const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId);
52
+ if (terminal) {
53
+ const registration = await connect();
54
+ try { await registerTerminalLaunch(registration, terminal, process.env.AGENTHUB_RECOVERY_OPERATION); }
55
+ finally { registration.close(); }
56
+ }
57
+ }
56
58
  return execWithEnv(launch.cmd, launch.args, launchEnv);
57
59
  }
58
60
  console.log(res.already ? `${peer} is already attached` : `${peer} attached (headless). Talk to it with: ahub say @${peer} "..."`);
@@ -19,6 +19,8 @@ export interface NativeCliEntryContext {
19
19
  launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
20
20
  readControl: typeof import("../../hub/control-client.ts").readControl;
21
21
  recordTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").recordTerminalLaunch;
22
+ prepareTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").prepareTerminalLaunch;
23
+ registerTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").registerTerminalLaunch;
22
24
  recoveryLock: typeof import("../../hub/recovery-store.ts").recoveryLock;
23
25
  processSignature: typeof import("../../pi/process-signature.ts").processSignature;
24
26
  buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { hubGitSync } from "../../hub/git.ts";
@@ -56,3 +56,57 @@ export function configureJcodeMcp(cwd: string, stateDir: string, bundle: string,
56
56
  } finally { if (existsSync(temporary)) unlinkSync(temporary); }
57
57
  return { changed: true, tracked };
58
58
  }
59
+
60
+ /**
61
+ * #572 AC4: jcode 0.99.0 has no flag or environment variable that chooses which MCP servers its `serve` and `acp`
62
+ * load (`--mcp-tools` and `JCODE_MCP_TOOLS` only change how their tools are exposed, and `JCODE_DISABLE_CLAUDE_MCP`
63
+ * does not stop a project `.mcp.json`). The hub-owned jcode therefore starts every server these files name, besides
64
+ * the hub's own. This lists their names for `ahub doctor`. It reads only `<JCODE_HOME>/mcp.json` and the project's
65
+ * `.jcode/mcp.json`, `.mcp.json` and `.claude/mcp.json`: no other path, no home search.
66
+ * Links are refused below the trusted root (ceiling: per-component `lstat` plus `O_NOFOLLOW` does not pin a directory against a concurrent same-user swap; that would need fd-relative traversal): each controlled directory component (`.jcode`, `.claude`) and the
67
+ * file itself must be a real directory or a real regular file, and the file is opened without following a link and
68
+ * checked again after the open (review I3). The project root and the home are the hub's own configured paths, so a
69
+ * system alias at or above them (macOS `/var`) stays legitimate. Names only: a command, url, header or environment
70
+ * value never leaves the file.
71
+ */
72
+ function readBoundedJson(root: string, parts: string[]): unknown | undefined {
73
+ let dir = root;
74
+ for (const part of parts.slice(0, -1)) {
75
+ dir = join(dir, part);
76
+ const info = lstatSync(dir, { throwIfNoEntry: false });
77
+ if (!info) return undefined; // absent: nothing to list
78
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsafe directory");
79
+ }
80
+ const file = join(dir, parts[parts.length - 1]!), info = lstatSync(file, { throwIfNoEntry: false });
81
+ if (!info) return undefined;
82
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 256 * 1024) throw new Error("unsafe file");
83
+ let fd: number;
84
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
85
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
86
+ try {
87
+ const open = fstatSync(fd);
88
+ if (!open.isFile() || open.nlink !== 1 || open.size > 256 * 1024 || open.dev !== info.dev || open.ino !== info.ino) throw new Error("unsafe file");
89
+ const bytes = Buffer.alloc(open.size);
90
+ let offset = 0;
91
+ while (offset < bytes.length) { const n = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!n) break; offset += n; }
92
+ return JSON.parse(bytes.subarray(0, offset).toString("utf8"));
93
+ } finally { closeSync(fd); }
94
+ }
95
+
96
+ export function jcodeExtraMcpServers(cwd: string, home: string): { servers: string[]; unreadable: string[] } {
97
+ const sources: [label: string, root: string, parts: string[]][] = [
98
+ ["mcp.json (JCODE_HOME)", home, ["mcp.json"]], [JCODE_MCP_FILE, cwd, [".jcode", "mcp.json"]],
99
+ [".mcp.json", cwd, [".mcp.json"]], [".claude/mcp.json", cwd, [".claude", "mcp.json"]],
100
+ ];
101
+ const names = new Set<string>(), unreadable: string[] = [];
102
+ for (const [label, root, parts] of sources) {
103
+ try {
104
+ const doc = readBoundedJson(root, parts);
105
+ if (doc === undefined) continue;
106
+ const servers = object(doc) ? (doc.mcpServers ?? doc.servers) : undefined;
107
+ if (!object(doc) || (servers !== undefined && !object(servers))) { unreadable.push(label); continue; }
108
+ for (const name of Object.keys(servers ?? {})) if (name !== JCODE_MCP_NAME) names.add(name);
109
+ } catch { unreadable.push(label); }
110
+ }
111
+ return { servers: [...names].sort(), unreadable };
112
+ }