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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -247,7 +247,13 @@ export class LocalAgentHost {
247
247
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
248
248
  // the backend's app-server; tests inject a fake.
249
249
  forwarderClientFactory;
250
- observerReconnectTimeoutMs;
250
+ // Builds Omnigent's short-lived cold-resume preload connection. This must be
251
+ // distinct from both the app-server owner/injector and the background
252
+ // observer: the owner's initialize handshake is the readiness probe, then a
253
+ // freshly initialized connection performs the one resume and closes.
254
+ preloadClientFactory;
255
+ /** Builds one short-lived message/interrupt client. */
256
+ injectionClientFactory;
251
257
  // Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
252
258
  // a `${runtime}::${retryHash}` composite for a per-agent retry budget.
253
259
  backends = new Map();
@@ -281,7 +287,7 @@ export class LocalAgentHost {
281
287
  /** Short-lived dedupe for managed fork notifications delivered after the
282
288
  * `thread/fork` response. Values are expected source Provider thread ids. */
283
289
  managedForkThreadStarts = new Map();
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, }) {
290
+ constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, preloadClientFactory, injectionClientFactory, sessionId, runtimeHomeSessionId, }) {
285
291
  this.config = config;
286
292
  this.sessionId = sessionId ?? "__default__";
287
293
  this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
@@ -292,10 +298,10 @@ export class LocalAgentHost {
292
298
  this.injectedAppServerClient = appServerClient;
293
299
  this.clock = now;
294
300
  this.backendIdleTtlMs = backendIdleTtlMs;
295
- this.observerReconnectTimeoutMs = observerReconnectTimeoutMs;
296
- this.forwarderClientFactory =
297
- forwarderClientFactory ??
298
- ((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
301
+ const defaultAttachedClientFactory = (appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) });
302
+ this.forwarderClientFactory = forwarderClientFactory ?? defaultAttachedClientFactory;
303
+ this.preloadClientFactory = preloadClientFactory ?? defaultAttachedClientFactory;
304
+ this.injectionClientFactory = injectionClientFactory ?? defaultAttachedClientFactory;
299
305
  }
300
306
  getBackend(runtime, budget) {
301
307
  this.reapIdleBackends();
@@ -412,36 +418,7 @@ export class LocalAgentHost {
412
418
  if (live.settledInteractions.has(interactionId)) {
413
419
  return { disposition: "already_resolved" };
414
420
  }
415
- if (live.interactionRecoveries.has(interactionId)) {
416
- return {
417
- disposition: "invalid",
418
- message: "interaction delivery is recovering; retry shortly",
419
- };
420
- }
421
- const submit = (client) => {
422
- // Store before entering the client: a transport can report a synchronous
423
- // write failure, and failover must already have the exact accepted answer
424
- // available for replay. A synchronous terminal event may delete it again.
425
- const hadPrevious = live.interactionSubmissions.has(interactionId);
426
- const previous = live.interactionSubmissions.get(interactionId);
427
- live.interactionSubmissions.set(interactionId, resolution);
428
- const result = client.resolveInteraction(interactionId, resolution);
429
- if (result.disposition !== "applied") {
430
- if (hadPrevious && previous)
431
- live.interactionSubmissions.set(interactionId, previous);
432
- else
433
- live.interactionSubmissions.delete(interactionId);
434
- }
435
- return result;
436
- };
437
- const owner = live.interactionOwners.get(interactionId);
438
- if (owner)
439
- return submit(owner);
440
- const injected = submit(live.injectClient);
441
- if (injected.disposition !== "not_found") {
442
- return injected;
443
- }
444
- return submit(live.forwarderClient);
421
+ return live.forwarderClient.resolveInteraction(interactionId, resolution);
445
422
  }
446
423
  /**
447
424
  * The command to run in a session's live terminal so it co-drives the codex
@@ -596,7 +573,7 @@ export class LocalAgentHost {
596
573
  snapshotSkills = await this.prepareExecutionSkills(execution);
597
574
  }
598
575
  catch (err) {
599
- this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill preparation failed: ${err instanceof Error ? err.message : String(err)}`);
576
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_preparation_failed", "skill preparation failed", err));
600
577
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
601
578
  return false;
602
579
  }
@@ -609,7 +586,7 @@ export class LocalAgentHost {
609
586
  }
610
587
  catch (err) {
611
588
  void snapshotSkills.skillsCleanup();
612
- this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
589
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_persistence_failed", "skill persistence failed", err));
613
590
  console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
614
591
  return false;
615
592
  }
@@ -627,37 +604,45 @@ export class LocalAgentHost {
627
604
  void snapshotSkills.skillsCleanup();
628
605
  return false;
629
606
  };
630
- const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
631
- if (!injectClient) {
632
- this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server is unavailable for this Session`);
607
+ const appServerOwner = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
608
+ if (!appServerOwner) {
609
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_unavailable", "app-server is unavailable for this Session"));
633
610
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
634
611
  return abandon();
635
612
  }
636
613
  try {
637
- await injectClient.ensureInitialized();
614
+ await appServerOwner.ensureInitialized();
638
615
  }
639
616
  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)}`);
617
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_start_failed", "app-server initialization failed during its 10s readiness phase", err));
641
618
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
642
619
  return abandon();
643
620
  }
644
- const appServerUrl = injectClient.terminalRemoteUrl();
621
+ const appServerUrl = appServerOwner.terminalRemoteUrl();
645
622
  if (!appServerUrl) {
646
- this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server started without a Terminal remote endpoint`);
623
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_endpoint_missing", "app-server started without a Terminal remote endpoint"));
647
624
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
648
625
  return abandon(); // needs the ws app-server (live-terminal mode)
649
626
  }
650
627
  // reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
651
628
  // app-server, which `thread/resume`s to subscribe to the thread's item/turn
652
629
  // notifications (the backend client injects; this one only observes).
630
+ // Fresh sessions must connect this listener before the TUI launches so its
631
+ // one-shot `thread/started` cannot race discovery. A known-thread cold resume
632
+ // already has its durable id: like Omnigent's `_codex_forward_known_thread`,
633
+ // its observer starts in the background only after the replacement TUI has
634
+ // launched and is never part of the resume admission boundary.
653
635
  const forwarderClient = this.forwarderClientFactory(appServerUrl);
654
- try {
655
- await forwarderClient.ensureInitialized();
656
- }
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)}`);
659
- console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
660
- return abandon();
636
+ const coldResume = Boolean(record?.codexSessionId);
637
+ if (!coldResume) {
638
+ try {
639
+ await forwarderClient.ensureInitialized();
640
+ }
641
+ catch (err) {
642
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach to the ready app-server", err));
643
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} fresh-thread listener init failed: ${err instanceof Error ? err.message : String(err)}`);
644
+ return abandon();
645
+ }
661
646
  }
662
647
  const model = execution.model ?? "";
663
648
  const reasoningEffort = execution.reasoningEffort ?? undefined;
@@ -674,7 +659,8 @@ export class LocalAgentHost {
674
659
  markTerminalReady = resolve;
675
660
  });
676
661
  const live = {
677
- injectClient,
662
+ appServerOwner,
663
+ appServerUrl,
678
664
  forwarderClient,
679
665
  forwarder: null,
680
666
  runtime,
@@ -698,16 +684,10 @@ export class LocalAgentHost {
698
684
  subscribing: false,
699
685
  rotationPending: false,
700
686
  stopped: false,
701
- observerAvailable: true,
687
+ observerAvailable: !coldResume,
702
688
  skillsCleanup: snapshotSkills.skillsCleanup,
703
- interactionOwners: new Map(),
704
- interactionStandbys: new Map(),
705
- interactionSubmissions: new Map(),
706
689
  canonicalInteractions: new Set(),
707
690
  settledInteractions: new Set(),
708
- interactionRecoveries: new Map(),
709
- interactionFailedClients: new Map(),
710
- disconnectedClients: new Set(),
711
691
  };
712
692
  let currentSessionId = localThreadId;
713
693
  let pendingRotationEvents = null;
@@ -795,163 +775,25 @@ export class LocalAgentHost {
795
775
  for (const se of n.next(agentEvent))
796
776
  emitCurrent(se);
797
777
  };
798
- const clearRecovery = (interactionId) => {
799
- live.interactionRecoveries.delete(interactionId);
800
- };
801
- const clearInteractionRouting = (interactionId) => {
802
- clearRecovery(interactionId);
803
- live.interactionOwners.delete(interactionId);
804
- live.interactionStandbys.delete(interactionId);
805
- live.interactionSubmissions.delete(interactionId);
806
- live.interactionFailedClients.delete(interactionId);
807
- };
808
- const markInteractionClientFailed = (interactionId, client) => {
809
- const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
810
- failed.add(client);
811
- live.interactionFailedClients.set(interactionId, failed);
812
- };
813
- const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
814
- live.interactionFailedClients.get(interactionId)?.has(client) === true;
815
- const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
816
- const finishRecovery = (interactionId, event) => {
817
- clearInteractionRouting(interactionId);
818
- forwardInteraction(event);
819
- };
820
- const beginRecovery = (interactionId, event) => {
821
- live.interactionOwners.delete(interactionId);
822
- live.interactionRecoveries.set(interactionId, { event });
823
- };
824
- const promoteInteractionOwner = (interactionId, replacement) => {
825
- const recovery = live.interactionRecoveries.get(interactionId);
826
- live.interactionOwners.set(interactionId, replacement);
827
- const submission = live.interactionSubmissions.get(interactionId);
828
- if (!submission) {
829
- clearRecovery(interactionId);
830
- return true;
831
- }
832
- const replayed = replacement.resolveInteraction(interactionId, submission);
833
- // A write failure can synchronously re-enter the listener and remove this
834
- // owner. Only clear recovery when the replacement still owns the request.
835
- if (replayed.disposition === "applied" &&
836
- live.interactionOwners.get(interactionId) === replacement) {
837
- clearRecovery(interactionId);
838
- return true;
839
- }
840
- if (live.interactionOwners.get(interactionId) === replacement) {
841
- live.interactionOwners.delete(interactionId);
842
- }
843
- if (recovery &&
844
- live.canonicalInteractions.has(interactionId) &&
845
- !live.settledInteractions.has(interactionId) &&
846
- !live.interactionRecoveries.has(interactionId)) {
847
- live.interactionRecoveries.set(interactionId, recovery);
848
- }
849
- return false;
850
- };
851
- const bindInteractionClient = (client) => {
852
- client.setInteractionListener((event) => {
853
- const interactionId = event.type === "requested"
854
- ? event.request.interactionId
855
- : event.interactionId;
856
- const owner = live.interactionOwners.get(interactionId);
857
- if (event.type === "requested") {
858
- // Receiving a native request proves this connection is alive and can
859
- // be retried even if an earlier copy failed on it.
860
- live.disconnectedClients.delete(client);
861
- const failed = live.interactionFailedClients.get(interactionId);
862
- failed?.delete(client);
863
- if (failed?.size === 0)
864
- live.interactionFailedClients.delete(interactionId);
865
- if (live.settledInteractions.has(interactionId))
866
- return;
867
- const recovery = live.interactionRecoveries.get(interactionId);
868
- if (recovery) {
869
- promoteInteractionOwner(interactionId, client);
870
- return;
871
- }
872
- if (owner && owner !== client) {
873
- const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
874
- standbys.add(client);
875
- live.interactionStandbys.set(interactionId, standbys);
876
- return;
877
- }
878
- live.interactionOwners.set(interactionId, client);
879
- forwardInteraction(event);
880
- return;
881
- }
882
- const recoverableOwnerFailure = event.type === "cancelled" &&
883
- (event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
884
- if (recoverableOwnerFailure)
885
- markInteractionClientFailed(interactionId, client);
886
- if (!owner || owner !== client) {
887
- const standbys = live.interactionStandbys.get(interactionId);
888
- standbys?.delete(client);
889
- if (standbys?.size === 0)
890
- live.interactionStandbys.delete(interactionId);
891
- const recovery = live.interactionRecoveries.get(interactionId);
892
- if (recoverableOwnerFailure &&
893
- live.canonicalInteractions.has(interactionId) &&
894
- allInteractionClientsUnavailable(interactionId)) {
895
- finishRecovery(interactionId, recovery?.event ?? event);
896
- }
897
- return;
898
- }
899
- if (recoverableOwnerFailure) {
900
- const standbys = live.interactionStandbys.get(interactionId);
901
- while (standbys && standbys.size > 0) {
902
- const replacement = standbys.values().next().value;
903
- if (!replacement)
904
- break;
905
- standbys.delete(replacement);
906
- if (interactionClientUnavailable(interactionId, replacement))
907
- continue;
908
- if (promoteInteractionOwner(interactionId, replacement)) {
909
- if (standbys.size === 0)
910
- live.interactionStandbys.delete(interactionId);
911
- return;
912
- }
913
- if (live.settledInteractions.has(interactionId))
914
- return;
915
- }
916
- if (!live.settledInteractions.has(interactionId)) {
917
- if (allInteractionClientsUnavailable(interactionId)) {
918
- finishRecovery(interactionId, event);
919
- }
920
- else {
921
- beginRecovery(interactionId, event);
922
- }
923
- return;
924
- }
925
- }
926
- clearInteractionRouting(interactionId);
927
- forwardInteraction(event);
928
- });
778
+ const bindObserverClient = (client) => {
779
+ client.setInteractionListener(forwardInteraction);
929
780
  client.setConnectionListener((state) => {
930
- if (state === "connected") {
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);
781
+ if (state === "connected" || live.stopped)
936
782
  return;
783
+ live.observerAvailable = false;
784
+ for (const interactionId of [...live.canonicalInteractions]) {
785
+ forwardInteraction({
786
+ type: "cancelled",
787
+ interactionId,
788
+ reason: "app_server_disconnected",
789
+ });
937
790
  }
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
- }
944
- for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
945
- if (allInteractionClientsUnavailable(interactionId)) {
946
- finishRecovery(interactionId, recovery.event);
947
- }
948
- }
791
+ this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
949
792
  });
950
793
  };
951
794
  const closeCanonicalInteractions = () => {
952
795
  for (const interactionId of live.canonicalInteractions) {
953
796
  rememberSettledInteraction(interactionId);
954
- clearInteractionRouting(interactionId);
955
797
  }
956
798
  live.canonicalInteractions.clear();
957
799
  };
@@ -1123,7 +965,7 @@ export class LocalAgentHost {
1123
965
  live.rotationPending = false;
1124
966
  for (const event of queued)
1125
967
  emit(event);
1126
- void this.subscribeUntilReady(live, threadId);
968
+ void this.subscribeUntilReady(newSessionId, live, threadId);
1127
969
  })
1128
970
  .catch(() => {
1129
971
  pendingRotationEvents = null;
@@ -1131,8 +973,7 @@ export class LocalAgentHost {
1131
973
  live.stopped = true;
1132
974
  });
1133
975
  };
1134
- bindInteractionClient(injectClient);
1135
- bindInteractionClient(forwarderClient);
976
+ bindObserverClient(forwarderClient);
1136
977
  this.liveSessions.set(localThreadId, live);
1137
978
  forwarder.start();
1138
979
  // Existing bindings resume through the public app-server API. A fresh thread
@@ -1142,58 +983,53 @@ export class LocalAgentHost {
1142
983
  // turn-less app-server thread resumable by the TUI.
1143
984
  try {
1144
985
  if (record?.codexSessionId) {
1145
- let resumeError;
1146
- let resumedThreadId;
1147
- const attempts = 20;
1148
- for (let attempt = 0; attempt < attempts; attempt += 1) {
986
+ // Match Omnigent's cold-resume preload exactly: one history-free
987
+ // `thread/resume` on a short-lived, separately initialized connection.
988
+ // The backend owner's handshake above is the app-server readiness
989
+ // probe; reusing that first connection here races Codex's startup state
990
+ // and can return `Not initialized`. A failure stays attached to the
991
+ // original binding and is reported by this phase; it is not polled.
992
+ const preloadClient = this.preloadClientFactory(appServerUrl);
993
+ try {
994
+ let resumed;
1149
995
  try {
1150
- const resumed = await injectClient.threadResume({
996
+ await preloadClient.ensureInitialized();
997
+ resumed = await preloadClient.threadResume({
1151
998
  threadId: record.codexSessionId,
1152
999
  ...threadWorkspaceParams(runtime, workspace, sandbox),
1153
1000
  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.
1157
1001
  excludeTurns: true,
1158
- initialTurnsPage: {
1159
- limit: 1,
1160
- sortDirection: "desc",
1161
- itemsView: "summary",
1162
- },
1163
1002
  });
1164
- resumedThreadId = resumed.threadId;
1165
- break;
1166
1003
  }
1167
- catch (error) {
1168
- resumeError = error;
1169
- if (!isRetryableThreadResumeError(error))
1170
- throw error;
1171
- if (attempt + 1 < attempts) {
1172
- await new Promise((resolve) => setTimeout(resolve, 150));
1173
- }
1004
+ finally {
1005
+ await preloadClient.stop().catch((error) => {
1006
+ console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1007
+ });
1174
1008
  }
1009
+ forwarder.noteThreadBound(resumed.threadId);
1010
+ this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
1175
1011
  }
1176
- if (resumedThreadId) {
1177
- forwarder.noteThreadBound(resumedThreadId);
1178
- this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
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.
1012
+ catch (error) {
1013
+ if (!isThreadNotReadyError(error) ||
1014
+ record.parentSessionId ||
1015
+ record.runtimeHomeOwnerSessionId) {
1016
+ throw error;
1017
+ }
1018
+ // Preserve the existing turn-less binding recovery without polling:
1019
+ // an ordinary native id with no rollout/turn has no Provider history
1020
+ // to retain. Fork bindings remain strict, and `thread not found` is
1021
+ // not classified here because it does not prove the history is empty.
1189
1022
  await this.sessionStore.delete(localThreadId);
1190
1023
  live.threadId = null;
1024
+ try {
1025
+ await forwarderClient.ensureInitialized();
1026
+ }
1027
+ catch (listenerError) {
1028
+ throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
1029
+ }
1030
+ live.observerAvailable = true;
1191
1031
  live.markTerminalReady(true);
1192
- }
1193
- else {
1194
- // A delayed/missing native index or a fork binding remains explicit:
1195
- // neither is proof that replacing Provider history is safe.
1196
- throw resumeError;
1032
+ console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
1197
1033
  }
1198
1034
  }
1199
1035
  else {
@@ -1208,7 +1044,10 @@ export class LocalAgentHost {
1208
1044
  live.stopped = true;
1209
1045
  forwarder.stop();
1210
1046
  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)}`);
1047
+ const failure = err instanceof CodexRuntimeError
1048
+ ? err
1049
+ : nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
1050
+ this.liveStartupErrors.set(localThreadId, failure);
1212
1051
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1213
1052
  return abandon();
1214
1053
  }
@@ -1248,7 +1087,57 @@ export class LocalAgentHost {
1248
1087
  })
1249
1088
  .catch(() => undefined);
1250
1089
  live.markReady(); // thread id known → injection can turn/start
1251
- void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
1090
+ if (live.observerAvailable) {
1091
+ void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
1092
+ }
1093
+ else {
1094
+ // Known-thread cold resume has already completed the dedicated preload. Its
1095
+ // observer is deliberately post-TUI background work, not Terminal
1096
+ // readiness. RunnerSession calls startLiveCodexObserver after launch.
1097
+ live.markTerminalReady(true);
1098
+ }
1099
+ }
1100
+ /** Start the known-thread observer after the replacement TUI has launched.
1101
+ * Fresh sessions already connected their discovery listener before launch;
1102
+ * this is therefore an idempotent no-op for them and for a healthy observer. */
1103
+ startLiveCodexObserver(localThreadId) {
1104
+ const live = this.liveSessions.get(localThreadId);
1105
+ if (!live ||
1106
+ live.stopped ||
1107
+ live.observerAvailable ||
1108
+ !live.threadId)
1109
+ return;
1110
+ void (async () => {
1111
+ try {
1112
+ await live.forwarderClient.ensureInitialized();
1113
+ if (live.stopped)
1114
+ return;
1115
+ live.observerAvailable = true;
1116
+ // Omnigent starts subscription as a sibling task after the transport
1117
+ // connects. A no/empty rollout remains event-driven retryable; any
1118
+ // other subscription rejection is diagnostic only and must not tear
1119
+ // down the otherwise usable Terminal/injection lifecycle.
1120
+ void this.subscribeUntilReady(localThreadId, live, live.threadId);
1121
+ }
1122
+ catch (error) {
1123
+ this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
1124
+ }
1125
+ })();
1126
+ }
1127
+ /** Omnigent treats the forwarder as a required component of one native
1128
+ * lifecycle: if its transport dies, it closes the app-server instead of
1129
+ * accepting turns that can no longer reach the canonical mirror. */
1130
+ failObserverLifecycle(localThreadId, live, error) {
1131
+ if (live.stopped)
1132
+ return;
1133
+ live.startupError = error;
1134
+ this.liveStartupErrors.set(localThreadId, error);
1135
+ live.forwarder.failOpenTurn(error);
1136
+ const appServerOwner = live.appServerOwner;
1137
+ this.stopLiveCodexSession(localThreadId);
1138
+ void appServerOwner.stop().catch((stopError) => {
1139
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server cleanup after observer failure failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1140
+ });
1252
1141
  }
1253
1142
  shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1254
1143
  const pending = live.managedFork;
@@ -1272,37 +1161,36 @@ export class LocalAgentHost {
1272
1161
  * Subscribe the forwarder connection to a thread (reference implementation's
1273
1162
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1274
1163
  * turn, so `thread/resume` is retried: park until the forwarder observes the
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.
1164
+ * thread active, then retry. Like Omnigent, the first attempt always uses
1165
+ * `excludeTurns`: a known-session cold resume therefore never reconstructs
1166
+ * historical running/completed state. Only a fresh thread whose first attempt
1167
+ * failed as not-ready retries without `excludeTurns`, backfilling the newly
1168
+ * materialized first turn and de-duplicating it against live notifications.
1279
1169
  */
1280
- async subscribeUntilReady(live, threadId) {
1170
+ async subscribeUntilReady(localThreadId, live, threadId) {
1171
+ let sawNotReady = false;
1281
1172
  while (!live.stopped) {
1282
1173
  try {
1283
1174
  const resp = await live.forwarderClient.threadResume({
1284
1175
  threadId,
1285
1176
  ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1286
1177
  approvalPolicy: live.approvalPolicy,
1287
- initialTurnsPage: {
1288
- limit: 1,
1289
- sortDirection: "desc",
1290
- itemsView: "summary",
1291
- },
1178
+ ...(!sawNotReady ? { excludeTurns: true } : {}),
1292
1179
  });
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);
1180
+ if (sawNotReady) {
1181
+ const turns = Array.isArray(resp.thread.turns)
1182
+ ? resp.thread.turns
1183
+ : [];
1184
+ live.forwarder.replayBackfill(turns);
1185
+ }
1300
1186
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1301
1187
  }
1302
1188
  catch (error) {
1303
1189
  if (!isThreadNotReadyError(error)) {
1304
- return false; // other failure injection still works via the backend client
1190
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
1191
+ return false;
1305
1192
  }
1193
+ sawNotReady = true;
1306
1194
  // Park until the thread goes active (its first turn materializes the
1307
1195
  // rollout); a short poll covers the flush race after "active".
1308
1196
  await new Promise((resolve) => {
@@ -1314,54 +1202,6 @@ export class LocalAgentHost {
1314
1202
  }
1315
1203
  return false;
1316
1204
  }
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
1205
  /** Await a live session's thread binding. `null` leaves the deadline to the
1366
1206
  * caller; a number keeps the Provider-local bound. Returns false on timeout /
1367
1207
  * no live session. Injection and the runner's `live.ready` gate on this. */
@@ -1385,9 +1225,9 @@ export class LocalAgentHost {
1385
1225
  clearTimeout(timer);
1386
1226
  return ok;
1387
1227
  }
1388
- /** Await the stronger Terminal gate: another app-server connection has
1389
- * successfully resumed the thread, so the detached TUI cannot race rollout
1390
- * discovery or indexing. */
1228
+ /** Await the observer subscription diagnostic. Codex/Traex Terminal startup
1229
+ * deliberately does not gate on this promise: the dedicated preload owns resume,
1230
+ * while the independent observer attaches in the background. */
1391
1231
  async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
1392
1232
  const claude = this.liveClaudeSessions.get(localThreadId);
1393
1233
  // Claude has no separate app-server indexing gate. Its TUI must launch first;
@@ -1411,8 +1251,24 @@ export class LocalAgentHost {
1411
1251
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
1412
1252
  liveSessionError(localThreadId) {
1413
1253
  return this.liveClaudeSessions.get(localThreadId)?.error
1414
- ?? this.liveSessions.get(localThreadId)?.startupError
1254
+ ?? this.liveSessions.get(localThreadId)?.startupError?.message
1255
+ ?? this.liveStartupErrors.get(localThreadId)?.message;
1256
+ }
1257
+ /** Structured phase + concrete cause used by the runner wire protocol. */
1258
+ liveSessionFailure(localThreadId) {
1259
+ const failure = this.liveSessions.get(localThreadId)?.startupError
1415
1260
  ?? this.liveStartupErrors.get(localThreadId);
1261
+ if (failure) {
1262
+ return {
1263
+ message: failure.message,
1264
+ code: failure.code,
1265
+ statusCode: failure.statusCode,
1266
+ };
1267
+ }
1268
+ const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
1269
+ return claudeError
1270
+ ? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
1271
+ : undefined;
1416
1272
  }
1417
1273
  /** Publish the background TUI/thread discovery failure so an executor
1418
1274
  * already waiting in the 60s bridge window exits immediately with the exact
@@ -1421,8 +1277,11 @@ export class LocalAgentHost {
1421
1277
  const live = this.liveSessions.get(localThreadId);
1422
1278
  if (!live || live.threadId || live.stopped)
1423
1279
  return false;
1424
- live.startupError = error.message;
1425
- this.liveStartupErrors.set(localThreadId, error.message);
1280
+ const failure = error instanceof CodexRuntimeError
1281
+ ? error
1282
+ : new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
1283
+ live.startupError = failure;
1284
+ this.liveStartupErrors.set(localThreadId, failure);
1426
1285
  live.markStartupFailed();
1427
1286
  return true;
1428
1287
  }
@@ -1505,13 +1364,16 @@ export class LocalAgentHost {
1505
1364
  if (index >= 0)
1506
1365
  live.pendingInjectedInputs.splice(index, 1);
1507
1366
  };
1367
+ const injectionClient = this.injectionClientFactory(live.appServerUrl);
1508
1368
  try {
1509
- // Inject via the BACKEND client (the forwarder connection only observes).
1369
+ await injectionClient.ensureInitialized();
1370
+ // Match Omnigent's executor: each message uses one initialized client
1371
+ // that closes as soon as turn/start or turn/steer is acknowledged.
1510
1372
  if (live.forwarder.isTurnOpen()) {
1511
1373
  const turnId = live.forwarder.currentTurnId();
1512
1374
  if (turnId) {
1513
1375
  injectionMethod = "turn/steer";
1514
- const steered = await live.injectClient.turnSteer({
1376
+ const steered = await injectionClient.turnSteer({
1515
1377
  threadId,
1516
1378
  expectedTurnId: turnId,
1517
1379
  input: nativeInput,
@@ -1528,15 +1390,12 @@ export class LocalAgentHost {
1528
1390
  pendingInput.state = "optimistic";
1529
1391
  }
1530
1392
  live.forwarder.noteTurnAccepted(steered.turnId);
1531
- if (!live.observerAvailable) {
1532
- this.armObserverReconnectDeadline(live);
1533
- }
1534
1393
  return "steered";
1535
1394
  }
1536
1395
  }
1537
1396
  // Carry the agent-spec model on the turn so a web-injected turn runs the
1538
1397
  // agent's model even if the TUI's config default differs.
1539
- const started = await live.injectClient.turnStart({
1398
+ const started = await injectionClient.turnStart({
1540
1399
  threadId,
1541
1400
  input: nativeInput,
1542
1401
  ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
@@ -1559,9 +1418,6 @@ export class LocalAgentHost {
1559
1418
  // Do not wait for the independent observer connection's `turn/started`:
1560
1419
  // a second message accepted in that window must steer, not double-start.
1561
1420
  live.forwarder.noteTurnAccepted(started.turnId);
1562
- if (!live.observerAvailable) {
1563
- this.armObserverReconnectDeadline(live);
1564
- }
1565
1421
  return "injected";
1566
1422
  }
1567
1423
  catch (error) {
@@ -1572,7 +1428,12 @@ export class LocalAgentHost {
1572
1428
  const startupDetail = live.forwarder.mcpStartupDetail();
1573
1429
  const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1574
1430
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1575
- throw new Error(detail, { cause: error });
1431
+ throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
1432
+ }
1433
+ finally {
1434
+ await injectionClient.stop().catch((stopError) => {
1435
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1436
+ });
1576
1437
  }
1577
1438
  });
1578
1439
  live.injectLock = run.then(() => undefined, () => undefined);
@@ -1623,27 +1484,34 @@ export class LocalAgentHost {
1623
1484
  if (!threadId)
1624
1485
  return pendingMcp.length > 0;
1625
1486
  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: "" });
1487
+ const interruptClient = this.injectionClientFactory(live.appServerUrl);
1488
+ try {
1489
+ await interruptClient.ensureInitialized();
1490
+ if (pendingMcp.length > 0) {
1491
+ // Codex's TUI cancels a provider-owned MCP startup round with an empty
1492
+ // turn id. Best-effort for Traex: it shares the app-server surface, while
1493
+ // the active-turn interrupt below remains authoritative if it rejects this.
1494
+ try {
1495
+ await interruptClient.turnInterrupt({ threadId, turnId: "" });
1496
+ }
1497
+ catch (error) {
1498
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
1499
+ }
1632
1500
  }
1633
- catch (error) {
1634
- console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
1501
+ if (turnId) {
1502
+ try {
1503
+ await interruptClient.turnInterrupt({ threadId, turnId });
1504
+ handled = true;
1505
+ }
1506
+ catch {
1507
+ // The startup cancellation above is still a handled Stop operation.
1508
+ }
1635
1509
  }
1510
+ return handled;
1636
1511
  }
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
- }
1512
+ finally {
1513
+ await interruptClient.stop().catch(() => undefined);
1645
1514
  }
1646
- return handled;
1647
1515
  }
1648
1516
  /** Stop + drop a session's live forwarder and its dedicated connection (session
1649
1517
  * close / runner shutdown). The backend inject client is shared — left running. */
@@ -1670,24 +1538,17 @@ export class LocalAgentHost {
1670
1538
  this.liveSessions.delete(localThreadId);
1671
1539
  live.stopped = true;
1672
1540
  if (!live.threadId) {
1673
- live.startupError ??= "native Session stopped before thread discovery completed";
1541
+ live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
1674
1542
  live.markStartupFailed();
1675
1543
  }
1676
- this.clearObserverReconnectDeadline(live);
1677
1544
  live.releaseActive?.();
1678
- live.injectClient.cancelInteractions("session_stopped");
1545
+ live.appServerOwner.cancelInteractions("session_stopped");
1679
1546
  live.forwarderClient.cancelInteractions("session_stopped");
1680
- live.injectClient.setInteractionListener(null);
1547
+ live.appServerOwner.setInteractionListener(null);
1681
1548
  live.forwarderClient.setInteractionListener(null);
1682
- live.injectClient.setConnectionListener(null);
1549
+ live.appServerOwner.setConnectionListener(null);
1683
1550
  live.forwarderClient.setConnectionListener(null);
1684
- live.interactionOwners.clear();
1685
- live.interactionStandbys.clear();
1686
- live.interactionSubmissions.clear();
1687
1551
  live.forwarder.stop();
1688
- live.interactionRecoveries.clear();
1689
- live.interactionFailedClients.clear();
1690
- live.disconnectedClients.clear();
1691
1552
  live.canonicalInteractions.clear();
1692
1553
  live.settledInteractions.clear();
1693
1554
  void live.forwarderClient.stop().catch(() => undefined);
@@ -2242,10 +2103,7 @@ export class LocalAgentHost {
2242
2103
  const codex = this.liveSessions.get(localThreadId);
2243
2104
  if (!codex)
2244
2105
  return false;
2245
- const failed = codex.forwarder.failOpenTurn(error);
2246
- if (failed)
2247
- this.clearObserverReconnectDeadline(codex);
2248
- return failed;
2106
+ return codex.forwarder.failOpenTurn(error);
2249
2107
  }
2250
2108
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
2251
2109
  async listModels(runtime) {
@@ -2275,7 +2133,7 @@ export class LocalAgentHost {
2275
2133
  return { ok: false, reason: "unsupported" };
2276
2134
  }
2277
2135
  try {
2278
- return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
2136
+ return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
2279
2137
  }
2280
2138
  catch (error) {
2281
2139
  return {
@@ -2347,7 +2205,7 @@ export class LocalAgentHost {
2347
2205
  return { ok: true, data: undefined };
2348
2206
  }
2349
2207
  const runtime = execution.provider;
2350
- const client = live?.injectClient ??
2208
+ const client = live?.appServerOwner ??
2351
2209
  this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
2352
2210
  if (!client) {
2353
2211
  return { ok: false, reason: "unsupported" };
@@ -2523,10 +2381,13 @@ export function isThreadNotReadyError(error) {
2523
2381
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2524
2382
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
2525
2383
  }
2526
- /** A persisted thread id that a freshly started app-server cannot load yet.
2527
- * During startup, both errors can be transient while the rollout index catches
2528
- * up. Retry the same id; never use either error as permission to replace it. */
2529
- export function isRetryableThreadResumeError(error) {
2530
- const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2531
- return message.includes("thread not found") || isThreadNotReadyError(error);
2384
+ /** Build the user-facing native lifecycle failure once, at the phase that owns
2385
+ * it. Downstream layers preserve this code/message verbatim instead of replacing
2386
+ * it with a generic "failed to start; see logs" wrapper. */
2387
+ function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
2388
+ const provider = runtime === "traex" ? "Traex" : "Codex";
2389
+ const detail = cause === undefined
2390
+ ? ""
2391
+ : `: ${cause instanceof Error ? cause.message : String(cause)}`;
2392
+ return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
2532
2393
  }