@rynx-ai/runtime 0.1.11-beta.21 → 0.1.11-beta.23
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 +7 -0
- package/dist/claude/native-integration.js +25 -2
- 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 +2 -7
- package/dist/codex-app-server/forwarder.js +12 -50
- package/dist/codex-app-server/mapping.d.ts +1 -0
- package/dist/codex-app-server/mapping.js +8 -1
- package/dist/codex-app-server/protocol.d.ts +1 -2
- package/dist/host.d.ts +36 -25
- package/dist/host.js +384 -441
- package/dist/index.d.ts +1 -1
- package/dist/runner/child.d.ts +18 -7
- package/dist/runner/child.js +169 -45
- package/dist/runner/manager.d.ts +14 -15
- package/dist/runner/manager.js +90 -98
- package/dist/runner/protocol.d.ts +16 -15
- package/dist/runner/startup-policy.d.ts +3 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/registry.js +3 -2
- package/dist/terminal/tmux.d.ts +29 -5
- package/dist/terminal/tmux.js +98 -52
- 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,
|
|
@@ -695,19 +681,15 @@ export class LocalAgentHost {
|
|
|
695
681
|
injectLock: Promise.resolve(),
|
|
696
682
|
pendingInjectedInputs: [],
|
|
697
683
|
publishInjectedInput: () => undefined,
|
|
684
|
+
publishInterrupted: () => undefined,
|
|
685
|
+
interruptedResponseId: null,
|
|
698
686
|
subscribing: false,
|
|
699
687
|
rotationPending: false,
|
|
700
688
|
stopped: false,
|
|
701
|
-
observerAvailable:
|
|
689
|
+
observerAvailable: !coldResume,
|
|
702
690
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
703
|
-
interactionOwners: new Map(),
|
|
704
|
-
interactionStandbys: new Map(),
|
|
705
|
-
interactionSubmissions: new Map(),
|
|
706
691
|
canonicalInteractions: new Set(),
|
|
707
692
|
settledInteractions: new Set(),
|
|
708
|
-
interactionRecoveries: new Map(),
|
|
709
|
-
interactionFailedClients: new Map(),
|
|
710
|
-
disconnectedClients: new Set(),
|
|
711
693
|
};
|
|
712
694
|
let currentSessionId = localThreadId;
|
|
713
695
|
let pendingRotationEvents = null;
|
|
@@ -718,11 +700,20 @@ export class LocalAgentHost {
|
|
|
718
700
|
}
|
|
719
701
|
emit(event);
|
|
720
702
|
};
|
|
703
|
+
live.publishInterrupted = (responseId) => {
|
|
704
|
+
if (live.interruptedResponseId === responseId)
|
|
705
|
+
return;
|
|
706
|
+
live.interruptedResponseId = responseId;
|
|
707
|
+
emitCurrent({
|
|
708
|
+
type: "session.interrupted",
|
|
709
|
+
sessionId: currentSessionId,
|
|
710
|
+
responseId,
|
|
711
|
+
});
|
|
712
|
+
};
|
|
721
713
|
let normalizer = null;
|
|
722
714
|
let currentResponseId = null;
|
|
723
715
|
const startNormalizer = (turnId) => {
|
|
724
|
-
const responseId =
|
|
725
|
-
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
716
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
726
717
|
if (normalizer && currentResponseId === responseId)
|
|
727
718
|
return normalizer;
|
|
728
719
|
currentResponseId = responseId;
|
|
@@ -795,163 +786,25 @@ export class LocalAgentHost {
|
|
|
795
786
|
for (const se of n.next(agentEvent))
|
|
796
787
|
emitCurrent(se);
|
|
797
788
|
};
|
|
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
|
-
});
|
|
789
|
+
const bindObserverClient = (client) => {
|
|
790
|
+
client.setInteractionListener(forwardInteraction);
|
|
929
791
|
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);
|
|
792
|
+
if (state === "connected" || live.stopped)
|
|
936
793
|
return;
|
|
794
|
+
live.observerAvailable = false;
|
|
795
|
+
for (const interactionId of [...live.canonicalInteractions]) {
|
|
796
|
+
forwardInteraction({
|
|
797
|
+
type: "cancelled",
|
|
798
|
+
interactionId,
|
|
799
|
+
reason: "app_server_disconnected",
|
|
800
|
+
});
|
|
937
801
|
}
|
|
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
|
-
}
|
|
802
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
|
|
949
803
|
});
|
|
950
804
|
};
|
|
951
805
|
const closeCanonicalInteractions = () => {
|
|
952
806
|
for (const interactionId of live.canonicalInteractions) {
|
|
953
807
|
rememberSettledInteraction(interactionId);
|
|
954
|
-
clearInteractionRouting(interactionId);
|
|
955
808
|
}
|
|
956
809
|
live.canonicalInteractions.clear();
|
|
957
810
|
};
|
|
@@ -1000,6 +853,22 @@ export class LocalAgentHost {
|
|
|
1000
853
|
normalizer = null;
|
|
1001
854
|
currentResponseId = null;
|
|
1002
855
|
};
|
|
856
|
+
const interruptCurrentTurn = () => {
|
|
857
|
+
closeCanonicalInteractions();
|
|
858
|
+
if (!normalizer)
|
|
859
|
+
return;
|
|
860
|
+
const interruptedResponseId = currentResponseId;
|
|
861
|
+
for (const se of normalizer.interrupt()) {
|
|
862
|
+
if (se.type === "session.interrupted" &&
|
|
863
|
+
live.interruptedResponseId === interruptedResponseId)
|
|
864
|
+
continue;
|
|
865
|
+
emitCurrent(se);
|
|
866
|
+
}
|
|
867
|
+
live.interruptedResponseId = null;
|
|
868
|
+
clearPendingInputsForResponse(interruptedResponseId);
|
|
869
|
+
normalizer = null;
|
|
870
|
+
currentResponseId = null;
|
|
871
|
+
};
|
|
1003
872
|
const sink = {
|
|
1004
873
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1005
874
|
onTurnObserved: (turnId) => {
|
|
@@ -1014,7 +883,7 @@ export class LocalAgentHost {
|
|
|
1014
883
|
: content;
|
|
1015
884
|
const signature = JSON.stringify(normalizedContent);
|
|
1016
885
|
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1017
|
-
if (pending?.state === "optimistic"
|
|
886
|
+
if (pending?.state === "optimistic") {
|
|
1018
887
|
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1019
888
|
return;
|
|
1020
889
|
}
|
|
@@ -1045,6 +914,7 @@ export class LocalAgentHost {
|
|
|
1045
914
|
// response without publishing a false idle edge in a running→running
|
|
1046
915
|
// transition.
|
|
1047
916
|
onTurnEnd: completeCurrentTurn,
|
|
917
|
+
onTurnInterrupted: interruptCurrentTurn,
|
|
1048
918
|
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1049
919
|
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1050
920
|
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
@@ -1123,7 +993,7 @@ export class LocalAgentHost {
|
|
|
1123
993
|
live.rotationPending = false;
|
|
1124
994
|
for (const event of queued)
|
|
1125
995
|
emit(event);
|
|
1126
|
-
void this.subscribeUntilReady(live, threadId);
|
|
996
|
+
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1127
997
|
})
|
|
1128
998
|
.catch(() => {
|
|
1129
999
|
pendingRotationEvents = null;
|
|
@@ -1131,8 +1001,7 @@ export class LocalAgentHost {
|
|
|
1131
1001
|
live.stopped = true;
|
|
1132
1002
|
});
|
|
1133
1003
|
};
|
|
1134
|
-
|
|
1135
|
-
bindInteractionClient(forwarderClient);
|
|
1004
|
+
bindObserverClient(forwarderClient);
|
|
1136
1005
|
this.liveSessions.set(localThreadId, live);
|
|
1137
1006
|
forwarder.start();
|
|
1138
1007
|
// Existing bindings resume through the public app-server API. A fresh thread
|
|
@@ -1142,58 +1011,53 @@ export class LocalAgentHost {
|
|
|
1142
1011
|
// turn-less app-server thread resumable by the TUI.
|
|
1143
1012
|
try {
|
|
1144
1013
|
if (record?.codexSessionId) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1014
|
+
// Match Omnigent's cold-resume preload exactly: one history-free
|
|
1015
|
+
// `thread/resume` on a short-lived, separately initialized connection.
|
|
1016
|
+
// The backend owner's handshake above is the app-server readiness
|
|
1017
|
+
// probe; reusing that first connection here races Codex's startup state
|
|
1018
|
+
// and can return `Not initialized`. A failure stays attached to the
|
|
1019
|
+
// original binding and is reported by this phase; it is not polled.
|
|
1020
|
+
const preloadClient = this.preloadClientFactory(appServerUrl);
|
|
1021
|
+
try {
|
|
1022
|
+
let resumed;
|
|
1149
1023
|
try {
|
|
1150
|
-
|
|
1024
|
+
await preloadClient.ensureInitialized();
|
|
1025
|
+
resumed = await preloadClient.threadResume({
|
|
1151
1026
|
threadId: record.codexSessionId,
|
|
1152
1027
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1153
1028
|
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
1029
|
excludeTurns: true,
|
|
1158
|
-
initialTurnsPage: {
|
|
1159
|
-
limit: 1,
|
|
1160
|
-
sortDirection: "desc",
|
|
1161
|
-
itemsView: "summary",
|
|
1162
|
-
},
|
|
1163
1030
|
});
|
|
1164
|
-
resumedThreadId = resumed.threadId;
|
|
1165
|
-
break;
|
|
1166
1031
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (attempt + 1 < attempts) {
|
|
1172
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1173
|
-
}
|
|
1032
|
+
finally {
|
|
1033
|
+
await preloadClient.stop().catch((error) => {
|
|
1034
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1035
|
+
});
|
|
1174
1036
|
}
|
|
1037
|
+
forwarder.noteThreadBound(resumed.threadId);
|
|
1038
|
+
this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
|
|
1175
1039
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
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.
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
if (!isThreadNotReadyError(error) ||
|
|
1042
|
+
record.parentSessionId ||
|
|
1043
|
+
record.runtimeHomeOwnerSessionId) {
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1046
|
+
// Preserve the existing turn-less binding recovery without polling:
|
|
1047
|
+
// an ordinary native id with no rollout/turn has no Provider history
|
|
1048
|
+
// to retain. Fork bindings remain strict, and `thread not found` is
|
|
1049
|
+
// not classified here because it does not prove the history is empty.
|
|
1189
1050
|
await this.sessionStore.delete(localThreadId);
|
|
1190
1051
|
live.threadId = null;
|
|
1052
|
+
try {
|
|
1053
|
+
await forwarderClient.ensureInitialized();
|
|
1054
|
+
}
|
|
1055
|
+
catch (listenerError) {
|
|
1056
|
+
throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
|
|
1057
|
+
}
|
|
1058
|
+
live.observerAvailable = true;
|
|
1191
1059
|
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;
|
|
1060
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
|
|
1197
1061
|
}
|
|
1198
1062
|
}
|
|
1199
1063
|
else {
|
|
@@ -1208,7 +1072,10 @@ export class LocalAgentHost {
|
|
|
1208
1072
|
live.stopped = true;
|
|
1209
1073
|
forwarder.stop();
|
|
1210
1074
|
void forwarderClient.stop().catch(() => undefined);
|
|
1211
|
-
|
|
1075
|
+
const failure = err instanceof CodexRuntimeError
|
|
1076
|
+
? err
|
|
1077
|
+
: nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
|
|
1078
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1212
1079
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1213
1080
|
return abandon();
|
|
1214
1081
|
}
|
|
@@ -1248,7 +1115,52 @@ export class LocalAgentHost {
|
|
|
1248
1115
|
})
|
|
1249
1116
|
.catch(() => undefined);
|
|
1250
1117
|
live.markReady(); // thread id known → injection can turn/start
|
|
1251
|
-
|
|
1118
|
+
if (live.observerAvailable) {
|
|
1119
|
+
void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
// Known-thread cold resume has already completed the dedicated preload. Its
|
|
1123
|
+
// observer is deliberately post-TUI background work, not Terminal
|
|
1124
|
+
// readiness. RunnerSession calls startLiveCodexObserver after launch.
|
|
1125
|
+
live.markTerminalReady(true);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/** Start the known-thread observer after the replacement TUI has launched.
|
|
1129
|
+
* Fresh sessions already connected their discovery listener before launch;
|
|
1130
|
+
* this is therefore an idempotent no-op for them and for a healthy observer. */
|
|
1131
|
+
startLiveCodexObserver(localThreadId) {
|
|
1132
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1133
|
+
if (!live ||
|
|
1134
|
+
live.stopped ||
|
|
1135
|
+
live.observerAvailable ||
|
|
1136
|
+
!live.threadId)
|
|
1137
|
+
return;
|
|
1138
|
+
void (async () => {
|
|
1139
|
+
try {
|
|
1140
|
+
await live.forwarderClient.ensureInitialized();
|
|
1141
|
+
if (live.stopped)
|
|
1142
|
+
return;
|
|
1143
|
+
live.observerAvailable = true;
|
|
1144
|
+
// Omnigent starts subscription as a sibling task after the transport
|
|
1145
|
+
// connects. A no/empty rollout remains event-driven retryable; any
|
|
1146
|
+
// other subscription rejection is diagnostic only and must not tear
|
|
1147
|
+
// down the otherwise usable Terminal/injection lifecycle.
|
|
1148
|
+
void this.subscribeUntilReady(localThreadId, live, live.threadId);
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
|
|
1152
|
+
}
|
|
1153
|
+
})();
|
|
1154
|
+
}
|
|
1155
|
+
/** Omnigent treats the forwarder as a required component of one native
|
|
1156
|
+
* lifecycle: if its transport dies, it closes the app-server instead of
|
|
1157
|
+
* accepting turns that can no longer reach the canonical mirror. */
|
|
1158
|
+
failObserverLifecycle(localThreadId, live, error) {
|
|
1159
|
+
if (live.stopped)
|
|
1160
|
+
return;
|
|
1161
|
+
live.startupError = error;
|
|
1162
|
+
this.liveStartupErrors.set(localThreadId, error);
|
|
1163
|
+
this.teardownLiveCodexSession(localThreadId, error);
|
|
1252
1164
|
}
|
|
1253
1165
|
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1254
1166
|
const pending = live.managedFork;
|
|
@@ -1272,37 +1184,36 @@ export class LocalAgentHost {
|
|
|
1272
1184
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1273
1185
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1274
1186
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1275
|
-
* thread active, then retry.
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1187
|
+
* thread active, then retry. Like Omnigent, the first attempt always uses
|
|
1188
|
+
* `excludeTurns`: a known-session cold resume therefore never reconstructs
|
|
1189
|
+
* historical running/completed state. Only a fresh thread whose first attempt
|
|
1190
|
+
* failed as not-ready retries without `excludeTurns`, backfilling the newly
|
|
1191
|
+
* materialized first turn and de-duplicating it against live notifications.
|
|
1279
1192
|
*/
|
|
1280
|
-
async subscribeUntilReady(live, threadId) {
|
|
1193
|
+
async subscribeUntilReady(localThreadId, live, threadId) {
|
|
1194
|
+
let sawNotReady = false;
|
|
1281
1195
|
while (!live.stopped) {
|
|
1282
1196
|
try {
|
|
1283
1197
|
const resp = await live.forwarderClient.threadResume({
|
|
1284
1198
|
threadId,
|
|
1285
1199
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1286
1200
|
approvalPolicy: live.approvalPolicy,
|
|
1287
|
-
|
|
1288
|
-
limit: 1,
|
|
1289
|
-
sortDirection: "desc",
|
|
1290
|
-
itemsView: "summary",
|
|
1291
|
-
},
|
|
1201
|
+
...(!sawNotReady ? { excludeTurns: true } : {}),
|
|
1292
1202
|
});
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
live.forwarder.reconcileResumeTurns(turns);
|
|
1203
|
+
if (sawNotReady) {
|
|
1204
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1205
|
+
? resp.thread.turns
|
|
1206
|
+
: [];
|
|
1207
|
+
live.forwarder.replayBackfill(turns);
|
|
1208
|
+
}
|
|
1300
1209
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1301
1210
|
}
|
|
1302
1211
|
catch (error) {
|
|
1303
1212
|
if (!isThreadNotReadyError(error)) {
|
|
1304
|
-
|
|
1213
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1214
|
+
return false;
|
|
1305
1215
|
}
|
|
1216
|
+
sawNotReady = true;
|
|
1306
1217
|
// Park until the thread goes active (its first turn materializes the
|
|
1307
1218
|
// rollout); a short poll covers the flush race after "active".
|
|
1308
1219
|
await new Promise((resolve) => {
|
|
@@ -1314,54 +1225,6 @@ export class LocalAgentHost {
|
|
|
1314
1225
|
}
|
|
1315
1226
|
return false;
|
|
1316
1227
|
}
|
|
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
1228
|
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1366
1229
|
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1367
1230
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
@@ -1385,9 +1248,9 @@ export class LocalAgentHost {
|
|
|
1385
1248
|
clearTimeout(timer);
|
|
1386
1249
|
return ok;
|
|
1387
1250
|
}
|
|
1388
|
-
/** Await the
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1251
|
+
/** Await the observer subscription diagnostic. Codex/Traex Terminal startup
|
|
1252
|
+
* deliberately does not gate on this promise: the dedicated preload owns resume,
|
|
1253
|
+
* while the independent observer attaches in the background. */
|
|
1391
1254
|
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1392
1255
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1393
1256
|
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
@@ -1411,8 +1274,24 @@ export class LocalAgentHost {
|
|
|
1411
1274
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1412
1275
|
liveSessionError(localThreadId) {
|
|
1413
1276
|
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1414
|
-
?? this.liveSessions.get(localThreadId)?.startupError
|
|
1277
|
+
?? this.liveSessions.get(localThreadId)?.startupError?.message
|
|
1278
|
+
?? this.liveStartupErrors.get(localThreadId)?.message;
|
|
1279
|
+
}
|
|
1280
|
+
/** Structured phase + concrete cause used by the runner wire protocol. */
|
|
1281
|
+
liveSessionFailure(localThreadId) {
|
|
1282
|
+
const failure = this.liveSessions.get(localThreadId)?.startupError
|
|
1415
1283
|
?? this.liveStartupErrors.get(localThreadId);
|
|
1284
|
+
if (failure) {
|
|
1285
|
+
return {
|
|
1286
|
+
message: failure.message,
|
|
1287
|
+
code: failure.code,
|
|
1288
|
+
statusCode: failure.statusCode,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1292
|
+
return claudeError
|
|
1293
|
+
? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
|
|
1294
|
+
: undefined;
|
|
1416
1295
|
}
|
|
1417
1296
|
/** Publish the background TUI/thread discovery failure so an executor
|
|
1418
1297
|
* already waiting in the 60s bridge window exits immediately with the exact
|
|
@@ -1421,8 +1300,11 @@ export class LocalAgentHost {
|
|
|
1421
1300
|
const live = this.liveSessions.get(localThreadId);
|
|
1422
1301
|
if (!live || live.threadId || live.stopped)
|
|
1423
1302
|
return false;
|
|
1424
|
-
|
|
1425
|
-
|
|
1303
|
+
const failure = error instanceof CodexRuntimeError
|
|
1304
|
+
? error
|
|
1305
|
+
: new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
|
|
1306
|
+
live.startupError = failure;
|
|
1307
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1426
1308
|
live.markStartupFailed();
|
|
1427
1309
|
return true;
|
|
1428
1310
|
}
|
|
@@ -1433,7 +1315,7 @@ export class LocalAgentHost {
|
|
|
1433
1315
|
* resumed from the persisted native id. Serialized per session so two
|
|
1434
1316
|
* injects can't double-open a turn.
|
|
1435
1317
|
*
|
|
1436
|
-
* Returns an {@link
|
|
1318
|
+
* Returns an {@link InjectResult}: `notLive` when this session has no live
|
|
1437
1319
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
1438
1320
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
1439
1321
|
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
@@ -1450,9 +1332,8 @@ export class LocalAgentHost {
|
|
|
1450
1332
|
const pendingInput = {
|
|
1451
1333
|
content,
|
|
1452
1334
|
signature: JSON.stringify(content),
|
|
1453
|
-
state:
|
|
1335
|
+
state: "awaiting",
|
|
1454
1336
|
observed: false,
|
|
1455
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1456
1337
|
};
|
|
1457
1338
|
claude.pendingInjectedInputs.push(pendingInput);
|
|
1458
1339
|
const forgetPendingInput = () => {
|
|
@@ -1466,8 +1347,17 @@ export class LocalAgentHost {
|
|
|
1466
1347
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1467
1348
|
expiry.unref?.();
|
|
1468
1349
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1350
|
+
let result;
|
|
1351
|
+
try {
|
|
1352
|
+
result = await this.injectClaude(claude, localThreadId, text, pendingInput);
|
|
1353
|
+
}
|
|
1354
|
+
catch (error) {
|
|
1355
|
+
forgetPendingInput();
|
|
1356
|
+
if (token)
|
|
1357
|
+
claude.pendingImageInputs.delete(token);
|
|
1358
|
+
throw error;
|
|
1359
|
+
}
|
|
1360
|
+
if (result.outcome !== "injected" && result.outcome !== "steered") {
|
|
1471
1361
|
forgetPendingInput();
|
|
1472
1362
|
if (token)
|
|
1473
1363
|
claude.pendingImageInputs.delete(token);
|
|
@@ -1475,28 +1365,27 @@ export class LocalAgentHost {
|
|
|
1475
1365
|
else if (pendingInput.observed) {
|
|
1476
1366
|
forgetPendingInput();
|
|
1477
1367
|
}
|
|
1478
|
-
return
|
|
1368
|
+
return result;
|
|
1479
1369
|
}
|
|
1480
1370
|
const live = this.liveSessions.get(localThreadId);
|
|
1481
1371
|
if (!live)
|
|
1482
|
-
return "notLive";
|
|
1372
|
+
return { outcome: "notLive" };
|
|
1483
1373
|
const run = live.injectLock.then(async () => {
|
|
1484
1374
|
if (live.rotationPending || live.stopped)
|
|
1485
|
-
return "failed";
|
|
1375
|
+
return { outcome: "failed" };
|
|
1486
1376
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1487
1377
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1488
1378
|
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1489
1379
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1490
1380
|
if (!bound || !threadId)
|
|
1491
|
-
return "notReady";
|
|
1381
|
+
return { outcome: "notReady" };
|
|
1492
1382
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1493
1383
|
const content = runtimeUserContent(runtimeInput);
|
|
1494
1384
|
const pendingInput = {
|
|
1495
1385
|
content,
|
|
1496
1386
|
signature: JSON.stringify(content),
|
|
1497
|
-
state:
|
|
1387
|
+
state: "awaiting",
|
|
1498
1388
|
observed: false,
|
|
1499
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1500
1389
|
};
|
|
1501
1390
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1502
1391
|
let injectionMethod = "turn/start";
|
|
@@ -1505,22 +1394,21 @@ export class LocalAgentHost {
|
|
|
1505
1394
|
if (index >= 0)
|
|
1506
1395
|
live.pendingInjectedInputs.splice(index, 1);
|
|
1507
1396
|
};
|
|
1397
|
+
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1508
1398
|
try {
|
|
1509
|
-
|
|
1399
|
+
await injectionClient.ensureInitialized();
|
|
1400
|
+
// Match Omnigent's executor: each message uses one initialized client
|
|
1401
|
+
// that closes as soon as turn/start or turn/steer is acknowledged.
|
|
1510
1402
|
if (live.forwarder.isTurnOpen()) {
|
|
1511
1403
|
const turnId = live.forwarder.currentTurnId();
|
|
1512
1404
|
if (turnId) {
|
|
1513
1405
|
injectionMethod = "turn/steer";
|
|
1514
|
-
const steered = await
|
|
1406
|
+
const steered = await injectionClient.turnSteer({
|
|
1515
1407
|
threadId,
|
|
1516
1408
|
expectedTurnId: turnId,
|
|
1517
1409
|
input: nativeInput,
|
|
1518
1410
|
});
|
|
1519
|
-
if (pendingInput.
|
|
1520
|
-
// The caller already persisted and published this user input
|
|
1521
|
-
// before waiting for the native Terminal to become ready.
|
|
1522
|
-
}
|
|
1523
|
-
else if (pendingInput.observed) {
|
|
1411
|
+
if (pendingInput.observed) {
|
|
1524
1412
|
forgetPendingInput();
|
|
1525
1413
|
}
|
|
1526
1414
|
else {
|
|
@@ -1528,15 +1416,12 @@ export class LocalAgentHost {
|
|
|
1528
1416
|
pendingInput.state = "optimistic";
|
|
1529
1417
|
}
|
|
1530
1418
|
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1531
|
-
|
|
1532
|
-
this.armObserverReconnectDeadline(live);
|
|
1533
|
-
}
|
|
1534
|
-
return "steered";
|
|
1419
|
+
return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
|
|
1535
1420
|
}
|
|
1536
1421
|
}
|
|
1537
1422
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
1538
1423
|
// agent's model even if the TUI's config default differs.
|
|
1539
|
-
const started = await
|
|
1424
|
+
const started = await injectionClient.turnStart({
|
|
1540
1425
|
threadId,
|
|
1541
1426
|
input: nativeInput,
|
|
1542
1427
|
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
@@ -1544,11 +1429,7 @@ export class LocalAgentHost {
|
|
|
1544
1429
|
...(live.model ? { model: live.model } : {}),
|
|
1545
1430
|
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1546
1431
|
});
|
|
1547
|
-
if (pendingInput.
|
|
1548
|
-
// The caller already persisted and published this user input before
|
|
1549
|
-
// waiting for the native Terminal to become ready.
|
|
1550
|
-
}
|
|
1551
|
-
else if (pendingInput.observed) {
|
|
1432
|
+
if (pendingInput.observed) {
|
|
1552
1433
|
forgetPendingInput();
|
|
1553
1434
|
}
|
|
1554
1435
|
else {
|
|
@@ -1559,10 +1440,7 @@ export class LocalAgentHost {
|
|
|
1559
1440
|
// Do not wait for the independent observer connection's `turn/started`:
|
|
1560
1441
|
// a second message accepted in that window must steer, not double-start.
|
|
1561
1442
|
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1562
|
-
|
|
1563
|
-
this.armObserverReconnectDeadline(live);
|
|
1564
|
-
}
|
|
1565
|
-
return "injected";
|
|
1443
|
+
return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
|
|
1566
1444
|
}
|
|
1567
1445
|
catch (error) {
|
|
1568
1446
|
forgetPendingInput();
|
|
@@ -1572,7 +1450,12 @@ export class LocalAgentHost {
|
|
|
1572
1450
|
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1573
1451
|
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1574
1452
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1575
|
-
throw
|
|
1453
|
+
throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
|
|
1454
|
+
}
|
|
1455
|
+
finally {
|
|
1456
|
+
await injectionClient.stop().catch((stopError) => {
|
|
1457
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1458
|
+
});
|
|
1576
1459
|
}
|
|
1577
1460
|
});
|
|
1578
1461
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1623,27 +1506,35 @@ export class LocalAgentHost {
|
|
|
1623
1506
|
if (!threadId)
|
|
1624
1507
|
return pendingMcp.length > 0;
|
|
1625
1508
|
let handled = pendingMcp.length > 0;
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1509
|
+
const interruptClient = this.injectionClientFactory(live.appServerUrl);
|
|
1510
|
+
try {
|
|
1511
|
+
await interruptClient.ensureInitialized();
|
|
1512
|
+
if (pendingMcp.length > 0) {
|
|
1513
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
1514
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
1515
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
1516
|
+
try {
|
|
1517
|
+
await interruptClient.turnInterrupt({ threadId, turnId: "" });
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
1521
|
+
}
|
|
1632
1522
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1523
|
+
if (turnId) {
|
|
1524
|
+
try {
|
|
1525
|
+
await interruptClient.turnInterrupt({ threadId, turnId });
|
|
1526
|
+
live.publishInterrupted(`resp_codex_${turnId}`);
|
|
1527
|
+
handled = true;
|
|
1528
|
+
}
|
|
1529
|
+
catch {
|
|
1530
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
1531
|
+
}
|
|
1635
1532
|
}
|
|
1533
|
+
return handled;
|
|
1636
1534
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
}
|
|
1535
|
+
finally {
|
|
1536
|
+
await interruptClient.stop().catch(() => undefined);
|
|
1645
1537
|
}
|
|
1646
|
-
return handled;
|
|
1647
1538
|
}
|
|
1648
1539
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
1649
1540
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
@@ -1670,30 +1561,44 @@ export class LocalAgentHost {
|
|
|
1670
1561
|
this.liveSessions.delete(localThreadId);
|
|
1671
1562
|
live.stopped = true;
|
|
1672
1563
|
if (!live.threadId) {
|
|
1673
|
-
live.startupError ??= "
|
|
1564
|
+
live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
|
|
1674
1565
|
live.markStartupFailed();
|
|
1675
1566
|
}
|
|
1676
|
-
this.clearObserverReconnectDeadline(live);
|
|
1677
1567
|
live.releaseActive?.();
|
|
1678
|
-
live.
|
|
1568
|
+
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1679
1569
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1680
|
-
live.
|
|
1570
|
+
live.appServerOwner.setInteractionListener(null);
|
|
1681
1571
|
live.forwarderClient.setInteractionListener(null);
|
|
1682
|
-
live.
|
|
1572
|
+
live.appServerOwner.setConnectionListener(null);
|
|
1683
1573
|
live.forwarderClient.setConnectionListener(null);
|
|
1684
|
-
live.interactionOwners.clear();
|
|
1685
|
-
live.interactionStandbys.clear();
|
|
1686
|
-
live.interactionSubmissions.clear();
|
|
1687
1574
|
live.forwarder.stop();
|
|
1688
|
-
live.interactionRecoveries.clear();
|
|
1689
|
-
live.interactionFailedClients.clear();
|
|
1690
|
-
live.disconnectedClients.clear();
|
|
1691
1575
|
live.canonicalInteractions.clear();
|
|
1692
1576
|
live.settledInteractions.clear();
|
|
1693
1577
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
1694
1578
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
1695
1579
|
void live.skillsCleanup?.();
|
|
1696
1580
|
}
|
|
1581
|
+
/** Tear down one codex-lineage native runtime without deleting its durable
|
|
1582
|
+
* session-store binding. Omnigent couples its auxiliary Terminal, observer,
|
|
1583
|
+
* forwarder and per-session app-server as one disposable runtime envelope;
|
|
1584
|
+
* the next message recreates that envelope and cold-resumes the native id. */
|
|
1585
|
+
teardownLiveCodexSession(localThreadId, error) {
|
|
1586
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1587
|
+
if (!live)
|
|
1588
|
+
return false;
|
|
1589
|
+
const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
|
|
1590
|
+
const backendKey = codexBackendKey(live.runtime, live.execution.budget ?? undefined);
|
|
1591
|
+
const backend = this.backends.get(backendKey);
|
|
1592
|
+
const appServerOwner = live.appServerOwner;
|
|
1593
|
+
this.stopLiveCodexSession(localThreadId);
|
|
1594
|
+
if (backend?.appServerClient === appServerOwner) {
|
|
1595
|
+
this.backends.delete(backendKey);
|
|
1596
|
+
}
|
|
1597
|
+
void appServerOwner.stop().catch((stopError) => {
|
|
1598
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1599
|
+
});
|
|
1600
|
+
return turnFailed;
|
|
1601
|
+
}
|
|
1697
1602
|
/** Complete the second shutdown phase after the runner has killed all native
|
|
1698
1603
|
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1699
1604
|
finalizeStoppedLiveSessions() {
|
|
@@ -1915,8 +1820,7 @@ export class LocalAgentHost {
|
|
|
1915
1820
|
};
|
|
1916
1821
|
const startNormalizer = (turnId) => {
|
|
1917
1822
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1918
|
-
const responseId =
|
|
1919
|
-
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
1823
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1920
1824
|
if (normalizer && currentResponseId === responseId)
|
|
1921
1825
|
return normalizer;
|
|
1922
1826
|
currentResponseId = responseId;
|
|
@@ -1961,6 +1865,8 @@ export class LocalAgentHost {
|
|
|
1961
1865
|
injectLock: Promise.resolve(),
|
|
1962
1866
|
pendingImageInputs: new Map(),
|
|
1963
1867
|
pendingInjectedInputs: [],
|
|
1868
|
+
currentResponseId: () => currentResponseId,
|
|
1869
|
+
publishInterrupted: () => undefined,
|
|
1964
1870
|
ready,
|
|
1965
1871
|
markReady,
|
|
1966
1872
|
failed,
|
|
@@ -1972,6 +1878,55 @@ export class LocalAgentHost {
|
|
|
1972
1878
|
...(forkIntent ? { forkIntent } : {}),
|
|
1973
1879
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
1974
1880
|
};
|
|
1881
|
+
live.publishInterrupted = (responseId) => {
|
|
1882
|
+
if (live.interruptedResponseId === responseId)
|
|
1883
|
+
return;
|
|
1884
|
+
live.interruptedResponseId = responseId;
|
|
1885
|
+
emitCurrent({
|
|
1886
|
+
type: "session.interrupted",
|
|
1887
|
+
sessionId: currentSessionId,
|
|
1888
|
+
responseId,
|
|
1889
|
+
});
|
|
1890
|
+
};
|
|
1891
|
+
const settleClaudeTurn = (interrupted, usage) => {
|
|
1892
|
+
if (!normalizer)
|
|
1893
|
+
return;
|
|
1894
|
+
const rid = currentResponseId;
|
|
1895
|
+
if (interrupted) {
|
|
1896
|
+
for (const se of normalizer.interrupt()) {
|
|
1897
|
+
if (se.type === "session.interrupted" &&
|
|
1898
|
+
live.interruptedResponseId === rid)
|
|
1899
|
+
continue;
|
|
1900
|
+
emitCurrent(se);
|
|
1901
|
+
}
|
|
1902
|
+
live.interruptedResponseId = undefined;
|
|
1903
|
+
}
|
|
1904
|
+
else {
|
|
1905
|
+
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
1906
|
+
if (usage) {
|
|
1907
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1908
|
+
emitCurrent(se);
|
|
1909
|
+
}
|
|
1910
|
+
for (const se of normalizer.next({ type: "done" }))
|
|
1911
|
+
emitCurrent(se);
|
|
1912
|
+
}
|
|
1913
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1914
|
+
normalizer = null;
|
|
1915
|
+
currentResponseId = undefined;
|
|
1916
|
+
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1917
|
+
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1918
|
+
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1919
|
+
if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1920
|
+
live.contextWarned = true;
|
|
1921
|
+
emitCurrent({
|
|
1922
|
+
type: "session.status",
|
|
1923
|
+
sessionId: currentSessionId,
|
|
1924
|
+
responseId: rid,
|
|
1925
|
+
status: "idle",
|
|
1926
|
+
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1975
1930
|
const sink = {
|
|
1976
1931
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1977
1932
|
onUserMessage: (text) => {
|
|
@@ -1983,12 +1938,13 @@ export class LocalAgentHost {
|
|
|
1983
1938
|
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1984
1939
|
const signature = JSON.stringify(normalizedContent);
|
|
1985
1940
|
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1986
|
-
if (pending
|
|
1941
|
+
if (pending) {
|
|
1942
|
+
pending.responseId = currentResponseId;
|
|
1987
1943
|
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
if (pending)
|
|
1944
|
+
if (pending.state === "optimistic")
|
|
1945
|
+
return;
|
|
1991
1946
|
pending.observed = true;
|
|
1947
|
+
}
|
|
1992
1948
|
for (const se of n.userInput(normalizedContent))
|
|
1993
1949
|
emitCurrent(se);
|
|
1994
1950
|
},
|
|
@@ -2015,33 +1971,12 @@ export class LocalAgentHost {
|
|
|
2015
1971
|
...(blockedOn ? { note: blockedOn } : {}),
|
|
2016
1972
|
});
|
|
2017
1973
|
},
|
|
2018
|
-
onTurnEnd: (usage) =>
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
2025
|
-
emitCurrent(se);
|
|
2026
|
-
}
|
|
2027
|
-
for (const se of normalizer.next({ type: "done" }))
|
|
2028
|
-
emitCurrent(se);
|
|
2029
|
-
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2030
|
-
normalizer = null;
|
|
2031
|
-
currentResponseId = undefined;
|
|
2032
|
-
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
2033
|
-
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
2034
|
-
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
2035
|
-
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
2036
|
-
live.contextWarned = true;
|
|
2037
|
-
emitCurrent({
|
|
2038
|
-
type: "session.status",
|
|
2039
|
-
sessionId: currentSessionId,
|
|
2040
|
-
responseId: rid,
|
|
2041
|
-
status: "idle",
|
|
2042
|
-
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
2043
|
-
});
|
|
2044
|
-
}
|
|
1974
|
+
onTurnEnd: (usage) => settleClaudeTurn(false, usage),
|
|
1975
|
+
onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
|
|
1976
|
+
onTurnInterruptRequested: () => {
|
|
1977
|
+
const responseId = currentResponseId;
|
|
1978
|
+
if (responseId)
|
|
1979
|
+
live.publishInterrupted(responseId);
|
|
2045
1980
|
},
|
|
2046
1981
|
onIdle: () => {
|
|
2047
1982
|
// Surface idle on the current turn WITHOUT finalizing it (see the sink's
|
|
@@ -2176,17 +2111,18 @@ export class LocalAgentHost {
|
|
|
2176
2111
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
2177
2112
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
2178
2113
|
* appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
|
|
2179
|
-
* fall-through-to-run signal. Returns {@link
|
|
2180
|
-
injectClaude(live, localThreadId, text) {
|
|
2114
|
+
* fall-through-to-run signal. Returns {@link InjectResult}. */
|
|
2115
|
+
injectClaude(live, localThreadId, text, pendingInput) {
|
|
2181
2116
|
const run = live.injectLock.then(async () => {
|
|
2182
2117
|
const ready = await this.waitLiveReady(localThreadId, 60_000);
|
|
2183
2118
|
if (!ready)
|
|
2184
|
-
return "notReady";
|
|
2119
|
+
return { outcome: "notReady" };
|
|
2185
2120
|
// Pane may have just relaunched — park until its injector re-attaches
|
|
2186
2121
|
// (attachTerminalInjector resets it) instead of returning false → fallback.
|
|
2187
2122
|
const injector = await this.waitInjector(live, 60_000);
|
|
2188
2123
|
if (!injector)
|
|
2189
|
-
return "notReady";
|
|
2124
|
+
return { outcome: "notReady" };
|
|
2125
|
+
const steered = live.forwarder.isTurnOpen();
|
|
2190
2126
|
// Abortable: the web Stop button cancels an in-flight paste/submit (before
|
|
2191
2127
|
// the message reaches claude) via interruptLive → injectAbort.abort().
|
|
2192
2128
|
const abort = new AbortController();
|
|
@@ -2197,11 +2133,18 @@ export class LocalAgentHost {
|
|
|
2197
2133
|
signal: abort.signal,
|
|
2198
2134
|
submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
|
|
2199
2135
|
});
|
|
2200
|
-
|
|
2136
|
+
if (!ok)
|
|
2137
|
+
return { outcome: "failed" };
|
|
2138
|
+
const responseId = pendingInput.responseId ?? live.currentResponseId();
|
|
2139
|
+
if (!responseId) {
|
|
2140
|
+
live.error = "Claude accepted the message but did not publish its native Turn identity";
|
|
2141
|
+
return { outcome: "failed" };
|
|
2142
|
+
}
|
|
2143
|
+
return { outcome: steered ? "steered" : "injected", responseId };
|
|
2201
2144
|
}
|
|
2202
2145
|
catch (error) {
|
|
2203
2146
|
live.error = error instanceof Error ? error.message : String(error);
|
|
2204
|
-
return "failed";
|
|
2147
|
+
return { outcome: "failed" };
|
|
2205
2148
|
}
|
|
2206
2149
|
finally {
|
|
2207
2150
|
if (live.injectAbort === abort)
|
|
@@ -2242,10 +2185,7 @@ export class LocalAgentHost {
|
|
|
2242
2185
|
const codex = this.liveSessions.get(localThreadId);
|
|
2243
2186
|
if (!codex)
|
|
2244
2187
|
return false;
|
|
2245
|
-
|
|
2246
|
-
if (failed)
|
|
2247
|
-
this.clearObserverReconnectDeadline(codex);
|
|
2248
|
-
return failed;
|
|
2188
|
+
return codex.forwarder.failOpenTurn(error);
|
|
2249
2189
|
}
|
|
2250
2190
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
2251
2191
|
async listModels(runtime) {
|
|
@@ -2275,7 +2215,7 @@ export class LocalAgentHost {
|
|
|
2275
2215
|
return { ok: false, reason: "unsupported" };
|
|
2276
2216
|
}
|
|
2277
2217
|
try {
|
|
2278
|
-
return { ok: true, data: await fn(live.
|
|
2218
|
+
return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
|
|
2279
2219
|
}
|
|
2280
2220
|
catch (error) {
|
|
2281
2221
|
return {
|
|
@@ -2347,7 +2287,7 @@ export class LocalAgentHost {
|
|
|
2347
2287
|
return { ok: true, data: undefined };
|
|
2348
2288
|
}
|
|
2349
2289
|
const runtime = execution.provider;
|
|
2350
|
-
const client = live?.
|
|
2290
|
+
const client = live?.appServerOwner ??
|
|
2351
2291
|
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2352
2292
|
if (!client) {
|
|
2353
2293
|
return { ok: false, reason: "unsupported" };
|
|
@@ -2523,10 +2463,13 @@ export function isThreadNotReadyError(error) {
|
|
|
2523
2463
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2524
2464
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
2525
2465
|
}
|
|
2526
|
-
/**
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
2529
|
-
|
|
2530
|
-
const
|
|
2531
|
-
|
|
2466
|
+
/** Build the user-facing native lifecycle failure once, at the phase that owns
|
|
2467
|
+
* it. Downstream layers preserve this code/message verbatim instead of replacing
|
|
2468
|
+
* it with a generic "failed to start; see logs" wrapper. */
|
|
2469
|
+
function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
|
|
2470
|
+
const provider = runtime === "traex" ? "Traex" : "Codex";
|
|
2471
|
+
const detail = cause === undefined
|
|
2472
|
+
? ""
|
|
2473
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
2474
|
+
return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
|
|
2532
2475
|
}
|