@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21

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.
package/dist/index.d.ts CHANGED
@@ -13,6 +13,6 @@ export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runne
13
13
  export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
14
  export { probeRuntimeStatus } from "./runtime-status.js";
15
15
  export { listRuntimeModels } from "./models-catalog.js";
16
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
16
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
17
17
  export type { TerminalAttachment, TmuxTerminalOptions } from "./terminal/tmux.js";
18
18
  export { TerminalRegistry } from "./terminal/registry.js";
package/dist/index.js CHANGED
@@ -12,5 +12,5 @@ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
12
12
  export { probeRuntimeStatus } from "./runtime-status.js";
13
13
  export { listRuntimeModels } from "./models-catalog.js";
14
14
  // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
15
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
15
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
16
16
  export { TerminalRegistry } from "./terminal/registry.js";
@@ -1,11 +1,12 @@
1
1
  import { type AgentRuntimeId, type AppConfig } from "@rynx-ai/core";
2
2
  import type { ModelListResponse } from "./codex-app-server/protocol.js";
3
3
  export interface RuntimeModelCatalogDeps {
4
+ readCodexModels?: () => Promise<unknown>;
4
5
  readTraexModels?: () => Promise<unknown>;
5
6
  readTraexDebugModels?: () => Promise<unknown>;
6
7
  }
7
8
  /**
8
9
  * The model list for a runtime, without an execution backend.
9
- * Falls back to the configured model if live Traex discovery is unavailable.
10
+ * Falls back to the configured model if local/native discovery is unavailable.
10
11
  */
11
12
  export declare function listRuntimeModels(config: AppConfig, runtime: AgentRuntimeId, deps?: RuntimeModelCatalogDeps): Promise<ModelListResponse | null>;
@@ -3,24 +3,37 @@
3
3
  *
4
4
  * The parent control plane no longer holds an app-server, so `/models` can't be
5
5
  * a live `model/list` RPC anymore. Following reference implementation's static-catalog model, we
6
- * serve a config-derived Codex list and Traex's native `models --json` catalog.
6
+ * serve Codex's local model cache and Traex's native `models --json` catalog.
7
7
  * claude already has its own static list ({@link listClaudeModels}).
8
8
  */
9
9
  import { execFile } from "node:child_process";
10
+ import { readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
10
12
  import { promisify } from "node:util";
11
- import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
13
+ import { getRuntimeProfile, resolveRuntimeBinary, resolveRuntimeHome, resolveRuntimeModel, } from "@rynx-ai/core";
12
14
  import { listClaudeModels } from "./claude/models.js";
13
15
  const execFileAsync = promisify(execFile);
14
16
  const TRAEX_MODELS_TIMEOUT_MS = 8_000;
15
17
  const TRAEX_MODELS_MAX_BYTES = 2 * 1024 * 1024;
16
18
  /**
17
19
  * The model list for a runtime, without an execution backend.
18
- * Falls back to the configured model if live Traex discovery is unavailable.
20
+ * Falls back to the configured model if local/native discovery is unavailable.
19
21
  */
20
22
  export async function listRuntimeModels(config, runtime, deps = {}) {
21
23
  if (runtime === "claude") {
22
24
  return listClaudeModels();
23
25
  }
26
+ if (runtime === "codex") {
27
+ try {
28
+ const configuredDefault = resolveRuntimeModel(config, runtime).trim();
29
+ const models = normalizeCodexModels(await (deps.readCodexModels ?? readCodexModels)(), configuredDefault);
30
+ if (models.length > 0)
31
+ return { data: models };
32
+ }
33
+ catch {
34
+ // Keep the configured fallback usable before Codex has populated its cache.
35
+ }
36
+ }
24
37
  if (runtime === "traex") {
25
38
  try {
26
39
  const value = await (deps.readTraexModels ?? readTraexModels)();
@@ -48,6 +61,72 @@ export async function listRuntimeModels(config, runtime, deps = {}) {
48
61
  }
49
62
  return { data: [{ id: model, model, isDefault: true }] };
50
63
  }
64
+ async function readCodexModels() {
65
+ const cachePath = join(resolveRuntimeHome(getRuntimeProfile("codex")), "models_cache.json");
66
+ return JSON.parse(await readFile(cachePath, "utf8"));
67
+ }
68
+ function normalizeCodexModels(value, configuredDefault) {
69
+ if (!value || typeof value !== "object" || Array.isArray(value))
70
+ return [];
71
+ const entries = value.models;
72
+ if (!Array.isArray(entries))
73
+ return [];
74
+ const models = [];
75
+ const seen = new Set();
76
+ for (const item of entries) {
77
+ if (!item || typeof item !== "object" || Array.isArray(item))
78
+ continue;
79
+ const record = item;
80
+ const id = typeof record.slug === "string" ? record.slug.trim() : "";
81
+ if (!id || seen.has(id) || record.visibility === "hide")
82
+ continue;
83
+ seen.add(id);
84
+ const supportedReasoningEfforts = Array.isArray(record.supported_reasoning_levels)
85
+ ? record.supported_reasoning_levels.flatMap((raw) => {
86
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
87
+ return [];
88
+ const effort = typeof raw.effort === "string"
89
+ ? raw.effort.trim()
90
+ : "";
91
+ if (!effort)
92
+ return [];
93
+ const description = raw.description;
94
+ return [{
95
+ reasoningEffort: effort,
96
+ ...(typeof description === "string" && description.trim()
97
+ ? { description: description.trim() }
98
+ : {}),
99
+ }];
100
+ })
101
+ : [];
102
+ const displayName = typeof record.display_name === "string"
103
+ ? record.display_name.trim()
104
+ : "";
105
+ const description = typeof record.description === "string"
106
+ ? record.description.trim()
107
+ : "";
108
+ const defaultReasoningEffort = typeof record.default_reasoning_level === "string"
109
+ ? record.default_reasoning_level.trim()
110
+ : "";
111
+ models.push({
112
+ id,
113
+ model: id,
114
+ ...(displayName ? { displayName } : {}),
115
+ ...(description ? { description } : {}),
116
+ isDefault: id === configuredDefault,
117
+ ...(supportedReasoningEfforts.length > 0 ? { supportedReasoningEfforts } : {}),
118
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
119
+ });
120
+ }
121
+ if (configuredDefault && !seen.has(configuredDefault)) {
122
+ models.unshift({
123
+ id: configuredDefault,
124
+ model: configuredDefault,
125
+ isDefault: true,
126
+ });
127
+ }
128
+ return models;
129
+ }
51
130
  async function readTraexModels() {
52
131
  const { stdout } = await execFileAsync(resolveRuntimeBinary("traex"), ["models", "--json"], {
53
132
  encoding: "utf8",
@@ -31,15 +31,20 @@ interface LiveCodexProvider {
31
31
  args: string[];
32
32
  cwd: string;
33
33
  env?: Record<string, string>;
34
+ skipTraexStartupPrompts?: boolean;
34
35
  } | null>;
35
36
  ensureLiveCodexSession?(localThreadId: string, emit: (event: SessionEvent) => void, opts: {
36
37
  workspace: SessionWorkspaceSnapshot;
37
38
  execution: ResolvedExecutionSnapshot;
38
39
  retargetMirror?: RetargetMirror;
39
40
  }): Promise<boolean>;
40
- waitLiveReady?(localThreadId: string, timeoutMs?: number): Promise<boolean>;
41
- waitTerminalReady?(localThreadId: string, timeoutMs?: number): Promise<boolean>;
41
+ /** `null` delegates the deadline to RunnerManager. */
42
+ waitLiveReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
43
+ /** `null` delegates the deadline to RunnerManager. */
44
+ waitTerminalReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
42
45
  liveSessionError?(localThreadId: string): string | undefined;
46
+ failLiveStartup?(localThreadId: string, error: Error): boolean;
47
+ failLiveSession?(localThreadId: string, error: Error): boolean;
43
48
  injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
44
49
  interruptLive?(localThreadId: string): Promise<boolean>;
45
50
  stopLiveCodexSession?(localThreadId: string, opts?: {
@@ -71,6 +76,9 @@ export declare class RunnerSession {
71
76
  /** Live terminals hosted by this session, and per-attach client handles. */
72
77
  private readonly terminals;
73
78
  private readonly attachments;
79
+ private readonly attachmentThreadIds;
80
+ private readonly traexStartupWatchers;
81
+ private readonly terminalWatchers;
74
82
  /** Opens are async; a close received before attach resolves tombstones the id. */
75
83
  private readonly pendingTerminalOpens;
76
84
  private readonly cancelledTerminalOpens;
@@ -89,18 +97,26 @@ export declare class RunnerSession {
89
97
  private mirrorChannel;
90
98
  /**
91
99
  * Eagerly bring up a session's codex-native live view: start the persistent
92
- * forwarder connection (which resume-subscribes to mirror every turn) and
93
- * launch the detached `codex --remote resume` TUI against the thread the
94
- * structured runtime created. The forwarder subscribes to that same thread,
95
- * so the TUI is usable immediately and its turns mirror to chat.
100
+ * observer connection before launching the detached Codex TUI. Fresh-session
101
+ * thread discovery then continues in the background while the first
102
+ * injection waits for the same bridge state.
96
103
  */
97
104
  private ensureLive;
105
+ private monitorCodexThreadStartup;
106
+ /** Discovery failure is terminal for this partial native launch. Publish the
107
+ * exact cause first so an in-flight injection wakes, then drop the observer
108
+ * and TUI so a later task retry creates a clean launch instead of reusing an
109
+ * already-rejected readiness promise. */
110
+ private failCodexThreadStartup;
98
111
  private inject;
99
112
  private interruptLive;
100
113
  /** Launch (idempotently) the session's codex TUI pane from the executor's
101
114
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
102
115
  * so the web attach reuses the same detached pane. */
103
116
  private launchCodexPane;
117
+ private watchNativeTerminal;
118
+ private cancelTraexStartupWatcher;
119
+ private skipTraexStartupPrompts;
104
120
  private stopLive;
105
121
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
106
122
  * scrub provider handoff files before the child process is allowed to exit. */
@@ -1,5 +1,28 @@
1
1
  import { TerminalRegistry } from "../terminal/registry.js";
2
2
  import { toWireError } from "./protocol.js";
3
+ import { isManagedNativeProvider } from "./startup-policy.js";
4
+ /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
5
+ const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
6
+ const TRAEX_STARTUP_WATCH_MS = 20_000;
7
+ const TRAEX_STARTUP_POLL_MS = 100;
8
+ const TRAEX_AUTHORIZATION_POLL_MS = 500;
9
+ const TRAEX_AUTHORIZATION_WAIT_MS = 15 * 60_000;
10
+ const TRAEX_PROMPT_RETRY_MS = 500;
11
+ function normalizeTraexPane(pane) {
12
+ return pane.toLowerCase().replace(/\s+/g, " ").trim();
13
+ }
14
+ function isTraexAuthorizationPending(pane) {
15
+ return pane.includes("waiting for authorization") &&
16
+ pane.includes("open this link in your browser") &&
17
+ pane.includes("press esc to cancel");
18
+ }
19
+ function isTerminalProtocolResponse(input) {
20
+ // xterm answers terminal queries through the same onData channel as real
21
+ // keystrokes. CSI carries device/focus/position reports; OSC carries color
22
+ // query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
23
+ // Neither is evidence that the user has taken over startup prompt handling.
24
+ return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
25
+ }
3
26
  export class RunnerSession {
4
27
  transport;
5
28
  executor;
@@ -7,6 +30,9 @@ export class RunnerSession {
7
30
  /** Live terminals hosted by this session, and per-attach client handles. */
8
31
  terminals = new TerminalRegistry();
9
32
  attachments = new Map();
33
+ attachmentThreadIds = new Map();
34
+ traexStartupWatchers = new Map();
35
+ terminalWatchers = new Map();
10
36
  /** Opens are async; a close received before attach resolves tombstones the id. */
11
37
  pendingTerminalOpens = new Set();
12
38
  cancelledTerminalOpens = new Set();
@@ -46,7 +72,17 @@ export class RunnerSession {
46
72
  });
47
73
  return;
48
74
  case "term.input":
49
- this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
75
+ {
76
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
77
+ const input = Buffer.from(msg.dataB64, "base64").toString("utf8");
78
+ // xterm sends device/focus reports through the same onData channel as
79
+ // keystrokes. They must reach the TUI without pretending the user has
80
+ // taken over startup prompt handling.
81
+ if (!isTerminalProtocolResponse(input)) {
82
+ this.cancelTraexStartupWatcher(localThreadId);
83
+ }
84
+ this.attachments.get(msg.attachId)?.write(input);
85
+ }
50
86
  return;
51
87
  case "term.resize":
52
88
  this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
@@ -57,6 +93,7 @@ export class RunnerSession {
57
93
  }
58
94
  const attachment = this.attachments.get(msg.attachId);
59
95
  this.attachments.delete(msg.attachId);
96
+ this.attachmentThreadIds.delete(msg.attachId);
60
97
  attachment?.kill();
61
98
  return;
62
99
  }
@@ -123,10 +160,9 @@ export class RunnerSession {
123
160
  }
124
161
  /**
125
162
  * 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.
163
+ * observer connection before launching the detached Codex TUI. Fresh-session
164
+ * thread discovery then continues in the background while the first
165
+ * injection waits for the same bridge state.
130
166
  */
131
167
  async ensureLive(msg) {
132
168
  const provider = this.liveProvider;
@@ -143,51 +179,59 @@ export class RunnerSession {
143
179
  reqId: msg.reqId,
144
180
  localThreadId: msg.localThreadId,
145
181
  ok: false,
146
- error: "live provider did not start",
182
+ error: provider.liveSessionError?.(msg.localThreadId)
183
+ ?? "live provider did not start",
147
184
  });
148
185
  return;
149
186
  }
150
187
  this.liveIds.add(msg.localThreadId);
188
+ // Codex-lineage startup is phase-bounded: app-server readiness is
189
+ // handled by its channel, then the already-connected
190
+ // observer gets a full window for the TUI's thread/started event.
191
+ // Claude retains the parent-owned SessionStart deadline.
192
+ const runtime = msg.execution?.provider;
193
+ // Do not add another provider-local deadline around resume/preload. The
194
+ // parent control request remains bounded for process safety; fresh-thread
195
+ // discovery owns the separate 30s phase below.
196
+ const terminalReadyTimeoutMs = isManagedNativeProvider(runtime) ? null : undefined;
151
197
  // Launch the TUI attached to the already-bound thread. Re-launch when the
152
198
  // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
153
199
  // reconnect after the TUI exited restarts it instead of skipping (a
154
200
  // launched-once guard would leave a dead "Pane is dead" husk forever).
155
201
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
156
202
  const terminalReady = provider.waitTerminalReady
157
- ? await provider.waitTerminalReady(msg.localThreadId)
203
+ ? await provider.waitTerminalReady(msg.localThreadId, terminalReadyTimeoutMs)
158
204
  : true;
159
205
  if (!terminalReady) {
206
+ const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
160
207
  this.transport.send({
161
208
  t: "live.ready",
162
209
  reqId: msg.reqId,
163
210
  localThreadId: msg.localThreadId,
164
211
  ok: false,
165
- error: "Provider thread was not ready for Terminal resume",
212
+ error: runtime === "codex" || runtime === "traex"
213
+ ? provider.liveSessionError?.(msg.localThreadId)
214
+ ?? `${providerName} app-server was ready, but its existing thread could not be resumed for the Terminal`
215
+ : "Provider thread was not ready for Terminal resume",
166
216
  });
167
217
  return;
168
218
  }
169
219
  await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
170
220
  }
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()
178
- ? terminal
179
- .capturePane()
180
- .split("\n")
181
- .map((line) => line.trim())
182
- .filter(Boolean)
183
- .slice(-6)
184
- .join(" ")
185
- .slice(-1_000)
186
- : "";
187
- const readinessError = provider.liveSessionError?.(msg.localThreadId)
188
- ?? (paneFailure
189
- ? `Provider terminal exited before session discovery: ${paneFailure}`
190
- : "live session was not ready before timeout");
221
+ let ready = true;
222
+ if (runtime === "codex" || runtime === "traex") {
223
+ // Discover thread/started in the background for 30s while injection
224
+ // waits up to 60s for either bridge readiness or that startup error.
225
+ // Do not serialize the two waits here.
226
+ this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
227
+ }
228
+ else if (msg.waitForReady !== false && provider.waitLiveReady) {
229
+ ready = await provider.waitLiveReady(msg.localThreadId, terminalReadyTimeoutMs);
230
+ }
231
+ const readinessError = ready
232
+ ? undefined
233
+ : provider.liveSessionError?.(msg.localThreadId)
234
+ ?? "live session was not ready before timeout";
191
235
  this.transport.send({
192
236
  t: "live.ready",
193
237
  reqId: msg.reqId,
@@ -206,12 +250,70 @@ export class RunnerSession {
206
250
  });
207
251
  }
208
252
  }
253
+ monitorCodexThreadStartup(localThreadId, runtime, provider) {
254
+ if (!provider.waitLiveReady)
255
+ return;
256
+ void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
257
+ if (ready)
258
+ return;
259
+ const terminal = this.terminals.get(`${localThreadId}-main`);
260
+ const paneFailure = terminal && !terminal.isAlive()
261
+ ? terminal
262
+ .capturePane()
263
+ .split("\n")
264
+ .map((line) => line.trim())
265
+ .filter(Boolean)
266
+ .slice(-6)
267
+ .join(" ")
268
+ .slice(-1_000)
269
+ : "";
270
+ const detail = provider.liveSessionError?.(localThreadId)
271
+ ?? (paneFailure
272
+ ? `Provider terminal exited before session discovery: ${paneFailure}`
273
+ : `${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`);
274
+ this.failCodexThreadStartup(localThreadId, provider, new Error(detail));
275
+ }).catch((error) => {
276
+ this.failCodexThreadStartup(localThreadId, provider, error instanceof Error ? error : new Error(String(error)));
277
+ });
278
+ }
279
+ /** Discovery failure is terminal for this partial native launch. Publish the
280
+ * exact cause first so an in-flight injection wakes, then drop the observer
281
+ * and TUI so a later task retry creates a clean launch instead of reusing an
282
+ * already-rejected readiness promise. */
283
+ failCodexThreadStartup(localThreadId, provider, error) {
284
+ if (!provider.failLiveStartup?.(localThreadId, error))
285
+ return;
286
+ const watcher = this.terminalWatchers.get(localThreadId);
287
+ if (watcher)
288
+ clearInterval(watcher);
289
+ this.terminalWatchers.delete(localThreadId);
290
+ this.cancelTraexStartupWatcher(localThreadId);
291
+ try {
292
+ this.terminals.close(`${localThreadId}-main`);
293
+ }
294
+ catch (closeError) {
295
+ console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
296
+ }
297
+ provider.stopLiveCodexSession?.(localThreadId);
298
+ this.liveIds.delete(localThreadId);
299
+ }
209
300
  async inject(msg) {
210
301
  const provider = this.liveProvider;
211
302
  try {
212
303
  const input = msg.input ?? msg.text;
213
304
  const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
214
- this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
305
+ // App-server injection is independent of the Terminal TUI startup, so it
306
+ // must not cancel prompt handling for the pane that is still starting.
307
+ const error = outcome === "injected" || outcome === "steered"
308
+ ? undefined
309
+ : provider.liveSessionError?.(msg.localThreadId);
310
+ this.transport.send({
311
+ t: "injected",
312
+ reqId: msg.reqId,
313
+ localThreadId: msg.localThreadId,
314
+ outcome,
315
+ ...(error ? { error } : {}),
316
+ });
215
317
  }
216
318
  catch (error) {
217
319
  this.transport.send({
@@ -248,7 +350,8 @@ export class RunnerSession {
248
350
  const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
249
351
  if (!spec)
250
352
  return;
251
- const term = this.terminals.getOrCreate(`${localThreadId}-main`, {
353
+ const terminalId = `${localThreadId}-main`;
354
+ const term = this.terminals.getOrCreate(terminalId, {
252
355
  cwd: spec.cwd,
253
356
  command: spec.command,
254
357
  args: spec.args,
@@ -256,9 +359,110 @@ export class RunnerSession {
256
359
  rows: rows ?? 40,
257
360
  ...(spec.env ? { env: spec.env } : {}),
258
361
  });
362
+ if (spec.skipTraexStartupPrompts) {
363
+ const watcher = Symbol(localThreadId);
364
+ this.traexStartupWatchers.set(localThreadId, watcher);
365
+ void this.skipTraexStartupPrompts(localThreadId, term, watcher);
366
+ }
259
367
  this.liveProvider.attachTerminalInjector?.(localThreadId, term);
368
+ this.watchNativeTerminal(localThreadId, terminalId, term);
369
+ }
370
+ watchNativeTerminal(localThreadId, terminalId, terminal) {
371
+ const previous = this.terminalWatchers.get(localThreadId);
372
+ if (previous)
373
+ clearInterval(previous);
374
+ let checking = false;
375
+ const timer = setInterval(() => {
376
+ if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
377
+ return;
378
+ checking = true;
379
+ const probe = terminal.isAliveAsync?.() ?? Promise.resolve(terminal.isAlive());
380
+ void probe.then((alive) => {
381
+ if (alive || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
382
+ return;
383
+ clearInterval(timer);
384
+ this.terminalWatchers.delete(localThreadId);
385
+ this.cancelTraexStartupWatcher(localThreadId);
386
+ this.liveProvider.failLiveSession?.(localThreadId, new Error("Provider terminal exited unexpectedly"));
387
+ this.failCodexThreadStartup(localThreadId, this.liveProvider, new Error("Provider terminal exited before native thread discovery completed"));
388
+ }).finally(() => {
389
+ checking = false;
390
+ });
391
+ }, 1_000);
392
+ timer.unref?.();
393
+ this.terminalWatchers.set(localThreadId, timer);
394
+ }
395
+ cancelTraexStartupWatcher(localThreadId) {
396
+ if (localThreadId)
397
+ this.traexStartupWatchers.delete(localThreadId);
398
+ }
399
+ async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
400
+ const prompts = [
401
+ {
402
+ id: "welcome",
403
+ matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
404
+ dismiss: () => terminal.sendEnter(),
405
+ },
406
+ {
407
+ id: "migration",
408
+ matches: (pane) => pane.includes("legacy trae cli data detected") &&
409
+ pane.includes("select what to import") &&
410
+ (pane.includes("skip for now") || pane.includes("don't ask again")),
411
+ dismiss: () => terminal.interrupt(),
412
+ },
413
+ {
414
+ id: "hooks",
415
+ matches: (pane) => pane.includes("hooks need review") &&
416
+ pane.includes("trust all and continue") &&
417
+ pane.includes("continue without trusting"),
418
+ dismiss: () => terminal.interrupt(),
419
+ },
420
+ ];
421
+ let activePromptId;
422
+ let lastDismissedAt = 0;
423
+ const startedAt = Date.now();
424
+ let deadline = startedAt + TRAEX_STARTUP_WATCH_MS;
425
+ const authorizationDeadline = startedAt + TRAEX_AUTHORIZATION_WAIT_MS;
426
+ const terminalId = `${localThreadId}-main`;
427
+ while (!this.shuttingDown &&
428
+ Date.now() < deadline &&
429
+ this.traexStartupWatchers.get(localThreadId) === watcher &&
430
+ this.terminals.get(terminalId) === terminal) {
431
+ // Do not treat a composer frame as completion: Traex can render it before
432
+ // the startup modals arrive. The bounded deadline stops this watcher.
433
+ const pane = normalizeTraexPane(terminal.capturePane());
434
+ const prompt = prompts.find((candidate) => candidate.matches(pane));
435
+ const now = Date.now();
436
+ const authorizationPending = isTraexAuthorizationPending(pane);
437
+ // Human device authorization routinely takes longer than the normal
438
+ // startup-modal window. Keep watching while that known screen remains,
439
+ // then preserve a full window for welcome/migration/hooks after sign-in.
440
+ // The separate cap prevents an abandoned auth screen from polling forever.
441
+ if (authorizationPending && now < authorizationDeadline) {
442
+ deadline = now + TRAEX_STARTUP_WATCH_MS;
443
+ }
444
+ if (!prompt) {
445
+ activePromptId = undefined;
446
+ }
447
+ else if (prompt.id !== activePromptId ||
448
+ now - lastDismissedAt >= TRAEX_PROMPT_RETRY_MS) {
449
+ prompt.dismiss();
450
+ activePromptId = prompt.id;
451
+ lastDismissedAt = now;
452
+ }
453
+ await new Promise((resolve) => {
454
+ const timer = setTimeout(resolve, authorizationPending ? TRAEX_AUTHORIZATION_POLL_MS : TRAEX_STARTUP_POLL_MS);
455
+ timer.unref();
456
+ });
457
+ }
458
+ if (this.traexStartupWatchers.get(localThreadId) === watcher) {
459
+ this.traexStartupWatchers.delete(localThreadId);
460
+ }
260
461
  }
261
462
  stopLive() {
463
+ for (const timer of this.terminalWatchers.values())
464
+ clearInterval(timer);
465
+ this.terminalWatchers.clear();
262
466
  for (const id of this.liveIds) {
263
467
  this.liveProvider.stopLiveCodexSession?.(id, {
264
468
  deferClaudeInteractionCleanup: true,
@@ -336,6 +540,8 @@ export class RunnerSession {
336
540
  return;
337
541
  }
338
542
  this.attachments.set(msg.attachId, attachment);
543
+ if (msg.localThreadId)
544
+ this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
339
545
  attachment.onData((chunk) => this.transport.send({
340
546
  t: "term.data",
341
547
  attachId: msg.attachId,
@@ -343,6 +549,7 @@ export class RunnerSession {
343
549
  }));
344
550
  attachment.onExit((info) => {
345
551
  this.attachments.delete(msg.attachId);
552
+ this.attachmentThreadIds.delete(msg.attachId);
346
553
  this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
347
554
  });
348
555
  this.transport.send({ t: "term.opened", attachId: msg.attachId, role });