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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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);
@@ -919,8 +955,59 @@ export class LocalAgentHost {
919
955
  }
920
956
  live.canonicalInteractions.clear();
921
957
  };
958
+ const completeCurrentTurn = (usage, reason) => {
959
+ closeCanonicalInteractions();
960
+ if (!normalizer)
961
+ return;
962
+ const completedResponseId = currentResponseId;
963
+ if (usage) {
964
+ for (const se of normalizer.next({ type: "turn_completed", usage }))
965
+ emitCurrent(se);
966
+ }
967
+ for (const se of normalizer.next({ type: "done" })) {
968
+ if (reason === "superseded" && se.type === "session.status")
969
+ continue;
970
+ emitCurrent(se);
971
+ }
972
+ clearPendingInputsForResponse(completedResponseId);
973
+ normalizer = null;
974
+ currentResponseId = null;
975
+ };
976
+ const failCurrentTurn = (error) => {
977
+ closeCanonicalInteractions();
978
+ if (!normalizer)
979
+ return;
980
+ const failedResponseId = currentResponseId;
981
+ const responseStopped = error.message === "Codex turn was interrupted";
982
+ const providerName = runtime === "traex" ? "Traex" : "Codex";
983
+ const message = responseStopped
984
+ ? "Response stopped"
985
+ : runtime === "traex"
986
+ ? error.message.replace(/^Codex\b/, providerName)
987
+ : error.message;
988
+ for (const se of normalizer.fail({
989
+ code: responseStopped
990
+ ? "response_stopped"
991
+ : runtime === "traex" ? "traex_error" : "codex_error",
992
+ message,
993
+ source: "execution",
994
+ })) {
995
+ emitCurrent(se.type === "session.status"
996
+ ? { ...se, status: "failed", note: message }
997
+ : se);
998
+ }
999
+ clearPendingInputsForResponse(failedResponseId);
1000
+ normalizer = null;
1001
+ currentResponseId = null;
1002
+ };
922
1003
  const sink = {
923
1004
  onTurnStart: (turnId) => startNormalizer(turnId),
1005
+ onTurnObserved: (turnId) => {
1006
+ const n = startNormalizer(turnId);
1007
+ for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
1008
+ emitCurrent(event);
1009
+ }
1010
+ },
924
1011
  onUserMessage: (content) => {
925
1012
  const normalizedContent = typeof content === "string"
926
1013
  ? [{ type: "input_text", text: content }]
@@ -942,45 +1029,40 @@ export class LocalAgentHost {
942
1029
  for (const se of n.next(event))
943
1030
  emitCurrent(se);
944
1031
  },
945
- onTurnEnd: (usage) => {
946
- closeCanonicalInteractions();
947
- if (!normalizer)
948
- return;
949
- const completedResponseId = currentResponseId;
950
- if (usage) {
951
- for (const se of normalizer.next({ type: "turn_completed", usage }))
952
- emitCurrent(se);
953
- }
954
- for (const se of normalizer.next({ type: "done" }))
955
- emitCurrent(se);
956
- clearPendingInputsForResponse(completedResponseId);
957
- normalizer = null;
958
- currentResponseId = null;
1032
+ onStatus: (note, statusKind) => {
1033
+ const responseId = currentResponseId ??
1034
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
1035
+ emitCurrent({
1036
+ type: "session.status",
1037
+ sessionId: currentSessionId,
1038
+ ...(responseId ? { responseId } : {}),
1039
+ status: "running",
1040
+ ...(note ? { note } : {}),
1041
+ ...(statusKind ? { statusKind } : {}),
1042
+ });
959
1043
  },
960
- onTurnError: (error) => {
961
- closeCanonicalInteractions();
962
- if (!normalizer)
1044
+ // A newer authoritative turn/started completes the old canonical
1045
+ // response without publishing a false idle edge in a running→running
1046
+ // transition.
1047
+ onTurnEnd: completeCurrentTurn,
1048
+ onRecoveredTurnStatus: (status, turnId, error) => {
1049
+ const responseId = turnId ? `resp_codex_${turnId}` : undefined;
1050
+ if (normalizer && (!responseId || currentResponseId === responseId)) {
1051
+ if (status === "failed")
1052
+ failCurrentTurn(error ?? new Error("Codex turn failed"));
1053
+ else
1054
+ completeCurrentTurn();
963
1055
  return;
964
- const failedResponseId = currentResponseId;
965
- const responseStopped = error.message === "Codex turn was interrupted";
966
- const providerName = runtime === "traex" ? "Traex" : "Codex";
967
- const message = responseStopped
968
- ? "Response stopped"
969
- : runtime === "traex"
970
- ? error.message.replace(/^Codex\b/, providerName)
971
- : error.message;
972
- for (const se of normalizer.fail({
973
- code: responseStopped
974
- ? "response_stopped"
975
- : runtime === "traex" ? "traex_error" : "codex_error",
976
- message,
977
- source: "execution",
978
- }))
979
- emitCurrent(se);
980
- clearPendingInputsForResponse(failedResponseId);
981
- normalizer = null;
982
- currentResponseId = null;
1056
+ }
1057
+ emitCurrent({
1058
+ type: "session.status",
1059
+ sessionId: currentSessionId,
1060
+ ...(responseId ? { responseId } : {}),
1061
+ status,
1062
+ ...(error ? { note: error.message } : {}),
1063
+ });
983
1064
  },
1065
+ onTurnError: failCurrentTurn,
984
1066
  // A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
985
1067
  shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
986
1068
  onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
@@ -995,6 +1077,7 @@ export class LocalAgentHost {
995
1077
  turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
996
1078
  assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
997
1079
  surfaceQueueStatus: runtime === "traex",
1080
+ mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
998
1081
  });
999
1082
  live.forwarder = forwarder;
1000
1083
  live.onNativeThreadRotated = (threadId, kind) => {
@@ -1068,7 +1151,15 @@ export class LocalAgentHost {
1068
1151
  threadId: record.codexSessionId,
1069
1152
  ...threadWorkspaceParams(runtime, workspace, sandbox),
1070
1153
  approvalPolicy,
1154
+ // The injection connection only needs to bind/subcribe. Codex
1155
+ // 0.146 can ignore initialTurnsPage and return the whole rollout,
1156
+ // so explicitly suppress history here; it is never replayed.
1071
1157
  excludeTurns: true,
1158
+ initialTurnsPage: {
1159
+ limit: 1,
1160
+ sortDirection: "desc",
1161
+ itemsView: "summary",
1162
+ },
1072
1163
  });
1073
1164
  resumedThreadId = resumed.threadId;
1074
1165
  break;
@@ -1083,11 +1174,25 @@ export class LocalAgentHost {
1083
1174
  }
1084
1175
  }
1085
1176
  if (resumedThreadId) {
1177
+ forwarder.noteThreadBound(resumedThreadId);
1086
1178
  this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1087
1179
  }
1180
+ else if (resumeError &&
1181
+ isThreadNotReadyError(resumeError) &&
1182
+ !record.parentSessionId &&
1183
+ !record.runtimeHomeOwnerSessionId) {
1184
+ // A native thread that still has no rollout after the bounded retry
1185
+ // window has never materialized a Turn. It is safe to forget this
1186
+ // ordinary binding and let the TUI create a fresh native thread.
1187
+ // Fork bindings stay strict because their Provider-native ancestry
1188
+ // must not be silently replaced.
1189
+ await this.sessionStore.delete(localThreadId);
1190
+ live.threadId = null;
1191
+ live.markTerminalReady(true);
1192
+ }
1088
1193
  else {
1089
- // Never replace an existing binding. A delayed native index is
1090
- // retryable; a genuinely missing thread remains an explicit error.
1194
+ // A delayed/missing native index or a fork binding remains explicit:
1195
+ // neither is proof that replacing Provider history is safe.
1091
1196
  throw resumeError;
1092
1197
  }
1093
1198
  }
@@ -1103,9 +1208,11 @@ export class LocalAgentHost {
1103
1208
  live.stopped = true;
1104
1209
  forwarder.stop();
1105
1210
  void forwarderClient.stop().catch(() => undefined);
1211
+ this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} native thread binding failed: ${err instanceof Error ? err.message : String(err)}`);
1106
1212
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1107
1213
  return abandon();
1108
1214
  }
1215
+ this.liveStartupErrors.delete(localThreadId);
1109
1216
  return true;
1110
1217
  }
1111
1218
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
@@ -1165,30 +1272,37 @@ export class LocalAgentHost {
1165
1272
  * Subscribe the forwarder connection to a thread (reference implementation's
1166
1273
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1167
1274
  * 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.
1275
+ * thread active, then retry. Resume only fetches the newest summarized Turn:
1276
+ * recovery reconciles the exact active Turn or publishes the newest explicit
1277
+ * terminal status without replaying historical items. Once resume succeeds,
1278
+ * subsequent turns arrive live.
1171
1279
  */
1172
1280
  async subscribeUntilReady(live, threadId) {
1173
- let sawNotReady = false;
1174
1281
  while (!live.stopped) {
1175
1282
  try {
1176
1283
  const resp = await live.forwarderClient.threadResume({
1177
1284
  threadId,
1178
1285
  ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1179
1286
  approvalPolicy: live.approvalPolicy,
1180
- excludeTurns: !sawNotReady,
1287
+ initialTurnsPage: {
1288
+ limit: 1,
1289
+ sortDirection: "desc",
1290
+ itemsView: "summary",
1291
+ },
1181
1292
  });
1182
- if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1183
- live.forwarder.replayBackfill(resp.thread.turns);
1184
- }
1293
+ // Codex 0.146.1 accepts initialTurnsPage but can still return the entire
1294
+ // rollout in chronological order. An identified active turn is matched
1295
+ // by id; the no-active fallback reads only the final explicit status.
1296
+ const turns = Array.isArray(resp.thread.turns)
1297
+ ? resp.thread.turns
1298
+ : [];
1299
+ live.forwarder.reconcileResumeTurns(turns);
1185
1300
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1186
1301
  }
1187
1302
  catch (error) {
1188
1303
  if (!isThreadNotReadyError(error)) {
1189
1304
  return false; // other failure — injection still works via the backend client
1190
1305
  }
1191
- sawNotReady = true;
1192
1306
  // Park until the thread goes active (its first turn materializes the
1193
1307
  // rollout); a short poll covers the flush race after "active".
1194
1308
  await new Promise((resolve) => {
@@ -1200,7 +1314,56 @@ export class LocalAgentHost {
1200
1314
  }
1201
1315
  return false;
1202
1316
  }
1203
- /** Await a live session's thread binding (bounded). Returns false on timeout /
1317
+ /** Restore the independent observer after an unexpected exit. The active turn
1318
+ * remains open during the bounded grace so resume can reconcile its exact id. */
1319
+ reconnectForwarder(live) {
1320
+ if (live.observerReconnect)
1321
+ return live.observerReconnect;
1322
+ const reconnect = (async () => {
1323
+ while (!live.stopped && live.threadId) {
1324
+ try {
1325
+ await live.forwarderClient.ensureInitialized();
1326
+ if (await this.subscribeUntilReady(live, live.threadId)) {
1327
+ live.observerAvailable = true;
1328
+ this.clearObserverReconnectDeadline(live);
1329
+ return;
1330
+ }
1331
+ }
1332
+ catch {
1333
+ // Retry below. Injection uses its independent client and remains usable.
1334
+ }
1335
+ await new Promise((resolve) => {
1336
+ const timer = setTimeout(resolve, 250);
1337
+ timer.unref?.();
1338
+ });
1339
+ }
1340
+ })();
1341
+ const settled = reconnect.finally(() => {
1342
+ if (live.observerReconnect === settled)
1343
+ live.observerReconnect = undefined;
1344
+ });
1345
+ live.observerReconnect = settled;
1346
+ return settled;
1347
+ }
1348
+ armObserverReconnectDeadline(live) {
1349
+ if (live.observerReconnectTimer || !live.forwarder.isTurnOpen())
1350
+ return;
1351
+ live.observerReconnectTimer = setTimeout(() => {
1352
+ live.observerReconnectTimer = undefined;
1353
+ if (live.stopped)
1354
+ return;
1355
+ const provider = live.runtime === "traex" ? "Traex" : "Codex";
1356
+ live.forwarder.failOpenTurn(new Error(`${provider} observer did not reconnect within ${this.observerReconnectTimeoutMs}ms`));
1357
+ }, this.observerReconnectTimeoutMs);
1358
+ live.observerReconnectTimer.unref?.();
1359
+ }
1360
+ clearObserverReconnectDeadline(live) {
1361
+ if (live.observerReconnectTimer)
1362
+ clearTimeout(live.observerReconnectTimer);
1363
+ live.observerReconnectTimer = undefined;
1364
+ }
1365
+ /** Await a live session's thread binding. `null` leaves the deadline to the
1366
+ * caller; a number keeps the Provider-local bound. Returns false on timeout /
1204
1367
  * no live session. Injection and the runner's `live.ready` gate on this. */
1205
1368
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1206
1369
  const claude = this.liveClaudeSessions.get(localThreadId);
@@ -1209,11 +1372,15 @@ export class LocalAgentHost {
1209
1372
  const live = this.liveSessions.get(localThreadId);
1210
1373
  if (!live)
1211
1374
  return false;
1375
+ const ready = live.ready.then(() => true);
1376
+ const failed = live.startupFailed.then(() => false);
1377
+ if (timeoutMs === null)
1378
+ return Promise.race([ready, failed]);
1212
1379
  let timer;
1213
1380
  const timeout = new Promise((resolve) => {
1214
1381
  timer = setTimeout(() => resolve(false), timeoutMs);
1215
1382
  });
1216
- const ok = await Promise.race([live.ready.then(() => true), timeout]);
1383
+ const ok = await Promise.race([ready, failed, timeout]);
1217
1384
  if (timer)
1218
1385
  clearTimeout(timer);
1219
1386
  return ok;
@@ -1230,6 +1397,8 @@ export class LocalAgentHost {
1230
1397
  const live = this.liveSessions.get(localThreadId);
1231
1398
  if (!live)
1232
1399
  return false;
1400
+ if (timeoutMs === null)
1401
+ return live.terminalReady;
1233
1402
  let timer;
1234
1403
  const timeout = new Promise((resolve) => {
1235
1404
  timer = setTimeout(() => resolve(false), timeoutMs);
@@ -1241,7 +1410,21 @@ export class LocalAgentHost {
1241
1410
  }
1242
1411
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
1243
1412
  liveSessionError(localThreadId) {
1244
- return this.liveClaudeSessions.get(localThreadId)?.error;
1413
+ return this.liveClaudeSessions.get(localThreadId)?.error
1414
+ ?? this.liveSessions.get(localThreadId)?.startupError
1415
+ ?? this.liveStartupErrors.get(localThreadId);
1416
+ }
1417
+ /** Publish the background TUI/thread discovery failure so an executor
1418
+ * already waiting in the 60s bridge window exits immediately with the exact
1419
+ * 30s discovery cause. */
1420
+ failLiveStartup(localThreadId, error) {
1421
+ const live = this.liveSessions.get(localThreadId);
1422
+ if (!live || live.threadId || live.stopped)
1423
+ return false;
1424
+ live.startupError = error.message;
1425
+ this.liveStartupErrors.set(localThreadId, error.message);
1426
+ live.markStartupFailed();
1427
+ return true;
1245
1428
  }
1246
1429
  /**
1247
1430
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -1302,7 +1485,7 @@ export class LocalAgentHost {
1302
1485
  return "failed";
1303
1486
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1304
1487
  // 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);
1488
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1306
1489
  const threadId = live.threadId ?? live.forwarder.threadId();
1307
1490
  if (!bound || !threadId)
1308
1491
  return "notReady";
@@ -1316,6 +1499,7 @@ export class LocalAgentHost {
1316
1499
  ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1317
1500
  };
1318
1501
  live.pendingInjectedInputs.push(pendingInput);
1502
+ let injectionMethod = "turn/start";
1319
1503
  const forgetPendingInput = () => {
1320
1504
  const index = live.pendingInjectedInputs.indexOf(pendingInput);
1321
1505
  if (index >= 0)
@@ -1326,6 +1510,7 @@ export class LocalAgentHost {
1326
1510
  if (live.forwarder.isTurnOpen()) {
1327
1511
  const turnId = live.forwarder.currentTurnId();
1328
1512
  if (turnId) {
1513
+ injectionMethod = "turn/steer";
1329
1514
  const steered = await live.injectClient.turnSteer({
1330
1515
  threadId,
1331
1516
  expectedTurnId: turnId,
@@ -1342,7 +1527,11 @@ export class LocalAgentHost {
1342
1527
  live.publishInjectedInput(steered.turnId, pendingInput.content);
1343
1528
  pendingInput.state = "optimistic";
1344
1529
  }
1345
- return "injected";
1530
+ live.forwarder.noteTurnAccepted(steered.turnId);
1531
+ if (!live.observerAvailable) {
1532
+ this.armObserverReconnectDeadline(live);
1533
+ }
1534
+ return "steered";
1346
1535
  }
1347
1536
  }
1348
1537
  // Carry the agent-spec model on the turn so a web-injected turn runs the
@@ -1366,13 +1555,22 @@ export class LocalAgentHost {
1366
1555
  live.publishInjectedInput(started.turnId, pendingInput.content);
1367
1556
  pendingInput.state = "optimistic";
1368
1557
  }
1558
+ // The injection RPC and active-turn write are one serialized operation.
1559
+ // Do not wait for the independent observer connection's `turn/started`:
1560
+ // a second message accepted in that window must steer, not double-start.
1561
+ live.forwarder.noteTurnAccepted(started.turnId);
1562
+ if (!live.observerAvailable) {
1563
+ this.armObserverReconnectDeadline(live);
1564
+ }
1369
1565
  return "injected";
1370
1566
  }
1371
1567
  catch (error) {
1372
1568
  forgetPendingInput();
1373
1569
  // Preserve the app-server's error instead of collapsing every failure
1374
1570
  // into the unactionable `live injection failed` string.
1375
- const detail = codexInjectionError(error);
1571
+ const baseDetail = codexRpcError(error, injectionMethod);
1572
+ const startupDetail = live.forwarder.mcpStartupDetail();
1573
+ const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1376
1574
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1377
1575
  throw new Error(detail, { cause: error });
1378
1576
  }
@@ -1417,21 +1615,35 @@ export class LocalAgentHost {
1417
1615
  const live = this.liveSessions.get(localThreadId);
1418
1616
  if (!live)
1419
1617
  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
1618
  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;
1619
+ const pendingMcp = live.forwarder.cancelMcpStartup();
1620
+ const turnId = live.forwarder.isTurnOpen()
1621
+ ? live.forwarder.currentTurnId()
1622
+ : null;
1623
+ if (!threadId)
1624
+ return pendingMcp.length > 0;
1625
+ let handled = pendingMcp.length > 0;
1626
+ if (pendingMcp.length > 0) {
1627
+ // Codex's TUI cancels a provider-owned MCP startup round with an empty
1628
+ // turn id. Best-effort for Traex: it shares the app-server surface, while
1629
+ // the active-turn interrupt below remains authoritative if it rejects this.
1630
+ try {
1631
+ await live.injectClient.turnInterrupt({ threadId, turnId: "" });
1632
+ }
1633
+ catch (error) {
1634
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
1635
+ }
1431
1636
  }
1432
- catch {
1433
- return false;
1637
+ if (turnId) {
1638
+ try {
1639
+ await live.injectClient.turnInterrupt({ threadId, turnId });
1640
+ handled = true;
1641
+ }
1642
+ catch {
1643
+ // The startup cancellation above is still a handled Stop operation.
1644
+ }
1434
1645
  }
1646
+ return handled;
1435
1647
  }
1436
1648
  /** Stop + drop a session's live forwarder and its dedicated connection (session
1437
1649
  * close / runner shutdown). The backend inject client is shared — left running. */
@@ -1457,6 +1669,11 @@ export class LocalAgentHost {
1457
1669
  return;
1458
1670
  this.liveSessions.delete(localThreadId);
1459
1671
  live.stopped = true;
1672
+ if (!live.threadId) {
1673
+ live.startupError ??= "native Session stopped before thread discovery completed";
1674
+ live.markStartupFailed();
1675
+ }
1676
+ this.clearObserverReconnectDeadline(live);
1460
1677
  live.releaseActive?.();
1461
1678
  live.injectClient.cancelInteractions("session_stopped");
1462
1679
  live.forwarderClient.cancelInteractions("session_stopped");
@@ -1522,7 +1739,7 @@ export class LocalAgentHost {
1522
1739
  });
1523
1740
  const settings = { ...effectiveSettings, ...hookSettings };
1524
1741
  const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
1525
- // The live session carries the immutable execution snapshot used by Chat.
1742
+ // The live session carries the daemon-owned execution state used by Chat.
1526
1743
  const args = buildClaudeTuiArgs({
1527
1744
  settingsJson: settingsPath,
1528
1745
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1530,7 +1747,10 @@ export class LocalAgentHost {
1530
1747
  // resolve the same native request while rynx still exposes it as pending.
1531
1748
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1532
1749
  settingSources: "",
1533
- model: live.execution.model,
1750
+ ...(live.execution.model ? { model: live.execution.model } : {}),
1751
+ ...(live.execution.reasoningEffort
1752
+ ? { reasoningEffort: live.execution.reasoningEffort }
1753
+ : {}),
1534
1754
  ...(live.execution.instructions
1535
1755
  ? { appendSystemPrompt: live.execution.instructions }
1536
1756
  : {}),
@@ -1633,8 +1853,16 @@ export class LocalAgentHost {
1633
1853
  throw failedPluginSkill.reason;
1634
1854
  }
1635
1855
  const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1856
+ const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
1857
+ const selectedNames = new Set();
1858
+ for (const skill of selectedSkills) {
1859
+ if (selectedNames.has(skill.name)) {
1860
+ throw new Error(`duplicate final Session skill name: ${skill.name}`);
1861
+ }
1862
+ selectedNames.add(skill.name);
1863
+ }
1636
1864
  return {
1637
- selectedSkills: [...skillEnv.resolved, ...pluginSkills],
1865
+ selectedSkills,
1638
1866
  skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1639
1867
  };
1640
1868
  }
@@ -1664,7 +1892,7 @@ export class LocalAgentHost {
1664
1892
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1665
1893
  return false;
1666
1894
  }
1667
- const model = execution.model;
1895
+ const model = execution.model ?? "";
1668
1896
  const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1669
1897
  void snapshotSkills.skillsCleanup();
1670
1898
  const permissionMode = execution.permissionMode;
@@ -1695,7 +1923,7 @@ export class LocalAgentHost {
1695
1923
  normalizer = new SessionNormalizer({
1696
1924
  sessionId: currentSessionId,
1697
1925
  responseId,
1698
- model,
1926
+ model: model || "claude",
1699
1927
  });
1700
1928
  return normalizer;
1701
1929
  };
@@ -1776,6 +2004,17 @@ export class LocalAgentHost {
1776
2004
  emitCurrent(se);
1777
2005
  },
1778
2006
  onInteraction: forwardInteraction,
2007
+ onStatus: (status, blockedOn) => {
2008
+ const responseId = currentResponseId ??
2009
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
2010
+ emitCurrent({
2011
+ type: "session.status",
2012
+ sessionId: currentSessionId,
2013
+ ...(responseId ? { responseId } : {}),
2014
+ status,
2015
+ ...(blockedOn ? { note: blockedOn } : {}),
2016
+ });
2017
+ },
1779
2018
  onTurnEnd: (usage) => {
1780
2019
  if (!normalizer)
1781
2020
  return;
@@ -1816,16 +2055,20 @@ export class LocalAgentHost {
1816
2055
  });
1817
2056
  }
1818
2057
  },
1819
- onTurnError: () => {
2058
+ onTurnError: (error) => {
1820
2059
  if (!normalizer)
1821
2060
  return;
1822
2061
  const rid = currentResponseId;
2062
+ const message = error.message || "Agent turn failed";
1823
2063
  for (const se of normalizer.fail({
1824
2064
  code: "agent_error",
1825
- message: "Agent turn failed",
2065
+ message,
1826
2066
  source: "execution",
1827
- }))
1828
- emitCurrent(se);
2067
+ })) {
2068
+ emitCurrent(se.type === "session.status"
2069
+ ? { ...se, status: "failed", note: message }
2070
+ : se);
2071
+ }
1829
2072
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1830
2073
  normalizer = null;
1831
2074
  currentResponseId = undefined;
@@ -1956,7 +2199,8 @@ export class LocalAgentHost {
1956
2199
  });
1957
2200
  return ok ? "injected" : "failed";
1958
2201
  }
1959
- catch {
2202
+ catch (error) {
2203
+ live.error = error instanceof Error ? error.message : String(error);
1960
2204
  return "failed";
1961
2205
  }
1962
2206
  finally {
@@ -1983,8 +2227,25 @@ export class LocalAgentHost {
1983
2227
  * (they inject via the app-server). Idempotent. */
1984
2228
  attachTerminalInjector(localThreadId, injector) {
1985
2229
  const live = this.liveClaudeSessions.get(localThreadId);
1986
- if (live)
2230
+ if (live) {
2231
+ if (live.injector === injector)
2232
+ return;
1987
2233
  live.injector = injector;
2234
+ live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
2235
+ }
2236
+ }
2237
+ /** Close an active native response when its terminal or runner disappears. */
2238
+ failLiveSession(localThreadId, error) {
2239
+ const claude = this.liveClaudeSessions.get(localThreadId);
2240
+ if (claude)
2241
+ return claude.forwarder.noteTerminalExit(error);
2242
+ const codex = this.liveSessions.get(localThreadId);
2243
+ if (!codex)
2244
+ return false;
2245
+ const failed = codex.forwarder.failOpenTurn(error);
2246
+ if (failed)
2247
+ this.clearObserverReconnectDeadline(codex);
2248
+ return failed;
1988
2249
  }
1989
2250
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
1990
2251
  async listModels(runtime) {
@@ -2113,7 +2374,7 @@ export class LocalAgentHost {
2113
2374
  threadId: record.codexSessionId,
2114
2375
  excludeTurns: true,
2115
2376
  ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2116
- model: execution.model,
2377
+ ...(execution.model ? { model: execution.model } : {}),
2117
2378
  approvalPolicy: execution.approvalPolicy,
2118
2379
  });
2119
2380
  }
@@ -2155,11 +2416,11 @@ export class LocalAgentHost {
2155
2416
  }
2156
2417
  }
2157
2418
  }
2158
- function codexInjectionError(error) {
2419
+ function codexRpcError(error, method) {
2159
2420
  if (error instanceof CodexTransportError) {
2160
- return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
2421
+ return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
2161
2422
  }
2162
- return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
2423
+ return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
2163
2424
  }
2164
2425
  export function parseCodexLoginStatus(exitCode, output) {
2165
2426
  const normalized = output.trim();
@@ -2184,15 +2445,18 @@ export function parseCodexLoginStatus(exitCode, output) {
2184
2445
  issues: normalized ? [normalized] : [],
2185
2446
  };
2186
2447
  }
2187
- /** Race a live session's `ready` promise against failure / timeout. */
2448
+ /** Race a live session's `ready` promise against failure and, when supplied,
2449
+ * a timeout. */
2188
2450
  function raceReady(ready, timeoutMs, failed) {
2189
2451
  let timer;
2190
- const timeout = new Promise((resolve) => {
2191
- timer = setTimeout(() => resolve(false), timeoutMs);
2192
- });
2193
- const outcomes = [ready.then(() => true), timeout];
2452
+ const outcomes = [ready.then(() => true)];
2194
2453
  if (failed)
2195
2454
  outcomes.push(failed.then(() => false));
2455
+ if (timeoutMs !== null) {
2456
+ outcomes.push(new Promise((resolve) => {
2457
+ timer = setTimeout(() => resolve(false), timeoutMs);
2458
+ }));
2459
+ }
2196
2460
  return Promise.race(outcomes).finally(() => {
2197
2461
  if (timer)
2198
2462
  clearTimeout(timer);