@rynx-ai/runtime 0.1.11-beta.20 → 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/claude/native-integration.d.ts +23 -1
- package/dist/claude/native-integration.js +69 -6
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/client.d.ts +4 -3
- package/dist/codex-app-server/client.js +35 -12
- package/dist/codex-app-server/forwarder.d.ts +29 -5
- package/dist/codex-app-server/forwarder.js +140 -29
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/protocol.d.ts +2 -3
- package/dist/host.d.ts +27 -20
- package/dist/host.js +337 -455
- package/dist/runner/child.d.ts +12 -5
- package/dist/runner/child.js +116 -37
- package/dist/runner/manager.d.ts +36 -18
- package/dist/runner/manager.js +176 -81
- package/dist/runner/protocol.d.ts +15 -20
- package/dist/runner/startup-policy.d.ts +3 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/tmux.d.ts +15 -0
- package/dist/terminal/tmux.js +50 -0
- package/package.json +2 -2
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
|
-
|
|
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,
|
|
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
|
-
|
|
296
|
-
this.forwarderClientFactory =
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
631
|
-
if (!
|
|
632
|
-
this.liveStartupErrors.set(localThreadId,
|
|
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
|
|
614
|
+
await appServerOwner.ensureInitialized();
|
|
638
615
|
}
|
|
639
616
|
catch (err) {
|
|
640
|
-
this.liveStartupErrors.set(localThreadId,
|
|
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 =
|
|
621
|
+
const appServerUrl = appServerOwner.terminalRemoteUrl();
|
|
645
622
|
if (!appServerUrl) {
|
|
646
|
-
this.liveStartupErrors.set(localThreadId,
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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:
|
|
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,168 +775,81 @@ export class LocalAgentHost {
|
|
|
795
775
|
for (const se of n.next(agentEvent))
|
|
796
776
|
emitCurrent(se);
|
|
797
777
|
};
|
|
798
|
-
const
|
|
799
|
-
|
|
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
|
-
|
|
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
|
};
|
|
800
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
801
|
+
closeCanonicalInteractions();
|
|
802
|
+
if (!normalizer)
|
|
803
|
+
return;
|
|
804
|
+
const completedResponseId = currentResponseId;
|
|
805
|
+
if (usage) {
|
|
806
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
807
|
+
emitCurrent(se);
|
|
808
|
+
}
|
|
809
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
810
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
811
|
+
continue;
|
|
812
|
+
emitCurrent(se);
|
|
813
|
+
}
|
|
814
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
815
|
+
normalizer = null;
|
|
816
|
+
currentResponseId = null;
|
|
817
|
+
};
|
|
818
|
+
const failCurrentTurn = (error) => {
|
|
819
|
+
closeCanonicalInteractions();
|
|
820
|
+
if (!normalizer)
|
|
821
|
+
return;
|
|
822
|
+
const failedResponseId = currentResponseId;
|
|
823
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
824
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
825
|
+
const message = responseStopped
|
|
826
|
+
? "Response stopped"
|
|
827
|
+
: runtime === "traex"
|
|
828
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
829
|
+
: error.message;
|
|
830
|
+
for (const se of normalizer.fail({
|
|
831
|
+
code: responseStopped
|
|
832
|
+
? "response_stopped"
|
|
833
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
834
|
+
message,
|
|
835
|
+
source: "execution",
|
|
836
|
+
})) {
|
|
837
|
+
emitCurrent(se.type === "session.status"
|
|
838
|
+
? { ...se, status: "failed", note: message }
|
|
839
|
+
: se);
|
|
840
|
+
}
|
|
841
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
842
|
+
normalizer = null;
|
|
843
|
+
currentResponseId = null;
|
|
844
|
+
};
|
|
958
845
|
const sink = {
|
|
959
846
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
847
|
+
onTurnObserved: (turnId) => {
|
|
848
|
+
const n = startNormalizer(turnId);
|
|
849
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
850
|
+
emitCurrent(event);
|
|
851
|
+
}
|
|
852
|
+
},
|
|
960
853
|
onUserMessage: (content) => {
|
|
961
854
|
const normalizedContent = typeof content === "string"
|
|
962
855
|
? [{ type: "input_text", text: content }]
|
|
@@ -990,45 +883,28 @@ export class LocalAgentHost {
|
|
|
990
883
|
...(statusKind ? { statusKind } : {}),
|
|
991
884
|
});
|
|
992
885
|
},
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
886
|
+
// A newer authoritative turn/started completes the old canonical
|
|
887
|
+
// response without publishing a false idle edge in a running→running
|
|
888
|
+
// transition.
|
|
889
|
+
onTurnEnd: completeCurrentTurn,
|
|
890
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
891
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
892
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
893
|
+
if (status === "failed")
|
|
894
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
895
|
+
else
|
|
896
|
+
completeCurrentTurn();
|
|
996
897
|
return;
|
|
997
|
-
const completedResponseId = currentResponseId;
|
|
998
|
-
if (usage) {
|
|
999
|
-
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1000
|
-
emitCurrent(se);
|
|
1001
898
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
closeCanonicalInteractions();
|
|
1010
|
-
if (!normalizer)
|
|
1011
|
-
return;
|
|
1012
|
-
const failedResponseId = currentResponseId;
|
|
1013
|
-
const responseStopped = error.message === "Codex turn was interrupted";
|
|
1014
|
-
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
1015
|
-
const message = responseStopped
|
|
1016
|
-
? "Response stopped"
|
|
1017
|
-
: runtime === "traex"
|
|
1018
|
-
? error.message.replace(/^Codex\b/, providerName)
|
|
1019
|
-
: error.message;
|
|
1020
|
-
for (const se of normalizer.fail({
|
|
1021
|
-
code: responseStopped
|
|
1022
|
-
? "response_stopped"
|
|
1023
|
-
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
1024
|
-
message,
|
|
1025
|
-
source: "execution",
|
|
1026
|
-
}))
|
|
1027
|
-
emitCurrent(se);
|
|
1028
|
-
clearPendingInputsForResponse(failedResponseId);
|
|
1029
|
-
normalizer = null;
|
|
1030
|
-
currentResponseId = null;
|
|
899
|
+
emitCurrent({
|
|
900
|
+
type: "session.status",
|
|
901
|
+
sessionId: currentSessionId,
|
|
902
|
+
...(responseId ? { responseId } : {}),
|
|
903
|
+
status,
|
|
904
|
+
...(error ? { note: error.message } : {}),
|
|
905
|
+
});
|
|
1031
906
|
},
|
|
907
|
+
onTurnError: failCurrentTurn,
|
|
1032
908
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
1033
909
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
1034
910
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
@@ -1089,7 +965,7 @@ export class LocalAgentHost {
|
|
|
1089
965
|
live.rotationPending = false;
|
|
1090
966
|
for (const event of queued)
|
|
1091
967
|
emit(event);
|
|
1092
|
-
void this.subscribeUntilReady(live, threadId);
|
|
968
|
+
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1093
969
|
})
|
|
1094
970
|
.catch(() => {
|
|
1095
971
|
pendingRotationEvents = null;
|
|
@@ -1097,8 +973,7 @@ export class LocalAgentHost {
|
|
|
1097
973
|
live.stopped = true;
|
|
1098
974
|
});
|
|
1099
975
|
};
|
|
1100
|
-
|
|
1101
|
-
bindInteractionClient(forwarderClient);
|
|
976
|
+
bindObserverClient(forwarderClient);
|
|
1102
977
|
this.liveSessions.set(localThreadId, live);
|
|
1103
978
|
forwarder.start();
|
|
1104
979
|
// Existing bindings resume through the public app-server API. A fresh thread
|
|
@@ -1108,57 +983,53 @@ export class LocalAgentHost {
|
|
|
1108
983
|
// turn-less app-server thread resumable by the TUI.
|
|
1109
984
|
try {
|
|
1110
985
|
if (record?.codexSessionId) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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;
|
|
1115
995
|
try {
|
|
1116
|
-
|
|
996
|
+
await preloadClient.ensureInitialized();
|
|
997
|
+
resumed = await preloadClient.threadResume({
|
|
1117
998
|
threadId: record.codexSessionId,
|
|
1118
999
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1119
1000
|
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.
|
|
1123
1001
|
excludeTurns: true,
|
|
1124
|
-
initialTurnsPage: {
|
|
1125
|
-
limit: 1,
|
|
1126
|
-
sortDirection: "desc",
|
|
1127
|
-
itemsView: "summary",
|
|
1128
|
-
},
|
|
1129
1002
|
});
|
|
1130
|
-
resumedThreadId = resumed.threadId;
|
|
1131
|
-
break;
|
|
1132
1003
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (attempt + 1 < attempts) {
|
|
1138
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1139
|
-
}
|
|
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
|
+
});
|
|
1140
1008
|
}
|
|
1009
|
+
forwarder.noteThreadBound(resumed.threadId);
|
|
1010
|
+
this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
|
|
1141
1011
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
// Fork bindings stay strict because their Provider-native ancestry
|
|
1153
|
-
// 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.
|
|
1154
1022
|
await this.sessionStore.delete(localThreadId);
|
|
1155
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;
|
|
1156
1031
|
live.markTerminalReady(true);
|
|
1157
|
-
|
|
1158
|
-
else {
|
|
1159
|
-
// A delayed/missing native index or a fork binding remains explicit:
|
|
1160
|
-
// neither is proof that replacing Provider history is safe.
|
|
1161
|
-
throw resumeError;
|
|
1032
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
|
|
1162
1033
|
}
|
|
1163
1034
|
}
|
|
1164
1035
|
else {
|
|
@@ -1173,7 +1044,10 @@ export class LocalAgentHost {
|
|
|
1173
1044
|
live.stopped = true;
|
|
1174
1045
|
forwarder.stop();
|
|
1175
1046
|
void forwarderClient.stop().catch(() => undefined);
|
|
1176
|
-
|
|
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);
|
|
1177
1051
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1178
1052
|
return abandon();
|
|
1179
1053
|
}
|
|
@@ -1213,7 +1087,57 @@ export class LocalAgentHost {
|
|
|
1213
1087
|
})
|
|
1214
1088
|
.catch(() => undefined);
|
|
1215
1089
|
live.markReady(); // thread id known → injection can turn/start
|
|
1216
|
-
|
|
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
|
+
});
|
|
1217
1141
|
}
|
|
1218
1142
|
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1219
1143
|
const pending = live.managedFork;
|
|
@@ -1237,37 +1161,36 @@ export class LocalAgentHost {
|
|
|
1237
1161
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1238
1162
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1239
1163
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1240
|
-
* thread active, then retry.
|
|
1241
|
-
*
|
|
1242
|
-
* historical
|
|
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.
|
|
1243
1169
|
*/
|
|
1244
|
-
async subscribeUntilReady(live, threadId) {
|
|
1170
|
+
async subscribeUntilReady(localThreadId, live, threadId) {
|
|
1171
|
+
let sawNotReady = false;
|
|
1245
1172
|
while (!live.stopped) {
|
|
1246
1173
|
try {
|
|
1247
1174
|
const resp = await live.forwarderClient.threadResume({
|
|
1248
1175
|
threadId,
|
|
1249
1176
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1250
1177
|
approvalPolicy: live.approvalPolicy,
|
|
1251
|
-
|
|
1252
|
-
limit: 1,
|
|
1253
|
-
sortDirection: "desc",
|
|
1254
|
-
itemsView: "summary",
|
|
1255
|
-
},
|
|
1178
|
+
...(!sawNotReady ? { excludeTurns: true } : {}),
|
|
1256
1179
|
});
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
: undefined;
|
|
1264
|
-
live.forwarder.reconcileActiveTurn(activeTurn);
|
|
1180
|
+
if (sawNotReady) {
|
|
1181
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1182
|
+
? resp.thread.turns
|
|
1183
|
+
: [];
|
|
1184
|
+
live.forwarder.replayBackfill(turns);
|
|
1185
|
+
}
|
|
1265
1186
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1266
1187
|
}
|
|
1267
1188
|
catch (error) {
|
|
1268
1189
|
if (!isThreadNotReadyError(error)) {
|
|
1269
|
-
|
|
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;
|
|
1270
1192
|
}
|
|
1193
|
+
sawNotReady = true;
|
|
1271
1194
|
// Park until the thread goes active (its first turn materializes the
|
|
1272
1195
|
// rollout); a short poll covers the flush race after "active".
|
|
1273
1196
|
await new Promise((resolve) => {
|
|
@@ -1279,54 +1202,6 @@ export class LocalAgentHost {
|
|
|
1279
1202
|
}
|
|
1280
1203
|
return false;
|
|
1281
1204
|
}
|
|
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
1205
|
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1331
1206
|
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1332
1207
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
@@ -1350,9 +1225,9 @@ export class LocalAgentHost {
|
|
|
1350
1225
|
clearTimeout(timer);
|
|
1351
1226
|
return ok;
|
|
1352
1227
|
}
|
|
1353
|
-
/** Await the
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
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. */
|
|
1356
1231
|
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1357
1232
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1358
1233
|
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
@@ -1376,8 +1251,24 @@ export class LocalAgentHost {
|
|
|
1376
1251
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1377
1252
|
liveSessionError(localThreadId) {
|
|
1378
1253
|
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1379
|
-
?? 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
|
|
1380
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;
|
|
1381
1272
|
}
|
|
1382
1273
|
/** Publish the background TUI/thread discovery failure so an executor
|
|
1383
1274
|
* already waiting in the 60s bridge window exits immediately with the exact
|
|
@@ -1386,8 +1277,11 @@ export class LocalAgentHost {
|
|
|
1386
1277
|
const live = this.liveSessions.get(localThreadId);
|
|
1387
1278
|
if (!live || live.threadId || live.stopped)
|
|
1388
1279
|
return false;
|
|
1389
|
-
|
|
1390
|
-
|
|
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);
|
|
1391
1285
|
live.markStartupFailed();
|
|
1392
1286
|
return true;
|
|
1393
1287
|
}
|
|
@@ -1470,36 +1364,20 @@ export class LocalAgentHost {
|
|
|
1470
1364
|
if (index >= 0)
|
|
1471
1365
|
live.pendingInjectedInputs.splice(index, 1);
|
|
1472
1366
|
};
|
|
1367
|
+
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1473
1368
|
try {
|
|
1474
|
-
|
|
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.
|
|
1475
1372
|
if (live.forwarder.isTurnOpen()) {
|
|
1476
1373
|
const turnId = live.forwarder.currentTurnId();
|
|
1477
1374
|
if (turnId) {
|
|
1478
1375
|
injectionMethod = "turn/steer";
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
}
|
|
1376
|
+
const steered = await injectionClient.turnSteer({
|
|
1377
|
+
threadId,
|
|
1378
|
+
expectedTurnId: turnId,
|
|
1379
|
+
input: nativeInput,
|
|
1380
|
+
});
|
|
1503
1381
|
if (pendingInput.state === "prepublished") {
|
|
1504
1382
|
// The caller already persisted and published this user input
|
|
1505
1383
|
// before waiting for the native Terminal to become ready.
|
|
@@ -1512,15 +1390,12 @@ export class LocalAgentHost {
|
|
|
1512
1390
|
pendingInput.state = "optimistic";
|
|
1513
1391
|
}
|
|
1514
1392
|
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1515
|
-
|
|
1516
|
-
this.armObserverReconnectDeadline(live);
|
|
1517
|
-
}
|
|
1518
|
-
return "injected";
|
|
1393
|
+
return "steered";
|
|
1519
1394
|
}
|
|
1520
1395
|
}
|
|
1521
1396
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
1522
1397
|
// agent's model even if the TUI's config default differs.
|
|
1523
|
-
const started = await
|
|
1398
|
+
const started = await injectionClient.turnStart({
|
|
1524
1399
|
threadId,
|
|
1525
1400
|
input: nativeInput,
|
|
1526
1401
|
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
@@ -1543,9 +1418,6 @@ export class LocalAgentHost {
|
|
|
1543
1418
|
// Do not wait for the independent observer connection's `turn/started`:
|
|
1544
1419
|
// a second message accepted in that window must steer, not double-start.
|
|
1545
1420
|
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1546
|
-
if (!live.observerAvailable) {
|
|
1547
|
-
this.armObserverReconnectDeadline(live);
|
|
1548
|
-
}
|
|
1549
1421
|
return "injected";
|
|
1550
1422
|
}
|
|
1551
1423
|
catch (error) {
|
|
@@ -1556,7 +1428,12 @@ export class LocalAgentHost {
|
|
|
1556
1428
|
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1557
1429
|
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1558
1430
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1559
|
-
throw
|
|
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
|
+
});
|
|
1560
1437
|
}
|
|
1561
1438
|
});
|
|
1562
1439
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1607,27 +1484,34 @@ export class LocalAgentHost {
|
|
|
1607
1484
|
if (!threadId)
|
|
1608
1485
|
return pendingMcp.length > 0;
|
|
1609
1486
|
let handled = pendingMcp.length > 0;
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
+
}
|
|
1616
1500
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
+
}
|
|
1619
1509
|
}
|
|
1510
|
+
return handled;
|
|
1620
1511
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
}
|
|
1512
|
+
finally {
|
|
1513
|
+
await interruptClient.stop().catch(() => undefined);
|
|
1629
1514
|
}
|
|
1630
|
-
return handled;
|
|
1631
1515
|
}
|
|
1632
1516
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
1633
1517
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
@@ -1654,24 +1538,17 @@ export class LocalAgentHost {
|
|
|
1654
1538
|
this.liveSessions.delete(localThreadId);
|
|
1655
1539
|
live.stopped = true;
|
|
1656
1540
|
if (!live.threadId) {
|
|
1657
|
-
live.startupError ??= "
|
|
1541
|
+
live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
|
|
1658
1542
|
live.markStartupFailed();
|
|
1659
1543
|
}
|
|
1660
|
-
this.clearObserverReconnectDeadline(live);
|
|
1661
1544
|
live.releaseActive?.();
|
|
1662
|
-
live.
|
|
1545
|
+
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1663
1546
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1664
|
-
live.
|
|
1547
|
+
live.appServerOwner.setInteractionListener(null);
|
|
1665
1548
|
live.forwarderClient.setInteractionListener(null);
|
|
1666
|
-
live.
|
|
1549
|
+
live.appServerOwner.setConnectionListener(null);
|
|
1667
1550
|
live.forwarderClient.setConnectionListener(null);
|
|
1668
|
-
live.interactionOwners.clear();
|
|
1669
|
-
live.interactionStandbys.clear();
|
|
1670
|
-
live.interactionSubmissions.clear();
|
|
1671
1551
|
live.forwarder.stop();
|
|
1672
|
-
live.interactionRecoveries.clear();
|
|
1673
|
-
live.interactionFailedClients.clear();
|
|
1674
|
-
live.disconnectedClients.clear();
|
|
1675
1552
|
live.canonicalInteractions.clear();
|
|
1676
1553
|
live.settledInteractions.clear();
|
|
1677
1554
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
@@ -1988,6 +1865,17 @@ export class LocalAgentHost {
|
|
|
1988
1865
|
emitCurrent(se);
|
|
1989
1866
|
},
|
|
1990
1867
|
onInteraction: forwardInteraction,
|
|
1868
|
+
onStatus: (status, blockedOn) => {
|
|
1869
|
+
const responseId = currentResponseId ??
|
|
1870
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
1871
|
+
emitCurrent({
|
|
1872
|
+
type: "session.status",
|
|
1873
|
+
sessionId: currentSessionId,
|
|
1874
|
+
...(responseId ? { responseId } : {}),
|
|
1875
|
+
status,
|
|
1876
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
1877
|
+
});
|
|
1878
|
+
},
|
|
1991
1879
|
onTurnEnd: (usage) => {
|
|
1992
1880
|
if (!normalizer)
|
|
1993
1881
|
return;
|
|
@@ -2028,16 +1916,20 @@ export class LocalAgentHost {
|
|
|
2028
1916
|
});
|
|
2029
1917
|
}
|
|
2030
1918
|
},
|
|
2031
|
-
onTurnError: () => {
|
|
1919
|
+
onTurnError: (error) => {
|
|
2032
1920
|
if (!normalizer)
|
|
2033
1921
|
return;
|
|
2034
1922
|
const rid = currentResponseId;
|
|
1923
|
+
const message = error.message || "Agent turn failed";
|
|
2035
1924
|
for (const se of normalizer.fail({
|
|
2036
1925
|
code: "agent_error",
|
|
2037
|
-
message
|
|
1926
|
+
message,
|
|
2038
1927
|
source: "execution",
|
|
2039
|
-
}))
|
|
2040
|
-
emitCurrent(se
|
|
1928
|
+
})) {
|
|
1929
|
+
emitCurrent(se.type === "session.status"
|
|
1930
|
+
? { ...se, status: "failed", note: message }
|
|
1931
|
+
: se);
|
|
1932
|
+
}
|
|
2041
1933
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2042
1934
|
normalizer = null;
|
|
2043
1935
|
currentResponseId = undefined;
|
|
@@ -2196,21 +2088,22 @@ export class LocalAgentHost {
|
|
|
2196
2088
|
* (they inject via the app-server). Idempotent. */
|
|
2197
2089
|
attachTerminalInjector(localThreadId, injector) {
|
|
2198
2090
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
2199
|
-
if (live)
|
|
2091
|
+
if (live) {
|
|
2092
|
+
if (live.injector === injector)
|
|
2093
|
+
return;
|
|
2200
2094
|
live.injector = injector;
|
|
2095
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2096
|
+
}
|
|
2201
2097
|
}
|
|
2202
2098
|
/** Close an active native response when its terminal or runner disappears. */
|
|
2203
2099
|
failLiveSession(localThreadId, error) {
|
|
2204
2100
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2205
2101
|
if (claude)
|
|
2206
|
-
return claude.forwarder.
|
|
2102
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
2207
2103
|
const codex = this.liveSessions.get(localThreadId);
|
|
2208
2104
|
if (!codex)
|
|
2209
2105
|
return false;
|
|
2210
|
-
|
|
2211
|
-
if (failed)
|
|
2212
|
-
this.clearObserverReconnectDeadline(codex);
|
|
2213
|
-
return failed;
|
|
2106
|
+
return codex.forwarder.failOpenTurn(error);
|
|
2214
2107
|
}
|
|
2215
2108
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
2216
2109
|
async listModels(runtime) {
|
|
@@ -2240,7 +2133,7 @@ export class LocalAgentHost {
|
|
|
2240
2133
|
return { ok: false, reason: "unsupported" };
|
|
2241
2134
|
}
|
|
2242
2135
|
try {
|
|
2243
|
-
return { ok: true, data: await fn(live.
|
|
2136
|
+
return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
|
|
2244
2137
|
}
|
|
2245
2138
|
catch (error) {
|
|
2246
2139
|
return {
|
|
@@ -2312,7 +2205,7 @@ export class LocalAgentHost {
|
|
|
2312
2205
|
return { ok: true, data: undefined };
|
|
2313
2206
|
}
|
|
2314
2207
|
const runtime = execution.provider;
|
|
2315
|
-
const client = live?.
|
|
2208
|
+
const client = live?.appServerOwner ??
|
|
2316
2209
|
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2317
2210
|
if (!client) {
|
|
2318
2211
|
return { ok: false, reason: "unsupported" };
|
|
@@ -2387,20 +2280,6 @@ function codexRpcError(error, method) {
|
|
|
2387
2280
|
}
|
|
2388
2281
|
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2389
2282
|
}
|
|
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));
|
|
2402
|
-
}
|
|
2403
|
-
const TURN_STEER_ACTIVATION_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 250, 440];
|
|
2404
2283
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2405
2284
|
const normalized = output.trim();
|
|
2406
2285
|
const match = normalized.match(/Logged in using (.+)$/im);
|
|
@@ -2502,10 +2381,13 @@ export function isThreadNotReadyError(error) {
|
|
|
2502
2381
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2503
2382
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
2504
2383
|
}
|
|
2505
|
-
/**
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
|
|
2509
|
-
const
|
|
2510
|
-
|
|
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);
|
|
2511
2393
|
}
|