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

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