@rynx-ai/runtime 0.1.11-beta.2 → 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.
package/dist/host.js CHANGED
@@ -15,6 +15,7 @@ import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server
15
15
  import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
16
16
  import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
17
17
  import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
18
+ import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
18
19
  import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
19
20
  import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
20
21
  import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
@@ -181,6 +182,8 @@ export class SpawnCodexCommandRunner {
181
182
  * budget (set proactively); claude has no programmatic compaction, so this
182
183
  * signal is the lever consumers surface to the user. */
183
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;
184
187
  function providerPluginSkillName(namespace, name) {
185
188
  const candidate = `${namespace}-${name}`;
186
189
  if (candidate.length <= 128)
@@ -244,6 +247,7 @@ export class LocalAgentHost {
244
247
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
245
248
  // the backend's app-server; tests inject a fake.
246
249
  forwarderClientFactory;
250
+ observerReconnectTimeoutMs;
247
251
  // Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
248
252
  // a `${runtime}::${retryHash}` composite for a per-agent retry budget.
249
253
  backends = new Map();
@@ -263,6 +267,10 @@ export class LocalAgentHost {
263
267
  // Per-session claude-native live forwarders (parallel to liveSessions; claude
264
268
  // has no app-server, so it tails the bridge + transcript instead of RPC).
265
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();
266
274
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
267
275
  * finalizes these synchronously afterwards to scrub raw interaction answers. */
268
276
  pendingClaudeFinalizers = new Set();
@@ -273,17 +281,18 @@ export class LocalAgentHost {
273
281
  /** Short-lived dedupe for managed fork notifications delivered after the
274
282
  * `thread/fork` response. Values are expected source Provider thread ids. */
275
283
  managedForkThreadStarts = new Map();
276
- 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, }) {
277
285
  this.config = config;
278
286
  this.sessionId = sessionId ?? "__default__";
279
287
  this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
280
288
  this.sessionStore = sessionStore;
281
289
  this.allowedRoots = allowedRoots;
282
- this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
290
+ this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
283
291
  this.injectedCommandRunner = commandRunner;
284
292
  this.injectedAppServerClient = appServerClient;
285
293
  this.clock = now;
286
294
  this.backendIdleTtlMs = backendIdleTtlMs;
295
+ this.observerReconnectTimeoutMs = observerReconnectTimeoutMs;
287
296
  this.forwarderClientFactory =
288
297
  forwarderClientFactory ??
289
298
  ((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
@@ -472,6 +481,9 @@ export class LocalAgentHost {
472
481
  // to the same effective sandbox.
473
482
  const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
474
483
  const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
484
+ const traexYolo = runtime === "traex" &&
485
+ sandbox === "danger-full-access" &&
486
+ approvalPolicy === "never";
475
487
  const configOverrides = sandbox === "workspace-write"
476
488
  ? ["sandbox_workspace_write.network_access=true"]
477
489
  : [];
@@ -500,9 +512,11 @@ export class LocalAgentHost {
500
512
  remoteUrl,
501
513
  ...(activeThreadId ? { threadId: activeThreadId } : {}),
502
514
  configOverrides,
515
+ codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
503
516
  additionalDirs: providerAdditionalDirs(live.workspace),
504
517
  }),
505
518
  cwd: live.workspace.cwd,
519
+ ...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
506
520
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
507
521
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
508
522
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
@@ -558,6 +572,7 @@ export class LocalAgentHost {
558
572
  }
559
573
  }
560
574
  async startLiveCodexSession(localThreadId, emit, opts) {
575
+ this.liveStartupErrors.delete(localThreadId);
561
576
  // cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
562
577
  // never run a new forwarder alongside a stale one for this id. ensureLive's has()
563
578
  // fast-path normally makes this a no-op; it closes the stop→re-ensure race that
@@ -581,6 +596,7 @@ export class LocalAgentHost {
581
596
  snapshotSkills = await this.prepareExecutionSkills(execution);
582
597
  }
583
598
  catch (err) {
599
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill preparation failed: ${err instanceof Error ? err.message : String(err)}`);
584
600
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
585
601
  return false;
586
602
  }
@@ -593,6 +609,7 @@ export class LocalAgentHost {
593
609
  }
594
610
  catch (err) {
595
611
  void snapshotSkills.skillsCleanup();
612
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
596
613
  console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
597
614
  return false;
598
615
  }
@@ -612,6 +629,7 @@ export class LocalAgentHost {
612
629
  };
613
630
  const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
614
631
  if (!injectClient) {
632
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server is unavailable for this Session`);
615
633
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
616
634
  return abandon();
617
635
  }
@@ -619,11 +637,13 @@ export class LocalAgentHost {
619
637
  await injectClient.ensureInitialized();
620
638
  }
621
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)}`);
622
641
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
623
642
  return abandon();
624
643
  }
625
644
  const appServerUrl = injectClient.terminalRemoteUrl();
626
645
  if (!appServerUrl) {
646
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server started without a Terminal remote endpoint`);
627
647
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
628
648
  return abandon(); // needs the ws app-server (live-terminal mode)
629
649
  }
@@ -635,15 +655,20 @@ export class LocalAgentHost {
635
655
  await forwarderClient.ensureInitialized();
636
656
  }
637
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)}`);
638
659
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
639
660
  return abandon();
640
661
  }
641
- const model = execution.model;
662
+ const model = execution.model ?? "";
642
663
  const reasoningEffort = execution.reasoningEffort ?? undefined;
643
664
  let markReady;
644
665
  const ready = new Promise((resolve) => {
645
666
  markReady = resolve;
646
667
  });
668
+ let markStartupFailed;
669
+ const startupFailed = new Promise((resolve) => {
670
+ markStartupFailed = resolve;
671
+ });
647
672
  let markTerminalReady;
648
673
  const terminalReady = new Promise((resolve) => {
649
674
  markTerminalReady = resolve;
@@ -663,6 +688,8 @@ export class LocalAgentHost {
663
688
  threadId: record?.codexSessionId ?? null,
664
689
  ready,
665
690
  markReady,
691
+ startupFailed,
692
+ markStartupFailed,
666
693
  terminalReady,
667
694
  markTerminalReady,
668
695
  injectLock: Promise.resolve(),
@@ -671,6 +698,7 @@ export class LocalAgentHost {
671
698
  subscribing: false,
672
699
  rotationPending: false,
673
700
  stopped: false,
701
+ observerAvailable: true,
674
702
  skillsCleanup: snapshotSkills.skillsCleanup,
675
703
  interactionOwners: new Map(),
676
704
  interactionStandbys: new Map(),
@@ -704,9 +732,8 @@ export class LocalAgentHost {
704
732
  // `turn/started` land on ONE response instead of splitting into random
705
733
  // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
706
734
  responseId,
707
- // Traex intentionally leaves the launch model empty to use its own
708
- // config. Canonical/Direct events still require a printable producer
709
- // label, so report the runtime when the exact model is unknown.
735
+ // A null Session model intentionally leaves selection to the Provider
736
+ // CLI. Canonical events still require a printable label.
710
737
  model: model || runtime,
711
738
  });
712
739
  return normalizer;
@@ -901,10 +928,19 @@ export class LocalAgentHost {
901
928
  });
902
929
  client.setConnectionListener((state) => {
903
930
  if (state === "connected") {
904
- 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);
905
936
  return;
906
937
  }
907
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
+ }
908
944
  for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
909
945
  if (allInteractionClientsUnavailable(interactionId)) {
910
946
  finishRecovery(interactionId, recovery.event);
@@ -942,6 +978,18 @@ export class LocalAgentHost {
942
978
  for (const se of n.next(event))
943
979
  emitCurrent(se);
944
980
  },
981
+ onStatus: (note, statusKind) => {
982
+ const responseId = currentResponseId ??
983
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
984
+ emitCurrent({
985
+ type: "session.status",
986
+ sessionId: currentSessionId,
987
+ ...(responseId ? { responseId } : {}),
988
+ status: "running",
989
+ ...(note ? { note } : {}),
990
+ ...(statusKind ? { statusKind } : {}),
991
+ });
992
+ },
945
993
  onTurnEnd: (usage) => {
946
994
  closeCanonicalInteractions();
947
995
  if (!normalizer)
@@ -995,6 +1043,7 @@ export class LocalAgentHost {
995
1043
  turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
996
1044
  assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
997
1045
  surfaceQueueStatus: runtime === "traex",
1046
+ mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
998
1047
  });
999
1048
  live.forwarder = forwarder;
1000
1049
  live.onNativeThreadRotated = (threadId, kind) => {
@@ -1068,7 +1117,15 @@ export class LocalAgentHost {
1068
1117
  threadId: record.codexSessionId,
1069
1118
  ...threadWorkspaceParams(runtime, workspace, sandbox),
1070
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.
1071
1123
  excludeTurns: true,
1124
+ initialTurnsPage: {
1125
+ limit: 1,
1126
+ sortDirection: "desc",
1127
+ itemsView: "summary",
1128
+ },
1072
1129
  });
1073
1130
  resumedThreadId = resumed.threadId;
1074
1131
  break;
@@ -1085,9 +1142,22 @@ export class LocalAgentHost {
1085
1142
  if (resumedThreadId) {
1086
1143
  this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1087
1144
  }
1145
+ else if (resumeError &&
1146
+ isThreadNotReadyError(resumeError) &&
1147
+ !record.parentSessionId &&
1148
+ !record.runtimeHomeOwnerSessionId) {
1149
+ // A native thread that still has no rollout after the bounded retry
1150
+ // window has never materialized a Turn. It is safe to forget this
1151
+ // ordinary binding and let the TUI create a fresh native thread.
1152
+ // Fork bindings stay strict because their Provider-native ancestry
1153
+ // must not be silently replaced.
1154
+ await this.sessionStore.delete(localThreadId);
1155
+ live.threadId = null;
1156
+ live.markTerminalReady(true);
1157
+ }
1088
1158
  else {
1089
- // Never replace an existing binding. A delayed native index is
1090
- // retryable; a genuinely missing thread remains an explicit error.
1159
+ // A delayed/missing native index or a fork binding remains explicit:
1160
+ // neither is proof that replacing Provider history is safe.
1091
1161
  throw resumeError;
1092
1162
  }
1093
1163
  }
@@ -1103,9 +1173,11 @@ export class LocalAgentHost {
1103
1173
  live.stopped = true;
1104
1174
  forwarder.stop();
1105
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)}`);
1106
1177
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1107
1178
  return abandon();
1108
1179
  }
1180
+ this.liveStartupErrors.delete(localThreadId);
1109
1181
  return true;
1110
1182
  }
1111
1183
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
@@ -1165,30 +1237,37 @@ export class LocalAgentHost {
1165
1237
  * Subscribe the forwarder connection to a thread (reference implementation's
1166
1238
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1167
1239
  * turn, so `thread/resume` is retried: park until the forwarder observes the
1168
- * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
1169
- * first turn's items (replayed, deduped against the live stream). Once resume
1170
- * 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.
1171
1243
  */
1172
1244
  async subscribeUntilReady(live, threadId) {
1173
- let sawNotReady = false;
1174
1245
  while (!live.stopped) {
1175
1246
  try {
1176
1247
  const resp = await live.forwarderClient.threadResume({
1177
1248
  threadId,
1178
1249
  ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1179
1250
  approvalPolicy: live.approvalPolicy,
1180
- excludeTurns: !sawNotReady,
1251
+ initialTurnsPage: {
1252
+ limit: 1,
1253
+ sortDirection: "desc",
1254
+ itemsView: "summary",
1255
+ },
1181
1256
  });
1182
- if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1183
- live.forwarder.replayBackfill(resp.thread.turns);
1184
- }
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);
1185
1265
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1186
1266
  }
1187
1267
  catch (error) {
1188
1268
  if (!isThreadNotReadyError(error)) {
1189
1269
  return false; // other failure — injection still works via the backend client
1190
1270
  }
1191
- sawNotReady = true;
1192
1271
  // Park until the thread goes active (its first turn materializes the
1193
1272
  // rollout); a short poll covers the flush race after "active".
1194
1273
  await new Promise((resolve) => {
@@ -1200,7 +1279,56 @@ export class LocalAgentHost {
1200
1279
  }
1201
1280
  return false;
1202
1281
  }
1203
- /** Await a live session's thread binding (bounded). Returns false on timeout /
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
+ }
1330
+ /** Await a live session's thread binding. `null` leaves the deadline to the
1331
+ * caller; a number keeps the Provider-local bound. Returns false on timeout /
1204
1332
  * no live session. Injection and the runner's `live.ready` gate on this. */
1205
1333
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1206
1334
  const claude = this.liveClaudeSessions.get(localThreadId);
@@ -1209,11 +1337,15 @@ export class LocalAgentHost {
1209
1337
  const live = this.liveSessions.get(localThreadId);
1210
1338
  if (!live)
1211
1339
  return false;
1340
+ const ready = live.ready.then(() => true);
1341
+ const failed = live.startupFailed.then(() => false);
1342
+ if (timeoutMs === null)
1343
+ return Promise.race([ready, failed]);
1212
1344
  let timer;
1213
1345
  const timeout = new Promise((resolve) => {
1214
1346
  timer = setTimeout(() => resolve(false), timeoutMs);
1215
1347
  });
1216
- const ok = await Promise.race([live.ready.then(() => true), timeout]);
1348
+ const ok = await Promise.race([ready, failed, timeout]);
1217
1349
  if (timer)
1218
1350
  clearTimeout(timer);
1219
1351
  return ok;
@@ -1230,6 +1362,8 @@ export class LocalAgentHost {
1230
1362
  const live = this.liveSessions.get(localThreadId);
1231
1363
  if (!live)
1232
1364
  return false;
1365
+ if (timeoutMs === null)
1366
+ return live.terminalReady;
1233
1367
  let timer;
1234
1368
  const timeout = new Promise((resolve) => {
1235
1369
  timer = setTimeout(() => resolve(false), timeoutMs);
@@ -1241,7 +1375,21 @@ export class LocalAgentHost {
1241
1375
  }
1242
1376
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
1243
1377
  liveSessionError(localThreadId) {
1244
- 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;
1245
1393
  }
1246
1394
  /**
1247
1395
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -1302,7 +1450,7 @@ export class LocalAgentHost {
1302
1450
  return "failed";
1303
1451
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1304
1452
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1305
- const bound = await this.waitLiveReady(localThreadId, 60_000);
1453
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1306
1454
  const threadId = live.threadId ?? live.forwarder.threadId();
1307
1455
  if (!bound || !threadId)
1308
1456
  return "notReady";
@@ -1316,6 +1464,7 @@ export class LocalAgentHost {
1316
1464
  ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1317
1465
  };
1318
1466
  live.pendingInjectedInputs.push(pendingInput);
1467
+ let injectionMethod = "turn/start";
1319
1468
  const forgetPendingInput = () => {
1320
1469
  const index = live.pendingInjectedInputs.indexOf(pendingInput);
1321
1470
  if (index >= 0)
@@ -1326,11 +1475,31 @@ export class LocalAgentHost {
1326
1475
  if (live.forwarder.isTurnOpen()) {
1327
1476
  const turnId = live.forwarder.currentTurnId();
1328
1477
  if (turnId) {
1329
- const steered = await live.injectClient.turnSteer({
1330
- threadId,
1331
- expectedTurnId: turnId,
1332
- input: nativeInput,
1333
- });
1478
+ injectionMethod = "turn/steer";
1479
+ let steered;
1480
+ let retryIndex = 0;
1481
+ while (!steered) {
1482
+ try {
1483
+ steered = await live.injectClient.turnSteer({
1484
+ threadId,
1485
+ expectedTurnId: turnId,
1486
+ input: nativeInput,
1487
+ });
1488
+ }
1489
+ catch (error) {
1490
+ const delayMs = TURN_STEER_ACTIVATION_RETRY_DELAYS_MS[retryIndex];
1491
+ if (delayMs === undefined ||
1492
+ !isTransientTurnSteerActivationRace(error) ||
1493
+ live.stopped ||
1494
+ live.rotationPending ||
1495
+ !live.forwarder.isTurnOpen() ||
1496
+ live.forwarder.currentTurnId() !== turnId) {
1497
+ throw error;
1498
+ }
1499
+ retryIndex += 1;
1500
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1501
+ }
1502
+ }
1334
1503
  if (pendingInput.state === "prepublished") {
1335
1504
  // The caller already persisted and published this user input
1336
1505
  // before waiting for the native Terminal to become ready.
@@ -1342,6 +1511,10 @@ export class LocalAgentHost {
1342
1511
  live.publishInjectedInput(steered.turnId, pendingInput.content);
1343
1512
  pendingInput.state = "optimistic";
1344
1513
  }
1514
+ live.forwarder.noteTurnAccepted(steered.turnId);
1515
+ if (!live.observerAvailable) {
1516
+ this.armObserverReconnectDeadline(live);
1517
+ }
1345
1518
  return "injected";
1346
1519
  }
1347
1520
  }
@@ -1366,13 +1539,22 @@ export class LocalAgentHost {
1366
1539
  live.publishInjectedInput(started.turnId, pendingInput.content);
1367
1540
  pendingInput.state = "optimistic";
1368
1541
  }
1542
+ // The injection RPC and active-turn write are one serialized operation.
1543
+ // Do not wait for the independent observer connection's `turn/started`:
1544
+ // a second message accepted in that window must steer, not double-start.
1545
+ live.forwarder.noteTurnAccepted(started.turnId);
1546
+ if (!live.observerAvailable) {
1547
+ this.armObserverReconnectDeadline(live);
1548
+ }
1369
1549
  return "injected";
1370
1550
  }
1371
1551
  catch (error) {
1372
1552
  forgetPendingInput();
1373
1553
  // Preserve the app-server's error instead of collapsing every failure
1374
1554
  // into the unactionable `live injection failed` string.
1375
- const detail = codexInjectionError(error);
1555
+ const baseDetail = codexRpcError(error, injectionMethod);
1556
+ const startupDetail = live.forwarder.mcpStartupDetail();
1557
+ const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1376
1558
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1377
1559
  throw new Error(detail, { cause: error });
1378
1560
  }
@@ -1417,21 +1599,35 @@ export class LocalAgentHost {
1417
1599
  const live = this.liveSessions.get(localThreadId);
1418
1600
  if (!live)
1419
1601
  return false;
1420
- // Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
1421
- // reference implementation no-ops (204) when no turn is active.
1422
- if (!live.forwarder.isTurnOpen())
1423
- return false;
1424
1602
  const threadId = live.threadId ?? live.forwarder.threadId();
1425
- const turnId = live.forwarder.currentTurnId();
1426
- if (!threadId || !turnId)
1427
- return false;
1428
- try {
1429
- await live.injectClient.turnInterrupt({ threadId, turnId });
1430
- return true;
1603
+ const pendingMcp = live.forwarder.cancelMcpStartup();
1604
+ const turnId = live.forwarder.isTurnOpen()
1605
+ ? live.forwarder.currentTurnId()
1606
+ : null;
1607
+ if (!threadId)
1608
+ return pendingMcp.length > 0;
1609
+ let handled = pendingMcp.length > 0;
1610
+ if (pendingMcp.length > 0) {
1611
+ // Codex's TUI cancels a provider-owned MCP startup round with an empty
1612
+ // turn id. Best-effort for Traex: it shares the app-server surface, while
1613
+ // the active-turn interrupt below remains authoritative if it rejects this.
1614
+ try {
1615
+ await live.injectClient.turnInterrupt({ threadId, turnId: "" });
1616
+ }
1617
+ catch (error) {
1618
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
1619
+ }
1431
1620
  }
1432
- catch {
1433
- return false;
1621
+ if (turnId) {
1622
+ try {
1623
+ await live.injectClient.turnInterrupt({ threadId, turnId });
1624
+ handled = true;
1625
+ }
1626
+ catch {
1627
+ // The startup cancellation above is still a handled Stop operation.
1628
+ }
1434
1629
  }
1630
+ return handled;
1435
1631
  }
1436
1632
  /** Stop + drop a session's live forwarder and its dedicated connection (session
1437
1633
  * close / runner shutdown). The backend inject client is shared — left running. */
@@ -1457,6 +1653,11 @@ export class LocalAgentHost {
1457
1653
  return;
1458
1654
  this.liveSessions.delete(localThreadId);
1459
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);
1460
1661
  live.releaseActive?.();
1461
1662
  live.injectClient.cancelInteractions("session_stopped");
1462
1663
  live.forwarderClient.cancelInteractions("session_stopped");
@@ -1522,7 +1723,7 @@ export class LocalAgentHost {
1522
1723
  });
1523
1724
  const settings = { ...effectiveSettings, ...hookSettings };
1524
1725
  const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
1525
- // The live session carries the immutable execution snapshot used by Chat.
1726
+ // The live session carries the daemon-owned execution state used by Chat.
1526
1727
  const args = buildClaudeTuiArgs({
1527
1728
  settingsJson: settingsPath,
1528
1729
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1530,7 +1731,10 @@ export class LocalAgentHost {
1530
1731
  // resolve the same native request while rynx still exposes it as pending.
1531
1732
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1532
1733
  settingSources: "",
1533
- model: live.execution.model,
1734
+ ...(live.execution.model ? { model: live.execution.model } : {}),
1735
+ ...(live.execution.reasoningEffort
1736
+ ? { reasoningEffort: live.execution.reasoningEffort }
1737
+ : {}),
1534
1738
  ...(live.execution.instructions
1535
1739
  ? { appendSystemPrompt: live.execution.instructions }
1536
1740
  : {}),
@@ -1633,8 +1837,16 @@ export class LocalAgentHost {
1633
1837
  throw failedPluginSkill.reason;
1634
1838
  }
1635
1839
  const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1840
+ const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
1841
+ const selectedNames = new Set();
1842
+ for (const skill of selectedSkills) {
1843
+ if (selectedNames.has(skill.name)) {
1844
+ throw new Error(`duplicate final Session skill name: ${skill.name}`);
1845
+ }
1846
+ selectedNames.add(skill.name);
1847
+ }
1636
1848
  return {
1637
- selectedSkills: [...skillEnv.resolved, ...pluginSkills],
1849
+ selectedSkills,
1638
1850
  skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1639
1851
  };
1640
1852
  }
@@ -1664,7 +1876,7 @@ export class LocalAgentHost {
1664
1876
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1665
1877
  return false;
1666
1878
  }
1667
- const model = execution.model;
1879
+ const model = execution.model ?? "";
1668
1880
  const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1669
1881
  void snapshotSkills.skillsCleanup();
1670
1882
  const permissionMode = execution.permissionMode;
@@ -1695,7 +1907,7 @@ export class LocalAgentHost {
1695
1907
  normalizer = new SessionNormalizer({
1696
1908
  sessionId: currentSessionId,
1697
1909
  responseId,
1698
- model,
1910
+ model: model || "claude",
1699
1911
  });
1700
1912
  return normalizer;
1701
1913
  };
@@ -1956,7 +2168,8 @@ export class LocalAgentHost {
1956
2168
  });
1957
2169
  return ok ? "injected" : "failed";
1958
2170
  }
1959
- catch {
2171
+ catch (error) {
2172
+ live.error = error instanceof Error ? error.message : String(error);
1960
2173
  return "failed";
1961
2174
  }
1962
2175
  finally {
@@ -1986,6 +2199,19 @@ export class LocalAgentHost {
1986
2199
  if (live)
1987
2200
  live.injector = injector;
1988
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
+ }
1989
2215
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
1990
2216
  async listModels(runtime) {
1991
2217
  const resolved = runtime ?? this.defaultRuntime;
@@ -2113,7 +2339,7 @@ export class LocalAgentHost {
2113
2339
  threadId: record.codexSessionId,
2114
2340
  excludeTurns: true,
2115
2341
  ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2116
- model: execution.model,
2342
+ ...(execution.model ? { model: execution.model } : {}),
2117
2343
  approvalPolicy: execution.approvalPolicy,
2118
2344
  });
2119
2345
  }
@@ -2155,12 +2381,26 @@ export class LocalAgentHost {
2155
2381
  }
2156
2382
  }
2157
2383
  }
2158
- function codexInjectionError(error) {
2384
+ function codexRpcError(error, method) {
2159
2385
  if (error instanceof CodexTransportError) {
2160
- return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
2386
+ return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
2161
2387
  }
2162
- return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
2388
+ return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
2389
+ }
2390
+ /**
2391
+ * Codex 0.146 can acknowledge `turn/start` a few milliseconds before its
2392
+ * internal active-turn pointer becomes visible to `turn/steer`. The injection
2393
+ * lock still prevents double-starts, but the immediately following steer must
2394
+ * briefly retry the same expected turn id. Keep this narrow: all other RPC
2395
+ * errors remain terminal and visible to the caller.
2396
+ */
2397
+ function isTransientTurnSteerActivationRace(error) {
2398
+ return error instanceof CodexTransportError &&
2399
+ error.code === -32600 &&
2400
+ (/no active turn to steer/i.test(error.message) ||
2401
+ /expected active turn id\b.+\bfound\b/i.test(error.message));
2163
2402
  }
2403
+ const TURN_STEER_ACTIVATION_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 250, 440];
2164
2404
  export function parseCodexLoginStatus(exitCode, output) {
2165
2405
  const normalized = output.trim();
2166
2406
  const match = normalized.match(/Logged in using (.+)$/im);
@@ -2184,15 +2424,18 @@ export function parseCodexLoginStatus(exitCode, output) {
2184
2424
  issues: normalized ? [normalized] : [],
2185
2425
  };
2186
2426
  }
2187
- /** Race a live session's `ready` promise against failure / timeout. */
2427
+ /** Race a live session's `ready` promise against failure and, when supplied,
2428
+ * a timeout. */
2188
2429
  function raceReady(ready, timeoutMs, failed) {
2189
2430
  let timer;
2190
- const timeout = new Promise((resolve) => {
2191
- timer = setTimeout(() => resolve(false), timeoutMs);
2192
- });
2193
- const outcomes = [ready.then(() => true), timeout];
2431
+ const outcomes = [ready.then(() => true)];
2194
2432
  if (failed)
2195
2433
  outcomes.push(failed.then(() => false));
2434
+ if (timeoutMs !== null) {
2435
+ outcomes.push(new Promise((resolve) => {
2436
+ timer = setTimeout(() => resolve(false), timeoutMs);
2437
+ }));
2438
+ }
2196
2439
  return Promise.race(outcomes).finally(() => {
2197
2440
  if (timer)
2198
2441
  clearTimeout(timer);