@staix/agent-hub 0.12.38 → 0.12.40

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 (86) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +19 -10
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/budget.md +2 -0
  6. package/docs/agent-notes/bus.md +2 -2
  7. package/docs/agent-notes/daemon.md +10 -5
  8. package/docs/agent-notes/models.md +4 -4
  9. package/docs/agent-notes/tasks.md +3 -0
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +167 -30
  13. package/docs/quickstart.md +25 -5
  14. package/docs/security.md +20 -8
  15. package/docs/smoke.md +2 -2
  16. package/docs/specs/2026-09-19-agent-hub-design.md +167 -72
  17. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  20. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  21. package/docs/verified.json +38 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +679 -193
  25. package/src/adapters/acp.ts +54 -3
  26. package/src/adapters/claude-channel.ts +35 -4
  27. package/src/adapters/codex-appserver.ts +12 -3
  28. package/src/adapters/definitions/builtin-metadata.ts +6 -4
  29. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  30. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  31. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  32. package/src/adapters/definitions/native-cli-entry-jcode.ts +90 -11
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  34. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  35. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  36. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  37. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  38. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  39. package/src/adapters/jcode.ts +165 -2
  40. package/src/adapters/local-worker.ts +21 -37
  41. package/src/adapters/pi.ts +7 -0
  42. package/src/adapters/registry.ts +1 -1
  43. package/src/cli/console-state.ts +1 -0
  44. package/src/cli/console.ts +11 -1
  45. package/src/cli/help.ts +13 -8
  46. package/src/cli/identity-audit.ts +33 -7
  47. package/src/cli/jcode-mcp.ts +1 -1
  48. package/src/cli/main.ts +138 -28
  49. package/src/cli/output.ts +5 -2
  50. package/src/cli/preview.ts +2 -2
  51. package/src/cli/status-lines.ts +1 -1
  52. package/src/cli/terminal-recovery.ts +54 -4
  53. package/src/cli/upgrade-interactive.ts +5 -0
  54. package/src/cli/upgrade-runtime.ts +13 -1
  55. package/src/cli/upgrade.ts +67 -1
  56. package/src/hub/board.ts +4 -2
  57. package/src/hub/bus.ts +8 -3
  58. package/src/hub/checks.ts +15 -5
  59. package/src/hub/conductor.ts +2 -1
  60. package/src/hub/config-apply.ts +20 -2
  61. package/src/hub/config-reload.ts +2 -8
  62. package/src/hub/control-client.ts +26 -3
  63. package/src/hub/daemon.ts +166 -67
  64. package/src/hub/delivery-superseded.ts +19 -2
  65. package/src/hub/events.ts +4 -1
  66. package/src/hub/hub-tools.ts +4 -4
  67. package/src/hub/inference.ts +4 -8
  68. package/src/hub/lifecycle.ts +3 -3
  69. package/src/hub/main-checkout.ts +36 -0
  70. package/src/hub/memory-profile-check.ts +6 -2
  71. package/src/hub/memory-profile.ts +18 -1
  72. package/src/hub/peers.ts +4 -2
  73. package/src/hub/ports.ts +1 -0
  74. package/src/hub/routing.ts +24 -14
  75. package/src/hub/settings.ts +1 -1
  76. package/src/hub/start-mode.ts +1 -1
  77. package/src/hub/task-repository.ts +136 -0
  78. package/src/hub/tasks.ts +101 -21
  79. package/src/hub/terminal-launch.ts +130 -0
  80. package/src/hub/usage.ts +44 -18
  81. package/src/models/route/config.ts +1 -1
  82. package/src/omniroute/client.ts +7 -19
  83. package/templates/AGENTS.block.md +1 -0
  84. package/templates/routing.toml +10 -44
  85. package/src/switchyard/config.ts +0 -45
  86. package/src/switchyard/sidecar.ts +0 -136
@@ -79,6 +79,8 @@ export interface AcpOptions {
79
79
  /** Optional launch environment; recovery authority is always removed before spawn. */
80
80
  env?: NodeJS.ProcessEnv;
81
81
  watchdogMs?: number;
82
+ /** #542 D4: how long an open tool call keeps a silent turn alive; defaults to 30 minutes, never below the watchdog. */
83
+ toolCeilingMs?: number;
82
84
  /** Bound for a journal-less watchdog cancellation to report its prompt; defaults to the 30 s ACP handshake cap. */
83
85
  cancelWaitMs?: number;
84
86
  /** ACP stdio MCP servers for the session (the hub's task tools). */
@@ -108,6 +110,7 @@ export interface AcpOptions {
108
110
  const HANDSHAKE_MS = 30_000;
109
111
  /** Tool calls whose arguments are remembered until their permission request arrives. */
110
112
  const TOOL_INPUT_CAP = 64;
113
+ const TOOL_CEILING_MS = 30 * 60_000;
111
114
 
112
115
  /**
113
116
  * The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
@@ -148,12 +151,17 @@ export class AcpPeer extends BasePeer {
148
151
  private readonly toolInputs = new Map<string, unknown>();
149
152
  private readonly toolText = new Map<string, string>(); // streamed argument text, per call, until it finishes
150
153
  private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
154
+ private readonly openTools = new Map<string, string>(); // this turn's started, unfinished tool calls (#542 D4)
155
+ private lastSignAt = 0;
151
156
  private primed = false;
152
157
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
153
158
  private stopping = false;
154
159
  private activeDeliveryId: string | undefined;
155
160
  private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
156
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[] = [];
157
165
  private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout>; reported: boolean; released: Promise<void>; release: () => void } | undefined;
158
166
  private promptActivity = false;
159
167
  private activityOpen = false;
@@ -319,6 +327,7 @@ export class AcpPeer extends BasePeer {
319
327
  if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
320
328
  if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
321
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");
322
331
  this.activeDeliveryId = undefined;
323
332
  const proc = this.proc;
324
333
  if (!proc) { this.setState("offline"); return; }
@@ -336,9 +345,11 @@ export class AcpPeer extends BasePeer {
336
345
  const turn = ++this.turn;
337
346
  this.activeDeliveryId = deliveryId;
338
347
  this.activeLegacy = deliveryId ? undefined : { turn, envs };
348
+ this.turnEnvs = envs; this.steerIds = [];
339
349
  this.deliveryAccepted = false;
340
350
  this.promptActivity = false;
341
351
  this.chunks = [];
352
+ this.openTools.clear();
342
353
  this.activityOpen = true;
343
354
  this.activityPrivate = envs.some(env => env.private === true);
344
355
  try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
@@ -360,13 +371,16 @@ export class AcpPeer extends BasePeer {
360
371
  this.observeUsage(result, "prompt_result");
361
372
  this.primed = true;
362
373
  const body = this.chunks.join("").trim();
363
- 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) });
364
377
  // Partial output cannot certify normal completion (#160).
365
378
  if (result?.stopReason !== "end_turn") this.reportTurnFailure(envs, `ACP prompt ended without normal completion (${String(result?.stopReason ?? "unknown")})`);
366
379
  if (deliveryId && this.activeDeliveryId === deliveryId) {
367
380
  this.acceptDelivery();
368
381
  this.delivery({ id: deliveryId, state: result?.stopReason === "end_turn" ? "completed" : "needs_review", ...(result?.stopReason === "end_turn" ? {} : { reason: "ACP prompt ended without normal completion" }) });
369
382
  }
383
+ this.settleSteers(result?.stopReason === "end_turn" ? "completed" : "needs_review", "ACP prompt ended without normal completion");
370
384
  })
371
385
  .catch((e: Error) => {
372
386
  if (turn !== this.turn) {
@@ -375,6 +389,7 @@ export class AcpPeer extends BasePeer {
375
389
  }
376
390
  this.endActivity("failed", true);
377
391
  const reason = sanitize(e.message);
392
+ this.settleSteers("needs_review", reason);
378
393
  this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
379
394
  const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
380
395
  if (quota) {
@@ -415,12 +430,23 @@ export class AcpPeer extends BasePeer {
415
430
  protected override onWatchdog(): void {
416
431
  if (this.state !== "busy") return;
417
432
  if (this.cancelWait) { this.clearWatchdog(); return; }
433
+ // #542 D4: a tool call that started and has not finished is running, not silent, up to its own ceiling.
434
+ if (this.openTools.size) {
435
+ const ceiling = Math.max(this.watchdogMs, this.opts.toolCeilingMs ?? TOOL_CEILING_MS);
436
+ const remaining = ceiling - (Date.now() - this.lastSignAt);
437
+ if (remaining > 0) { this.touch(Math.min(this.watchdogMs, remaining)); return; }
438
+ // Privacy latches for the turn and fails closed, as in emitActivity; the title is agent-written, so one quoted line.
439
+ try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
440
+ const tools = this.activityPrivate ? "a tool call" : [...new Set(this.openTools.values())].map(title => JSON.stringify(activityText(title).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 80))).join(", ");
441
+ this.opts.log?.(`[${this.id}] open tool call (${tools}) silent past the ${Math.round(ceiling / 60_000)} min tool ceiling; cancelling the turn`);
442
+ }
418
443
  this.endActivity("watchdog cancelled", true);
419
444
  const cancelledTurn = this.turn;
420
445
  this.cancelPermissions();
421
446
  this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
422
447
  const durable = !!this.activeDeliveryId;
423
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");
424
450
  this.activeDeliveryId = undefined;
425
451
  this.notify("session/cancel", { sessionId: this.sessionId });
426
452
  this.turn++; // whatever the cancelled prompt still reports is stale
@@ -458,6 +484,28 @@ export class AcpPeer extends BasePeer {
458
484
  if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
459
485
  }
460
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
+
461
509
  private acceptDelivery(): void {
462
510
  if (!this.activeDeliveryId || this.deliveryAccepted) return;
463
511
  this.deliveryAccepted = true;
@@ -470,6 +518,7 @@ export class AcpPeer extends BasePeer {
470
518
  for (const p of this.pending.values()) p.reject(new Error(reason));
471
519
  this.pending.clear();
472
520
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason });
521
+ this.settleSteers("needs_review", reason);
473
522
  this.activeDeliveryId = undefined;
474
523
  this.setState("offline");
475
524
  }
@@ -482,7 +531,7 @@ export class AcpPeer extends BasePeer {
482
531
  } catch {
483
532
  return; // not protocol output
484
533
  }
485
- if (this.state === "busy" && !this.cancelWait) this.touch();
534
+ if (this.state === "busy" && !this.cancelWait) { this.lastSignAt = Date.now(); this.touch(); }
486
535
 
487
536
  if (msg.method === "session/update") {
488
537
  if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
@@ -517,7 +566,9 @@ export class AcpPeer extends BasePeer {
517
566
  if (u.sessionUpdate === "tool_call" && typeof u.title === "string") this.toolTitles.set(u.toolCallId, u.title);
518
567
  const text = Array.isArray(u.content) ? u.content.map((c: any) => (c?.type === "content" && c.content?.type === "text" ? String(c.content.text) : "")).join("") : "";
519
568
  if (text) this.toolText.set(u.toolCallId, text);
520
- if (u.status === "completed" || u.status === "failed") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId), this.toolTitles.delete(u.toolCallId));
569
+ if (u.status === "completed" || u.status === "failed") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId), this.toolTitles.delete(u.toolCallId), this.openTools.delete(u.toolCallId));
570
+ else if (u.sessionUpdate === "tool_call" || this.openTools.has(u.toolCallId)) this.openTools.set(u.toolCallId, this.toolTitles.get(u.toolCallId) ?? "tool");
571
+ while (this.openTools.size > TOOL_INPUT_CAP) this.openTools.delete(this.openTools.keys().next().value as string);
521
572
  for (const map of [this.toolInputs, this.toolText, this.toolTitles]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
522
573
  }
523
574
  else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
@@ -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 = new URL(req.url).searchParams.get("launchId");
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");
@@ -22,8 +22,8 @@ type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
22
22
  * Preserve CLI-wrapper argv plans for definitions that still declare them and the frozen A1 compatibility export.
23
23
  * Native startup and daemon factories are supplied separately by the runtime definitions.
24
24
  */
25
- function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext): LaunchPlan {
26
- const mode = kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
25
+ function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
26
+ const mode = explicitMode || kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
27
27
  const unattended = ctx.unattended && (kind === "claude" || kind === "codex") ? ["--unattended"] : [];
28
28
  const args = [ctx.entrypoint, "--project", ctx.projectRoot, kind, ...mode, ...unattended,
29
29
  ...(ctx.model && !ctx.userArgs.includes("--model") ? ["--model", ctx.model] : []), ...ctx.userArgs];
@@ -145,19 +145,21 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
145
145
  {
146
146
  api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
147
147
  roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
148
- start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("jcode", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
148
+ start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, explicitHeadlessMode: true, launch: ctx => compatibilityLaunch("jcode", ctx, true), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
149
149
  identity: { mcp: "project-file" },
150
150
  permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
151
151
  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" } },
152
152
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
153
153
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
154
- cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
154
+ cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
155
155
  },
156
156
  ] satisfies AdapterDefinition[]);
157
157
 
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([
@@ -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 proxyUrl = res.proxyUrl;
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
+ const url = new URL(proxyUrl); url.searchParams.set("launchId", terminal.launchId); proxyUrl = url.toString();
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, 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
  }
@@ -1,30 +1,109 @@
1
1
  import { retainedLaunchWarning } from "./native-cli-retained.ts";
2
2
  import { resolve } from "node:path";
3
3
  import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
4
+ import type { ControlClient } from "../../hub/control-client.ts";
5
+ import type { JcodeTuiBinding, JcodeTuiState } from "../jcode.ts";
4
6
  import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
5
7
 
8
+ const WAITING = "jcode's TUI opens after its first hub turn; send it work, or Ctrl-C";
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
+
15
+ export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryContext["fail"], signal: AbortSignal,
16
+ options: Record<string, string | undefined>, first?: JcodeTuiState): Promise<Extract<JcodeTuiState, { state: "ready" }> | undefined> {
17
+ let binding: JcodeTuiBinding | undefined = first?.binding, announced = false, current = first;
18
+ while (!signal.aborted) {
19
+ if (!current) {
20
+ const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
21
+ if (signal.aborted) return;
22
+ refuseNamedOption(reply, fail);
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");
24
+ current = reply as JcodeTuiState;
25
+ }
26
+ if (binding && (binding.launchId !== current.binding.launchId || binding.sessionId !== current.binding.sessionId)) fail("Jcode launch changed while waiting");
27
+ binding = current.binding;
28
+ if (current.state === "ready") return current;
29
+ if (!announced) { console.log(WAITING); announced = true; }
30
+ await new Promise<void>(resolve => {
31
+ const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
32
+ const timer = setTimeout(finish, 200); signal.addEventListener("abort", finish, { once: true });
33
+ if (signal.aborted) finish();
34
+ });
35
+ current = undefined;
36
+ }
37
+ }
38
+
6
39
  export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
7
40
  const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
8
- const { launcherPreview, qwenSessionId, configureJcodeMcp } = ctx.helpers;
9
- if (args.includes("--print-command") || args.includes("--dry-run")) {
10
- try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
11
- catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
41
+ const { launcherPreview, qwenSessionId, configureJcodeMcp, readControl, startModeOf } = ctx.helpers;
42
+ const print = args.includes("--print-command");
43
+ if (args.includes("--dry-run") || print) {
44
+ // Pure previews never create a native store or start a session. Only a person may resolve an existing private tuple.
45
+ if (!print || args.includes("--dry-run") || !ctx.person) {
46
+ try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
47
+ catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
48
+ }
12
49
  }
13
- const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
14
- if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
50
+ const { one, rest } = takeFlags(args.filter(arg => arg !== "--print-command"), ["--mode", "--model", "--provider-profile", "--session-id"], []);
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>]");
15
52
  if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
16
- const hub = await connect();
53
+ const control = readControl(stateDir);
54
+ const mode = one["--mode"] ?? (ctx.instance ? "headless" : control?.startModes?.jcode ?? startModeOf(ctx.projectConfig().peers, "jcode"));
55
+ if (print && (mode === "headless" || !control?.features?.includes("native_tui_launch"))) {
56
+ try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
57
+ catch { return fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
58
+ }
59
+ if (mode === "tui" && !ctx.person) fail("Jcode's private TUI is person-only; run ahub jcode in your terminal");
60
+ const hub = await connect(), abort = new AbortController();
61
+ let interrupted = false;
62
+ const interruptedWait = () => { if (abort.signal.aborted && !interrupted) fail("hub connection closed while waiting for Jcode; retry from a terminal"); return abort.signal.aborted; };
63
+ const interrupt = () => { interrupted = true; abort.abort(); hub.close(); process.exitCode = 130; };
64
+ process.once("SIGINT", interrupt);
65
+ const oldClose = hub.onClose;
66
+ hub.onClose = (code, reason) => { if (!abort.signal.aborted) abort.abort(); oldClose?.(code, reason); };
17
67
  try {
18
68
  const snapshot = (await hub.request({ t: "status" })).status;
19
69
  const rostered = rosterInStatus(snapshot, "jcode");
20
70
  if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
21
71
  if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
22
72
  const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
23
- // The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
73
+ const options = { model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] };
74
+ if (print) {
75
+ if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
76
+ const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
77
+ refuseNamedOption(reply, fail);
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");
79
+ return console.log(JSON.stringify(reply.launch, null, 2));
80
+ }
81
+ if (mode === "tui") {
82
+ if (!hub.features.includes("native_tui_launch")) fail("the running hub does not support Jcode TUI launch; upgrade it before retrying");
83
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
84
+ if (one["--mode"] || Object.values(options).some(value => value !== undefined)) fail("run ahub jcode with these options in a terminal");
85
+ const reply = await hub.request({ t: "peer_start", peer: "jcode" });
86
+ if (!reply.ok) fail(reply.error);
87
+ console.log(reply.command ?? "Jcode terminal requested"); return;
88
+ }
89
+ }
24
90
  const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
25
91
  if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
26
- const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
92
+ const reply = await hub.request({ t: "start", peer: "jcode", args: { mode, ...options }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
93
+ if (interruptedWait()) return;
27
94
  if (!reply.ok) fail(reply.error);
28
- console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
29
- } finally { hub.close(); }
95
+ if (mode === "headless") { console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox"); return; }
96
+ const ready = await waitForJcodeTui(hub, fail, abort.signal, options);
97
+ if (interruptedWait() || !ready) return;
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).
100
+ const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
101
+ if (interruptedWait()) return;
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).
107
+ ctx.execWithEnv(confirmed.launch.cmd, confirmed.launch.args, { ...ctx.helpers.childEnv(), ...confirmed.launch.env });
108
+ } finally { process.removeListener("SIGINT", interrupt); hub.onClose = oldClose; hub.close(); }
30
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} "..."`);
@@ -7,6 +7,7 @@ export interface NativeCliEntryContext {
7
7
  instance?: string; executionCwd?: string;
8
8
  /** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
9
9
  hubStarted?: true;
10
+ person?: boolean;
10
11
  args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
11
12
  projectConfig(): HubConfig;
12
13
  connect(): Promise<ControlClient>;
@@ -18,6 +19,8 @@ export interface NativeCliEntryContext {
18
19
  launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
19
20
  readControl: typeof import("../../hub/control-client.ts").readControl;
20
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;
21
24
  recoveryLock: typeof import("../../hub/recovery-store.ts").recoveryLock;
22
25
  processSignature: typeof import("../../pi/process-signature.ts").processSignature;
23
26
  buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;