@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.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 (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -1,20 +1,216 @@
1
- import { TerminalRegistry } from "../terminal/registry.js";
2
- import { toWireError } from "./protocol.js";
1
+ /**
2
+ * Child-side runner session. One per runner process (i.e. per session); owns the
3
+ * single execution backend via the injected executor (a {@link LocalAgentHost}
4
+ * in production, which in a per-session process holds exactly one backend). It
5
+ * translates inbound {@link ToChild} control messages into the live co-drive
6
+ * surface (bring up the session's forwarder + TUI, inject / interrupt turns) and
7
+ * per-thread capabilities, answered against the same backend so the parent never
8
+ * needs an app-server of its own.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { appendFileSync, mkdirSync, renameSync, rmSync, statSync, } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { AgentRuntimeError, } from "@rynx-ai/core";
14
+ import { TerminalRegistry, } from "../terminal/registry.js";
15
+ import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
16
+ import { isCodexLineageProvider } from "./startup-policy.js";
17
+ import { RunnerTransportWriteError, } from "./transport.js";
18
+ const TERMINAL_PREPARATION_TTL_MS = 30_000;
19
+ /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
20
+ const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
21
+ function providerDisplayName(runtime) {
22
+ return runtime === "traex" ? "Traex" : runtime === "claude" ? "Claude" : "Codex";
23
+ }
24
+ function providerFailure(provider, localThreadId, fallback) {
25
+ return provider.liveSessionFailure?.(localThreadId) ?? {
26
+ message: provider.liveSessionError?.(localThreadId) ?? fallback.message,
27
+ code: fallback.code,
28
+ statusCode: 503,
29
+ };
30
+ }
31
+ function nativePhaseError(runtime, code, phase, cause) {
32
+ const detail = cause === undefined
33
+ ? ""
34
+ : `: ${cause instanceof Error ? cause.message : String(cause)}`;
35
+ return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
36
+ }
37
+ function terminalFailureDetail(pane) {
38
+ // A captured pane is a screen snapshot, not stderr. Do not present ordinary
39
+ // TUI chrome ("Working", shortcuts, model/status bars) as the process's exit
40
+ // cause. Retain only lines that plausibly carry an actual startup/runtime
41
+ // failure; the structured error still reports the proven pane exit itself.
42
+ return pane
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .filter(Boolean)
46
+ .filter((line) => /\b(?:error|fatal|failed|failure|panic|refused|denied|unauthorized|invalid)\b/i.test(line))
47
+ .slice(-6)
48
+ .join(" ")
49
+ .slice(-1_000);
50
+ }
51
+ const TRAEX_STARTUP_WATCH_MS = 20_000;
52
+ const TRAEX_STARTUP_DISCOVERY_MS = 5_000;
53
+ const TRAEX_STARTUP_POLL_MS = 100;
54
+ const TRAEX_AUTHORIZATION_POLL_MS = 500;
55
+ const TRAEX_AUTHORIZATION_WAIT_MS = 15 * 60_000;
56
+ const TRAEX_ESCAPE_CONFIRM_MS = 1_000;
57
+ const TRAEX_ESCAPE_COOLDOWN_MS = 1_000;
58
+ const TRAEX_REPEAT_PROMPT_CONFIRM_MS = 5_000;
59
+ const MIRROR_IMAGE_ACK_TIMEOUT_MS = 30_000;
60
+ const MIRROR_ACK_TIMEOUT_MS = 30_000;
61
+ const MIRROR_DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024;
62
+ class MirrorDeliveryError extends Error {
63
+ classification;
64
+ deliveryId;
65
+ constructor(message, classification, deliveryId) {
66
+ super(message);
67
+ this.classification = classification;
68
+ this.deliveryId = deliveryId;
69
+ this.name = "MirrorDeliveryError";
70
+ }
71
+ }
72
+ class MirrorGenerationSupersededError extends Error {
73
+ generation;
74
+ deliveryId;
75
+ constructor(generation, deliveryId) {
76
+ super(`mirror generation ${generation} was superseded by Session rotation`);
77
+ this.generation = generation;
78
+ this.deliveryId = deliveryId;
79
+ this.name = "MirrorGenerationSupersededError";
80
+ }
81
+ }
82
+ /** Delivery retry schedule: 1, 2, 4, 8, 16, 30, 30… seconds.
83
+ * Clamp before exponentiation so an indefinitely transient outage cannot
84
+ * overflow after thousands of attempts. */
85
+ export function mirrorRetryDelayMs(failedAttempt) {
86
+ const exponent = Math.min(Math.max(failedAttempt - 1, 0), 32);
87
+ return Math.min(2 ** exponent, 30) * 1_000;
88
+ }
89
+ function defaultMirrorDeliveryPolicy(event) {
90
+ switch (event.type) {
91
+ case "response.output_item.done":
92
+ case "session.input.consumed":
93
+ case "session.interaction.requested":
94
+ case "session.interaction.resolved":
95
+ case "session.interaction.cancelled":
96
+ return "ordinary";
97
+ default:
98
+ return "best-effort";
99
+ }
100
+ }
101
+ function appendMirrorDeadLetter(path, entry) {
102
+ const line = `${JSON.stringify(entry)}\n`;
103
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
104
+ let currentBytes = 0;
105
+ try {
106
+ currentBytes = statSync(path).size;
107
+ }
108
+ catch {
109
+ // absent
110
+ }
111
+ if (currentBytes >= MIRROR_DEAD_LETTER_MAX_BYTES) {
112
+ rmSync(`${path}.1`, { force: true });
113
+ try {
114
+ renameSync(path, `${path}.1`);
115
+ }
116
+ catch {
117
+ // absent/raced — append creates the active file below
118
+ }
119
+ }
120
+ appendFileSync(path, line, { mode: 0o600 });
121
+ }
122
+ function normalizeTraexPane(pane) {
123
+ return pane.toLowerCase().replace(/\s+/g, " ").trim();
124
+ }
125
+ function isTraexAuthorizationPending(pane) {
126
+ return pane.includes("waiting for authorization") &&
127
+ pane.includes("open this link in your browser") &&
128
+ pane.includes("press esc to cancel");
129
+ }
130
+ function isTraexStartupActivity(pane) {
131
+ return pane.includes("updating traecode cli") || pane.includes("updating trae cli");
132
+ }
133
+ function isTerminalProtocolResponse(input) {
134
+ // xterm answers terminal queries through the same onData channel as real
135
+ // keystrokes. CSI carries device/focus/position reports; OSC carries color
136
+ // query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
137
+ // Neither is evidence that the user has taken over startup prompt handling.
138
+ return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
139
+ }
140
+ function detachGeneratedImageResult(event) {
141
+ if (event.type !== "response.output_item.done" ||
142
+ event.item.type !== "function_call_output") {
143
+ return undefined;
144
+ }
145
+ const data = event.item.data;
146
+ const pending = data.__rynxGeneratedImage;
147
+ if (!pending?.result)
148
+ return undefined;
149
+ const { result, ...withoutResult } = pending;
150
+ return {
151
+ result,
152
+ event: {
153
+ ...event,
154
+ item: {
155
+ ...event.item,
156
+ data: {
157
+ ...data,
158
+ __rynxGeneratedImage: withoutResult,
159
+ },
160
+ },
161
+ },
162
+ };
163
+ }
164
+ function generatedImagePreviewUnavailable(event) {
165
+ if (event.type !== "response.output_item.done" ||
166
+ event.item.type !== "function_call_output") {
167
+ return event;
168
+ }
169
+ const data = event.item.data;
170
+ const { __rynxGeneratedImage: _omitted, ...publicData } = data;
171
+ return {
172
+ ...event,
173
+ item: {
174
+ ...event.item,
175
+ data: {
176
+ ...publicData,
177
+ output: `${event.item.data.output}\nImage preview unavailable.`.trim(),
178
+ },
179
+ },
180
+ };
181
+ }
3
182
  export class RunnerSession {
4
183
  transport;
5
184
  executor;
6
185
  onShutdown;
7
186
  /** Live terminals hosted by this session, and per-attach client handles. */
8
187
  terminals = new TerminalRegistry();
188
+ preparations = new Map();
9
189
  attachments = new Map();
10
190
  attachmentThreadIds = new Map();
191
+ attachmentRoles = new Map();
11
192
  traexStartupWatchers = new Map();
193
+ traexStartupGates = new Map();
194
+ codexStartupMonitors = new Map();
195
+ terminalWatchers = new Map();
12
196
  /** Opens are async; a close received before attach resolves tombstones the id. */
13
197
  pendingTerminalOpens = new Set();
14
198
  cancelledTerminalOpens = new Set();
15
199
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
16
200
  liveIds = new Set();
201
+ mirrorQueues = new Map();
202
+ mirrorAcks = new Map();
203
+ mirrorImageAcks = new Map();
204
+ mirrorRetryWaiters = new Map();
205
+ rotationPublishAcks = new Map();
206
+ rotationAppliedAcks = new Map();
207
+ /** Provider name retained for asynchronous Terminal-exit diagnostics. */
208
+ liveRuntimes = new Map();
209
+ terminalStatuses = new Map();
210
+ consecutiveMirrorFailures = 0;
211
+ mirrorDegraded = false;
17
212
  shuttingDown = false;
213
+ shutdownPromise = null;
18
214
  constructor({ transport, executor, onShutdown }) {
19
215
  this.transport = transport;
20
216
  this.executor = executor;
@@ -24,15 +220,107 @@ export class RunnerSession {
24
220
  transport.close();
25
221
  });
26
222
  this.transport.onMessage((msg) => this.handle(msg));
223
+ this.transport.onClose((error) => {
224
+ this.rejectMirrorAcks(new MirrorDeliveryError(error?.message ?? "runner transport closed before mirror acknowledgement", "ambiguous"));
225
+ this.rejectMirrorImageAcks(error ?? new Error("runner transport closed"));
226
+ this.rejectRotationAcks(error ?? new Error("runner transport closed"));
227
+ });
27
228
  this.transport.send({ t: "ready" });
28
229
  }
29
230
  handle(msg) {
30
231
  switch (msg.t) {
232
+ case "mirror.ack": {
233
+ const pending = this.mirrorAcks.get(msg.deliveryId);
234
+ if (!pending || msg.generation !== pending.generation) {
235
+ return;
236
+ }
237
+ clearTimeout(pending.timer);
238
+ this.mirrorAcks.delete(msg.deliveryId);
239
+ pending.resolve();
240
+ return;
241
+ }
242
+ case "mirror.nack": {
243
+ const pending = this.mirrorAcks.get(msg.deliveryId);
244
+ if (!pending || msg.generation !== pending.generation) {
245
+ return;
246
+ }
247
+ clearTimeout(pending.timer);
248
+ this.mirrorAcks.delete(msg.deliveryId);
249
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification, msg.deliveryId));
250
+ return;
251
+ }
252
+ case "mirror.image.ack": {
253
+ const pending = this.mirrorImageAcks.get(msg.transferId);
254
+ if (!pending || msg.generation !== pending.generation) {
255
+ return;
256
+ }
257
+ if (pending.seq !== msg.seq) {
258
+ pending.reject(new Error("generated image acknowledgement sequence changed"));
259
+ return;
260
+ }
261
+ if (pending.timer)
262
+ clearTimeout(pending.timer);
263
+ this.mirrorImageAcks.delete(msg.transferId);
264
+ pending.resolve();
265
+ return;
266
+ }
267
+ case "mirror.image.hold": {
268
+ const pending = this.mirrorImageAcks.get(msg.transferId);
269
+ if (!pending ||
270
+ pending.seq !== msg.seq ||
271
+ msg.generation !== pending.generation)
272
+ return;
273
+ if (pending.timer)
274
+ clearTimeout(pending.timer);
275
+ pending.timer = undefined;
276
+ return;
277
+ }
278
+ case "mirror.image.nack": {
279
+ const pending = this.mirrorImageAcks.get(msg.transferId);
280
+ if (!pending ||
281
+ pending.seq !== msg.seq ||
282
+ msg.generation !== pending.generation)
283
+ return;
284
+ if (pending.timer)
285
+ clearTimeout(pending.timer);
286
+ this.mirrorImageAcks.delete(msg.transferId);
287
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
288
+ return;
289
+ }
290
+ case "rotate.ack": {
291
+ const pending = this.rotationPublishAcks.get(msg.rotationId);
292
+ if (!pending || pending.generation !== msg.generation)
293
+ return;
294
+ clearTimeout(pending.timer);
295
+ this.rotationPublishAcks.delete(msg.rotationId);
296
+ pending.resolve();
297
+ return;
298
+ }
299
+ case "rotate.nack": {
300
+ const pending = this.rotationPublishAcks.get(msg.rotationId);
301
+ if (!pending || pending.generation !== msg.generation)
302
+ return;
303
+ clearTimeout(pending.timer);
304
+ this.rotationPublishAcks.delete(msg.rotationId);
305
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
306
+ return;
307
+ }
308
+ case "rotate.applied.ack": {
309
+ const pending = this.rotationAppliedAcks.get(msg.rotationId);
310
+ if (!pending || pending.generation !== msg.generation)
311
+ return;
312
+ clearTimeout(pending.timer);
313
+ this.rotationAppliedAcks.delete(msg.rotationId);
314
+ pending.resolve();
315
+ return;
316
+ }
31
317
  case "cap":
32
318
  void this.runCap(msg.capId, msg.name, msg.args);
33
319
  return;
34
320
  case "term.open":
35
- if (this.pendingTerminalOpens.has(msg.attachId) || this.attachments.has(msg.attachId)) {
321
+ if (this.pendingTerminalOpens.has(msg.attachId) ||
322
+ this.preparations.has(msg.attachId) ||
323
+ this.attachments.has(msg.attachId)) {
36
324
  this.transport.send({
37
325
  t: "term.error",
38
326
  attachId: msg.attachId,
@@ -47,20 +335,35 @@ export class RunnerSession {
47
335
  this.cancelledTerminalOpens.delete(msg.attachId);
48
336
  });
49
337
  return;
338
+ case "term.seed.read":
339
+ void this.readTerminalSeed(msg);
340
+ return;
341
+ case "term.start":
342
+ void this.startTerminal(msg);
343
+ return;
344
+ case "term.read":
345
+ void this.readTerminalOutput(msg);
346
+ return;
50
347
  case "term.input":
51
- this.cancelTraexStartupWatcher(this.attachmentThreadIds.get(msg.attachId));
52
- this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
348
+ void this.writeTerminalInput(msg);
53
349
  return;
54
350
  case "term.resize":
55
- this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
351
+ void this.resizeTerminal(msg);
56
352
  return;
57
353
  case "term.close": {
58
354
  if (this.pendingTerminalOpens.has(msg.attachId)) {
59
355
  this.cancelledTerminalOpens.add(msg.attachId);
60
356
  }
357
+ const pending = this.preparations.get(msg.attachId);
358
+ if (pending) {
359
+ clearTimeout(pending.timer);
360
+ this.preparations.delete(msg.attachId);
361
+ pending.preparation.kill();
362
+ }
61
363
  const attachment = this.attachments.get(msg.attachId);
62
364
  this.attachments.delete(msg.attachId);
63
365
  this.attachmentThreadIds.delete(msg.attachId);
366
+ this.attachmentRoles.delete(msg.attachId);
64
367
  attachment?.kill();
65
368
  return;
66
369
  }
@@ -74,11 +377,17 @@ export class RunnerSession {
74
377
  case "inject":
75
378
  void this.inject(msg);
76
379
  return;
380
+ case "collaboration.update":
381
+ void this.updateCollaborationMode(msg);
382
+ return;
77
383
  case "live.interrupt":
78
384
  void this.interruptLive(msg);
79
385
  return;
386
+ case "terminal.reap":
387
+ void this.reapTerminal(msg);
388
+ return;
80
389
  case "shutdown":
81
- this.shutdown();
390
+ void this.shutdown();
82
391
  return;
83
392
  }
84
393
  }
@@ -109,28 +418,571 @@ export class RunnerSession {
109
418
  * new session AND tells the daemon to alias the runner (terminal transfer) so
110
419
  * the new session stays injectable. */
111
420
  mirrorChannel(localThreadId) {
112
- const target = { id: localThreadId };
113
- const emit = (event) => this.transport.send({ t: "mirror", sessionId: target.id, event });
421
+ const target = { id: localThreadId, generation: 0 };
422
+ let pendingRetarget;
423
+ const emit = (event, delivery) => {
424
+ const sessionId = target.id;
425
+ const generation = target.generation;
426
+ if (event.type === "session.status") {
427
+ this.terminalStatuses.set(sessionId, event.status);
428
+ }
429
+ return this.enqueueMirror(delivery?.lane ?? "main", () => this.sendMirroredEvent(sessionId, event, delivery, generation, () => generation === target.generation));
430
+ };
114
431
  const retarget = (newId, meta) => {
115
- target.id = newId;
116
- this.transport.send({
117
- t: "rotate",
118
- from: localThreadId,
119
- to: newId,
120
- kind: meta.kind,
121
- workspace: meta.workspace,
122
- execution: meta.execution,
123
- ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
432
+ let publication = pendingRetarget;
433
+ if (!publication && target.id === newId)
434
+ return Promise.resolve();
435
+ if (publication && publication.to !== newId) {
436
+ return Promise.reject(new Error(`Session rotation to ${publication.to} is still pending; cannot retarget to ${newId}`));
437
+ }
438
+ if (!publication) {
439
+ const previousGeneration = target.generation;
440
+ publication = {
441
+ from: target.id,
442
+ to: newId,
443
+ generation: previousGeneration + 1,
444
+ rotationId: `rotate_${randomUUID()}`,
445
+ meta,
446
+ };
447
+ pendingRetarget = publication;
448
+ // Fence every source delivery immediately, but do not mutate the target
449
+ // identity or transfer resources until the parent ACKs publication.
450
+ target.generation = publication.generation;
451
+ this.cancelMirrorGeneration(previousGeneration);
452
+ }
453
+ if (publication.operation)
454
+ return publication.operation;
455
+ const current = publication;
456
+ const operation = this.enqueueMirror("control", async () => {
457
+ if (!current.resourcesApplied) {
458
+ let attempt = 0;
459
+ let publicationAcked = false;
460
+ while (!publicationAcked) {
461
+ attempt += 1;
462
+ try {
463
+ await this.sendRotationFrame(current.rotationId, current.generation, {
464
+ t: "rotate",
465
+ rotationId: current.rotationId,
466
+ generation: current.generation,
467
+ from: current.from,
468
+ to: current.to,
469
+ kind: current.meta.kind,
470
+ workspace: current.meta.workspace,
471
+ execution: current.meta.execution,
472
+ ...(current.meta.parentSessionId
473
+ ? { parentSessionId: current.meta.parentSessionId }
474
+ : {}),
475
+ });
476
+ publicationAcked = true;
477
+ }
478
+ catch (error) {
479
+ const failure = error instanceof MirrorDeliveryError
480
+ ? error
481
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
482
+ if (failure.classification === "permanent" && attempt >= 3) {
483
+ // The parent still owns source/target reservations for this
484
+ // half-published cutover. End the child so failHandle releases
485
+ // them; leaving a live fenced pane would deadlock both ids.
486
+ await this.shutdown();
487
+ throw failure;
488
+ }
489
+ await this.waitForMirrorRetry(attempt, current.generation);
490
+ }
491
+ }
492
+ if (pendingRetarget !== current)
493
+ return;
494
+ this.transferNativeSessionResources(current.from, current.to);
495
+ target.id = current.to;
496
+ current.resourcesApplied = true;
497
+ }
498
+ if (pendingRetarget !== current)
499
+ return;
500
+ if (!current.hostApplied) {
501
+ await current.meta.beforeApplied?.();
502
+ if (pendingRetarget !== current)
503
+ return;
504
+ current.hostApplied = true;
505
+ }
506
+ // Ordered after the actual child-side ownership transfer. The parent
507
+ // keeps source and target admissions reserved until it observes this.
508
+ await this.sendRotationAppliedFrame(current.rotationId, current.generation, {
509
+ t: "rotate.applied",
510
+ rotationId: current.rotationId,
511
+ generation: current.generation,
512
+ from: current.from,
513
+ to: current.to,
514
+ });
515
+ if (pendingRetarget !== current)
516
+ return;
517
+ pendingRetarget = undefined;
518
+ }, { recoverable: true }).finally(() => {
519
+ if (pendingRetarget === current)
520
+ delete current.operation;
124
521
  });
522
+ current.operation = operation;
523
+ return operation;
125
524
  };
126
525
  return { emit, retarget };
127
526
  }
527
+ transferNativeSessionResources(sourceId, targetId) {
528
+ if (sourceId === targetId)
529
+ return;
530
+ const sourceTerminalId = `${sourceId}-main`;
531
+ const targetTerminalId = `${targetId}-main`;
532
+ this.terminals.transfer(sourceTerminalId, targetTerminalId);
533
+ const watcher = this.terminalWatchers.get(sourceId);
534
+ if (watcher)
535
+ clearInterval(watcher);
536
+ this.terminalWatchers.delete(sourceId);
537
+ const runtime = this.liveRuntimes.get(sourceId);
538
+ if (runtime) {
539
+ this.liveRuntimes.delete(sourceId);
540
+ this.liveRuntimes.set(targetId, runtime);
541
+ }
542
+ const status = this.terminalStatuses.get(sourceId);
543
+ if (status) {
544
+ this.terminalStatuses.delete(sourceId);
545
+ this.terminalStatuses.set(targetId, status);
546
+ }
547
+ if (this.liveIds.delete(sourceId))
548
+ this.liveIds.add(targetId);
549
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
550
+ if (sessionId === sourceId)
551
+ this.attachmentThreadIds.set(attachId, targetId);
552
+ }
553
+ const terminal = this.terminals.get(targetTerminalId);
554
+ if (terminal)
555
+ this.watchNativeTerminal(targetId, targetTerminalId, terminal);
556
+ }
557
+ enqueueMirror(lane, operation, options = {}) {
558
+ const previous = this.mirrorQueues.get(lane) ?? Promise.resolve();
559
+ const queued = previous.then(operation);
560
+ const tail = queued.catch((error) => {
561
+ if (!this.shuttingDown && !options.recoverable) {
562
+ this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
563
+ void this.shutdown();
564
+ }
565
+ });
566
+ this.mirrorQueues.set(lane, tail);
567
+ void tail.finally(() => {
568
+ if (this.mirrorQueues.get(lane) === tail)
569
+ this.mirrorQueues.delete(lane);
570
+ });
571
+ return queued;
572
+ }
573
+ async sendMirroredEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
574
+ if (!isCurrent())
575
+ return;
576
+ try {
577
+ const detached = detachGeneratedImageResult(event);
578
+ if (!detached) {
579
+ await this.deliverMirrorEvent(sessionId, event, delivery, generation, isCurrent);
580
+ return;
581
+ }
582
+ if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
583
+ await this.deliverMirrorEvent(sessionId, generatedImagePreviewUnavailable(detached.event), delivery, generation, isCurrent);
584
+ return;
585
+ }
586
+ await this.deliverMirrorImageEvent(sessionId, detached.event, detached.result, delivery, generation, isCurrent);
587
+ }
588
+ catch (error) {
589
+ if (error instanceof MirrorGenerationSupersededError)
590
+ return;
591
+ throw error;
592
+ }
593
+ }
594
+ async deliverMirrorImageEvent(sessionId, event, result, delivery, generation, isCurrent) {
595
+ const transferId = `img_${randomUUID()}`;
596
+ const abandon = () => this.transport.sendAndDrain({
597
+ t: "mirror.image.abandon",
598
+ transferId,
599
+ generation,
600
+ }).catch(() => {
601
+ // Parent/child teardown already releases process-local transfer state.
602
+ });
603
+ let seq = 0;
604
+ try {
605
+ await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
606
+ t: "mirror.image.begin",
607
+ transferId,
608
+ sessionId,
609
+ event,
610
+ totalChars: result.length,
611
+ generation,
612
+ }, isCurrent);
613
+ for (let offset = 0; offset < result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
614
+ if (!isCurrent())
615
+ throw new MirrorGenerationSupersededError(generation);
616
+ seq += 1;
617
+ await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
618
+ t: "mirror.image.chunk",
619
+ transferId,
620
+ seq,
621
+ data: result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
622
+ generation,
623
+ }, isCurrent);
624
+ }
625
+ }
626
+ catch (error) {
627
+ await abandon();
628
+ throw error;
629
+ }
630
+ seq += 1;
631
+ const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
632
+ let attempt = 0;
633
+ while (!this.shuttingDown) {
634
+ if (!isCurrent()) {
635
+ delivery?.onOutcome?.("superseded");
636
+ await abandon();
637
+ return;
638
+ }
639
+ attempt += 1;
640
+ try {
641
+ await this.sendMirrorImageFrame(transferId, seq, generation, {
642
+ t: "mirror.image.commit",
643
+ transferId,
644
+ seq,
645
+ generation,
646
+ }, "ambiguous");
647
+ this.noteMirrorDeliveryRecovered();
648
+ delivery?.onOutcome?.("confirmed");
649
+ return;
650
+ }
651
+ catch (error) {
652
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
653
+ delivery?.onOutcome?.("superseded");
654
+ await abandon();
655
+ return;
656
+ }
657
+ if (this.shuttingDown)
658
+ break;
659
+ const failure = error instanceof MirrorDeliveryError
660
+ ? error
661
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
662
+ this.noteMirrorDeliveryFailed(failure);
663
+ if (policy === "best-effort") {
664
+ delivery?.onOutcome?.("dropped");
665
+ await abandon();
666
+ return;
667
+ }
668
+ if (failure.classification === "ambiguous" &&
669
+ (policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
670
+ this.noteMirrorDeliveryRecovered();
671
+ delivery?.onOutcome?.("ambiguous");
672
+ await abandon();
673
+ return;
674
+ }
675
+ if (failure.classification === "permanent" &&
676
+ policy !== "compaction" &&
677
+ attempt >= 3) {
678
+ if (policy === "ordinary" && delivery?.deadLetterPath) {
679
+ try {
680
+ appendMirrorDeadLetter(delivery.deadLetterPath, {
681
+ ts: Date.now() / 1_000,
682
+ session_id: sessionId,
683
+ event_type: "session_event",
684
+ reason: "permanent IPC persistence failure after retries",
685
+ delivered_ambiguous: false,
686
+ http_status: null,
687
+ transport_error: null,
688
+ payload: {
689
+ source_id: delivery.sourceId,
690
+ attempts: attempt,
691
+ event,
692
+ generated_image_result: result,
693
+ },
694
+ });
695
+ }
696
+ catch (deadLetterError) {
697
+ // The dead-letter writer is observability-only and never
698
+ // turns an exhausted poison item into a runner-wide failure.
699
+ console.error("[mirror] failed to append dead letter:", deadLetterError);
700
+ }
701
+ }
702
+ delivery?.onOutcome?.("dropped");
703
+ await abandon();
704
+ return;
705
+ }
706
+ await this.waitForMirrorRetry(attempt, generation);
707
+ }
708
+ }
709
+ await abandon();
710
+ throw new MirrorDeliveryError("runner shut down before generated image delivery was handled", "transient");
711
+ }
712
+ async deliverMirrorImageTransportFrame(transferId, seq, generation, frame, isCurrent) {
713
+ let attempt = 0;
714
+ while (!this.shuttingDown) {
715
+ if (!isCurrent())
716
+ throw new MirrorGenerationSupersededError(generation);
717
+ attempt += 1;
718
+ try {
719
+ await this.sendMirrorImageFrame(transferId, seq, generation, frame, "transient");
720
+ return;
721
+ }
722
+ catch (error) {
723
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent())
724
+ throw error;
725
+ const failure = error instanceof MirrorDeliveryError
726
+ ? error
727
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
728
+ this.noteMirrorDeliveryFailed(failure);
729
+ await this.waitForMirrorRetry(attempt, generation);
730
+ }
731
+ }
732
+ throw new MirrorDeliveryError("runner shut down during generated image transfer", "transient");
733
+ }
734
+ async deliverMirrorEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
735
+ const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
736
+ let attempt = 0;
737
+ let lastFailedDeliveryId;
738
+ while (!this.shuttingDown) {
739
+ if (!isCurrent()) {
740
+ delivery?.onOutcome?.("superseded");
741
+ await this.abandonMirrorDelivery(lastFailedDeliveryId, generation);
742
+ return;
743
+ }
744
+ attempt += 1;
745
+ try {
746
+ await this.sendMirrorEventFrame(sessionId, event, generation, attempt);
747
+ this.noteMirrorDeliveryRecovered();
748
+ delivery?.onOutcome?.("confirmed");
749
+ return;
750
+ }
751
+ catch (error) {
752
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
753
+ delivery?.onOutcome?.("superseded");
754
+ await this.abandonMirrorDelivery(error instanceof MirrorGenerationSupersededError
755
+ ? error.deliveryId
756
+ : error instanceof MirrorDeliveryError
757
+ ? error.deliveryId
758
+ : lastFailedDeliveryId, generation);
759
+ return;
760
+ }
761
+ if (this.shuttingDown)
762
+ break;
763
+ const failure = error instanceof MirrorDeliveryError
764
+ ? error
765
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
766
+ lastFailedDeliveryId = failure.deliveryId;
767
+ this.noteMirrorDeliveryFailed(failure);
768
+ if (policy === "best-effort") {
769
+ console.warn(`[mirror] best-effort event dropped: ${failure.message}`);
770
+ delivery?.onOutcome?.("dropped");
771
+ return;
772
+ }
773
+ if (failure.classification === "ambiguous" &&
774
+ (policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
775
+ console.warn(`[mirror] ordinary delivery result is ambiguous; treating as handled: ${failure.message}`);
776
+ this.noteMirrorDeliveryRecovered();
777
+ delivery?.onOutcome?.("ambiguous");
778
+ await this.abandonMirrorDelivery(failure.deliveryId, generation);
779
+ return;
780
+ }
781
+ if (failure.classification === "permanent" &&
782
+ policy !== "compaction" &&
783
+ attempt >= 3) {
784
+ if (policy === "ordinary" && delivery?.deadLetterPath) {
785
+ try {
786
+ appendMirrorDeadLetter(delivery.deadLetterPath, {
787
+ ts: Date.now() / 1_000,
788
+ session_id: sessionId,
789
+ event_type: "session_event",
790
+ reason: "permanent IPC persistence failure after retries",
791
+ delivered_ambiguous: false,
792
+ http_status: null,
793
+ transport_error: null,
794
+ payload: {
795
+ source_id: delivery.sourceId,
796
+ attempts: attempt,
797
+ event,
798
+ },
799
+ });
800
+ }
801
+ catch (deadLetterError) {
802
+ console.error("[mirror] failed to append dead letter:", deadLetterError);
803
+ }
804
+ }
805
+ console.error(`[mirror] permanent delivery failed after ${attempt} attempts; skipping: ${failure.message}`);
806
+ delivery?.onOutcome?.("dropped");
807
+ await this.abandonMirrorDelivery(failure.deliveryId, generation);
808
+ return;
809
+ }
810
+ await this.waitForMirrorRetry(attempt, generation);
811
+ }
812
+ }
813
+ throw new MirrorDeliveryError("runner shut down before mirror delivery was handled", "transient");
814
+ }
815
+ noteMirrorDeliveryFailed(error) {
816
+ this.consecutiveMirrorFailures += 1;
817
+ if (!this.mirrorDegraded && this.consecutiveMirrorFailures >= 5) {
818
+ this.mirrorDegraded = true;
819
+ console.warn(`[mirror] delivery degraded after ${this.consecutiveMirrorFailures} consecutive failures: ${error.message}`);
820
+ }
821
+ }
822
+ noteMirrorDeliveryRecovered() {
823
+ if (this.mirrorDegraded)
824
+ console.info("[mirror] delivery recovered");
825
+ this.consecutiveMirrorFailures = 0;
826
+ this.mirrorDegraded = false;
827
+ }
828
+ sendMirrorEventFrame(sessionId, event, generation, attempt) {
829
+ const deliveryId = `mirror_${randomUUID()}`;
830
+ return new Promise((resolve, reject) => {
831
+ const timer = setTimeout(() => {
832
+ this.mirrorAcks.delete(deliveryId);
833
+ reject(new MirrorDeliveryError("mirror acknowledgement timed out", "ambiguous", deliveryId));
834
+ }, MIRROR_ACK_TIMEOUT_MS);
835
+ timer.unref?.();
836
+ this.mirrorAcks.set(deliveryId, { generation, resolve, reject, timer });
837
+ void this.transport.sendAndDrain({
838
+ t: "mirror",
839
+ deliveryId,
840
+ generation,
841
+ attempt,
842
+ sessionId,
843
+ event,
844
+ }).catch((error) => {
845
+ const pending = this.mirrorAcks.get(deliveryId);
846
+ if (!pending)
847
+ return;
848
+ clearTimeout(pending.timer);
849
+ this.mirrorAcks.delete(deliveryId);
850
+ const classification = error instanceof RunnerTransportWriteError &&
851
+ error.delivery === "not-sent"
852
+ ? "transient"
853
+ : "ambiguous";
854
+ pending.reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification, deliveryId));
855
+ });
856
+ });
857
+ }
858
+ async abandonMirrorDelivery(deliveryId, generation) {
859
+ if (!deliveryId)
860
+ return;
861
+ await this.transport.sendAndDrain({
862
+ t: "mirror.abandon",
863
+ deliveryId,
864
+ generation,
865
+ }).catch(() => {
866
+ // Parent/child teardown already releases process-local projection state.
867
+ });
868
+ }
869
+ waitForMirrorRetry(failedAttempt, generation) {
870
+ return new Promise((resolve) => {
871
+ const waiters = this.mirrorRetryWaiters.get(generation) ?? new Set();
872
+ let timer;
873
+ const done = () => {
874
+ clearTimeout(timer);
875
+ waiters.delete(done);
876
+ if (waiters.size === 0)
877
+ this.mirrorRetryWaiters.delete(generation);
878
+ resolve();
879
+ };
880
+ timer = setTimeout(done, mirrorRetryDelayMs(failedAttempt));
881
+ timer.unref?.();
882
+ waiters.add(done);
883
+ this.mirrorRetryWaiters.set(generation, waiters);
884
+ });
885
+ }
886
+ cancelMirrorGeneration(generation) {
887
+ const error = new MirrorGenerationSupersededError(generation);
888
+ for (const [deliveryId, pending] of this.mirrorAcks) {
889
+ if (pending.generation !== generation)
890
+ continue;
891
+ clearTimeout(pending.timer);
892
+ this.mirrorAcks.delete(deliveryId);
893
+ pending.reject(new MirrorGenerationSupersededError(generation, deliveryId));
894
+ }
895
+ for (const [transferId, pending] of this.mirrorImageAcks) {
896
+ if (pending.generation !== generation)
897
+ continue;
898
+ if (pending.timer)
899
+ clearTimeout(pending.timer);
900
+ this.mirrorImageAcks.delete(transferId);
901
+ pending.reject(error);
902
+ }
903
+ for (const wake of this.mirrorRetryWaiters.get(generation) ?? [])
904
+ wake();
905
+ this.mirrorRetryWaiters.delete(generation);
906
+ }
907
+ rejectMirrorAcks(error) {
908
+ for (const pending of this.mirrorAcks.values()) {
909
+ clearTimeout(pending.timer);
910
+ pending.reject(error);
911
+ }
912
+ this.mirrorAcks.clear();
913
+ }
914
+ sendMirrorImageFrame(transferId, seq, generation, frame, timeoutClassification = "transient") {
915
+ return new Promise((resolve, reject) => {
916
+ const pending = { generation, seq, resolve, reject };
917
+ const timer = setTimeout(() => {
918
+ if (this.mirrorImageAcks.get(transferId) !== pending)
919
+ return;
920
+ this.mirrorImageAcks.delete(transferId);
921
+ reject(new MirrorDeliveryError("generated image transfer acknowledgement timed out", timeoutClassification));
922
+ }, MIRROR_IMAGE_ACK_TIMEOUT_MS);
923
+ timer.unref?.();
924
+ pending.timer = timer;
925
+ this.mirrorImageAcks.set(transferId, pending);
926
+ void this.transport.sendAndDrain(frame).catch((error) => {
927
+ if (this.mirrorImageAcks.get(transferId) !== pending)
928
+ return;
929
+ clearTimeout(timer);
930
+ this.mirrorImageAcks.delete(transferId);
931
+ const classification = timeoutClassification === "ambiguous" &&
932
+ (!(error instanceof RunnerTransportWriteError) || error.delivery === "ambiguous")
933
+ ? "ambiguous"
934
+ : "transient";
935
+ reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification));
936
+ });
937
+ });
938
+ }
939
+ rejectMirrorImageAcks(error) {
940
+ for (const pending of this.mirrorImageAcks.values()) {
941
+ if (pending.timer)
942
+ clearTimeout(pending.timer);
943
+ pending.reject(error);
944
+ }
945
+ this.mirrorImageAcks.clear();
946
+ }
947
+ sendRotationFrame(rotationId, generation, frame) {
948
+ return new Promise((resolve, reject) => {
949
+ const timer = setTimeout(() => {
950
+ this.rotationPublishAcks.delete(rotationId);
951
+ reject(new Error("Session rotation acknowledgement timed out"));
952
+ }, MIRROR_ACK_TIMEOUT_MS);
953
+ timer.unref?.();
954
+ this.rotationPublishAcks.set(rotationId, { generation, resolve, reject, timer });
955
+ this.transport.send(frame);
956
+ });
957
+ }
958
+ sendRotationAppliedFrame(rotationId, generation, frame) {
959
+ return new Promise((resolve, reject) => {
960
+ const timer = setTimeout(() => {
961
+ this.rotationAppliedAcks.delete(rotationId);
962
+ reject(new Error("Session rotation applied acknowledgement timed out"));
963
+ }, MIRROR_ACK_TIMEOUT_MS);
964
+ timer.unref?.();
965
+ this.rotationAppliedAcks.set(rotationId, { generation, resolve, reject, timer });
966
+ this.transport.send(frame);
967
+ });
968
+ }
969
+ rejectRotationAcks(error) {
970
+ for (const pending of this.rotationPublishAcks.values()) {
971
+ clearTimeout(pending.timer);
972
+ pending.reject(error);
973
+ }
974
+ this.rotationPublishAcks.clear();
975
+ for (const pending of this.rotationAppliedAcks.values()) {
976
+ clearTimeout(pending.timer);
977
+ pending.reject(error);
978
+ }
979
+ this.rotationAppliedAcks.clear();
980
+ }
128
981
  /**
129
- * Eagerly bring up a session's codex-native live view: start the persistent
130
- * forwarder connection (which resume-subscribes to mirror every turn) and
131
- * launch the detached `codex --remote resume` TUI against the thread the
132
- * structured runtime created. The forwarder subscribes to that same thread,
133
- * so the TUI is usable immediately and its turns mirror to chat.
982
+ * Eagerly bring up a session's codex-native live view. Fresh sessions connect
983
+ * the discovery listener before launching the detached TUI; known-thread
984
+ * resumes preload first, launch the replacement TUI, then start their
985
+ * persistent observer in the background.
134
986
  */
135
987
  async ensureLive(msg) {
136
988
  const provider = this.liveProvider;
@@ -142,43 +994,142 @@ export class RunnerSession {
142
994
  retargetMirror: retarget,
143
995
  });
144
996
  if (!started) {
997
+ const runtime = msg.execution?.provider;
145
998
  this.transport.send({
146
999
  t: "live.ready",
147
1000
  reqId: msg.reqId,
148
1001
  localThreadId: msg.localThreadId,
149
1002
  ok: false,
150
- error: "live provider did not start",
1003
+ error: providerFailure(provider, msg.localThreadId, {
1004
+ runtime,
1005
+ code: "native_start_failed",
1006
+ message: `${providerDisplayName(runtime)} native session did not start`,
1007
+ }),
151
1008
  });
152
1009
  return;
153
1010
  }
154
1011
  this.liveIds.add(msg.localThreadId);
1012
+ const runtime = msg.execution?.provider;
1013
+ if (runtime)
1014
+ this.liveRuntimes.set(msg.localThreadId, runtime);
155
1015
  // Launch the TUI attached to the already-bound thread. Re-launch when the
156
1016
  // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
157
1017
  // reconnect after the TUI exited restarts it instead of skipping (a
158
1018
  // launched-once guard would leave a dead "Pane is dead" husk forever).
159
1019
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
160
- const terminalReady = provider.waitTerminalReady
161
- ? await provider.waitTerminalReady(msg.localThreadId)
162
- : true;
1020
+ // Codex/Traex `ensureLiveCodexSession` already performs the backend
1021
+ // preload for an existing thread. Match the native lifecycle: launch
1022
+ // the TUI immediately after that preload and let the observer subscribe
1023
+ // in the background. Fresh sessions likewise launch before discovery.
1024
+ const terminalReady = isCodexLineageProvider(runtime)
1025
+ ? true
1026
+ : provider.waitTerminalReady
1027
+ ? await provider.waitTerminalReady(msg.localThreadId)
1028
+ : true;
163
1029
  if (!terminalReady) {
1030
+ const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
164
1031
  this.transport.send({
165
1032
  t: "live.ready",
166
1033
  reqId: msg.reqId,
167
1034
  localThreadId: msg.localThreadId,
168
1035
  ok: false,
169
- error: "Provider thread was not ready for Terminal resume",
1036
+ error: providerFailure(provider, msg.localThreadId, {
1037
+ runtime,
1038
+ code: "native_resume_failed",
1039
+ message: runtime === "codex" || runtime === "traex"
1040
+ ? `${providerName} app-server was ready, but its existing session could not be resumed for the Terminal`
1041
+ : "Provider thread was not ready for Terminal resume",
1042
+ }),
170
1043
  });
171
1044
  return;
172
1045
  }
173
- await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
174
- }
175
- const ready = msg.waitForReady === false
176
- ? true
177
- : provider.waitLiveReady
178
- ? await provider.waitLiveReady(msg.localThreadId)
179
- : true;
180
- const terminal = this.terminals.get(`${msg.localThreadId}-main`);
181
- const paneFailure = !ready && terminal && !terminal.isAlive()
1046
+ try {
1047
+ await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
1048
+ }
1049
+ catch (error) {
1050
+ throw error instanceof AgentRuntimeError
1051
+ ? error
1052
+ : nativePhaseError(runtime, "native_terminal_launch_failed", "Terminal failed to launch", error);
1053
+ }
1054
+ }
1055
+ // reference implementation's known-thread path launches `codex --remote resume` first,
1056
+ // then creates `_codex_forward_known_thread` as background work. Keep the
1057
+ // ordering explicit here: observer connection failure cannot delay or
1058
+ // reject cold-resume admission.
1059
+ if (isCodexLineageProvider(runtime)) {
1060
+ provider.startLiveCodexObserver?.(msg.localThreadId);
1061
+ }
1062
+ let ready = true;
1063
+ if (runtime === "codex" || runtime === "traex") {
1064
+ // Discover thread/started in the background for 30s while injection
1065
+ // waits up to 60s for either bridge readiness or that startup error.
1066
+ // Do not serialize the two waits here.
1067
+ this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
1068
+ }
1069
+ else if (msg.waitForReady !== false && provider.waitLiveReady) {
1070
+ ready = await provider.waitLiveReady(msg.localThreadId);
1071
+ }
1072
+ // Message delivery is serialized behind Traex's startup modal handling.
1073
+ // Otherwise app-server can accept a Turn while the TUI watcher is still
1074
+ // sending Escape, and the same key becomes `turn/interrupt` as soon as
1075
+ // the modal disappears. Setup-terminal opens remain non-blocking.
1076
+ if (runtime === "traex" && msg.waitForReady !== false) {
1077
+ await this.traexStartupGates.get(msg.localThreadId);
1078
+ }
1079
+ const readinessError = ready
1080
+ ? undefined
1081
+ : providerFailure(provider, msg.localThreadId, {
1082
+ runtime,
1083
+ code: "native_readiness_failed",
1084
+ message: `${providerDisplayName(runtime)} native session was not ready`,
1085
+ });
1086
+ this.transport.send({
1087
+ t: "live.ready",
1088
+ reqId: msg.reqId,
1089
+ localThreadId: msg.localThreadId,
1090
+ ok: ready,
1091
+ ...(ready ? {} : { error: readinessError }),
1092
+ });
1093
+ }
1094
+ catch (error) {
1095
+ this.transport.send({
1096
+ t: "live.ready",
1097
+ reqId: msg.reqId,
1098
+ localThreadId: msg.localThreadId,
1099
+ ok: false,
1100
+ error: toWireError(error),
1101
+ });
1102
+ }
1103
+ }
1104
+ monitorCodexThreadStartup(localThreadId, runtime, provider) {
1105
+ // Starting (or declining to start) a new monitor invalidates every callback
1106
+ // owned by the previous launch before it can touch the replacement state.
1107
+ this.codexStartupMonitors.delete(localThreadId);
1108
+ if (!provider.waitLiveReady)
1109
+ return;
1110
+ if (provider.liveSessionFailure?.(localThreadId)?.code === "login_required")
1111
+ return;
1112
+ const expectedTerminal = this.terminals.get(`${localThreadId}-main`);
1113
+ const monitor = Symbol(localThreadId);
1114
+ this.codexStartupMonitors.set(localThreadId, monitor);
1115
+ const claimCurrentLaunch = () => {
1116
+ if (this.codexStartupMonitors.get(localThreadId) !== monitor ||
1117
+ this.terminals.get(`${localThreadId}-main`) !== expectedTerminal)
1118
+ return false;
1119
+ this.codexStartupMonitors.delete(localThreadId);
1120
+ return true;
1121
+ };
1122
+ void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
1123
+ if (ready) {
1124
+ if (this.codexStartupMonitors.get(localThreadId) === monitor) {
1125
+ this.codexStartupMonitors.delete(localThreadId);
1126
+ }
1127
+ return;
1128
+ }
1129
+ if (!claimCurrentLaunch())
1130
+ return;
1131
+ const terminal = this.terminals.get(`${localThreadId}-main`);
1132
+ const paneFailure = terminal && !terminal.isAlive()
182
1133
  ? terminal
183
1134
  .capturePane()
184
1135
  .split("\n")
@@ -188,44 +1139,109 @@ export class RunnerSession {
188
1139
  .join(" ")
189
1140
  .slice(-1_000)
190
1141
  : "";
191
- const readinessError = provider.liveSessionError?.(msg.localThreadId)
1142
+ const detail = provider.liveSessionError?.(localThreadId)
192
1143
  ?? (paneFailure
193
- ? `Provider terminal exited before session discovery: ${paneFailure}`
194
- : "live session was not ready before timeout");
1144
+ ? `${providerDisplayName(runtime)} Terminal exited before native session discovery: ${paneFailure}`
1145
+ : `${runtime === "traex" ? "Traex" : "Codex"} TUI did not publish thread/started within ${CODEX_THREAD_START_TIMEOUT_MS / 1_000}s after app-server and observer readiness`);
1146
+ this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
1147
+ ? "native_terminal_exited_before_session"
1148
+ : "native_thread_discovery_timeout"), expectedTerminal);
1149
+ }).catch((error) => {
1150
+ if (!claimCurrentLaunch())
1151
+ return;
1152
+ this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
1153
+ ? error
1154
+ : nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error), expectedTerminal);
1155
+ });
1156
+ }
1157
+ /** Discovery failure is terminal for this partial native launch. Publish the
1158
+ * exact cause first so an in-flight injection wakes, then drop the observer
1159
+ * and TUI so a later task retry creates a clean launch instead of reusing an
1160
+ * already-rejected readiness promise. */
1161
+ failCodexThreadStartup(localThreadId, provider, error, expectedTerminal) {
1162
+ if (!provider.failLiveStartup?.(localThreadId, error))
1163
+ return false;
1164
+ this.codexStartupMonitors.delete(localThreadId);
1165
+ const watcher = this.terminalWatchers.get(localThreadId);
1166
+ if (watcher)
1167
+ clearInterval(watcher);
1168
+ this.terminalWatchers.delete(localThreadId);
1169
+ this.cancelTraexStartupWatcher(localThreadId);
1170
+ try {
1171
+ this.terminals.close(`${localThreadId}-main`, expectedTerminal);
1172
+ }
1173
+ catch (closeError) {
1174
+ console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
1175
+ }
1176
+ if (provider.teardownLiveCodexSession) {
1177
+ provider.teardownLiveCodexSession(localThreadId);
1178
+ }
1179
+ else {
1180
+ provider.stopLiveCodexSession?.(localThreadId);
1181
+ }
1182
+ this.liveIds.delete(localThreadId);
1183
+ return true;
1184
+ }
1185
+ async inject(msg) {
1186
+ const provider = this.liveProvider;
1187
+ try {
1188
+ const input = msg.input ?? msg.text;
1189
+ const result = (await provider.injectMessage?.(msg.localThreadId, input, msg.options))
1190
+ ?? { outcome: "notLive" };
1191
+ // App-server injection is independent of the Terminal TUI startup, so it
1192
+ // must not cancel prompt handling for the pane that is still starting.
1193
+ const error = result.outcome === "injected" || result.outcome === "steered"
1194
+ ? undefined
1195
+ : providerFailure(provider, msg.localThreadId, {
1196
+ code: result.outcome === "notReady"
1197
+ ? "native_thread_not_ready"
1198
+ : result.outcome === "notLive"
1199
+ ? "native_session_not_live"
1200
+ : "native_message_injection_failed",
1201
+ message: result.outcome === "notReady"
1202
+ ? "Native session was not ready for message injection"
1203
+ : result.outcome === "notLive"
1204
+ ? "Native session was not live for message injection"
1205
+ : "Native message injection failed",
1206
+ });
195
1207
  this.transport.send({
196
- t: "live.ready",
1208
+ t: "injected",
197
1209
  reqId: msg.reqId,
198
1210
  localThreadId: msg.localThreadId,
199
- ok: ready,
200
- ...(ready ? {} : { error: readinessError }),
1211
+ result,
1212
+ ...(error ? { error } : {}),
201
1213
  });
202
1214
  }
203
1215
  catch (error) {
204
1216
  this.transport.send({
205
- t: "live.ready",
1217
+ t: "injected",
206
1218
  reqId: msg.reqId,
207
1219
  localThreadId: msg.localThreadId,
208
- ok: false,
209
- error: error instanceof Error ? error.message : String(error),
1220
+ result: { outcome: "failed" },
1221
+ error: toWireError(error),
210
1222
  });
211
1223
  }
212
1224
  }
213
- async inject(msg) {
214
- const provider = this.liveProvider;
1225
+ async updateCollaborationMode(msg) {
215
1226
  try {
216
- const input = msg.input ?? msg.text;
217
- const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
218
- if (outcome === "injected")
219
- this.cancelTraexStartupWatcher(msg.localThreadId);
220
- this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
1227
+ if (!this.liveProvider.updateCollaborationMode) {
1228
+ throw new AgentRuntimeError("Native collaboration mode is unavailable", 409, "collaboration_mode_unavailable");
1229
+ }
1230
+ await this.liveProvider.updateCollaborationMode(msg.localThreadId, msg.mode);
1231
+ this.transport.send({
1232
+ t: "collaboration.updated",
1233
+ reqId: msg.reqId,
1234
+ localThreadId: msg.localThreadId,
1235
+ ok: true,
1236
+ });
221
1237
  }
222
1238
  catch (error) {
223
1239
  this.transport.send({
224
- t: "injected",
1240
+ t: "collaboration.updated",
225
1241
  reqId: msg.reqId,
226
1242
  localThreadId: msg.localThreadId,
227
- outcome: "failed",
228
- error: error instanceof Error ? error.message : String(error),
1243
+ ok: false,
1244
+ error: toWireError(error),
229
1245
  });
230
1246
  }
231
1247
  }
@@ -237,7 +1253,7 @@ export class RunnerSession {
237
1253
  ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
238
1254
  }
239
1255
  catch (e) {
240
- error = e instanceof Error ? e.message : String(e);
1256
+ error = toWireError(e);
241
1257
  }
242
1258
  this.transport.send({
243
1259
  t: "interrupted",
@@ -247,6 +1263,43 @@ export class RunnerSession {
247
1263
  ...(error ? { error } : {}),
248
1264
  });
249
1265
  }
1266
+ async reapTerminal(msg) {
1267
+ let error;
1268
+ try {
1269
+ const lifecycle = this.terminals.lifecycle(msg.terminalId);
1270
+ const watcher = this.terminalWatchers.get(msg.localThreadId);
1271
+ if (watcher)
1272
+ clearInterval(watcher);
1273
+ this.terminalWatchers.delete(msg.localThreadId);
1274
+ this.cancelTraexStartupWatcher(msg.localThreadId);
1275
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
1276
+ if (sessionId !== msg.localThreadId)
1277
+ continue;
1278
+ this.attachments.get(attachId)?.kill();
1279
+ this.attachments.delete(attachId);
1280
+ this.attachmentThreadIds.delete(attachId);
1281
+ this.attachmentRoles.delete(attachId);
1282
+ }
1283
+ this.terminals.close(msg.terminalId);
1284
+ if (lifecycle === "auxiliary") {
1285
+ await this.liveProvider.teardownAuxiliaryTerminalRuntime?.(msg.localThreadId);
1286
+ this.liveIds.delete(msg.localThreadId);
1287
+ this.liveRuntimes.delete(msg.localThreadId);
1288
+ this.terminalStatuses.delete(msg.localThreadId);
1289
+ }
1290
+ }
1291
+ catch (cause) {
1292
+ error = toWireError(cause);
1293
+ }
1294
+ this.transport.send({
1295
+ t: "terminal.reaped",
1296
+ reqId: msg.reqId,
1297
+ localThreadId: msg.localThreadId,
1298
+ terminalId: msg.terminalId,
1299
+ ok: error === undefined,
1300
+ ...(error ? { error } : {}),
1301
+ });
1302
+ }
250
1303
  /** Launch (idempotently) the session's codex TUI pane from the executor's
251
1304
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
252
1305
  * so the web attach reuses the same detached pane. */
@@ -255,21 +1308,107 @@ export class RunnerSession {
255
1308
  if (!spec)
256
1309
  return;
257
1310
  const terminalId = `${localThreadId}-main`;
258
- const coldStart = !this.terminals.has(terminalId);
259
1311
  const term = this.terminals.getOrCreate(terminalId, {
1312
+ lifecycle: spec.lifecycle,
260
1313
  cwd: spec.cwd,
261
1314
  command: spec.command,
262
1315
  args: spec.args,
263
1316
  cols: cols ?? 120,
264
1317
  rows: rows ?? 40,
265
1318
  ...(spec.env ? { env: spec.env } : {}),
1319
+ ...(spec.scrollback === undefined ? {} : { scrollback: spec.scrollback }),
1320
+ ...(spec.tmuxAllowPassthrough === undefined
1321
+ ? {}
1322
+ : { tmuxAllowPassthrough: spec.tmuxAllowPassthrough }),
1323
+ ...(spec.tmuxStartOnAttach === undefined
1324
+ ? {}
1325
+ : { tmuxStartOnAttach: spec.tmuxStartOnAttach }),
1326
+ ...(spec.keepAliveAfterExit === undefined
1327
+ ? {}
1328
+ : { keepAliveAfterExit: spec.keepAliveAfterExit }),
266
1329
  });
267
- if (coldStart && spec.skipTraexStartupPrompts) {
1330
+ if (spec.skipTraexStartupPrompts) {
268
1331
  const watcher = Symbol(localThreadId);
269
1332
  this.traexStartupWatchers.set(localThreadId, watcher);
270
- void this.skipTraexStartupPrompts(localThreadId, term, watcher);
1333
+ const gate = this.skipTraexStartupPrompts(localThreadId, term, watcher);
1334
+ this.traexStartupGates.set(localThreadId, gate);
1335
+ void gate.finally(() => {
1336
+ if (this.traexStartupGates.get(localThreadId) === gate) {
1337
+ this.traexStartupGates.delete(localThreadId);
1338
+ }
1339
+ });
271
1340
  }
272
1341
  this.liveProvider.attachTerminalInjector?.(localThreadId, term);
1342
+ this.watchNativeTerminal(localThreadId, terminalId, term);
1343
+ }
1344
+ watchNativeTerminal(localThreadId, terminalId, terminal) {
1345
+ const previous = this.terminalWatchers.get(localThreadId);
1346
+ if (previous)
1347
+ clearInterval(previous);
1348
+ let checking = false;
1349
+ const timer = setInterval(() => {
1350
+ if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
1351
+ return;
1352
+ checking = true;
1353
+ const probe = terminal.lifecycleLivenessAsync
1354
+ ? terminal.lifecycleLivenessAsync()
1355
+ : terminal.livenessAsync
1356
+ ? terminal.livenessAsync()
1357
+ : terminal.isAliveAsync
1358
+ ? terminal.isAliveAsync().then((alive) => alive ? "alive" : "dead")
1359
+ : Promise.resolve(terminal.isAlive() ? "alive" : "dead");
1360
+ void probe.then((liveness) => {
1361
+ // A failed pane probe is inconclusive. Only reference implementation's lifecycle
1362
+ // evidence — capture target gone or explicit pane_dead — may fail it.
1363
+ if (liveness !== "dead" ||
1364
+ this.shuttingDown ||
1365
+ this.terminals.get(terminalId) !== terminal)
1366
+ return;
1367
+ clearInterval(timer);
1368
+ this.terminalWatchers.delete(localThreadId);
1369
+ this.cancelTraexStartupWatcher(localThreadId);
1370
+ const runtime = this.liveRuntimes.get(localThreadId);
1371
+ const lifecycle = this.terminals.lifecycle(terminalId);
1372
+ const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
1373
+ const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined), terminal);
1374
+ if (startupFailed || lifecycle === undefined)
1375
+ return;
1376
+ try {
1377
+ this.terminals.close(terminalId, terminal);
1378
+ }
1379
+ catch (closeError) {
1380
+ console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
1381
+ }
1382
+ const status = this.terminalStatuses.get(localThreadId) ?? "unknown";
1383
+ if (lifecycle === "auxiliary") {
1384
+ console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited`);
1385
+ }
1386
+ else {
1387
+ if (status !== "idle") {
1388
+ this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "required_terminal_exited", "Required Terminal exited unexpectedly", paneFailure || undefined));
1389
+ }
1390
+ this.liveProvider.stopLiveCodexSession?.(localThreadId);
1391
+ this.liveIds.delete(localThreadId);
1392
+ this.liveRuntimes.delete(localThreadId);
1393
+ this.terminalStatuses.delete(localThreadId);
1394
+ }
1395
+ this.enqueueMirror("main", () => {
1396
+ this.transport.send({
1397
+ t: "terminal.lifecycle.ended",
1398
+ localThreadId,
1399
+ terminalId,
1400
+ lifecycle,
1401
+ status,
1402
+ });
1403
+ });
1404
+ }).catch((error) => {
1405
+ console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
1406
+ }).finally(() => {
1407
+ checking = false;
1408
+ });
1409
+ }, 1_000);
1410
+ timer.unref?.();
1411
+ this.terminalWatchers.set(localThreadId, timer);
273
1412
  }
274
1413
  cancelTraexStartupWatcher(localThreadId) {
275
1414
  if (localThreadId)
@@ -279,46 +1418,118 @@ export class RunnerSession {
279
1418
  const prompts = [
280
1419
  {
281
1420
  id: "welcome",
282
- matches: (pane) => pane.includes("Welcome to TRAE CLI") && pane.includes("Press enter to continue"),
1421
+ matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
283
1422
  dismiss: () => terminal.sendEnter(),
1423
+ requiresEscapeConfirmation: false,
284
1424
  },
285
1425
  {
286
1426
  id: "migration",
287
- matches: (pane) => pane.includes("Legacy TRAE CLI data detected") &&
288
- pane.includes("Would you like to migrate detected data to the new TRAE CLI storage?") &&
289
- pane.includes("Select what to import:"),
1427
+ matches: (pane) => (pane.includes("legacy traecode cli data detected") ||
1428
+ pane.includes("legacy trae cli data detected")) &&
1429
+ pane.includes("select what to import") &&
1430
+ (pane.includes("skip for now") || pane.includes("don't ask again")),
290
1431
  dismiss: () => terminal.interrupt(),
1432
+ requiresEscapeConfirmation: true,
291
1433
  },
292
1434
  {
293
1435
  id: "hooks",
294
- matches: (pane) => pane.includes("Hooks need review") &&
295
- pane.includes("hooks are new or changed.") &&
296
- pane.includes("Trust all and continue") &&
297
- pane.includes("Continue without trusting"),
1436
+ matches: (pane) => pane.includes("hooks need review") &&
1437
+ pane.includes("trust all and continue") &&
1438
+ pane.includes("continue without trusting"),
298
1439
  dismiss: () => terminal.interrupt(),
1440
+ requiresEscapeConfirmation: true,
299
1441
  },
300
1442
  ];
301
- const dismissed = new Set();
302
- const deadline = Date.now() + 20_000;
1443
+ let activeWelcome = false;
1444
+ let pendingEscapePromptId;
1445
+ const dismissedPromptIds = new Set();
1446
+ let dismissedAnyPrompt = false;
1447
+ const startedAt = Date.now();
1448
+ let deadline = startedAt + TRAEX_STARTUP_WATCH_MS;
1449
+ let discoveryDeadline = startedAt + TRAEX_STARTUP_DISCOVERY_MS;
1450
+ const authorizationDeadline = startedAt + TRAEX_AUTHORIZATION_WAIT_MS;
303
1451
  const terminalId = `${localThreadId}-main`;
304
1452
  while (!this.shuttingDown &&
305
1453
  Date.now() < deadline &&
306
1454
  this.traexStartupWatchers.get(localThreadId) === watcher &&
307
1455
  this.terminals.get(terminalId) === terminal) {
308
- const pane = terminal.capturePane();
309
- // Once the normal composer is visible, startup is complete and there is
310
- // no reason to keep polling the tmux pane for the remainder of the window.
311
- if (pane.includes("TRAE CLI Next"))
312
- break;
1456
+ // Do not treat a composer frame as completion: Traex can render it before
1457
+ // the startup modals arrive. The bounded deadline stops this watcher.
1458
+ const pane = normalizeTraexPane(terminal.capturePane());
313
1459
  const prompt = prompts.find((candidate) => candidate.matches(pane));
314
- if (prompt && !dismissed.has(prompt.id)) {
315
- prompt.dismiss();
316
- dismissed.add(prompt.id);
317
- if (dismissed.size === prompts.length)
1460
+ const now = Date.now();
1461
+ const authorizationPending = isTraexAuthorizationPending(pane);
1462
+ if (isTraexStartupActivity(pane)) {
1463
+ discoveryDeadline = now + TRAEX_STARTUP_DISCOVERY_MS;
1464
+ }
1465
+ // Human device authorization routinely takes longer than the normal
1466
+ // startup-modal window. Keep watching while that known screen remains,
1467
+ // then preserve a full window for welcome/migration/hooks after sign-in.
1468
+ // The separate cap prevents an abandoned auth screen from polling forever.
1469
+ if (authorizationPending && now < authorizationDeadline) {
1470
+ deadline = now + TRAEX_STARTUP_WATCH_MS;
1471
+ }
1472
+ if (!prompt) {
1473
+ activeWelcome = false;
1474
+ pendingEscapePromptId = undefined;
1475
+ // Every dismissal is followed by a one-second cooldown before the next
1476
+ // capture. If no known modal remains then, startup prompt handling is
1477
+ // complete and message injection may safely begin.
1478
+ if (dismissedAnyPrompt)
1479
+ break;
1480
+ // A clean Traex home may have no startup modal at all. Preserve a
1481
+ // five-second discovery window for late modals, but do not impose the
1482
+ // old full 20-second watcher lifetime on every cold message. A visible
1483
+ // updater keeps the full startup window because prompts may follow it.
1484
+ if (now >= discoveryDeadline &&
1485
+ !authorizationPending &&
1486
+ !isTraexStartupActivity(pane))
318
1487
  break;
319
1488
  }
1489
+ else if (!prompt.requiresEscapeConfirmation) {
1490
+ pendingEscapePromptId = undefined;
1491
+ if (!activeWelcome && !dismissedPromptIds.has(prompt.id)) {
1492
+ prompt.dismiss();
1493
+ dismissedPromptIds.add(prompt.id);
1494
+ dismissedAnyPrompt = true;
1495
+ await new Promise((resolve) => {
1496
+ const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
1497
+ timer.unref();
1498
+ });
1499
+ continue;
1500
+ }
1501
+ activeWelcome = true;
1502
+ }
1503
+ else if (prompt.id !== pendingEscapePromptId) {
1504
+ activeWelcome = false;
1505
+ pendingEscapePromptId = prompt.id;
1506
+ await new Promise((resolve) => {
1507
+ // Traex can show hooks -> migration -> hooks during one startup. The
1508
+ // trailing hooks screen normally disappears on its own, so observe a
1509
+ // previously handled prompt type for five seconds before acting.
1510
+ const timer = setTimeout(resolve, dismissedPromptIds.has(prompt.id)
1511
+ ? TRAEX_REPEAT_PROMPT_CONFIRM_MS
1512
+ : TRAEX_ESCAPE_CONFIRM_MS);
1513
+ timer.unref();
1514
+ });
1515
+ continue;
1516
+ }
1517
+ else {
1518
+ prompt.dismiss();
1519
+ dismissedPromptIds.add(prompt.id);
1520
+ dismissedAnyPrompt = true;
1521
+ // Keep the id through the cooldown. If the same prompt is genuinely
1522
+ // still active after a sent key, the next check retries it; a prompt
1523
+ // that disappeared during the cooldown never receives another key.
1524
+ pendingEscapePromptId = prompt.id;
1525
+ await new Promise((resolve) => {
1526
+ const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
1527
+ timer.unref();
1528
+ });
1529
+ continue;
1530
+ }
320
1531
  await new Promise((resolve) => {
321
- const timer = setTimeout(resolve, 100);
1532
+ const timer = setTimeout(resolve, authorizationPending ? TRAEX_AUTHORIZATION_POLL_MS : TRAEX_STARTUP_POLL_MS);
322
1533
  timer.unref();
323
1534
  });
324
1535
  }
@@ -327,28 +1538,54 @@ export class RunnerSession {
327
1538
  }
328
1539
  }
329
1540
  stopLive() {
1541
+ for (const timer of this.terminalWatchers.values())
1542
+ clearInterval(timer);
1543
+ this.terminalWatchers.clear();
1544
+ this.codexStartupMonitors.clear();
330
1545
  for (const id of this.liveIds) {
331
1546
  this.liveProvider.stopLiveCodexSession?.(id, {
332
1547
  deferClaudeInteractionCleanup: true,
333
1548
  });
334
1549
  }
335
1550
  this.liveIds.clear();
1551
+ this.liveRuntimes.clear();
336
1552
  }
337
1553
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
338
1554
  * scrub provider handoff files before the child process is allowed to exit. */
339
1555
  shutdown() {
340
- if (this.shuttingDown)
341
- return;
1556
+ if (this.shutdownPromise)
1557
+ return this.shutdownPromise;
342
1558
  this.shuttingDown = true;
343
- this.stopLive();
344
- this.terminals.closeAll();
345
- this.liveProvider.finalizeStoppedLiveSessions?.();
346
- this.onShutdown();
1559
+ this.shutdownPromise = (async () => {
1560
+ const shutdownError = new Error("runner is shutting down");
1561
+ this.rejectMirrorAcks(shutdownError);
1562
+ this.rejectMirrorImageAcks(shutdownError);
1563
+ this.rejectRotationAcks(shutdownError);
1564
+ for (const waiters of this.mirrorRetryWaiters.values()) {
1565
+ for (const wake of waiters)
1566
+ wake();
1567
+ }
1568
+ this.mirrorRetryWaiters.clear();
1569
+ this.stopLive();
1570
+ for (const pending of this.preparations.values()) {
1571
+ clearTimeout(pending.timer);
1572
+ pending.preparation.kill();
1573
+ }
1574
+ this.preparations.clear();
1575
+ for (const attachment of this.attachments.values())
1576
+ attachment.kill();
1577
+ this.terminals.closeAll();
1578
+ this.attachments.clear();
1579
+ this.attachmentThreadIds.clear();
1580
+ this.attachmentRoles.clear();
1581
+ await this.liveProvider.finalizeStoppedLiveSessions?.();
1582
+ this.onShutdown();
1583
+ })();
1584
+ return this.shutdownPromise;
347
1585
  }
348
1586
  async openTerminal(msg) {
349
1587
  try {
350
- // codex/claude-native session (no explicit command): DUMB ATTACH — reference implementation's
351
- // reattach (codex_native.py:905-942, `app_server=None`). A tab switch ONLY
1588
+ // codex/claude-native session (no explicit command): DUMB ATTACH. A tab switch ONLY
352
1589
  // attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
353
1590
  // dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
354
1591
  // explicit restart. A missing/dead pane → `term.error`, so the web shows the
@@ -370,6 +1607,7 @@ export class RunnerSession {
370
1607
  }
371
1608
  // Login-shell terminal (explicit command): create-or-reuse as before.
372
1609
  this.terminals.getOrCreate(msg.terminalId, {
1610
+ lifecycle: "auxiliary",
373
1611
  cwd: msg.cwd,
374
1612
  command: msg.command,
375
1613
  args: msg.args,
@@ -387,14 +1625,14 @@ export class RunnerSession {
387
1625
  });
388
1626
  }
389
1627
  }
390
- /** Attach an already-created terminal and forward its data/exit to the parent. */
1628
+ /** Prepare captured history without starting the live control client. */
391
1629
  async attachExisting(msg) {
392
- const { attachment, role } = await this.terminals.attach(msg.terminalId, msg.role, {
1630
+ const { preparation, role } = await this.terminals.prepare(msg.terminalId, msg.role, {
393
1631
  cols: msg.cols,
394
1632
  rows: msg.rows,
395
1633
  });
396
1634
  if (this.cancelledTerminalOpens.has(msg.attachId)) {
397
- attachment.kill();
1635
+ preparation.kill();
398
1636
  this.transport.send({
399
1637
  t: "term.error",
400
1638
  attachId: msg.attachId,
@@ -403,20 +1641,168 @@ export class RunnerSession {
403
1641
  });
404
1642
  return;
405
1643
  }
406
- this.attachments.set(msg.attachId, attachment);
1644
+ const timer = setTimeout(() => {
1645
+ const pending = this.preparations.get(msg.attachId);
1646
+ if (!pending || pending.preparation !== preparation)
1647
+ return;
1648
+ this.preparations.delete(msg.attachId);
1649
+ this.attachmentThreadIds.delete(msg.attachId);
1650
+ this.attachmentRoles.delete(msg.attachId);
1651
+ preparation.kill();
1652
+ }, TERMINAL_PREPARATION_TTL_MS);
1653
+ timer.unref?.();
1654
+ this.preparations.set(msg.attachId, { preparation, timer });
1655
+ this.attachmentRoles.set(msg.attachId, role);
407
1656
  if (msg.localThreadId)
408
1657
  this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
409
- attachment.onData((chunk) => this.transport.send({
410
- t: "term.data",
1658
+ this.transport.send({
1659
+ t: "term.prepared",
411
1660
  attachId: msg.attachId,
412
- dataB64: Buffer.from(chunk, "utf8").toString("base64"),
413
- }));
414
- attachment.onExit((info) => {
415
- this.attachments.delete(msg.attachId);
1661
+ role,
1662
+ seedBytes: preparation.seedBytes,
1663
+ ...(preparation.dimensions ?? {}),
1664
+ });
1665
+ }
1666
+ async readTerminalSeed(msg) {
1667
+ const pending = this.preparations.get(msg.attachId);
1668
+ if (!pending) {
1669
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1670
+ return;
1671
+ }
1672
+ try {
1673
+ const result = await pending.preparation.readSeed(msg.offset, msg.maxBytes);
1674
+ await this.transport.sendAndDrain({
1675
+ t: "term.seed.chunk",
1676
+ attachId: msg.attachId,
1677
+ reqId: msg.reqId,
1678
+ dataB64: Buffer.from(result.data).toString("base64"),
1679
+ nextOffset: result.nextOffset,
1680
+ done: result.done,
1681
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1682
+ });
1683
+ }
1684
+ catch (error) {
1685
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1686
+ }
1687
+ }
1688
+ async startTerminal(msg) {
1689
+ const pending = this.preparations.get(msg.attachId);
1690
+ if (!pending) {
1691
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1692
+ return;
1693
+ }
1694
+ clearTimeout(pending.timer);
1695
+ this.preparations.delete(msg.attachId);
1696
+ try {
1697
+ const attachment = await pending.preparation.start();
1698
+ this.attachments.set(msg.attachId, attachment);
1699
+ attachment.onResize(({ cols, rows }) => {
1700
+ this.transport.send({ t: "term.dimensions", attachId: msg.attachId, cols, rows });
1701
+ });
1702
+ await this.transport.sendAndDrain({
1703
+ t: "term.started",
1704
+ attachId: msg.attachId,
1705
+ reqId: msg.reqId,
1706
+ });
1707
+ void attachment.readerDone.then(async (info) => {
1708
+ await this.transport.sendAndDrain({
1709
+ t: "term.reader.done",
1710
+ attachId: msg.attachId,
1711
+ finalOffset: info.finalOffset,
1712
+ reason: info.reason,
1713
+ exitCode: info.exitCode,
1714
+ }).catch(() => undefined);
1715
+ });
1716
+ }
1717
+ catch (error) {
1718
+ pending.preparation.kill();
416
1719
  this.attachmentThreadIds.delete(msg.attachId);
417
- this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
1720
+ this.attachmentRoles.delete(msg.attachId);
1721
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1722
+ }
1723
+ }
1724
+ async readTerminalOutput(msg) {
1725
+ const attachment = this.attachments.get(msg.attachId);
1726
+ if (!attachment) {
1727
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1728
+ return;
1729
+ }
1730
+ try {
1731
+ const result = await attachment.read(msg.offset, msg.maxBytes);
1732
+ await this.transport.sendAndDrain({
1733
+ t: "term.chunk",
1734
+ attachId: msg.attachId,
1735
+ reqId: msg.reqId,
1736
+ dataB64: Buffer.from(result.data).toString("base64"),
1737
+ nextOffset: result.nextOffset,
1738
+ done: result.done,
1739
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1740
+ });
1741
+ }
1742
+ catch (error) {
1743
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1744
+ }
1745
+ }
1746
+ async writeTerminalInput(msg) {
1747
+ if (this.attachmentRoles.get(msg.attachId) !== "owner") {
1748
+ await this.transport.sendAndDrain({
1749
+ t: "term.ack",
1750
+ attachId: msg.attachId,
1751
+ reqId: msg.reqId,
1752
+ operation: "input",
1753
+ });
1754
+ return;
1755
+ }
1756
+ const attachment = this.attachments.get(msg.attachId);
1757
+ if (!attachment) {
1758
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1759
+ return;
1760
+ }
1761
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
1762
+ const input = Buffer.from(msg.dataB64, "base64");
1763
+ if (!isTerminalProtocolResponse(input.toString("utf8"))) {
1764
+ this.cancelTraexStartupWatcher(localThreadId);
1765
+ }
1766
+ try {
1767
+ await attachment.write(input);
1768
+ await this.transport.sendAndDrain({
1769
+ t: "term.ack",
1770
+ attachId: msg.attachId,
1771
+ reqId: msg.reqId,
1772
+ operation: "input",
1773
+ });
1774
+ }
1775
+ catch (error) {
1776
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1777
+ }
1778
+ }
1779
+ async resizeTerminal(msg) {
1780
+ const attachment = this.attachments.get(msg.attachId);
1781
+ if (!attachment) {
1782
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1783
+ return;
1784
+ }
1785
+ try {
1786
+ await attachment.resize(msg.cols, msg.rows);
1787
+ await this.transport.sendAndDrain({
1788
+ t: "term.ack",
1789
+ attachId: msg.attachId,
1790
+ reqId: msg.reqId,
1791
+ operation: "resize",
1792
+ });
1793
+ }
1794
+ catch (error) {
1795
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1796
+ }
1797
+ }
1798
+ sendTerminalOperationError(attachId, reqId, error) {
1799
+ this.transport.send({
1800
+ t: "term.error",
1801
+ attachId,
1802
+ reqId,
1803
+ code: "terminal_open_failed",
1804
+ message: error instanceof Error ? error.message : String(error),
418
1805
  });
419
- this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
420
1806
  }
421
1807
  async runCap(capId, name, args) {
422
1808
  try {