@rynx-ai/runtime 0.1.11-beta.19 → 0.1.11-beta.20

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.
@@ -283,6 +283,8 @@ export declare class ClaudeLiveSession {
283
283
  * sent an Escape — an Escape into idle claude submits an empty turn, which
284
284
  * claude answers with a stray "No response requested." bubble. */
285
285
  isTurnOpen(): boolean;
286
+ /** Fail an open turn exactly once when its native terminal/runner disappears. */
287
+ failOpenTurn(error: Error): boolean;
286
288
  private closeTurn;
287
289
  private closeTurnError;
288
290
  private resetMessageCorrelation;
@@ -1086,6 +1086,13 @@ export class ClaudeLiveSession {
1086
1086
  isTurnOpen() {
1087
1087
  return this.turnOpen;
1088
1088
  }
1089
+ /** Fail an open turn exactly once when its native terminal/runner disappears. */
1090
+ failOpenTurn(error) {
1091
+ if (!this.turnOpen)
1092
+ return false;
1093
+ this.closeTurnError(error);
1094
+ return true;
1095
+ }
1089
1096
  closeTurn() {
1090
1097
  if (!this.turnOpen)
1091
1098
  return;
@@ -120,6 +120,11 @@ export declare class CodexSessionForwarder {
120
120
  * not doubled.
121
121
  */
122
122
  replayBackfill(turns: ResumedTurn[]): void;
123
+ /** Reconcile only the exact active turn after an observer resume. Historical
124
+ * items are intentionally not replayed on an existing-thread reconnect. */
125
+ reconcileActiveTurn(turn: ResumedTurn | undefined): boolean;
126
+ /** Fail an open response exactly once when its observer or terminal exits. */
127
+ failOpenTurn(error: Error): boolean;
123
128
  private handle;
124
129
  private scheduleCompletion;
125
130
  private refreshCompletionGrace;
@@ -175,6 +175,27 @@ export class CodexSessionForwarder {
175
175
  this.sink.onTurnEnd(mapped.usage);
176
176
  }
177
177
  }
178
+ /** Reconcile only the exact active turn after an observer resume. Historical
179
+ * items are intentionally not replayed on an existing-thread reconnect. */
180
+ reconcileActiveTurn(turn) {
181
+ if (!this.turnOpen || !turn || turn.status === "inProgress")
182
+ return false;
183
+ const resumedTurnId = turn.id ?? turn.turnId;
184
+ if (!this.currentTurnIdValue || resumedTurnId !== this.currentTurnIdValue)
185
+ return false;
186
+ const mapped = mapCodexNotification("turn/completed", { turn });
187
+ this.settle(mapped.fatalError
188
+ ? { kind: "error", error: mapped.fatalError }
189
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
190
+ return true;
191
+ }
192
+ /** Fail an open response exactly once when its observer or terminal exits. */
193
+ failOpenTurn(error) {
194
+ if (!this.turnOpen)
195
+ return false;
196
+ this.settle({ kind: "error", error });
197
+ return true;
198
+ }
178
199
  handle(method, params) {
179
200
  if (method === "thread/started" || method === "thread.started") {
180
201
  const tid = threadIdFrom(params);
@@ -105,11 +105,14 @@ export interface ThreadStartParams {
105
105
  }
106
106
  export interface ThreadResumeParams extends ThreadStartParams {
107
107
  threadId: string;
108
- /** When true, the resume response omits the thread's `turns` backlog (used to
109
- * SUBSCRIBE without re-replaying history). When false/absent, the response
110
- * carries `thread.turns[].items[]` — the backfill the forwarder replays for a
111
- * fresh thread's first turn (reference implementation's `_replay_resume_response`). */
108
+ /** Legacy whole-backlog suppression. New callers should prefer
109
+ * `initialTurnsPage` so recovery can fetch one summarized terminal status. */
112
110
  excludeTurns?: boolean;
111
+ initialTurnsPage?: {
112
+ limit?: number | null;
113
+ sortDirection?: "asc" | "desc" | null;
114
+ itemsView?: "notLoaded" | "summary" | "full" | null;
115
+ } | null;
113
116
  }
114
117
  /** One turn in a resumed thread's backlog (`thread/resume` response). */
115
118
  export interface ResumedTurn {
@@ -28,6 +28,7 @@ async function freeLoopbackPort() {
28
28
  });
29
29
  });
30
30
  }
31
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
31
32
  export class WsRpcChannel {
32
33
  opts;
33
34
  child = null;
@@ -40,7 +41,7 @@ export class WsRpcChannel {
40
41
  url = "";
41
42
  constructor(opts) {
42
43
  this.opts = opts;
43
- this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
44
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
44
45
  }
45
46
  onLine(cb) {
46
47
  this.lineCb = cb;
@@ -114,7 +115,7 @@ export class WsRpcChannel {
114
115
  // eslint-disable-next-line no-constant-condition
115
116
  while (Date.now() < deadline) {
116
117
  try {
117
- return await this.tryConnect(url);
118
+ return await this.tryConnect(url, Math.max(1, deadline - Date.now()));
118
119
  }
119
120
  catch (error) {
120
121
  lastError = error;
@@ -123,9 +124,9 @@ export class WsRpcChannel {
123
124
  }
124
125
  throw new Error(`codex app-server ws did not become ready at ${url}: ${lastError?.message ?? "timeout"}`);
125
126
  }
126
- tryConnect(url) {
127
+ tryConnect(url, handshakeTimeout) {
127
128
  return new Promise((resolve, reject) => {
128
- const ws = new WebSocket(url);
129
+ const ws = new WebSocket(url, { handshakeTimeout });
129
130
  const onOpen = () => {
130
131
  ws.off("error", onError);
131
132
  resolve(ws);
@@ -166,7 +167,7 @@ export class ExternalWsChannel {
166
167
  /** The `ws://IP:PORT` of the already-running app-server to attach to. */
167
168
  url, opts = {}) {
168
169
  this.url = url;
169
- this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
170
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
170
171
  }
171
172
  onLine(cb) {
172
173
  this.lineCb = cb;
@@ -178,20 +179,16 @@ export class ExternalWsChannel {
178
179
  return this.ws?.readyState === WebSocket.OPEN;
179
180
  }
180
181
  async start() {
181
- const deadline = Date.now() + this.readyTimeoutMs;
182
- let lastError;
183
- while (Date.now() < deadline) {
184
- try {
185
- this.ws = await this.connect(this.url);
186
- break;
187
- }
188
- catch (error) {
189
- lastError = error;
190
- await new Promise((r) => setTimeout(r, 150));
191
- }
182
+ // A channel instance is reusable after an observer disconnect. Do not let
183
+ // the prior closed socket make a failed reconnect look successful, and arm
184
+ // close delivery for the newly connected socket.
185
+ this.ws = null;
186
+ this.closedEmitted = false;
187
+ try {
188
+ this.ws = await this.connect(this.url);
192
189
  }
193
- if (!this.ws) {
194
- throw new Error(`could not attach to app-server ws ${this.url}: ${lastError?.message ?? "timeout"}`);
190
+ catch (error) {
191
+ throw new Error(`could not attach to app-server ws ${this.url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
195
192
  }
196
193
  this.ws.on("message", (data) => this.lineCb?.(data.toString()));
197
194
  this.ws.on("close", (code) => this.emitClose(code ?? null, null, null));
@@ -227,7 +224,10 @@ export class ExternalWsChannel {
227
224
  }
228
225
  connect(url) {
229
226
  return new Promise((resolve, reject) => {
230
- const ws = new WebSocket(url);
227
+ // The app-server owner has already completed its readiness probe. Use one
228
+ // bounded attach, then reuse this exact connection as the forwarder
229
+ // instead of running a second startup retry stage.
230
+ const ws = new WebSocket(url, { handshakeTimeout: this.readyTimeoutMs });
231
231
  const onOpen = () => {
232
232
  ws.off("error", onError);
233
233
  resolve(ws);
package/dist/host.d.ts CHANGED
@@ -125,6 +125,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
125
125
  private readonly clock;
126
126
  private readonly backendIdleTtlMs;
127
127
  private readonly forwarderClientFactory;
128
+ private readonly observerReconnectTimeoutMs;
128
129
  private readonly backends;
129
130
  private readonly sessionId;
130
131
  private readonly runtimeHomeSessionId;
@@ -133,6 +134,10 @@ export declare class LocalAgentHost implements CodexCapabilities {
133
134
  private readonly runtimeHomes;
134
135
  private readonly liveSessions;
135
136
  private readonly liveClaudeSessions;
137
+ /** Exact pre-live startup failure, retained after the partial handle has been
138
+ * cleaned up so Core/plugin callers receive the failed phase, not a generic
139
+ * `live session unavailable` wrapper. */
140
+ private readonly liveStartupErrors;
136
141
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
137
142
  * finalizes these synchronously afterwards to scrub raw interaction answers. */
138
143
  private readonly pendingClaudeFinalizers;
@@ -141,7 +146,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
141
146
  /** Short-lived dedupe for managed fork notifications delivered after the
142
147
  * `thread/fork` response. Values are expected source Provider thread ids. */
143
148
  private readonly managedForkThreadStarts;
144
- constructor({ config, commandRunner, sessionStore, allowedRoots, appServerClient, now, backendIdleTtlMs, forwarderClientFactory, sessionId, runtimeHomeSessionId, }: {
149
+ constructor({ config, commandRunner, sessionStore, allowedRoots, appServerClient, now, backendIdleTtlMs, forwarderClientFactory, observerReconnectTimeoutMs, sessionId, runtimeHomeSessionId, }: {
145
150
  config: AppConfig;
146
151
  commandRunner?: CodexCommandRunner;
147
152
  sessionStore?: CodexSessionStore;
@@ -153,6 +158,8 @@ export declare class LocalAgentHost implements CodexCapabilities {
153
158
  backendIdleTtlMs?: number;
154
159
  /** Override the live-session forwarder connection factory (tests inject a fake). */
155
160
  forwarderClientFactory?: (appServerUrl: string) => CodexAppServerClient;
161
+ /** Grace window for observer reconnect before an active response is failed. */
162
+ observerReconnectTimeoutMs?: number;
156
163
  /** The rynx session (localThreadId) this host serves — a per-session runner
157
164
  * child sets it from `RYNX_RUNNER_SESSION` so the private CODEX_HOME is
158
165
  * session-scoped. The shared `__cap__` child / tests fall back to a sentinel
@@ -218,11 +225,16 @@ export declare class LocalAgentHost implements CodexCapabilities {
218
225
  * Subscribe the forwarder connection to a thread (reference implementation's
219
226
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
220
227
  * turn, so `thread/resume` is retried: park until the forwarder observes the
221
- * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
222
- * first turn's items (replayed, deduped against the live stream). Once resume
223
- * succeeds, subsequent turns arrive as live notifications on this connection.
228
+ * thread active, then retry. Resume only fetches the newest summarized Turn:
229
+ * recovery reconciles the exact active Turn's terminal status and never replays
230
+ * historical items. Once resume succeeds, subsequent turns arrive live.
224
231
  */
225
232
  private subscribeUntilReady;
233
+ /** Restore the independent observer after an unexpected exit. The active turn
234
+ * remains open during the bounded grace so resume can reconcile its exact id. */
235
+ private reconnectForwarder;
236
+ private armObserverReconnectDeadline;
237
+ private clearObserverReconnectDeadline;
226
238
  /** Await a live session's thread binding. `null` leaves the deadline to the
227
239
  * caller; a number keeps the Provider-local bound. Returns false on timeout /
228
240
  * no live session. Injection and the runner's `live.ready` gate on this. */
@@ -233,6 +245,10 @@ export declare class LocalAgentHost implements CodexCapabilities {
233
245
  waitTerminalReady(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
234
246
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
235
247
  liveSessionError(localThreadId: string): string | undefined;
248
+ /** Publish the background TUI/thread discovery failure so an executor
249
+ * already waiting in the 60s bridge window exits immediately with the exact
250
+ * 30s discovery cause. */
251
+ failLiveStartup(localThreadId: string, error: Error): boolean;
236
252
  /**
237
253
  * Inject a user turn into a session's live codex thread — reference implementation's
238
254
  * single-writer web send. `turn/steer` when a turn is open (mid-turn
@@ -292,6 +308,8 @@ export declare class LocalAgentHost implements CodexCapabilities {
292
308
  * terminal registry — the host does not hold tmux). Ignored for codex sessions
293
309
  * (they inject via the app-server). Idempotent. */
294
310
  attachTerminalInjector(localThreadId: string, injector: TerminalInjector): void;
311
+ /** Close an active native response when its terminal or runner disappears. */
312
+ failLiveSession(localThreadId: string, error: Error): boolean;
295
313
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
296
314
  listModels(runtime?: AgentRuntimeId): Promise<ModelListResponse | null>;
297
315
  /**
package/dist/host.js CHANGED
@@ -182,6 +182,8 @@ export class SpawnCodexCommandRunner {
182
182
  * budget (set proactively); claude has no programmatic compaction, so this
183
183
  * signal is the lever consumers surface to the user. */
184
184
  const CONTEXT_WARN_RATIO = 0.8;
185
+ /** Wait for native bridge/thread binding before injection gives up. */
186
+ const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
185
187
  function providerPluginSkillName(namespace, name) {
186
188
  const candidate = `${namespace}-${name}`;
187
189
  if (candidate.length <= 128)
@@ -245,6 +247,7 @@ export class LocalAgentHost {
245
247
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
246
248
  // the backend's app-server; tests inject a fake.
247
249
  forwarderClientFactory;
250
+ observerReconnectTimeoutMs;
248
251
  // Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
249
252
  // a `${runtime}::${retryHash}` composite for a per-agent retry budget.
250
253
  backends = new Map();
@@ -264,6 +267,10 @@ export class LocalAgentHost {
264
267
  // Per-session claude-native live forwarders (parallel to liveSessions; claude
265
268
  // has no app-server, so it tails the bridge + transcript instead of RPC).
266
269
  liveClaudeSessions = new Map();
270
+ /** Exact pre-live startup failure, retained after the partial handle has been
271
+ * cleaned up so Core/plugin callers receive the failed phase, not a generic
272
+ * `live session unavailable` wrapper. */
273
+ liveStartupErrors = new Map();
267
274
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
268
275
  * finalizes these synchronously afterwards to scrub raw interaction answers. */
269
276
  pendingClaudeFinalizers = new Set();
@@ -274,7 +281,7 @@ export class LocalAgentHost {
274
281
  /** Short-lived dedupe for managed fork notifications delivered after the
275
282
  * `thread/fork` response. Values are expected source Provider thread ids. */
276
283
  managedForkThreadStarts = new Map();
277
- constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, runtimeHomeSessionId, }) {
284
+ constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, observerReconnectTimeoutMs = 15_000, sessionId, runtimeHomeSessionId, }) {
278
285
  this.config = config;
279
286
  this.sessionId = sessionId ?? "__default__";
280
287
  this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
@@ -285,6 +292,7 @@ export class LocalAgentHost {
285
292
  this.injectedAppServerClient = appServerClient;
286
293
  this.clock = now;
287
294
  this.backendIdleTtlMs = backendIdleTtlMs;
295
+ this.observerReconnectTimeoutMs = observerReconnectTimeoutMs;
288
296
  this.forwarderClientFactory =
289
297
  forwarderClientFactory ??
290
298
  ((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
@@ -564,6 +572,7 @@ export class LocalAgentHost {
564
572
  }
565
573
  }
566
574
  async startLiveCodexSession(localThreadId, emit, opts) {
575
+ this.liveStartupErrors.delete(localThreadId);
567
576
  // cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
568
577
  // never run a new forwarder alongside a stale one for this id. ensureLive's has()
569
578
  // fast-path normally makes this a no-op; it closes the stop→re-ensure race that
@@ -587,6 +596,7 @@ export class LocalAgentHost {
587
596
  snapshotSkills = await this.prepareExecutionSkills(execution);
588
597
  }
589
598
  catch (err) {
599
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill preparation failed: ${err instanceof Error ? err.message : String(err)}`);
590
600
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
591
601
  return false;
592
602
  }
@@ -599,6 +609,7 @@ export class LocalAgentHost {
599
609
  }
600
610
  catch (err) {
601
611
  void snapshotSkills.skillsCleanup();
612
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
602
613
  console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
603
614
  return false;
604
615
  }
@@ -618,6 +629,7 @@ export class LocalAgentHost {
618
629
  };
619
630
  const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
620
631
  if (!injectClient) {
632
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server is unavailable for this Session`);
621
633
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
622
634
  return abandon();
623
635
  }
@@ -625,11 +637,13 @@ export class LocalAgentHost {
625
637
  await injectClient.ensureInitialized();
626
638
  }
627
639
  catch (err) {
640
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server initialization failed during its 10s readiness phase: ${err instanceof Error ? err.message : String(err)}`);
628
641
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
629
642
  return abandon();
630
643
  }
631
644
  const appServerUrl = injectClient.terminalRemoteUrl();
632
645
  if (!appServerUrl) {
646
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server started without a Terminal remote endpoint`);
633
647
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
634
648
  return abandon(); // needs the ws app-server (live-terminal mode)
635
649
  }
@@ -641,6 +655,7 @@ export class LocalAgentHost {
641
655
  await forwarderClient.ensureInitialized();
642
656
  }
643
657
  catch (err) {
658
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} observer could not attach to the ready app-server: ${err instanceof Error ? err.message : String(err)}`);
644
659
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
645
660
  return abandon();
646
661
  }
@@ -650,6 +665,10 @@ export class LocalAgentHost {
650
665
  const ready = new Promise((resolve) => {
651
666
  markReady = resolve;
652
667
  });
668
+ let markStartupFailed;
669
+ const startupFailed = new Promise((resolve) => {
670
+ markStartupFailed = resolve;
671
+ });
653
672
  let markTerminalReady;
654
673
  const terminalReady = new Promise((resolve) => {
655
674
  markTerminalReady = resolve;
@@ -669,6 +688,8 @@ export class LocalAgentHost {
669
688
  threadId: record?.codexSessionId ?? null,
670
689
  ready,
671
690
  markReady,
691
+ startupFailed,
692
+ markStartupFailed,
672
693
  terminalReady,
673
694
  markTerminalReady,
674
695
  injectLock: Promise.resolve(),
@@ -677,6 +698,7 @@ export class LocalAgentHost {
677
698
  subscribing: false,
678
699
  rotationPending: false,
679
700
  stopped: false,
701
+ observerAvailable: true,
680
702
  skillsCleanup: snapshotSkills.skillsCleanup,
681
703
  interactionOwners: new Map(),
682
704
  interactionStandbys: new Map(),
@@ -906,10 +928,19 @@ export class LocalAgentHost {
906
928
  });
907
929
  client.setConnectionListener((state) => {
908
930
  if (state === "connected") {
909
- live.disconnectedClients.delete(client);
931
+ // Observer transport recovery alone does not prove that an in-flight
932
+ // interaction request was replayed onto this connection. Its request
933
+ // listener clears disconnectedClients when that duplicate arrives.
934
+ if (client !== live.forwarderClient)
935
+ live.disconnectedClients.delete(client);
910
936
  return;
911
937
  }
912
938
  live.disconnectedClients.add(client);
939
+ if (client === live.forwarderClient && !live.stopped) {
940
+ live.observerAvailable = false;
941
+ this.armObserverReconnectDeadline(live);
942
+ void this.reconnectForwarder(live);
943
+ }
913
944
  for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
914
945
  if (allInteractionClientsUnavailable(interactionId)) {
915
946
  finishRecovery(interactionId, recovery.event);
@@ -1086,7 +1117,15 @@ export class LocalAgentHost {
1086
1117
  threadId: record.codexSessionId,
1087
1118
  ...threadWorkspaceParams(runtime, workspace, sandbox),
1088
1119
  approvalPolicy,
1120
+ // The injection connection only needs to bind/subcribe. Codex
1121
+ // 0.146 can ignore initialTurnsPage and return the whole rollout,
1122
+ // so explicitly suppress history here; it is never replayed.
1089
1123
  excludeTurns: true,
1124
+ initialTurnsPage: {
1125
+ limit: 1,
1126
+ sortDirection: "desc",
1127
+ itemsView: "summary",
1128
+ },
1090
1129
  });
1091
1130
  resumedThreadId = resumed.threadId;
1092
1131
  break;
@@ -1134,9 +1173,11 @@ export class LocalAgentHost {
1134
1173
  live.stopped = true;
1135
1174
  forwarder.stop();
1136
1175
  void forwarderClient.stop().catch(() => undefined);
1176
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} native thread binding failed: ${err instanceof Error ? err.message : String(err)}`);
1137
1177
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1138
1178
  return abandon();
1139
1179
  }
1180
+ this.liveStartupErrors.delete(localThreadId);
1140
1181
  return true;
1141
1182
  }
1142
1183
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
@@ -1196,30 +1237,37 @@ export class LocalAgentHost {
1196
1237
  * Subscribe the forwarder connection to a thread (reference implementation's
1197
1238
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1198
1239
  * turn, so `thread/resume` is retried: park until the forwarder observes the
1199
- * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
1200
- * first turn's items (replayed, deduped against the live stream). Once resume
1201
- * succeeds, subsequent turns arrive as live notifications on this connection.
1240
+ * thread active, then retry. Resume only fetches the newest summarized Turn:
1241
+ * recovery reconciles the exact active Turn's terminal status and never replays
1242
+ * historical items. Once resume succeeds, subsequent turns arrive live.
1202
1243
  */
1203
1244
  async subscribeUntilReady(live, threadId) {
1204
- let sawNotReady = false;
1205
1245
  while (!live.stopped) {
1206
1246
  try {
1207
1247
  const resp = await live.forwarderClient.threadResume({
1208
1248
  threadId,
1209
1249
  ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1210
1250
  approvalPolicy: live.approvalPolicy,
1211
- excludeTurns: !sawNotReady,
1251
+ initialTurnsPage: {
1252
+ limit: 1,
1253
+ sortDirection: "desc",
1254
+ itemsView: "summary",
1255
+ },
1212
1256
  });
1213
- if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1214
- live.forwarder.replayBackfill(resp.thread.turns);
1215
- }
1257
+ // Codex 0.146.1 accepts initialTurnsPage but can still return the entire
1258
+ // rollout in chronological order. Never infer newest from array position:
1259
+ // recovery may settle only the exact active turn id already owned here.
1260
+ const activeTurnId = live.forwarder.currentTurnId();
1261
+ const activeTurn = activeTurnId && Array.isArray(resp.thread.turns)
1262
+ ? resp.thread.turns.find((turn) => (turn.id ?? turn.turnId) === activeTurnId)
1263
+ : undefined;
1264
+ live.forwarder.reconcileActiveTurn(activeTurn);
1216
1265
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1217
1266
  }
1218
1267
  catch (error) {
1219
1268
  if (!isThreadNotReadyError(error)) {
1220
1269
  return false; // other failure — injection still works via the backend client
1221
1270
  }
1222
- sawNotReady = true;
1223
1271
  // Park until the thread goes active (its first turn materializes the
1224
1272
  // rollout); a short poll covers the flush race after "active".
1225
1273
  await new Promise((resolve) => {
@@ -1231,6 +1279,54 @@ export class LocalAgentHost {
1231
1279
  }
1232
1280
  return false;
1233
1281
  }
1282
+ /** Restore the independent observer after an unexpected exit. The active turn
1283
+ * remains open during the bounded grace so resume can reconcile its exact id. */
1284
+ reconnectForwarder(live) {
1285
+ if (live.observerReconnect)
1286
+ return live.observerReconnect;
1287
+ const reconnect = (async () => {
1288
+ while (!live.stopped && live.threadId) {
1289
+ try {
1290
+ await live.forwarderClient.ensureInitialized();
1291
+ if (await this.subscribeUntilReady(live, live.threadId)) {
1292
+ live.observerAvailable = true;
1293
+ this.clearObserverReconnectDeadline(live);
1294
+ return;
1295
+ }
1296
+ }
1297
+ catch {
1298
+ // Retry below. Injection uses its independent client and remains usable.
1299
+ }
1300
+ await new Promise((resolve) => {
1301
+ const timer = setTimeout(resolve, 250);
1302
+ timer.unref?.();
1303
+ });
1304
+ }
1305
+ })();
1306
+ const settled = reconnect.finally(() => {
1307
+ if (live.observerReconnect === settled)
1308
+ live.observerReconnect = undefined;
1309
+ });
1310
+ live.observerReconnect = settled;
1311
+ return settled;
1312
+ }
1313
+ armObserverReconnectDeadline(live) {
1314
+ if (live.observerReconnectTimer || !live.forwarder.isTurnOpen())
1315
+ return;
1316
+ live.observerReconnectTimer = setTimeout(() => {
1317
+ live.observerReconnectTimer = undefined;
1318
+ if (live.stopped)
1319
+ return;
1320
+ const provider = live.runtime === "traex" ? "Traex" : "Codex";
1321
+ live.forwarder.failOpenTurn(new Error(`${provider} observer did not reconnect within ${this.observerReconnectTimeoutMs}ms`));
1322
+ }, this.observerReconnectTimeoutMs);
1323
+ live.observerReconnectTimer.unref?.();
1324
+ }
1325
+ clearObserverReconnectDeadline(live) {
1326
+ if (live.observerReconnectTimer)
1327
+ clearTimeout(live.observerReconnectTimer);
1328
+ live.observerReconnectTimer = undefined;
1329
+ }
1234
1330
  /** Await a live session's thread binding. `null` leaves the deadline to the
1235
1331
  * caller; a number keeps the Provider-local bound. Returns false on timeout /
1236
1332
  * no live session. Injection and the runner's `live.ready` gate on this. */
@@ -1241,13 +1337,15 @@ export class LocalAgentHost {
1241
1337
  const live = this.liveSessions.get(localThreadId);
1242
1338
  if (!live)
1243
1339
  return false;
1340
+ const ready = live.ready.then(() => true);
1341
+ const failed = live.startupFailed.then(() => false);
1244
1342
  if (timeoutMs === null)
1245
- return live.ready.then(() => true);
1343
+ return Promise.race([ready, failed]);
1246
1344
  let timer;
1247
1345
  const timeout = new Promise((resolve) => {
1248
1346
  timer = setTimeout(() => resolve(false), timeoutMs);
1249
1347
  });
1250
- const ok = await Promise.race([live.ready.then(() => true), timeout]);
1348
+ const ok = await Promise.race([ready, failed, timeout]);
1251
1349
  if (timer)
1252
1350
  clearTimeout(timer);
1253
1351
  return ok;
@@ -1277,7 +1375,21 @@ export class LocalAgentHost {
1277
1375
  }
1278
1376
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
1279
1377
  liveSessionError(localThreadId) {
1280
- return this.liveClaudeSessions.get(localThreadId)?.error;
1378
+ return this.liveClaudeSessions.get(localThreadId)?.error
1379
+ ?? this.liveSessions.get(localThreadId)?.startupError
1380
+ ?? this.liveStartupErrors.get(localThreadId);
1381
+ }
1382
+ /** Publish the background TUI/thread discovery failure so an executor
1383
+ * already waiting in the 60s bridge window exits immediately with the exact
1384
+ * 30s discovery cause. */
1385
+ failLiveStartup(localThreadId, error) {
1386
+ const live = this.liveSessions.get(localThreadId);
1387
+ if (!live || live.threadId || live.stopped)
1388
+ return false;
1389
+ live.startupError = error.message;
1390
+ this.liveStartupErrors.set(localThreadId, error.message);
1391
+ live.markStartupFailed();
1392
+ return true;
1281
1393
  }
1282
1394
  /**
1283
1395
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -1338,7 +1450,7 @@ export class LocalAgentHost {
1338
1450
  return "failed";
1339
1451
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1340
1452
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1341
- const bound = await this.waitLiveReady(localThreadId, 60_000);
1453
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1342
1454
  const threadId = live.threadId ?? live.forwarder.threadId();
1343
1455
  if (!bound || !threadId)
1344
1456
  return "notReady";
@@ -1400,6 +1512,9 @@ export class LocalAgentHost {
1400
1512
  pendingInput.state = "optimistic";
1401
1513
  }
1402
1514
  live.forwarder.noteTurnAccepted(steered.turnId);
1515
+ if (!live.observerAvailable) {
1516
+ this.armObserverReconnectDeadline(live);
1517
+ }
1403
1518
  return "injected";
1404
1519
  }
1405
1520
  }
@@ -1428,6 +1543,9 @@ export class LocalAgentHost {
1428
1543
  // Do not wait for the independent observer connection's `turn/started`:
1429
1544
  // a second message accepted in that window must steer, not double-start.
1430
1545
  live.forwarder.noteTurnAccepted(started.turnId);
1546
+ if (!live.observerAvailable) {
1547
+ this.armObserverReconnectDeadline(live);
1548
+ }
1431
1549
  return "injected";
1432
1550
  }
1433
1551
  catch (error) {
@@ -1535,6 +1653,11 @@ export class LocalAgentHost {
1535
1653
  return;
1536
1654
  this.liveSessions.delete(localThreadId);
1537
1655
  live.stopped = true;
1656
+ if (!live.threadId) {
1657
+ live.startupError ??= "native Session stopped before thread discovery completed";
1658
+ live.markStartupFailed();
1659
+ }
1660
+ this.clearObserverReconnectDeadline(live);
1538
1661
  live.releaseActive?.();
1539
1662
  live.injectClient.cancelInteractions("session_stopped");
1540
1663
  live.forwarderClient.cancelInteractions("session_stopped");
@@ -2076,6 +2199,19 @@ export class LocalAgentHost {
2076
2199
  if (live)
2077
2200
  live.injector = injector;
2078
2201
  }
2202
+ /** Close an active native response when its terminal or runner disappears. */
2203
+ failLiveSession(localThreadId, error) {
2204
+ const claude = this.liveClaudeSessions.get(localThreadId);
2205
+ if (claude)
2206
+ return claude.forwarder.failOpenTurn(error);
2207
+ const codex = this.liveSessions.get(localThreadId);
2208
+ if (!codex)
2209
+ return false;
2210
+ const failed = codex.forwarder.failOpenTurn(error);
2211
+ if (failed)
2212
+ this.clearObserverReconnectDeadline(codex);
2213
+ return failed;
2214
+ }
2079
2215
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
2080
2216
  async listModels(runtime) {
2081
2217
  const resolved = runtime ?? this.defaultRuntime;
@@ -38,11 +38,13 @@ interface LiveCodexProvider {
38
38
  execution: ResolvedExecutionSnapshot;
39
39
  retargetMirror?: RetargetMirror;
40
40
  }): Promise<boolean>;
41
- /** `null` delegates the only deadline to RunnerManager. */
41
+ /** `null` delegates the deadline to RunnerManager. */
42
42
  waitLiveReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
43
- /** `null` delegates the only deadline to RunnerManager. */
43
+ /** `null` delegates the deadline to RunnerManager. */
44
44
  waitTerminalReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
45
45
  liveSessionError?(localThreadId: string): string | undefined;
46
+ failLiveStartup?(localThreadId: string, error: Error): boolean;
47
+ failLiveSession?(localThreadId: string, error: Error): boolean;
46
48
  injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
47
49
  interruptLive?(localThreadId: string): Promise<boolean>;
48
50
  stopLiveCodexSession?(localThreadId: string, opts?: {
@@ -76,6 +78,7 @@ export declare class RunnerSession {
76
78
  private readonly attachments;
77
79
  private readonly attachmentThreadIds;
78
80
  private readonly traexStartupWatchers;
81
+ private readonly terminalWatchers;
79
82
  /** Opens are async; a close received before attach resolves tombstones the id. */
80
83
  private readonly pendingTerminalOpens;
81
84
  private readonly cancelledTerminalOpens;
@@ -94,18 +97,24 @@ export declare class RunnerSession {
94
97
  private mirrorChannel;
95
98
  /**
96
99
  * Eagerly bring up a session's codex-native live view: start the persistent
97
- * forwarder connection (which resume-subscribes to mirror every turn) and
98
- * launch the detached `codex --remote resume` TUI against the thread the
99
- * structured runtime created. The forwarder subscribes to that same thread,
100
- * 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.
101
103
  */
102
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;
103
111
  private inject;
104
112
  private interruptLive;
105
113
  /** Launch (idempotently) the session's codex TUI pane from the executor's
106
114
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
107
115
  * so the web attach reuses the same detached pane. */
108
116
  private launchCodexPane;
117
+ private watchNativeTerminal;
109
118
  private cancelTraexStartupWatcher;
110
119
  private skipTraexStartupPrompts;
111
120
  private stopLive;
@@ -1,6 +1,8 @@
1
1
  import { TerminalRegistry } from "../terminal/registry.js";
2
2
  import { toWireError } from "./protocol.js";
3
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;
4
6
  const TRAEX_STARTUP_WATCH_MS = 20_000;
5
7
  const TRAEX_STARTUP_POLL_MS = 100;
6
8
  const TRAEX_AUTHORIZATION_POLL_MS = 500;
@@ -30,6 +32,7 @@ export class RunnerSession {
30
32
  attachments = new Map();
31
33
  attachmentThreadIds = new Map();
32
34
  traexStartupWatchers = new Map();
35
+ terminalWatchers = new Map();
33
36
  /** Opens are async; a close received before attach resolves tombstones the id. */
34
37
  pendingTerminalOpens = new Set();
35
38
  cancelledTerminalOpens = new Set();
@@ -157,10 +160,9 @@ export class RunnerSession {
157
160
  }
158
161
  /**
159
162
  * Eagerly bring up a session's codex-native live view: start the persistent
160
- * forwarder connection (which resume-subscribes to mirror every turn) and
161
- * launch the detached `codex --remote resume` TUI against the thread the
162
- * structured runtime created. The forwarder subscribes to that same thread,
163
- * 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.
164
166
  */
165
167
  async ensureLive(msg) {
166
168
  const provider = this.liveProvider;
@@ -177,58 +179,59 @@ export class RunnerSession {
177
179
  reqId: msg.reqId,
178
180
  localThreadId: msg.localThreadId,
179
181
  ok: false,
180
- error: "live provider did not start",
182
+ error: provider.liveSessionError?.(msg.localThreadId)
183
+ ?? "live provider did not start",
181
184
  });
182
185
  return;
183
186
  }
184
187
  this.liveIds.add(msg.localThreadId);
185
- // Native Provider startup has one authoritative parent-side deadline.
186
- // Codex and Traex can accept/queue a turn while MCP startup settles;
187
- // Claude instead waits for SessionStart before terminal injection. The
188
- // historical 20-second gates raced all three valid startup paths.
189
- const providerStartupTimeoutMs = isManagedNativeProvider(msg.execution?.provider)
190
- ? null
191
- : undefined;
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;
192
197
  // Launch the TUI attached to the already-bound thread. Re-launch when the
193
198
  // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
194
199
  // reconnect after the TUI exited restarts it instead of skipping (a
195
200
  // launched-once guard would leave a dead "Pane is dead" husk forever).
196
201
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
197
202
  const terminalReady = provider.waitTerminalReady
198
- ? await provider.waitTerminalReady(msg.localThreadId, providerStartupTimeoutMs)
203
+ ? await provider.waitTerminalReady(msg.localThreadId, terminalReadyTimeoutMs)
199
204
  : true;
200
205
  if (!terminalReady) {
206
+ const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
201
207
  this.transport.send({
202
208
  t: "live.ready",
203
209
  reqId: msg.reqId,
204
210
  localThreadId: msg.localThreadId,
205
211
  ok: false,
206
- 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",
207
216
  });
208
217
  return;
209
218
  }
210
219
  await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
211
220
  }
212
- const ready = msg.waitForReady === false
213
- ? true
214
- : provider.waitLiveReady
215
- ? await provider.waitLiveReady(msg.localThreadId, providerStartupTimeoutMs)
216
- : true;
217
- const terminal = this.terminals.get(`${msg.localThreadId}-main`);
218
- const paneFailure = !ready && terminal && !terminal.isAlive()
219
- ? terminal
220
- .capturePane()
221
- .split("\n")
222
- .map((line) => line.trim())
223
- .filter(Boolean)
224
- .slice(-6)
225
- .join(" ")
226
- .slice(-1_000)
227
- : "";
228
- const readinessError = provider.liveSessionError?.(msg.localThreadId)
229
- ?? (paneFailure
230
- ? `Provider terminal exited before session discovery: ${paneFailure}`
231
- : "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";
232
235
  this.transport.send({
233
236
  t: "live.ready",
234
237
  reqId: msg.reqId,
@@ -247,6 +250,53 @@ export class RunnerSession {
247
250
  });
248
251
  }
249
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
+ }
250
300
  async inject(msg) {
251
301
  const provider = this.liveProvider;
252
302
  try {
@@ -254,7 +304,16 @@ export class RunnerSession {
254
304
  const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
255
305
  // App-server injection is independent of the Terminal TUI startup, so it
256
306
  // must not cancel prompt handling for the pane that is still starting.
257
- this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
307
+ const error = outcome === "injected"
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
+ });
258
317
  }
259
318
  catch (error) {
260
319
  this.transport.send({
@@ -306,6 +365,32 @@ export class RunnerSession {
306
365
  void this.skipTraexStartupPrompts(localThreadId, term, watcher);
307
366
  }
308
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);
309
394
  }
310
395
  cancelTraexStartupWatcher(localThreadId) {
311
396
  if (localThreadId)
@@ -375,6 +460,9 @@ export class RunnerSession {
375
460
  }
376
461
  }
377
462
  stopLive() {
463
+ for (const timer of this.terminalWatchers.values())
464
+ clearInterval(timer);
465
+ this.terminalWatchers.clear();
378
466
  for (const id of this.liveIds) {
379
467
  this.liveProvider.stopLiveCodexSession?.(id, {
380
468
  deferClaudeInteractionCleanup: true,
@@ -56,9 +56,8 @@ export interface RunnerManagerOptions {
56
56
  liveStartTimeoutMs?: number;
57
57
  /** Max wait for a Provider-native thread to become ready. */
58
58
  liveReadyTimeoutMs?: number;
59
- /** End-to-end native readiness deadline for message delivery. The runner
60
- * child delegates its Provider-local gates to this single timeout. Setup-pane
61
- * requests keep the shorter `liveStartTimeoutMs`. */
59
+ /** Claude SessionStart deadline. Codex/Traex use `liveStartTimeoutMs` for the
60
+ * pane/observer acknowledgement; their thread discovery is asynchronous. */
62
61
  nativeLiveStartTimeoutMs?: number;
63
62
  /** Max wait for a native interrupt acknowledgement before reporting it
64
63
  * unproven. Callers may then use the explicit force-stop path. */
@@ -236,8 +235,9 @@ export declare class RunnerManager implements AgentCapabilities {
236
235
  /**
237
236
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
238
237
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
239
- * needed. Idempotent. Resolves true once the codex thread is bound; false for a
240
- * non-codex / non-live session (the caller then uses the normal run path).
238
+ * needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
239
+ * app-server/observer and pane are launched; thread discovery continues in
240
+ * parallel with injection. Other providers retain their readiness gate.
241
241
  */
242
242
  ensureLiveSession(localThreadId: string, opts: {
243
243
  workspace: SessionWorkspaceSnapshot;
@@ -326,6 +326,7 @@ export declare class RunnerManager implements AgentCapabilities {
326
326
  * terminateHandle immediately afterwards, so these are the final events for
327
327
  * the abandoned responses. */
328
328
  private failStaleActiveResponses;
329
+ private failActiveResponses;
329
330
  /** Track only response lifecycle, not output volume. Output deltas from a
330
331
  * runaway TUI/Provider must not refresh the stale-active deadline. */
331
332
  private observeHandleRuntimeEvent;
@@ -34,13 +34,15 @@ 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 should acknowledge quickly; never pin its HTTP request. */
38
- const DEFAULT_LIVE_START_TIMEOUT_MS = 10_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. */
40
+ const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
39
41
  /** Legacy fallback for a provider without a native startup policy. */
40
42
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
41
- /** Native CLIs can spend tens of seconds in provider-owned startup (MCP
42
- * degradation for Codex/Traex; terminal + SessionStart discovery for Claude).
43
- * Keep one outer deadline instead of racing it with child-local gates. */
43
+ /** Claude has no app-server bridge and keeps a parent-owned SessionStart
44
+ * deadline. Codex-lineage startup instead acknowledges pane/observer startup
45
+ * and lets thread discovery race injection. */
44
46
  const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
45
47
  /** A submitted TUI command should become a mirrored turn or native rotation
46
48
  * quickly. If it does not, the runner is fenced by a verified process-tree
@@ -500,8 +502,9 @@ export class RunnerManager {
500
502
  /**
501
503
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
502
504
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
503
- * needed. Idempotent. Resolves true once the codex thread is bound; false for a
504
- * non-codex / non-live session (the caller then uses the normal run path).
505
+ * needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
506
+ * app-server/observer and pane are launched; thread discovery continues in
507
+ * parallel with injection. Other providers retain their readiness gate.
505
508
  */
506
509
  ensureLiveSession(localThreadId, opts) {
507
510
  const admission = this.reserveAdmission();
@@ -542,14 +545,23 @@ export class RunnerManager {
542
545
  return new Promise((resolve) => {
543
546
  const timeoutMs = !waitForReady
544
547
  ? this.liveStartTimeoutMs
545
- : isManagedNativeProvider(opts.execution.provider)
548
+ : opts.execution.provider === "claude"
546
549
  ? this.nativeLiveStartTimeoutMs
547
- : this.liveReadyTimeoutMs;
550
+ : isManagedNativeProvider(opts.execution.provider)
551
+ ? this.liveStartTimeoutMs
552
+ : this.liveReadyTimeoutMs;
548
553
  const timeout = setTimeout(() => {
549
554
  if (!handle.live.delete(reqId))
550
555
  return;
551
556
  const finishTimeout = () => {
552
- const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
557
+ const provider = opts.execution.provider;
558
+ const reason = !waitForReady
559
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
560
+ : provider === "claude"
561
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
562
+ : provider === "codex" || provider === "traex"
563
+ ? `${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`
564
+ : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
553
565
  this.liveErrors.set(localThreadId, reason);
554
566
  this.liveSessionKeys.delete(localThreadId);
555
567
  // A child that cannot answer a bounded control round-trip is unsafe
@@ -1270,7 +1282,7 @@ export class RunnerManager {
1270
1282
  }
1271
1283
  }
1272
1284
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
1273
- failHandle(handle, reason) {
1285
+ failHandle(handle, reason, opts = {}) {
1274
1286
  if (handle.dead) {
1275
1287
  return;
1276
1288
  }
@@ -1289,6 +1301,9 @@ export class RunnerManager {
1289
1301
  }
1290
1302
  const tail = handle.stderr.join("\n");
1291
1303
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
1304
+ if (opts.failActiveResponses !== false) {
1305
+ this.failActiveResponses(handle, "runner_crashed", message);
1306
+ }
1292
1307
  const error = new AgentRuntimeError(message, 500, "runner_crashed");
1293
1308
  for (const pending of handle.caps.values()) {
1294
1309
  pending.reject(error);
@@ -1319,7 +1334,7 @@ export class RunnerManager {
1319
1334
  catch {
1320
1335
  // The process signal below remains the authoritative shutdown path.
1321
1336
  }
1322
- this.failHandle(handle, reason);
1337
+ this.failHandle(handle, reason, { failActiveResponses: false });
1323
1338
  }
1324
1339
  this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1325
1340
  let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
@@ -1385,6 +1400,9 @@ export class RunnerManager {
1385
1400
  * terminateHandle immediately afterwards, so these are the final events for
1386
1401
  * the abandoned responses. */
1387
1402
  failStaleActiveResponses(handle, idleForMs) {
1403
+ this.failActiveResponses(handle, "stale_runner_reaped", `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`);
1404
+ }
1405
+ failActiveResponses(handle, code, message) {
1388
1406
  const responseIds = [...handle.activeResponseIds];
1389
1407
  handle.activeResponseIds.clear();
1390
1408
  const sessionId = this.currentTerminalSessionId(handle, handle.key);
@@ -1394,8 +1412,8 @@ export class RunnerManager {
1394
1412
  responseId,
1395
1413
  error: {
1396
1414
  source: "execution",
1397
- code: "stale_runner_reaped",
1398
- message: `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`,
1415
+ code,
1416
+ message,
1399
1417
  },
1400
1418
  });
1401
1419
  }
@@ -70,8 +70,9 @@ export type ToChild = {
70
70
  }
71
71
  /** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
72
72
  * its turns mirror to the bus regardless of whether the web has attached the
73
- * terminal. Idempotent; the child replies `live.ready` (echoing `reqId`) once
74
- * the thread is bound. */
73
+ * terminal. For Codex-lineage fresh sessions, `live.ready` acknowledges that
74
+ * the pane and observer exist; background thread discovery races the
75
+ * executor's bridge wait. */
75
76
  | {
76
77
  t: "live.ensure";
77
78
  reqId: string;
@@ -160,8 +161,9 @@ export type FromChild = {
160
161
  execution: ResolvedExecutionSnapshot;
161
162
  parentSessionId?: string;
162
163
  }
163
- /** Result of a `live.ensure`: `ok` once the requested gate is reached (pane
164
- * started for setup, otherwise native thread bound). Echoes `reqId`. */
164
+ /** Result of a `live.ensure`: for Codex-lineage sessions `ok` means the pane
165
+ * and observer started; thread discovery continues in parallel with
166
+ * injection. Other Providers retain their own readiness gate. */
165
167
  | {
166
168
  t: "live.ready";
167
169
  reqId: string;
@@ -112,7 +112,7 @@ export declare class TmuxTerminal {
112
112
  * there stalls the runner child's event loop (freezing the PTY stream → the
113
113
  * terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
114
114
  * subprocess + timeout for exactly this reason. */
115
- private isAliveAsync;
115
+ isAliveAsync(): Promise<boolean>;
116
116
  /** Type literal text into the pane (agent injection / co-drive from a
117
117
  * non-PTY caller). `-l` sends the text literally rather than as key names. */
118
118
  sendKeys(text: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.19",
3
+ "version": "0.1.11-beta.20",
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.19"
30
+ "@rynx-ai/core": "0.1.11-beta.20"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.18.1"