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

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.
@@ -7,7 +7,7 @@
7
7
  * per-thread capabilities, answered against the same backend so the parent never
8
8
  * needs an app-server of its own.
9
9
  */
10
- import { type AgentCapabilities, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
10
+ import { type AgentCapabilities, type LiveSessionFailure, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
11
11
  import type { SessionEvent } from "@rynx-ai/core";
12
12
  import type { TerminalInjector } from "../claude/native-integration.js";
13
13
  import type { ResolveInteractionResult } from "../interactions.js";
@@ -38,11 +38,16 @@ interface LiveCodexProvider {
38
38
  execution: ResolvedExecutionSnapshot;
39
39
  retargetMirror?: RetargetMirror;
40
40
  }): Promise<boolean>;
41
+ /** Known-thread Codex/Traex resume only: start the independent transcript
42
+ * observer after the replacement TUI has launched. Fresh discovery connects
43
+ * its listener before launch and this method is an idempotent no-op. */
44
+ startLiveCodexObserver?(localThreadId: string): void;
41
45
  /** `null` delegates the deadline to RunnerManager. */
42
46
  waitLiveReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
43
47
  /** `null` delegates the deadline to RunnerManager. */
44
48
  waitTerminalReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
45
49
  liveSessionError?(localThreadId: string): string | undefined;
50
+ liveSessionFailure?(localThreadId: string): LiveSessionFailure | undefined;
46
51
  failLiveStartup?(localThreadId: string, error: Error): boolean;
47
52
  failLiveSession?(localThreadId: string, error: Error): boolean;
48
53
  injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
@@ -84,6 +89,8 @@ export declare class RunnerSession {
84
89
  private readonly cancelledTerminalOpens;
85
90
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
86
91
  private readonly liveIds;
92
+ /** Provider name retained for asynchronous Terminal-exit diagnostics. */
93
+ private readonly liveRuntimes;
87
94
  private shuttingDown;
88
95
  constructor({ transport, executor, onShutdown }: RunnerSessionDeps);
89
96
  private handle;
@@ -96,10 +103,10 @@ export declare class RunnerSession {
96
103
  * the new session stays injectable. */
97
104
  private mirrorChannel;
98
105
  /**
99
- * Eagerly bring up a session's codex-native live view: start the persistent
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.
106
+ * Eagerly bring up a session's codex-native live view. Fresh sessions connect
107
+ * the discovery listener before launching the detached TUI; known-thread
108
+ * resumes preload first, launch the replacement TUI, then start their
109
+ * persistent observer in the background.
103
110
  */
104
111
  private ensureLive;
105
112
  private monitorCodexThreadStartup;
@@ -1,8 +1,43 @@
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 { AgentRuntimeError, } from "@rynx-ai/core";
1
11
  import { TerminalRegistry } from "../terminal/registry.js";
2
- import { toWireError } from "./protocol.js";
3
- import { isManagedNativeProvider } from "./startup-policy.js";
12
+ import { toWireError, } from "./protocol.js";
13
+ import { isCodexLineageProvider } from "./startup-policy.js";
4
14
  /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
5
15
  const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
16
+ function providerDisplayName(runtime) {
17
+ return runtime === "traex" ? "Traex" : runtime === "claude" ? "Claude" : "Codex";
18
+ }
19
+ function providerFailure(provider, localThreadId, fallback) {
20
+ return provider.liveSessionFailure?.(localThreadId) ?? {
21
+ message: provider.liveSessionError?.(localThreadId) ?? fallback.message,
22
+ code: fallback.code,
23
+ statusCode: 503,
24
+ };
25
+ }
26
+ function nativePhaseError(runtime, code, phase, cause) {
27
+ const detail = cause === undefined
28
+ ? ""
29
+ : `: ${cause instanceof Error ? cause.message : String(cause)}`;
30
+ return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
31
+ }
32
+ function terminalFailureDetail(pane) {
33
+ return pane
34
+ .split("\n")
35
+ .map((line) => line.trim())
36
+ .filter(Boolean)
37
+ .slice(-6)
38
+ .join(" ")
39
+ .slice(-1_000);
40
+ }
6
41
  const TRAEX_STARTUP_WATCH_MS = 20_000;
7
42
  const TRAEX_STARTUP_POLL_MS = 100;
8
43
  const TRAEX_AUTHORIZATION_POLL_MS = 500;
@@ -38,6 +73,8 @@ export class RunnerSession {
38
73
  cancelledTerminalOpens = new Set();
39
74
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
40
75
  liveIds = new Set();
76
+ /** Provider name retained for asynchronous Terminal-exit diagnostics. */
77
+ liveRuntimes = new Map();
41
78
  shuttingDown = false;
42
79
  constructor({ transport, executor, onShutdown }) {
43
80
  this.transport = transport;
@@ -159,10 +196,10 @@ export class RunnerSession {
159
196
  return { emit, retarget };
160
197
  }
161
198
  /**
162
- * Eagerly bring up a session's codex-native live view: start the persistent
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.
199
+ * Eagerly bring up a session's codex-native live view. Fresh sessions connect
200
+ * the discovery listener before launching the detached TUI; known-thread
201
+ * resumes preload first, launch the replacement TUI, then start their
202
+ * persistent observer in the background.
166
203
  */
167
204
  async ensureLive(msg) {
168
205
  const provider = this.liveProvider;
@@ -174,34 +211,38 @@ export class RunnerSession {
174
211
  retargetMirror: retarget,
175
212
  });
176
213
  if (!started) {
214
+ const runtime = msg.execution?.provider;
177
215
  this.transport.send({
178
216
  t: "live.ready",
179
217
  reqId: msg.reqId,
180
218
  localThreadId: msg.localThreadId,
181
219
  ok: false,
182
- error: provider.liveSessionError?.(msg.localThreadId)
183
- ?? "live provider did not start",
220
+ error: providerFailure(provider, msg.localThreadId, {
221
+ runtime,
222
+ code: "native_start_failed",
223
+ message: `${providerDisplayName(runtime)} native session did not start`,
224
+ }),
184
225
  });
185
226
  return;
186
227
  }
187
228
  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
229
  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;
230
+ if (runtime)
231
+ this.liveRuntimes.set(msg.localThreadId, runtime);
197
232
  // Launch the TUI attached to the already-bound thread. Re-launch when the
198
233
  // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
199
234
  // reconnect after the TUI exited restarts it instead of skipping (a
200
235
  // launched-once guard would leave a dead "Pane is dead" husk forever).
201
236
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
202
- const terminalReady = provider.waitTerminalReady
203
- ? await provider.waitTerminalReady(msg.localThreadId, terminalReadyTimeoutMs)
204
- : true;
237
+ // Codex/Traex `ensureLiveCodexSession` already performs the backend
238
+ // preload for an existing thread. Match the native lifecycle: launch
239
+ // the TUI immediately after that preload and let the observer subscribe
240
+ // in the background. Fresh sessions likewise launch before discovery.
241
+ const terminalReady = isCodexLineageProvider(runtime)
242
+ ? true
243
+ : provider.waitTerminalReady
244
+ ? await provider.waitTerminalReady(msg.localThreadId)
245
+ : true;
205
246
  if (!terminalReady) {
206
247
  const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
207
248
  this.transport.send({
@@ -209,14 +250,31 @@ export class RunnerSession {
209
250
  reqId: msg.reqId,
210
251
  localThreadId: msg.localThreadId,
211
252
  ok: false,
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",
253
+ error: providerFailure(provider, msg.localThreadId, {
254
+ runtime,
255
+ code: "native_resume_failed",
256
+ message: runtime === "codex" || runtime === "traex"
257
+ ? `${providerName} app-server was ready, but its existing session could not be resumed for the Terminal`
258
+ : "Provider thread was not ready for Terminal resume",
259
+ }),
216
260
  });
217
261
  return;
218
262
  }
219
- await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
263
+ try {
264
+ await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
265
+ }
266
+ catch (error) {
267
+ throw error instanceof AgentRuntimeError
268
+ ? error
269
+ : nativePhaseError(runtime, "native_terminal_launch_failed", "Terminal failed to launch", error);
270
+ }
271
+ }
272
+ // Omnigent's known-thread path launches `codex --remote resume` first,
273
+ // then creates `_codex_forward_known_thread` as background work. Keep the
274
+ // ordering explicit here: observer connection failure cannot delay or
275
+ // reject cold-resume admission.
276
+ if (isCodexLineageProvider(runtime)) {
277
+ provider.startLiveCodexObserver?.(msg.localThreadId);
220
278
  }
221
279
  let ready = true;
222
280
  if (runtime === "codex" || runtime === "traex") {
@@ -226,12 +284,15 @@ export class RunnerSession {
226
284
  this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
227
285
  }
228
286
  else if (msg.waitForReady !== false && provider.waitLiveReady) {
229
- ready = await provider.waitLiveReady(msg.localThreadId, terminalReadyTimeoutMs);
287
+ ready = await provider.waitLiveReady(msg.localThreadId);
230
288
  }
231
289
  const readinessError = ready
232
290
  ? undefined
233
- : provider.liveSessionError?.(msg.localThreadId)
234
- ?? "live session was not ready before timeout";
291
+ : providerFailure(provider, msg.localThreadId, {
292
+ runtime,
293
+ code: "native_readiness_failed",
294
+ message: `${providerDisplayName(runtime)} native session was not ready`,
295
+ });
235
296
  this.transport.send({
236
297
  t: "live.ready",
237
298
  reqId: msg.reqId,
@@ -246,7 +307,7 @@ export class RunnerSession {
246
307
  reqId: msg.reqId,
247
308
  localThreadId: msg.localThreadId,
248
309
  ok: false,
249
- error: error instanceof Error ? error.message : String(error),
310
+ error: toWireError(error),
250
311
  });
251
312
  }
252
313
  }
@@ -269,11 +330,15 @@ export class RunnerSession {
269
330
  : "";
270
331
  const detail = provider.liveSessionError?.(localThreadId)
271
332
  ?? (paneFailure
272
- ? `Provider terminal exited before session discovery: ${paneFailure}`
333
+ ? `${providerDisplayName(runtime)} Terminal exited before native session discovery: ${paneFailure}`
273
334
  : `${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));
335
+ this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
336
+ ? "native_terminal_exited_before_session"
337
+ : "native_thread_discovery_timeout"));
275
338
  }).catch((error) => {
276
- this.failCodexThreadStartup(localThreadId, provider, error instanceof Error ? error : new Error(String(error)));
339
+ this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
340
+ ? error
341
+ : nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error));
277
342
  });
278
343
  }
279
344
  /** Discovery failure is terminal for this partial native launch. Publish the
@@ -306,7 +371,18 @@ export class RunnerSession {
306
371
  // must not cancel prompt handling for the pane that is still starting.
307
372
  const error = outcome === "injected" || outcome === "steered"
308
373
  ? undefined
309
- : provider.liveSessionError?.(msg.localThreadId);
374
+ : providerFailure(provider, msg.localThreadId, {
375
+ code: outcome === "notReady"
376
+ ? "native_thread_not_ready"
377
+ : outcome === "notLive"
378
+ ? "native_session_not_live"
379
+ : "native_message_injection_failed",
380
+ message: outcome === "notReady"
381
+ ? "Native session was not ready for message injection"
382
+ : outcome === "notLive"
383
+ ? "Native session was not live for message injection"
384
+ : "Native message injection failed",
385
+ });
310
386
  this.transport.send({
311
387
  t: "injected",
312
388
  reqId: msg.reqId,
@@ -321,7 +397,7 @@ export class RunnerSession {
321
397
  reqId: msg.reqId,
322
398
  localThreadId: msg.localThreadId,
323
399
  outcome: "failed",
324
- error: error instanceof Error ? error.message : String(error),
400
+ error: toWireError(error),
325
401
  });
326
402
  }
327
403
  }
@@ -333,7 +409,7 @@ export class RunnerSession {
333
409
  ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
334
410
  }
335
411
  catch (e) {
336
- error = e instanceof Error ? e.message : String(e);
412
+ error = toWireError(e);
337
413
  }
338
414
  this.transport.send({
339
415
  t: "interrupted",
@@ -383,8 +459,10 @@ export class RunnerSession {
383
459
  clearInterval(timer);
384
460
  this.terminalWatchers.delete(localThreadId);
385
461
  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"));
462
+ const runtime = this.liveRuntimes.get(localThreadId);
463
+ const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
464
+ this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
465
+ this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
388
466
  }).finally(() => {
389
467
  checking = false;
390
468
  });
@@ -469,6 +547,7 @@ export class RunnerSession {
469
547
  });
470
548
  }
471
549
  this.liveIds.clear();
550
+ this.liveRuntimes.clear();
472
551
  }
473
552
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
474
553
  * scrub provider handoff files before the child process is allowed to exit. */
@@ -16,7 +16,7 @@
16
16
  * channel.
17
17
  */
18
18
  import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
19
- import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
19
+ import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type LiveSessionFailure, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
20
20
  import { type CodexSessionStore } from "../host.js";
21
21
  import type { ResolveInteractionResult } from "../interactions.js";
22
22
  import { type InjectOutcome, type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
@@ -53,12 +53,13 @@ export interface RunnerManagerOptions {
53
53
  shutdownGraceMs?: number;
54
54
  /** Final bounded wait for child exit after SIGKILL. */
55
55
  shutdownKillGraceMs?: number;
56
- /** Max wait for a setup-pane launch acknowledgement. */
56
+ /** Max wait for a legacy setup-pane launch acknowledgement. Codex/Traex use
57
+ * their internal phase deadlines and are not wrapped by this timer. */
57
58
  liveStartTimeoutMs?: number;
58
59
  /** Max wait for a Provider-native thread to become ready. */
59
60
  liveReadyTimeoutMs?: number;
60
- /** Claude SessionStart deadline. Codex/Traex use `liveStartTimeoutMs` for the
61
- * pane/observer acknowledgement; their thread discovery is asynchronous. */
61
+ /** Claude SessionStart deadline. Codex/Traex have no parent-owned startup
62
+ * deadline; their app-server/discovery/injection phases report directly. */
62
63
  nativeLiveStartTimeoutMs?: number;
63
64
  /** Max wait for a native interrupt acknowledgement before reporting it
64
65
  * unproven. Callers may then use the explicit force-stop path. */
@@ -247,9 +248,10 @@ export declare class RunnerManager implements AgentCapabilities {
247
248
  /**
248
249
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
249
250
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
250
- * needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
251
- * app-server/observer and pane are launched; thread discovery continues in
252
- * parallel with injection. Other providers retain their readiness gate.
251
+ * needed. Idempotent. For Codex-lineage sessions, resolves after backend
252
+ * preload and pane launch; known-thread observer attach and fresh-thread
253
+ * discovery continue in the background. Other providers retain their
254
+ * readiness gate.
253
255
  */
254
256
  ensureLiveSession(localThreadId: string, opts: {
255
257
  workspace: SessionWorkspaceSnapshot;
@@ -268,6 +270,7 @@ export declare class RunnerManager implements AgentCapabilities {
268
270
  }): Promise<boolean>;
269
271
  private requestLiveSession;
270
272
  lastLiveSessionError(localThreadId: string): string | undefined;
273
+ lastLiveSessionFailure(localThreadId: string): LiveSessionFailure | undefined;
271
274
  /**
272
275
  * Inject a user turn into a session's live codex thread — reference implementation's
273
276
  * single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
@@ -24,7 +24,7 @@ import { listRuntimeModels } from "../models-catalog.js";
24
24
  import { probeRuntimeStatus } from "../runtime-status.js";
25
25
  import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
26
26
  import { fromWireError, } from "./protocol.js";
27
- import { isManagedNativeProvider } from "./startup-policy.js";
27
+ import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
28
28
  import { StdioRunnerTransport } from "./transport.js";
29
29
  /** Routing key for the shared capability runner (slash-command RPCs). */
30
30
  const CAP_KEY = "__cap__";
@@ -34,9 +34,8 @@ const STDERR_TAIL_LINES = 40;
34
34
  const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
35
35
  /** Time allowed for exit after SIGKILL before shutdown reports failure. */
36
36
  const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
37
- /** Setup-pane launch includes the app-server readiness phase but does not wait
38
- * for native thread discovery. Keep enough headroom around the app-server's
39
- * own 10s gate so scheduling/IPC overhead cannot win the same deadline. */
37
+ /** Legacy setup-pane acknowledgement deadline for providers that do not own
38
+ * phase-specific startup errors. Codex/Traex are deliberately excluded. */
40
39
  const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
41
40
  /** Legacy fallback for a provider without a native startup policy. */
42
41
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
@@ -509,9 +508,10 @@ export class RunnerManager {
509
508
  /**
510
509
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
511
510
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
512
- * needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
513
- * app-server/observer and pane are launched; thread discovery continues in
514
- * parallel with injection. Other providers retain their readiness gate.
511
+ * needed. Idempotent. For Codex-lineage sessions, resolves after backend
512
+ * preload and pane launch; known-thread observer attach and fresh-thread
513
+ * discovery continue in the background. Other providers retain their
514
+ * readiness gate.
515
515
  */
516
516
  ensureLiveSession(localThreadId, opts) {
517
517
  const admission = this.reserveAdmission();
@@ -550,55 +550,66 @@ export class RunnerManager {
550
550
  this.liveSessionKeys.add(localThreadId);
551
551
  const reqId = randomUUID();
552
552
  return new Promise((resolve) => {
553
- const timeoutMs = !waitForReady
554
- ? this.liveStartTimeoutMs
555
- : opts.execution.provider === "claude"
556
- ? this.nativeLiveStartTimeoutMs
557
- : isManagedNativeProvider(opts.execution.provider)
558
- ? this.liveStartTimeoutMs
553
+ const provider = opts.execution.provider;
554
+ const timeoutMs = isCodexLineageProvider(provider)
555
+ ? null
556
+ : !waitForReady
557
+ ? this.liveStartTimeoutMs
558
+ : provider === "claude"
559
+ ? this.nativeLiveStartTimeoutMs
559
560
  : this.liveReadyTimeoutMs;
560
- const timeout = setTimeout(() => {
561
- if (!handle.live.delete(reqId))
562
- return;
563
- const finishTimeout = () => {
564
- const provider = opts.execution.provider;
565
- const reason = !waitForReady
566
- ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
567
- : provider === "claude"
568
- ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
569
- : provider === "codex" || provider === "traex"
570
- ? `${provider === "traex" ? "Traex" : "Codex"} app-server/observer/Terminal launch was not acknowledged within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
561
+ const timeout = timeoutMs === null
562
+ ? undefined
563
+ : setTimeout(() => {
564
+ if (!handle.live.delete(reqId))
565
+ return;
566
+ const finishTimeout = () => {
567
+ const reason = !waitForReady
568
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
569
+ : provider === "claude"
570
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
571
571
  : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
572
- this.liveErrors.set(localThreadId, reason);
573
- this.liveSessionKeys.delete(localThreadId);
574
- // A child that cannot answer a bounded control round-trip is unsafe
575
- // to reuse. Reap it so the next click gets a fresh runner.
576
- void this.terminateHandle(handle, reason).catch((error) => {
577
- logTerminationFailure(handle, error);
578
- });
579
- return false;
580
- };
581
- const reservation = allowReservedForkTarget
582
- ? undefined
583
- : this.forkReservations.get(localThreadId);
584
- if (reservation) {
585
- void reservation
586
- .then(() => finishTimeout())
587
- .then(resolve, () => resolve(false));
588
- return;
589
- }
590
- resolve(finishTimeout());
591
- }, timeoutMs);
592
- timeout.unref?.();
572
+ this.liveErrors.set(localThreadId, {
573
+ message: reason,
574
+ code: provider === "claude"
575
+ ? "claude_session_start_timeout"
576
+ : "native_runner_ack_timeout",
577
+ statusCode: 503,
578
+ });
579
+ this.liveSessionKeys.delete(localThreadId);
580
+ // Legacy providers without phase-owned startup errors retain the
581
+ // bounded control guard. Codex/Traex never enter this branch.
582
+ void this.terminateHandle(handle, reason).catch((error) => {
583
+ logTerminationFailure(handle, error);
584
+ });
585
+ return false;
586
+ };
587
+ const reservation = allowReservedForkTarget
588
+ ? undefined
589
+ : this.forkReservations.get(localThreadId);
590
+ if (reservation) {
591
+ void reservation
592
+ .then(() => finishTimeout())
593
+ .then(resolve, () => resolve(false));
594
+ return;
595
+ }
596
+ resolve(finishTimeout());
597
+ }, timeoutMs);
598
+ timeout?.unref?.();
593
599
  handle.live.set(reqId, (res) => {
594
- clearTimeout(timeout);
600
+ if (timeout)
601
+ clearTimeout(timeout);
595
602
  const ok = res.ok ?? false;
596
603
  const finish = () => {
597
604
  if (ok) {
598
605
  this.liveErrors.delete(localThreadId);
599
606
  }
600
607
  else {
601
- this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
608
+ this.liveErrors.set(localThreadId, res.error ?? {
609
+ message: "live session did not become ready",
610
+ code: "native_readiness_failed",
611
+ statusCode: 503,
612
+ });
602
613
  }
603
614
  return ok;
604
615
  };
@@ -626,7 +637,11 @@ export class RunnerManager {
626
637
  });
627
638
  }
628
639
  lastLiveSessionError(localThreadId) {
629
- return this.liveErrors.get(localThreadId);
640
+ return this.liveErrors.get(localThreadId)?.message;
641
+ }
642
+ lastLiveSessionFailure(localThreadId) {
643
+ const failure = this.liveErrors.get(localThreadId);
644
+ return failure ? { ...failure } : undefined;
630
645
  }
631
646
  /**
632
647
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -659,7 +674,15 @@ export class RunnerManager {
659
674
  this.liveErrors.delete(localThreadId);
660
675
  }
661
676
  else {
662
- this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
677
+ this.liveErrors.set(localThreadId, res.error ?? {
678
+ message: `live injection ${outcome}`,
679
+ code: outcome === "notReady"
680
+ ? "native_thread_not_ready"
681
+ : outcome === "notLive"
682
+ ? "native_session_not_live"
683
+ : "native_message_injection_failed",
684
+ statusCode: 503,
685
+ });
663
686
  }
664
687
  return outcome;
665
688
  };
@@ -714,7 +737,7 @@ export class RunnerManager {
714
737
  handle.live.set(reqId, (reply) => {
715
738
  resolve(reply.interactionResult ?? {
716
739
  disposition: "invalid",
717
- message: reply.error ?? "runner did not return an interaction result",
740
+ message: reply.error?.message ?? "runner did not return an interaction result",
718
741
  });
719
742
  });
720
743
  handle.transport.send({
@@ -1318,7 +1341,10 @@ export class RunnerManager {
1318
1341
  terminal._fail(message);
1319
1342
  }
1320
1343
  for (const resolve of handle.live.values()) {
1321
- resolve({ ok: false, error: message });
1344
+ resolve({
1345
+ ok: false,
1346
+ error: { message, code: "runner_crashed", statusCode: 500 },
1347
+ });
1322
1348
  }
1323
1349
  handle.caps.clear();
1324
1350
  handle.terminals.clear();
@@ -64,9 +64,9 @@ export type ToChild = {
64
64
  }
65
65
  /** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
66
66
  * its turns mirror to the bus regardless of whether the web has attached the
67
- * terminal. For Codex-lineage fresh sessions, `live.ready` acknowledges that
68
- * the pane and observer exist; background thread discovery races the
69
- * executor's bridge wait. */
67
+ * terminal. Fresh sessions connect the discovery listener before TUI launch;
68
+ * known-thread resumes launch the TUI after backend preload and attach their
69
+ * observer in the background. */
70
70
  | {
71
71
  t: "live.ensure";
72
72
  reqId: string;
@@ -155,15 +155,16 @@ export type FromChild = {
155
155
  execution: ResolvedExecutionSnapshot;
156
156
  parentSessionId?: string;
157
157
  }
158
- /** Result of a `live.ensure`: for Codex-lineage sessions `ok` means the pane
159
- * and observer started; thread discovery continues in parallel with
160
- * injection. Other Providers retain their own readiness gate. */
158
+ /** Result of a `live.ensure`: for Codex-lineage sessions `ok` means backend
159
+ * preload and pane launch completed. A resume observer and fresh-thread
160
+ * discovery may continue in the background. Other Providers retain their
161
+ * own readiness gate. */
161
162
  | {
162
163
  t: "live.ready";
163
164
  reqId: string;
164
165
  localThreadId: string;
165
166
  ok: boolean;
166
- error?: string;
167
+ error?: WireError;
167
168
  }
168
169
  /** Result of an `inject`: `outcome` distinguishes a new turn, an active-turn
169
170
  * steer, and failures so the caller never falls back to a second output path.
@@ -173,7 +174,7 @@ export type FromChild = {
173
174
  reqId: string;
174
175
  localThreadId: string;
175
176
  outcome: InjectOutcome;
176
- error?: string;
177
+ error?: WireError;
177
178
  }
178
179
  /** Result of a `live.interrupt`: `ok` when an active turn was interrupted (false
179
180
  * when there was none). Echoes the request `reqId`. */
@@ -182,7 +183,7 @@ export type FromChild = {
182
183
  reqId: string;
183
184
  localThreadId: string;
184
185
  ok: boolean;
185
- error?: string;
186
+ error?: WireError;
186
187
  } | {
187
188
  t: "interaction.resolved";
188
189
  reqId: string;
@@ -2,3 +2,6 @@ import type { AgentRuntimeId } from "@rynx-ai/core";
2
2
  /** Current live providers are native CLI sessions. Keep this explicit so a
3
3
  * future non-native provider does not silently inherit their startup budget. */
4
4
  export declare function isManagedNativeProvider(provider: AgentRuntimeId | undefined): provider is AgentRuntimeId;
5
+ /** Codex-lineage providers own app-server, thread-discovery, and injection
6
+ * phase errors. Claude instead retains its parent-owned SessionStart gate. */
7
+ export declare function isCodexLineageProvider(provider: AgentRuntimeId | undefined): provider is Exclude<AgentRuntimeId, "claude">;
@@ -3,3 +3,8 @@
3
3
  export function isManagedNativeProvider(provider) {
4
4
  return provider === "codex" || provider === "traex" || provider === "claude";
5
5
  }
6
+ /** Codex-lineage providers own app-server, thread-discovery, and injection
7
+ * phase errors. Claude instead retains its parent-owned SessionStart gate. */
8
+ export function isCodexLineageProvider(provider) {
9
+ return provider === "codex" || provider === "traex";
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.21",
3
+ "version": "0.1.11-beta.22",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,7 +27,7 @@
27
27
  "node-pty": "1.2.0-beta.15",
28
28
  "smol-toml": "1.7.1",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.21"
30
+ "@rynx-ai/core": "0.1.11-beta.22"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.18.1"