@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.31

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 (44) 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 +2 -0
  6. package/dist/claude/native-bridge.js +23 -0
  7. package/dist/claude/native-hook-main.js +62 -0
  8. package/dist/claude/native-integration.d.ts +50 -10
  9. package/dist/claude/native-integration.js +262 -37
  10. package/dist/claude/session-status.d.ts +39 -0
  11. package/dist/claude/session-status.js +163 -0
  12. package/dist/claude/transcript.js +27 -17
  13. package/dist/codex-app-server/client.d.ts +10 -6
  14. package/dist/codex-app-server/client.js +67 -15
  15. package/dist/codex-app-server/forwarder.d.ts +92 -3
  16. package/dist/codex-app-server/forwarder.js +509 -56
  17. package/dist/codex-app-server/mapping.d.ts +3 -6
  18. package/dist/codex-app-server/mapping.js +174 -28
  19. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  20. package/dist/codex-app-server/mcp-startup.js +63 -0
  21. package/dist/codex-app-server/protocol.d.ts +64 -7
  22. package/dist/codex-app-server/ws-channel.js +19 -19
  23. package/dist/codex-home.js +2 -4
  24. package/dist/host.d.ts +64 -21
  25. package/dist/host.js +1333 -468
  26. package/dist/index.d.ts +1 -1
  27. package/dist/input-resources.d.ts +4 -0
  28. package/dist/input-resources.js +21 -5
  29. package/dist/models-catalog.d.ts +2 -1
  30. package/dist/models-catalog.js +94 -6
  31. package/dist/runner/child.d.ts +49 -21
  32. package/dist/runner/child.js +629 -48
  33. package/dist/runner/manager.d.ts +54 -13
  34. package/dist/runner/manager.js +479 -114
  35. package/dist/runner/protocol.d.ts +62 -19
  36. package/dist/runner/protocol.js +5 -0
  37. package/dist/runner/startup-policy.d.ts +7 -0
  38. package/dist/runner/startup-policy.js +10 -0
  39. package/dist/terminal/claude-tui.d.ts +3 -1
  40. package/dist/terminal/claude-tui.js +3 -1
  41. package/dist/terminal/registry.js +3 -2
  42. package/dist/terminal/tmux.d.ts +50 -7
  43. package/dist/terminal/tmux.js +168 -47
  44. package/package.json +4 -3
@@ -1,5 +1,118 @@
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 { AgentRuntimeError, } from "@rynx-ai/core";
1
12
  import { TerminalRegistry } from "../terminal/registry.js";
2
- import { toWireError } from "./protocol.js";
13
+ import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
14
+ import { isCodexLineageProvider } from "./startup-policy.js";
15
+ /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
16
+ const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
17
+ function providerDisplayName(runtime) {
18
+ return runtime === "traex" ? "Traex" : runtime === "claude" ? "Claude" : "Codex";
19
+ }
20
+ function providerFailure(provider, localThreadId, fallback) {
21
+ return provider.liveSessionFailure?.(localThreadId) ?? {
22
+ message: provider.liveSessionError?.(localThreadId) ?? fallback.message,
23
+ code: fallback.code,
24
+ statusCode: 503,
25
+ };
26
+ }
27
+ function nativePhaseError(runtime, code, phase, cause) {
28
+ const detail = cause === undefined
29
+ ? ""
30
+ : `: ${cause instanceof Error ? cause.message : String(cause)}`;
31
+ return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
32
+ }
33
+ function terminalFailureDetail(pane) {
34
+ // A captured pane is a screen snapshot, not stderr. Do not present ordinary
35
+ // TUI chrome ("Working", shortcuts, model/status bars) as the process's exit
36
+ // cause. Retain only lines that plausibly carry an actual startup/runtime
37
+ // failure; the structured error still reports the proven pane exit itself.
38
+ return pane
39
+ .split("\n")
40
+ .map((line) => line.trim())
41
+ .filter(Boolean)
42
+ .filter((line) => /\b(?:error|fatal|failed|failure|panic|refused|denied|unauthorized|invalid)\b/i.test(line))
43
+ .slice(-6)
44
+ .join(" ")
45
+ .slice(-1_000);
46
+ }
47
+ const TRAEX_STARTUP_WATCH_MS = 20_000;
48
+ const TRAEX_STARTUP_DISCOVERY_MS = 5_000;
49
+ const TRAEX_STARTUP_POLL_MS = 100;
50
+ const TRAEX_AUTHORIZATION_POLL_MS = 500;
51
+ const TRAEX_AUTHORIZATION_WAIT_MS = 15 * 60_000;
52
+ const TRAEX_ESCAPE_CONFIRM_MS = 1_000;
53
+ const TRAEX_ESCAPE_COOLDOWN_MS = 1_000;
54
+ const TRAEX_REPEAT_PROMPT_CONFIRM_MS = 5_000;
55
+ const MIRROR_IMAGE_ACK_TIMEOUT_MS = 30_000;
56
+ function normalizeTraexPane(pane) {
57
+ return pane.toLowerCase().replace(/\s+/g, " ").trim();
58
+ }
59
+ function isTraexAuthorizationPending(pane) {
60
+ return pane.includes("waiting for authorization") &&
61
+ pane.includes("open this link in your browser") &&
62
+ pane.includes("press esc to cancel");
63
+ }
64
+ function isTraexStartupActivity(pane) {
65
+ return pane.includes("updating traecode cli") || pane.includes("updating trae cli");
66
+ }
67
+ function isTerminalProtocolResponse(input) {
68
+ // xterm answers terminal queries through the same onData channel as real
69
+ // keystrokes. CSI carries device/focus/position reports; OSC carries color
70
+ // query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
71
+ // Neither is evidence that the user has taken over startup prompt handling.
72
+ return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
73
+ }
74
+ function detachGeneratedImageResult(event) {
75
+ if (event.type !== "response.output_item.done" ||
76
+ event.item.type !== "function_call_output") {
77
+ return undefined;
78
+ }
79
+ const data = event.item.data;
80
+ const pending = data.__rynxGeneratedImage;
81
+ if (!pending?.result)
82
+ return undefined;
83
+ const { result, ...withoutResult } = pending;
84
+ return {
85
+ result,
86
+ event: {
87
+ ...event,
88
+ item: {
89
+ ...event.item,
90
+ data: {
91
+ ...data,
92
+ __rynxGeneratedImage: withoutResult,
93
+ },
94
+ },
95
+ },
96
+ };
97
+ }
98
+ function generatedImagePreviewUnavailable(event) {
99
+ if (event.type !== "response.output_item.done" ||
100
+ event.item.type !== "function_call_output") {
101
+ return event;
102
+ }
103
+ const data = event.item.data;
104
+ const { __rynxGeneratedImage: _omitted, ...publicData } = data;
105
+ return {
106
+ ...event,
107
+ item: {
108
+ ...event.item,
109
+ data: {
110
+ ...publicData,
111
+ output: `${event.item.data.output}\nImage preview unavailable.`.trim(),
112
+ },
113
+ },
114
+ };
115
+ }
3
116
  export class RunnerSession {
4
117
  transport;
5
118
  executor;
@@ -7,11 +120,19 @@ export class RunnerSession {
7
120
  /** Live terminals hosted by this session, and per-attach client handles. */
8
121
  terminals = new TerminalRegistry();
9
122
  attachments = new Map();
123
+ attachmentThreadIds = new Map();
124
+ traexStartupWatchers = new Map();
125
+ traexStartupGates = new Map();
126
+ terminalWatchers = new Map();
10
127
  /** Opens are async; a close received before attach resolves tombstones the id. */
11
128
  pendingTerminalOpens = new Set();
12
129
  cancelledTerminalOpens = new Set();
13
130
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
14
131
  liveIds = new Set();
132
+ mirrorQueue = Promise.resolve();
133
+ mirrorImageAcks = new Map();
134
+ /** Provider name retained for asynchronous Terminal-exit diagnostics. */
135
+ liveRuntimes = new Map();
15
136
  shuttingDown = false;
16
137
  constructor({ transport, executor, onShutdown }) {
17
138
  this.transport = transport;
@@ -22,10 +143,36 @@ export class RunnerSession {
22
143
  transport.close();
23
144
  });
24
145
  this.transport.onMessage((msg) => this.handle(msg));
146
+ this.transport.onClose((error) => {
147
+ this.rejectMirrorImageAcks(error ?? new Error("runner transport closed"));
148
+ });
25
149
  this.transport.send({ t: "ready" });
26
150
  }
27
151
  handle(msg) {
28
152
  switch (msg.t) {
153
+ case "mirror.image.ack": {
154
+ const pending = this.mirrorImageAcks.get(msg.transferId);
155
+ if (!pending)
156
+ return;
157
+ if (pending.seq !== msg.seq) {
158
+ pending.reject(new Error("generated image acknowledgement sequence changed"));
159
+ return;
160
+ }
161
+ if (pending.timer)
162
+ clearTimeout(pending.timer);
163
+ this.mirrorImageAcks.delete(msg.transferId);
164
+ pending.resolve();
165
+ return;
166
+ }
167
+ case "mirror.image.hold": {
168
+ const pending = this.mirrorImageAcks.get(msg.transferId);
169
+ if (!pending || pending.seq !== msg.seq)
170
+ return;
171
+ if (pending.timer)
172
+ clearTimeout(pending.timer);
173
+ pending.timer = undefined;
174
+ return;
175
+ }
29
176
  case "cap":
30
177
  void this.runCap(msg.capId, msg.name, msg.args);
31
178
  return;
@@ -46,7 +193,17 @@ export class RunnerSession {
46
193
  });
47
194
  return;
48
195
  case "term.input":
49
- this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
196
+ {
197
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
198
+ const input = Buffer.from(msg.dataB64, "base64").toString("utf8");
199
+ // xterm sends device/focus reports through the same onData channel as
200
+ // keystrokes. They must reach the TUI without pretending the user has
201
+ // taken over startup prompt handling.
202
+ if (!isTerminalProtocolResponse(input)) {
203
+ this.cancelTraexStartupWatcher(localThreadId);
204
+ }
205
+ this.attachments.get(msg.attachId)?.write(input);
206
+ }
50
207
  return;
51
208
  case "term.resize":
52
209
  this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
@@ -57,6 +214,7 @@ export class RunnerSession {
57
214
  }
58
215
  const attachment = this.attachments.get(msg.attachId);
59
216
  this.attachments.delete(msg.attachId);
217
+ this.attachmentThreadIds.delete(msg.attachId);
60
218
  attachment?.kill();
61
219
  return;
62
220
  }
@@ -70,6 +228,9 @@ export class RunnerSession {
70
228
  case "inject":
71
229
  void this.inject(msg);
72
230
  return;
231
+ case "collaboration.update":
232
+ void this.updateCollaborationMode(msg);
233
+ return;
73
234
  case "live.interrupt":
74
235
  void this.interruptLive(msg);
75
236
  return;
@@ -106,27 +267,97 @@ export class RunnerSession {
106
267
  * the new session stays injectable. */
107
268
  mirrorChannel(localThreadId) {
108
269
  const target = { id: localThreadId };
109
- const emit = (event) => this.transport.send({ t: "mirror", sessionId: target.id, event });
270
+ const emit = (event) => {
271
+ const sessionId = target.id;
272
+ this.enqueueMirror(() => this.sendMirroredEvent(sessionId, event));
273
+ };
110
274
  const retarget = (newId, meta) => {
111
275
  target.id = newId;
112
- this.transport.send({
113
- t: "rotate",
114
- from: localThreadId,
115
- to: newId,
116
- kind: meta.kind,
117
- workspace: meta.workspace,
118
- execution: meta.execution,
119
- ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
276
+ this.enqueueMirror(() => {
277
+ this.transport.send({
278
+ t: "rotate",
279
+ from: localThreadId,
280
+ to: newId,
281
+ kind: meta.kind,
282
+ workspace: meta.workspace,
283
+ execution: meta.execution,
284
+ ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
285
+ });
120
286
  });
121
287
  };
122
288
  return { emit, retarget };
123
289
  }
290
+ enqueueMirror(operation) {
291
+ this.mirrorQueue = this.mirrorQueue.then(operation).catch((error) => {
292
+ if (!this.shuttingDown) {
293
+ this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
294
+ this.shutdown();
295
+ }
296
+ });
297
+ }
298
+ async sendMirroredEvent(sessionId, event) {
299
+ const detached = detachGeneratedImageResult(event);
300
+ if (!detached) {
301
+ this.transport.send({ t: "mirror", sessionId, event });
302
+ return;
303
+ }
304
+ if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
305
+ this.transport.send({
306
+ t: "mirror",
307
+ sessionId,
308
+ event: generatedImagePreviewUnavailable(detached.event),
309
+ });
310
+ return;
311
+ }
312
+ const transferId = `img_${randomUUID()}`;
313
+ let seq = 0;
314
+ await this.sendMirrorImageFrame(transferId, seq, {
315
+ t: "mirror.image.begin",
316
+ transferId,
317
+ sessionId,
318
+ event: detached.event,
319
+ totalChars: detached.result.length,
320
+ });
321
+ for (let offset = 0; offset < detached.result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
322
+ seq += 1;
323
+ await this.sendMirrorImageFrame(transferId, seq, {
324
+ t: "mirror.image.chunk",
325
+ transferId,
326
+ seq,
327
+ data: detached.result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
328
+ });
329
+ }
330
+ seq += 1;
331
+ await this.sendMirrorImageFrame(transferId, seq, {
332
+ t: "mirror.image.commit",
333
+ transferId,
334
+ seq,
335
+ });
336
+ }
337
+ sendMirrorImageFrame(transferId, seq, frame) {
338
+ return new Promise((resolve, reject) => {
339
+ const timer = setTimeout(() => {
340
+ this.mirrorImageAcks.delete(transferId);
341
+ reject(new Error("generated image transfer acknowledgement timed out"));
342
+ }, MIRROR_IMAGE_ACK_TIMEOUT_MS);
343
+ timer.unref?.();
344
+ this.mirrorImageAcks.set(transferId, { seq, resolve, reject, timer });
345
+ this.transport.send(frame);
346
+ });
347
+ }
348
+ rejectMirrorImageAcks(error) {
349
+ for (const pending of this.mirrorImageAcks.values()) {
350
+ if (pending.timer)
351
+ clearTimeout(pending.timer);
352
+ pending.reject(error);
353
+ }
354
+ this.mirrorImageAcks.clear();
355
+ }
124
356
  /**
125
- * Eagerly bring up a session's codex-native live view: start the persistent
126
- * forwarder connection (which resume-subscribes to mirror every turn) and
127
- * launch the detached `codex --remote resume` TUI against the thread the
128
- * structured runtime created. The forwarder subscribes to that same thread,
129
- * so the TUI is usable immediately and its turns mirror to chat.
357
+ * Eagerly bring up a session's codex-native live view. Fresh sessions connect
358
+ * the discovery listener before launching the detached TUI; known-thread
359
+ * resumes preload first, launch the replacement TUI, then start their
360
+ * persistent observer in the background.
130
361
  */
131
362
  async ensureLive(msg) {
132
363
  const provider = this.liveProvider;
@@ -138,43 +369,121 @@ export class RunnerSession {
138
369
  retargetMirror: retarget,
139
370
  });
140
371
  if (!started) {
372
+ const runtime = msg.execution?.provider;
141
373
  this.transport.send({
142
374
  t: "live.ready",
143
375
  reqId: msg.reqId,
144
376
  localThreadId: msg.localThreadId,
145
377
  ok: false,
146
- error: "live provider did not start",
378
+ error: providerFailure(provider, msg.localThreadId, {
379
+ runtime,
380
+ code: "native_start_failed",
381
+ message: `${providerDisplayName(runtime)} native session did not start`,
382
+ }),
147
383
  });
148
384
  return;
149
385
  }
150
386
  this.liveIds.add(msg.localThreadId);
387
+ const runtime = msg.execution?.provider;
388
+ if (runtime)
389
+ this.liveRuntimes.set(msg.localThreadId, runtime);
151
390
  // Launch the TUI attached to the already-bound thread. Re-launch when the
152
391
  // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
153
392
  // reconnect after the TUI exited restarts it instead of skipping (a
154
393
  // launched-once guard would leave a dead "Pane is dead" husk forever).
155
394
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
156
- const terminalReady = provider.waitTerminalReady
157
- ? await provider.waitTerminalReady(msg.localThreadId)
158
- : true;
395
+ // Codex/Traex `ensureLiveCodexSession` already performs the backend
396
+ // preload for an existing thread. Match the native lifecycle: launch
397
+ // the TUI immediately after that preload and let the observer subscribe
398
+ // in the background. Fresh sessions likewise launch before discovery.
399
+ const terminalReady = isCodexLineageProvider(runtime)
400
+ ? true
401
+ : provider.waitTerminalReady
402
+ ? await provider.waitTerminalReady(msg.localThreadId)
403
+ : true;
159
404
  if (!terminalReady) {
405
+ const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
160
406
  this.transport.send({
161
407
  t: "live.ready",
162
408
  reqId: msg.reqId,
163
409
  localThreadId: msg.localThreadId,
164
410
  ok: false,
165
- error: "Provider thread was not ready for Terminal resume",
411
+ error: providerFailure(provider, msg.localThreadId, {
412
+ runtime,
413
+ code: "native_resume_failed",
414
+ message: runtime === "codex" || runtime === "traex"
415
+ ? `${providerName} app-server was ready, but its existing session could not be resumed for the Terminal`
416
+ : "Provider thread was not ready for Terminal resume",
417
+ }),
166
418
  });
167
419
  return;
168
420
  }
169
- await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
421
+ try {
422
+ await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
423
+ }
424
+ catch (error) {
425
+ throw error instanceof AgentRuntimeError
426
+ ? error
427
+ : nativePhaseError(runtime, "native_terminal_launch_failed", "Terminal failed to launch", error);
428
+ }
170
429
  }
171
- const ready = msg.waitForReady === false
172
- ? true
173
- : provider.waitLiveReady
174
- ? await provider.waitLiveReady(msg.localThreadId)
175
- : true;
176
- const terminal = this.terminals.get(`${msg.localThreadId}-main`);
177
- const paneFailure = !ready && terminal && !terminal.isAlive()
430
+ // reference implementation's known-thread path launches `codex --remote resume` first,
431
+ // then creates `_codex_forward_known_thread` as background work. Keep the
432
+ // ordering explicit here: observer connection failure cannot delay or
433
+ // reject cold-resume admission.
434
+ if (isCodexLineageProvider(runtime)) {
435
+ provider.startLiveCodexObserver?.(msg.localThreadId);
436
+ }
437
+ let ready = true;
438
+ if (runtime === "codex" || runtime === "traex") {
439
+ // Discover thread/started in the background for 30s while injection
440
+ // waits up to 60s for either bridge readiness or that startup error.
441
+ // Do not serialize the two waits here.
442
+ this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
443
+ }
444
+ else if (msg.waitForReady !== false && provider.waitLiveReady) {
445
+ ready = await provider.waitLiveReady(msg.localThreadId);
446
+ }
447
+ // Message delivery is serialized behind Traex's startup modal handling.
448
+ // Otherwise app-server can accept a Turn while the TUI watcher is still
449
+ // sending Escape, and the same key becomes `turn/interrupt` as soon as
450
+ // the modal disappears. Setup-terminal opens remain non-blocking.
451
+ if (runtime === "traex" && msg.waitForReady !== false) {
452
+ await this.traexStartupGates.get(msg.localThreadId);
453
+ }
454
+ const readinessError = ready
455
+ ? undefined
456
+ : providerFailure(provider, msg.localThreadId, {
457
+ runtime,
458
+ code: "native_readiness_failed",
459
+ message: `${providerDisplayName(runtime)} native session was not ready`,
460
+ });
461
+ this.transport.send({
462
+ t: "live.ready",
463
+ reqId: msg.reqId,
464
+ localThreadId: msg.localThreadId,
465
+ ok: ready,
466
+ ...(ready ? {} : { error: readinessError }),
467
+ });
468
+ }
469
+ catch (error) {
470
+ this.transport.send({
471
+ t: "live.ready",
472
+ reqId: msg.reqId,
473
+ localThreadId: msg.localThreadId,
474
+ ok: false,
475
+ error: toWireError(error),
476
+ });
477
+ }
478
+ }
479
+ monitorCodexThreadStartup(localThreadId, runtime, provider) {
480
+ if (!provider.waitLiveReady)
481
+ return;
482
+ void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
483
+ if (ready)
484
+ return;
485
+ const terminal = this.terminals.get(`${localThreadId}-main`);
486
+ const paneFailure = terminal && !terminal.isAlive()
178
487
  ? terminal
179
488
  .capturePane()
180
489
  .split("\n")
@@ -184,42 +493,106 @@ export class RunnerSession {
184
493
  .join(" ")
185
494
  .slice(-1_000)
186
495
  : "";
187
- const readinessError = provider.liveSessionError?.(msg.localThreadId)
496
+ const detail = provider.liveSessionError?.(localThreadId)
188
497
  ?? (paneFailure
189
- ? `Provider terminal exited before session discovery: ${paneFailure}`
190
- : "live session was not ready before timeout");
498
+ ? `${providerDisplayName(runtime)} Terminal exited before native session discovery: ${paneFailure}`
499
+ : `${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`);
500
+ this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
501
+ ? "native_terminal_exited_before_session"
502
+ : "native_thread_discovery_timeout"));
503
+ }).catch((error) => {
504
+ this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
505
+ ? error
506
+ : nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error));
507
+ });
508
+ }
509
+ /** Discovery failure is terminal for this partial native launch. Publish the
510
+ * exact cause first so an in-flight injection wakes, then drop the observer
511
+ * and TUI so a later task retry creates a clean launch instead of reusing an
512
+ * already-rejected readiness promise. */
513
+ failCodexThreadStartup(localThreadId, provider, error) {
514
+ if (!provider.failLiveStartup?.(localThreadId, error))
515
+ return false;
516
+ const watcher = this.terminalWatchers.get(localThreadId);
517
+ if (watcher)
518
+ clearInterval(watcher);
519
+ this.terminalWatchers.delete(localThreadId);
520
+ this.cancelTraexStartupWatcher(localThreadId);
521
+ try {
522
+ this.terminals.close(`${localThreadId}-main`);
523
+ }
524
+ catch (closeError) {
525
+ console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
526
+ }
527
+ if (provider.teardownLiveCodexSession) {
528
+ provider.teardownLiveCodexSession(localThreadId);
529
+ }
530
+ else {
531
+ provider.stopLiveCodexSession?.(localThreadId);
532
+ }
533
+ this.liveIds.delete(localThreadId);
534
+ return true;
535
+ }
536
+ async inject(msg) {
537
+ const provider = this.liveProvider;
538
+ try {
539
+ const input = msg.input ?? msg.text;
540
+ const result = (await provider.injectMessage?.(msg.localThreadId, input, msg.options))
541
+ ?? { outcome: "notLive" };
542
+ // App-server injection is independent of the Terminal TUI startup, so it
543
+ // must not cancel prompt handling for the pane that is still starting.
544
+ const error = result.outcome === "injected" || result.outcome === "steered"
545
+ ? undefined
546
+ : providerFailure(provider, msg.localThreadId, {
547
+ code: result.outcome === "notReady"
548
+ ? "native_thread_not_ready"
549
+ : result.outcome === "notLive"
550
+ ? "native_session_not_live"
551
+ : "native_message_injection_failed",
552
+ message: result.outcome === "notReady"
553
+ ? "Native session was not ready for message injection"
554
+ : result.outcome === "notLive"
555
+ ? "Native session was not live for message injection"
556
+ : "Native message injection failed",
557
+ });
191
558
  this.transport.send({
192
- t: "live.ready",
559
+ t: "injected",
193
560
  reqId: msg.reqId,
194
561
  localThreadId: msg.localThreadId,
195
- ok: ready,
196
- ...(ready ? {} : { error: readinessError }),
562
+ result,
563
+ ...(error ? { error } : {}),
197
564
  });
198
565
  }
199
566
  catch (error) {
200
567
  this.transport.send({
201
- t: "live.ready",
568
+ t: "injected",
202
569
  reqId: msg.reqId,
203
570
  localThreadId: msg.localThreadId,
204
- ok: false,
205
- error: error instanceof Error ? error.message : String(error),
571
+ result: { outcome: "failed" },
572
+ error: toWireError(error),
206
573
  });
207
574
  }
208
575
  }
209
- async inject(msg) {
210
- const provider = this.liveProvider;
576
+ async updateCollaborationMode(msg) {
211
577
  try {
212
- const input = msg.input ?? msg.text;
213
- const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
214
- this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
578
+ if (!this.liveProvider.updateCollaborationMode) {
579
+ throw new AgentRuntimeError("Native collaboration mode is unavailable", 409, "collaboration_mode_unavailable");
580
+ }
581
+ await this.liveProvider.updateCollaborationMode(msg.localThreadId, msg.mode);
582
+ this.transport.send({
583
+ t: "collaboration.updated",
584
+ reqId: msg.reqId,
585
+ localThreadId: msg.localThreadId,
586
+ ok: true,
587
+ });
215
588
  }
216
589
  catch (error) {
217
590
  this.transport.send({
218
- t: "injected",
591
+ t: "collaboration.updated",
219
592
  reqId: msg.reqId,
220
593
  localThreadId: msg.localThreadId,
221
- outcome: "failed",
222
- error: error instanceof Error ? error.message : String(error),
594
+ ok: false,
595
+ error: toWireError(error),
223
596
  });
224
597
  }
225
598
  }
@@ -231,7 +604,7 @@ export class RunnerSession {
231
604
  ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
232
605
  }
233
606
  catch (e) {
234
- error = e instanceof Error ? e.message : String(e);
607
+ error = toWireError(e);
235
608
  }
236
609
  this.transport.send({
237
610
  t: "interrupted",
@@ -248,7 +621,8 @@ export class RunnerSession {
248
621
  const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
249
622
  if (!spec)
250
623
  return;
251
- const term = this.terminals.getOrCreate(`${localThreadId}-main`, {
624
+ const terminalId = `${localThreadId}-main`;
625
+ const term = this.terminals.getOrCreate(terminalId, {
252
626
  cwd: spec.cwd,
253
627
  command: spec.command,
254
628
  args: spec.args,
@@ -256,15 +630,218 @@ export class RunnerSession {
256
630
  rows: rows ?? 40,
257
631
  ...(spec.env ? { env: spec.env } : {}),
258
632
  });
633
+ if (spec.skipTraexStartupPrompts) {
634
+ const watcher = Symbol(localThreadId);
635
+ this.traexStartupWatchers.set(localThreadId, watcher);
636
+ const gate = this.skipTraexStartupPrompts(localThreadId, term, watcher);
637
+ this.traexStartupGates.set(localThreadId, gate);
638
+ void gate.finally(() => {
639
+ if (this.traexStartupGates.get(localThreadId) === gate) {
640
+ this.traexStartupGates.delete(localThreadId);
641
+ }
642
+ });
643
+ }
259
644
  this.liveProvider.attachTerminalInjector?.(localThreadId, term);
645
+ this.watchNativeTerminal(localThreadId, terminalId, term);
646
+ }
647
+ watchNativeTerminal(localThreadId, terminalId, terminal) {
648
+ const previous = this.terminalWatchers.get(localThreadId);
649
+ if (previous)
650
+ clearInterval(previous);
651
+ let checking = false;
652
+ const timer = setInterval(() => {
653
+ if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
654
+ return;
655
+ checking = true;
656
+ const probe = terminal.lifecycleLivenessAsync
657
+ ? terminal.lifecycleLivenessAsync()
658
+ : terminal.livenessAsync
659
+ ? terminal.livenessAsync()
660
+ : terminal.isAliveAsync
661
+ ? terminal.isAliveAsync().then((alive) => alive ? "alive" : "dead")
662
+ : Promise.resolve(terminal.isAlive() ? "alive" : "dead");
663
+ void probe.then((liveness) => {
664
+ // A failed pane probe is inconclusive. Only reference implementation's lifecycle
665
+ // evidence — capture target gone or explicit pane_dead — may fail it.
666
+ if (liveness !== "dead" ||
667
+ this.shuttingDown ||
668
+ this.terminals.get(terminalId) !== terminal)
669
+ return;
670
+ clearInterval(timer);
671
+ this.terminalWatchers.delete(localThreadId);
672
+ this.cancelTraexStartupWatcher(localThreadId);
673
+ const runtime = this.liveRuntimes.get(localThreadId);
674
+ const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
675
+ const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
676
+ if (isCodexLineageProvider(runtime)) {
677
+ if (!startupFailed) {
678
+ try {
679
+ this.terminals.close(terminalId);
680
+ }
681
+ catch (closeError) {
682
+ console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
683
+ }
684
+ const exitError = nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined);
685
+ if (this.liveProvider.teardownLiveCodexSession) {
686
+ this.liveProvider.teardownLiveCodexSession(localThreadId, exitError);
687
+ }
688
+ else {
689
+ this.liveProvider.failLiveSession?.(localThreadId, exitError);
690
+ this.liveProvider.stopLiveCodexSession?.(localThreadId);
691
+ }
692
+ this.liveIds.delete(localThreadId);
693
+ console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited; native runtime torn down for cold resume`);
694
+ }
695
+ return;
696
+ }
697
+ this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
698
+ }).catch((error) => {
699
+ console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
700
+ }).finally(() => {
701
+ checking = false;
702
+ });
703
+ }, 1_000);
704
+ timer.unref?.();
705
+ this.terminalWatchers.set(localThreadId, timer);
706
+ }
707
+ cancelTraexStartupWatcher(localThreadId) {
708
+ if (localThreadId)
709
+ this.traexStartupWatchers.delete(localThreadId);
710
+ }
711
+ async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
712
+ const prompts = [
713
+ {
714
+ id: "welcome",
715
+ matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
716
+ dismiss: () => terminal.sendEnter(),
717
+ requiresEscapeConfirmation: false,
718
+ },
719
+ {
720
+ id: "migration",
721
+ matches: (pane) => (pane.includes("legacy traecode cli data detected") ||
722
+ pane.includes("legacy trae cli data detected")) &&
723
+ pane.includes("select what to import") &&
724
+ (pane.includes("skip for now") || pane.includes("don't ask again")),
725
+ dismiss: () => terminal.interrupt(),
726
+ requiresEscapeConfirmation: true,
727
+ },
728
+ {
729
+ id: "hooks",
730
+ matches: (pane) => pane.includes("hooks need review") &&
731
+ pane.includes("trust all and continue") &&
732
+ pane.includes("continue without trusting"),
733
+ dismiss: () => terminal.interrupt(),
734
+ requiresEscapeConfirmation: true,
735
+ },
736
+ ];
737
+ let activeWelcome = false;
738
+ let pendingEscapePromptId;
739
+ const dismissedPromptIds = new Set();
740
+ let dismissedAnyPrompt = false;
741
+ const startedAt = Date.now();
742
+ let deadline = startedAt + TRAEX_STARTUP_WATCH_MS;
743
+ let discoveryDeadline = startedAt + TRAEX_STARTUP_DISCOVERY_MS;
744
+ const authorizationDeadline = startedAt + TRAEX_AUTHORIZATION_WAIT_MS;
745
+ const terminalId = `${localThreadId}-main`;
746
+ while (!this.shuttingDown &&
747
+ Date.now() < deadline &&
748
+ this.traexStartupWatchers.get(localThreadId) === watcher &&
749
+ this.terminals.get(terminalId) === terminal) {
750
+ // Do not treat a composer frame as completion: Traex can render it before
751
+ // the startup modals arrive. The bounded deadline stops this watcher.
752
+ const pane = normalizeTraexPane(terminal.capturePane());
753
+ const prompt = prompts.find((candidate) => candidate.matches(pane));
754
+ const now = Date.now();
755
+ const authorizationPending = isTraexAuthorizationPending(pane);
756
+ if (isTraexStartupActivity(pane)) {
757
+ discoveryDeadline = now + TRAEX_STARTUP_DISCOVERY_MS;
758
+ }
759
+ // Human device authorization routinely takes longer than the normal
760
+ // startup-modal window. Keep watching while that known screen remains,
761
+ // then preserve a full window for welcome/migration/hooks after sign-in.
762
+ // The separate cap prevents an abandoned auth screen from polling forever.
763
+ if (authorizationPending && now < authorizationDeadline) {
764
+ deadline = now + TRAEX_STARTUP_WATCH_MS;
765
+ }
766
+ if (!prompt) {
767
+ activeWelcome = false;
768
+ pendingEscapePromptId = undefined;
769
+ // Every dismissal is followed by a one-second cooldown before the next
770
+ // capture. If no known modal remains then, startup prompt handling is
771
+ // complete and message injection may safely begin.
772
+ if (dismissedAnyPrompt)
773
+ break;
774
+ // A clean Traex home may have no startup modal at all. Preserve a
775
+ // five-second discovery window for late modals, but do not impose the
776
+ // old full 20-second watcher lifetime on every cold message. A visible
777
+ // updater keeps the full startup window because prompts may follow it.
778
+ if (now >= discoveryDeadline &&
779
+ !authorizationPending &&
780
+ !isTraexStartupActivity(pane))
781
+ break;
782
+ }
783
+ else if (!prompt.requiresEscapeConfirmation) {
784
+ pendingEscapePromptId = undefined;
785
+ if (!activeWelcome && !dismissedPromptIds.has(prompt.id)) {
786
+ prompt.dismiss();
787
+ dismissedPromptIds.add(prompt.id);
788
+ dismissedAnyPrompt = true;
789
+ await new Promise((resolve) => {
790
+ const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
791
+ timer.unref();
792
+ });
793
+ continue;
794
+ }
795
+ activeWelcome = true;
796
+ }
797
+ else if (prompt.id !== pendingEscapePromptId) {
798
+ activeWelcome = false;
799
+ pendingEscapePromptId = prompt.id;
800
+ await new Promise((resolve) => {
801
+ // Traex can show hooks -> migration -> hooks during one startup. The
802
+ // trailing hooks screen normally disappears on its own, so observe a
803
+ // previously handled prompt type for five seconds before acting.
804
+ const timer = setTimeout(resolve, dismissedPromptIds.has(prompt.id)
805
+ ? TRAEX_REPEAT_PROMPT_CONFIRM_MS
806
+ : TRAEX_ESCAPE_CONFIRM_MS);
807
+ timer.unref();
808
+ });
809
+ continue;
810
+ }
811
+ else {
812
+ prompt.dismiss();
813
+ dismissedPromptIds.add(prompt.id);
814
+ dismissedAnyPrompt = true;
815
+ // Keep the id through the cooldown. If the same prompt is genuinely
816
+ // still active after a sent key, the next check retries it; a prompt
817
+ // that disappeared during the cooldown never receives another key.
818
+ pendingEscapePromptId = prompt.id;
819
+ await new Promise((resolve) => {
820
+ const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
821
+ timer.unref();
822
+ });
823
+ continue;
824
+ }
825
+ await new Promise((resolve) => {
826
+ const timer = setTimeout(resolve, authorizationPending ? TRAEX_AUTHORIZATION_POLL_MS : TRAEX_STARTUP_POLL_MS);
827
+ timer.unref();
828
+ });
829
+ }
830
+ if (this.traexStartupWatchers.get(localThreadId) === watcher) {
831
+ this.traexStartupWatchers.delete(localThreadId);
832
+ }
260
833
  }
261
834
  stopLive() {
835
+ for (const timer of this.terminalWatchers.values())
836
+ clearInterval(timer);
837
+ this.terminalWatchers.clear();
262
838
  for (const id of this.liveIds) {
263
839
  this.liveProvider.stopLiveCodexSession?.(id, {
264
840
  deferClaudeInteractionCleanup: true,
265
841
  });
266
842
  }
267
843
  this.liveIds.clear();
844
+ this.liveRuntimes.clear();
268
845
  }
269
846
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
270
847
  * scrub provider handoff files before the child process is allowed to exit. */
@@ -272,6 +849,7 @@ export class RunnerSession {
272
849
  if (this.shuttingDown)
273
850
  return;
274
851
  this.shuttingDown = true;
852
+ this.rejectMirrorImageAcks(new Error("runner is shutting down"));
275
853
  this.stopLive();
276
854
  this.terminals.closeAll();
277
855
  this.liveProvider.finalizeStoppedLiveSessions?.();
@@ -336,6 +914,8 @@ export class RunnerSession {
336
914
  return;
337
915
  }
338
916
  this.attachments.set(msg.attachId, attachment);
917
+ if (msg.localThreadId)
918
+ this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
339
919
  attachment.onData((chunk) => this.transport.send({
340
920
  t: "term.data",
341
921
  attachId: msg.attachId,
@@ -343,6 +923,7 @@ export class RunnerSession {
343
923
  }));
344
924
  attachment.onExit((info) => {
345
925
  this.attachments.delete(msg.attachId);
926
+ this.attachmentThreadIds.delete(msg.attachId);
346
927
  this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
347
928
  });
348
929
  this.transport.send({ t: "term.opened", attachId: msg.attachId, role });