@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-integration.d.ts +35 -7
- package/dist/claude/native-integration.js +204 -32
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/forwarder.d.ts +66 -2
- package/dist/codex-app-server/forwarder.js +329 -11
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/protocol.d.ts +8 -5
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +28 -7
- package/dist/host.js +356 -92
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +82 -3
- package/dist/runner/child.d.ts +22 -6
- package/dist/runner/child.js +237 -30
- package/dist/runner/manager.d.ts +87 -4
- package/dist/runner/manager.js +610 -56
- package/dist/runner/protocol.d.ts +11 -15
- package/dist/runner/startup-policy.d.ts +4 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/tmux.d.ts +29 -2
- package/dist/terminal/tmux.js +122 -14
- package/package.json +4 -3
package/dist/host.js
CHANGED
|
@@ -15,6 +15,7 @@ import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server
|
|
|
15
15
|
import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
|
|
16
16
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
17
17
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
18
|
+
import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
|
|
18
19
|
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
19
20
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
20
21
|
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
@@ -181,6 +182,8 @@ export class SpawnCodexCommandRunner {
|
|
|
181
182
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
182
183
|
* signal is the lever consumers surface to the user. */
|
|
183
184
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
185
|
+
/** Wait for native bridge/thread binding before injection gives up. */
|
|
186
|
+
const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
|
|
184
187
|
function providerPluginSkillName(namespace, name) {
|
|
185
188
|
const candidate = `${namespace}-${name}`;
|
|
186
189
|
if (candidate.length <= 128)
|
|
@@ -244,6 +247,7 @@ export class LocalAgentHost {
|
|
|
244
247
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
245
248
|
// the backend's app-server; tests inject a fake.
|
|
246
249
|
forwarderClientFactory;
|
|
250
|
+
observerReconnectTimeoutMs;
|
|
247
251
|
// Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
|
|
248
252
|
// a `${runtime}::${retryHash}` composite for a per-agent retry budget.
|
|
249
253
|
backends = new Map();
|
|
@@ -263,6 +267,10 @@ export class LocalAgentHost {
|
|
|
263
267
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
264
268
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
265
269
|
liveClaudeSessions = new Map();
|
|
270
|
+
/** Exact pre-live startup failure, retained after the partial handle has been
|
|
271
|
+
* cleaned up so Core/plugin callers receive the failed phase, not a generic
|
|
272
|
+
* `live session unavailable` wrapper. */
|
|
273
|
+
liveStartupErrors = new Map();
|
|
266
274
|
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
267
275
|
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
268
276
|
pendingClaudeFinalizers = new Set();
|
|
@@ -273,17 +281,18 @@ export class LocalAgentHost {
|
|
|
273
281
|
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
274
282
|
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
275
283
|
managedForkThreadStarts = new Map();
|
|
276
|
-
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, runtimeHomeSessionId, }) {
|
|
284
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, observerReconnectTimeoutMs = 15_000, sessionId, runtimeHomeSessionId, }) {
|
|
277
285
|
this.config = config;
|
|
278
286
|
this.sessionId = sessionId ?? "__default__";
|
|
279
287
|
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
280
288
|
this.sessionStore = sessionStore;
|
|
281
289
|
this.allowedRoots = allowedRoots;
|
|
282
|
-
this.defaultRuntime = config.
|
|
290
|
+
this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
|
|
283
291
|
this.injectedCommandRunner = commandRunner;
|
|
284
292
|
this.injectedAppServerClient = appServerClient;
|
|
285
293
|
this.clock = now;
|
|
286
294
|
this.backendIdleTtlMs = backendIdleTtlMs;
|
|
295
|
+
this.observerReconnectTimeoutMs = observerReconnectTimeoutMs;
|
|
287
296
|
this.forwarderClientFactory =
|
|
288
297
|
forwarderClientFactory ??
|
|
289
298
|
((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
|
|
@@ -472,6 +481,9 @@ export class LocalAgentHost {
|
|
|
472
481
|
// to the same effective sandbox.
|
|
473
482
|
const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
|
|
474
483
|
const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
|
|
484
|
+
const traexYolo = runtime === "traex" &&
|
|
485
|
+
sandbox === "danger-full-access" &&
|
|
486
|
+
approvalPolicy === "never";
|
|
475
487
|
const configOverrides = sandbox === "workspace-write"
|
|
476
488
|
? ["sandbox_workspace_write.network_access=true"]
|
|
477
489
|
: [];
|
|
@@ -500,9 +512,11 @@ export class LocalAgentHost {
|
|
|
500
512
|
remoteUrl,
|
|
501
513
|
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
502
514
|
configOverrides,
|
|
515
|
+
codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
|
|
503
516
|
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
504
517
|
}),
|
|
505
518
|
cwd: live.workspace.cwd,
|
|
519
|
+
...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
|
|
506
520
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
507
521
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
508
522
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -558,6 +572,7 @@ export class LocalAgentHost {
|
|
|
558
572
|
}
|
|
559
573
|
}
|
|
560
574
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
575
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
561
576
|
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
562
577
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
563
578
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
@@ -581,6 +596,7 @@ export class LocalAgentHost {
|
|
|
581
596
|
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
582
597
|
}
|
|
583
598
|
catch (err) {
|
|
599
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill preparation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
584
600
|
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
585
601
|
return false;
|
|
586
602
|
}
|
|
@@ -593,6 +609,7 @@ export class LocalAgentHost {
|
|
|
593
609
|
}
|
|
594
610
|
catch (err) {
|
|
595
611
|
void snapshotSkills.skillsCleanup();
|
|
612
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
596
613
|
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
597
614
|
return false;
|
|
598
615
|
}
|
|
@@ -612,6 +629,7 @@ export class LocalAgentHost {
|
|
|
612
629
|
};
|
|
613
630
|
const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
614
631
|
if (!injectClient) {
|
|
632
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server is unavailable for this Session`);
|
|
615
633
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
616
634
|
return abandon();
|
|
617
635
|
}
|
|
@@ -619,11 +637,13 @@ export class LocalAgentHost {
|
|
|
619
637
|
await injectClient.ensureInitialized();
|
|
620
638
|
}
|
|
621
639
|
catch (err) {
|
|
640
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server initialization failed during its 10s readiness phase: ${err instanceof Error ? err.message : String(err)}`);
|
|
622
641
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
623
642
|
return abandon();
|
|
624
643
|
}
|
|
625
644
|
const appServerUrl = injectClient.terminalRemoteUrl();
|
|
626
645
|
if (!appServerUrl) {
|
|
646
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server started without a Terminal remote endpoint`);
|
|
627
647
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
628
648
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
629
649
|
}
|
|
@@ -635,15 +655,20 @@ export class LocalAgentHost {
|
|
|
635
655
|
await forwarderClient.ensureInitialized();
|
|
636
656
|
}
|
|
637
657
|
catch (err) {
|
|
658
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} observer could not attach to the ready app-server: ${err instanceof Error ? err.message : String(err)}`);
|
|
638
659
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
639
660
|
return abandon();
|
|
640
661
|
}
|
|
641
|
-
const model = execution.model;
|
|
662
|
+
const model = execution.model ?? "";
|
|
642
663
|
const reasoningEffort = execution.reasoningEffort ?? undefined;
|
|
643
664
|
let markReady;
|
|
644
665
|
const ready = new Promise((resolve) => {
|
|
645
666
|
markReady = resolve;
|
|
646
667
|
});
|
|
668
|
+
let markStartupFailed;
|
|
669
|
+
const startupFailed = new Promise((resolve) => {
|
|
670
|
+
markStartupFailed = resolve;
|
|
671
|
+
});
|
|
647
672
|
let markTerminalReady;
|
|
648
673
|
const terminalReady = new Promise((resolve) => {
|
|
649
674
|
markTerminalReady = resolve;
|
|
@@ -663,6 +688,8 @@ export class LocalAgentHost {
|
|
|
663
688
|
threadId: record?.codexSessionId ?? null,
|
|
664
689
|
ready,
|
|
665
690
|
markReady,
|
|
691
|
+
startupFailed,
|
|
692
|
+
markStartupFailed,
|
|
666
693
|
terminalReady,
|
|
667
694
|
markTerminalReady,
|
|
668
695
|
injectLock: Promise.resolve(),
|
|
@@ -671,6 +698,7 @@ export class LocalAgentHost {
|
|
|
671
698
|
subscribing: false,
|
|
672
699
|
rotationPending: false,
|
|
673
700
|
stopped: false,
|
|
701
|
+
observerAvailable: true,
|
|
674
702
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
675
703
|
interactionOwners: new Map(),
|
|
676
704
|
interactionStandbys: new Map(),
|
|
@@ -704,9 +732,8 @@ export class LocalAgentHost {
|
|
|
704
732
|
// `turn/started` land on ONE response instead of splitting into random
|
|
705
733
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
706
734
|
responseId,
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
// label, so report the runtime when the exact model is unknown.
|
|
735
|
+
// A null Session model intentionally leaves selection to the Provider
|
|
736
|
+
// CLI. Canonical events still require a printable label.
|
|
710
737
|
model: model || runtime,
|
|
711
738
|
});
|
|
712
739
|
return normalizer;
|
|
@@ -901,10 +928,19 @@ export class LocalAgentHost {
|
|
|
901
928
|
});
|
|
902
929
|
client.setConnectionListener((state) => {
|
|
903
930
|
if (state === "connected") {
|
|
904
|
-
|
|
931
|
+
// Observer transport recovery alone does not prove that an in-flight
|
|
932
|
+
// interaction request was replayed onto this connection. Its request
|
|
933
|
+
// listener clears disconnectedClients when that duplicate arrives.
|
|
934
|
+
if (client !== live.forwarderClient)
|
|
935
|
+
live.disconnectedClients.delete(client);
|
|
905
936
|
return;
|
|
906
937
|
}
|
|
907
938
|
live.disconnectedClients.add(client);
|
|
939
|
+
if (client === live.forwarderClient && !live.stopped) {
|
|
940
|
+
live.observerAvailable = false;
|
|
941
|
+
this.armObserverReconnectDeadline(live);
|
|
942
|
+
void this.reconnectForwarder(live);
|
|
943
|
+
}
|
|
908
944
|
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
909
945
|
if (allInteractionClientsUnavailable(interactionId)) {
|
|
910
946
|
finishRecovery(interactionId, recovery.event);
|
|
@@ -919,8 +955,59 @@ export class LocalAgentHost {
|
|
|
919
955
|
}
|
|
920
956
|
live.canonicalInteractions.clear();
|
|
921
957
|
};
|
|
958
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
959
|
+
closeCanonicalInteractions();
|
|
960
|
+
if (!normalizer)
|
|
961
|
+
return;
|
|
962
|
+
const completedResponseId = currentResponseId;
|
|
963
|
+
if (usage) {
|
|
964
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
965
|
+
emitCurrent(se);
|
|
966
|
+
}
|
|
967
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
968
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
969
|
+
continue;
|
|
970
|
+
emitCurrent(se);
|
|
971
|
+
}
|
|
972
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
973
|
+
normalizer = null;
|
|
974
|
+
currentResponseId = null;
|
|
975
|
+
};
|
|
976
|
+
const failCurrentTurn = (error) => {
|
|
977
|
+
closeCanonicalInteractions();
|
|
978
|
+
if (!normalizer)
|
|
979
|
+
return;
|
|
980
|
+
const failedResponseId = currentResponseId;
|
|
981
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
982
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
983
|
+
const message = responseStopped
|
|
984
|
+
? "Response stopped"
|
|
985
|
+
: runtime === "traex"
|
|
986
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
987
|
+
: error.message;
|
|
988
|
+
for (const se of normalizer.fail({
|
|
989
|
+
code: responseStopped
|
|
990
|
+
? "response_stopped"
|
|
991
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
992
|
+
message,
|
|
993
|
+
source: "execution",
|
|
994
|
+
})) {
|
|
995
|
+
emitCurrent(se.type === "session.status"
|
|
996
|
+
? { ...se, status: "failed", note: message }
|
|
997
|
+
: se);
|
|
998
|
+
}
|
|
999
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
1000
|
+
normalizer = null;
|
|
1001
|
+
currentResponseId = null;
|
|
1002
|
+
};
|
|
922
1003
|
const sink = {
|
|
923
1004
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1005
|
+
onTurnObserved: (turnId) => {
|
|
1006
|
+
const n = startNormalizer(turnId);
|
|
1007
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
1008
|
+
emitCurrent(event);
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
924
1011
|
onUserMessage: (content) => {
|
|
925
1012
|
const normalizedContent = typeof content === "string"
|
|
926
1013
|
? [{ type: "input_text", text: content }]
|
|
@@ -942,45 +1029,40 @@ export class LocalAgentHost {
|
|
|
942
1029
|
for (const se of n.next(event))
|
|
943
1030
|
emitCurrent(se);
|
|
944
1031
|
},
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
clearPendingInputsForResponse(completedResponseId);
|
|
957
|
-
normalizer = null;
|
|
958
|
-
currentResponseId = null;
|
|
1032
|
+
onStatus: (note, statusKind) => {
|
|
1033
|
+
const responseId = currentResponseId ??
|
|
1034
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
1035
|
+
emitCurrent({
|
|
1036
|
+
type: "session.status",
|
|
1037
|
+
sessionId: currentSessionId,
|
|
1038
|
+
...(responseId ? { responseId } : {}),
|
|
1039
|
+
status: "running",
|
|
1040
|
+
...(note ? { note } : {}),
|
|
1041
|
+
...(statusKind ? { statusKind } : {}),
|
|
1042
|
+
});
|
|
959
1043
|
},
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1044
|
+
// A newer authoritative turn/started completes the old canonical
|
|
1045
|
+
// response without publishing a false idle edge in a running→running
|
|
1046
|
+
// transition.
|
|
1047
|
+
onTurnEnd: completeCurrentTurn,
|
|
1048
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1049
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1050
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
1051
|
+
if (status === "failed")
|
|
1052
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
1053
|
+
else
|
|
1054
|
+
completeCurrentTurn();
|
|
963
1055
|
return;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
?
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
for (const se of normalizer.fail({
|
|
973
|
-
code: responseStopped
|
|
974
|
-
? "response_stopped"
|
|
975
|
-
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
976
|
-
message,
|
|
977
|
-
source: "execution",
|
|
978
|
-
}))
|
|
979
|
-
emitCurrent(se);
|
|
980
|
-
clearPendingInputsForResponse(failedResponseId);
|
|
981
|
-
normalizer = null;
|
|
982
|
-
currentResponseId = null;
|
|
1056
|
+
}
|
|
1057
|
+
emitCurrent({
|
|
1058
|
+
type: "session.status",
|
|
1059
|
+
sessionId: currentSessionId,
|
|
1060
|
+
...(responseId ? { responseId } : {}),
|
|
1061
|
+
status,
|
|
1062
|
+
...(error ? { note: error.message } : {}),
|
|
1063
|
+
});
|
|
983
1064
|
},
|
|
1065
|
+
onTurnError: failCurrentTurn,
|
|
984
1066
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
985
1067
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
986
1068
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
@@ -995,6 +1077,7 @@ export class LocalAgentHost {
|
|
|
995
1077
|
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
996
1078
|
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
997
1079
|
surfaceQueueStatus: runtime === "traex",
|
|
1080
|
+
mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
|
|
998
1081
|
});
|
|
999
1082
|
live.forwarder = forwarder;
|
|
1000
1083
|
live.onNativeThreadRotated = (threadId, kind) => {
|
|
@@ -1068,7 +1151,15 @@ export class LocalAgentHost {
|
|
|
1068
1151
|
threadId: record.codexSessionId,
|
|
1069
1152
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1070
1153
|
approvalPolicy,
|
|
1154
|
+
// The injection connection only needs to bind/subcribe. Codex
|
|
1155
|
+
// 0.146 can ignore initialTurnsPage and return the whole rollout,
|
|
1156
|
+
// so explicitly suppress history here; it is never replayed.
|
|
1071
1157
|
excludeTurns: true,
|
|
1158
|
+
initialTurnsPage: {
|
|
1159
|
+
limit: 1,
|
|
1160
|
+
sortDirection: "desc",
|
|
1161
|
+
itemsView: "summary",
|
|
1162
|
+
},
|
|
1072
1163
|
});
|
|
1073
1164
|
resumedThreadId = resumed.threadId;
|
|
1074
1165
|
break;
|
|
@@ -1083,11 +1174,25 @@ export class LocalAgentHost {
|
|
|
1083
1174
|
}
|
|
1084
1175
|
}
|
|
1085
1176
|
if (resumedThreadId) {
|
|
1177
|
+
forwarder.noteThreadBound(resumedThreadId);
|
|
1086
1178
|
this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
|
|
1087
1179
|
}
|
|
1180
|
+
else if (resumeError &&
|
|
1181
|
+
isThreadNotReadyError(resumeError) &&
|
|
1182
|
+
!record.parentSessionId &&
|
|
1183
|
+
!record.runtimeHomeOwnerSessionId) {
|
|
1184
|
+
// A native thread that still has no rollout after the bounded retry
|
|
1185
|
+
// window has never materialized a Turn. It is safe to forget this
|
|
1186
|
+
// ordinary binding and let the TUI create a fresh native thread.
|
|
1187
|
+
// Fork bindings stay strict because their Provider-native ancestry
|
|
1188
|
+
// must not be silently replaced.
|
|
1189
|
+
await this.sessionStore.delete(localThreadId);
|
|
1190
|
+
live.threadId = null;
|
|
1191
|
+
live.markTerminalReady(true);
|
|
1192
|
+
}
|
|
1088
1193
|
else {
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1194
|
+
// A delayed/missing native index or a fork binding remains explicit:
|
|
1195
|
+
// neither is proof that replacing Provider history is safe.
|
|
1091
1196
|
throw resumeError;
|
|
1092
1197
|
}
|
|
1093
1198
|
}
|
|
@@ -1103,9 +1208,11 @@ export class LocalAgentHost {
|
|
|
1103
1208
|
live.stopped = true;
|
|
1104
1209
|
forwarder.stop();
|
|
1105
1210
|
void forwarderClient.stop().catch(() => undefined);
|
|
1211
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} native thread binding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1106
1212
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1107
1213
|
return abandon();
|
|
1108
1214
|
}
|
|
1215
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1109
1216
|
return true;
|
|
1110
1217
|
}
|
|
1111
1218
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
@@ -1165,30 +1272,37 @@ export class LocalAgentHost {
|
|
|
1165
1272
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1166
1273
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1167
1274
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1168
|
-
* thread active, then retry
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1275
|
+
* thread active, then retry. Resume only fetches the newest summarized Turn:
|
|
1276
|
+
* recovery reconciles the exact active Turn or publishes the newest explicit
|
|
1277
|
+
* terminal status without replaying historical items. Once resume succeeds,
|
|
1278
|
+
* subsequent turns arrive live.
|
|
1171
1279
|
*/
|
|
1172
1280
|
async subscribeUntilReady(live, threadId) {
|
|
1173
|
-
let sawNotReady = false;
|
|
1174
1281
|
while (!live.stopped) {
|
|
1175
1282
|
try {
|
|
1176
1283
|
const resp = await live.forwarderClient.threadResume({
|
|
1177
1284
|
threadId,
|
|
1178
1285
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1179
1286
|
approvalPolicy: live.approvalPolicy,
|
|
1180
|
-
|
|
1287
|
+
initialTurnsPage: {
|
|
1288
|
+
limit: 1,
|
|
1289
|
+
sortDirection: "desc",
|
|
1290
|
+
itemsView: "summary",
|
|
1291
|
+
},
|
|
1181
1292
|
});
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1293
|
+
// Codex 0.146.1 accepts initialTurnsPage but can still return the entire
|
|
1294
|
+
// rollout in chronological order. An identified active turn is matched
|
|
1295
|
+
// by id; the no-active fallback reads only the final explicit status.
|
|
1296
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1297
|
+
? resp.thread.turns
|
|
1298
|
+
: [];
|
|
1299
|
+
live.forwarder.reconcileResumeTurns(turns);
|
|
1185
1300
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1186
1301
|
}
|
|
1187
1302
|
catch (error) {
|
|
1188
1303
|
if (!isThreadNotReadyError(error)) {
|
|
1189
1304
|
return false; // other failure — injection still works via the backend client
|
|
1190
1305
|
}
|
|
1191
|
-
sawNotReady = true;
|
|
1192
1306
|
// Park until the thread goes active (its first turn materializes the
|
|
1193
1307
|
// rollout); a short poll covers the flush race after "active".
|
|
1194
1308
|
await new Promise((resolve) => {
|
|
@@ -1200,7 +1314,56 @@ export class LocalAgentHost {
|
|
|
1200
1314
|
}
|
|
1201
1315
|
return false;
|
|
1202
1316
|
}
|
|
1203
|
-
/**
|
|
1317
|
+
/** Restore the independent observer after an unexpected exit. The active turn
|
|
1318
|
+
* remains open during the bounded grace so resume can reconcile its exact id. */
|
|
1319
|
+
reconnectForwarder(live) {
|
|
1320
|
+
if (live.observerReconnect)
|
|
1321
|
+
return live.observerReconnect;
|
|
1322
|
+
const reconnect = (async () => {
|
|
1323
|
+
while (!live.stopped && live.threadId) {
|
|
1324
|
+
try {
|
|
1325
|
+
await live.forwarderClient.ensureInitialized();
|
|
1326
|
+
if (await this.subscribeUntilReady(live, live.threadId)) {
|
|
1327
|
+
live.observerAvailable = true;
|
|
1328
|
+
this.clearObserverReconnectDeadline(live);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
catch {
|
|
1333
|
+
// Retry below. Injection uses its independent client and remains usable.
|
|
1334
|
+
}
|
|
1335
|
+
await new Promise((resolve) => {
|
|
1336
|
+
const timer = setTimeout(resolve, 250);
|
|
1337
|
+
timer.unref?.();
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
})();
|
|
1341
|
+
const settled = reconnect.finally(() => {
|
|
1342
|
+
if (live.observerReconnect === settled)
|
|
1343
|
+
live.observerReconnect = undefined;
|
|
1344
|
+
});
|
|
1345
|
+
live.observerReconnect = settled;
|
|
1346
|
+
return settled;
|
|
1347
|
+
}
|
|
1348
|
+
armObserverReconnectDeadline(live) {
|
|
1349
|
+
if (live.observerReconnectTimer || !live.forwarder.isTurnOpen())
|
|
1350
|
+
return;
|
|
1351
|
+
live.observerReconnectTimer = setTimeout(() => {
|
|
1352
|
+
live.observerReconnectTimer = undefined;
|
|
1353
|
+
if (live.stopped)
|
|
1354
|
+
return;
|
|
1355
|
+
const provider = live.runtime === "traex" ? "Traex" : "Codex";
|
|
1356
|
+
live.forwarder.failOpenTurn(new Error(`${provider} observer did not reconnect within ${this.observerReconnectTimeoutMs}ms`));
|
|
1357
|
+
}, this.observerReconnectTimeoutMs);
|
|
1358
|
+
live.observerReconnectTimer.unref?.();
|
|
1359
|
+
}
|
|
1360
|
+
clearObserverReconnectDeadline(live) {
|
|
1361
|
+
if (live.observerReconnectTimer)
|
|
1362
|
+
clearTimeout(live.observerReconnectTimer);
|
|
1363
|
+
live.observerReconnectTimer = undefined;
|
|
1364
|
+
}
|
|
1365
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1366
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1204
1367
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1205
1368
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1206
1369
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
@@ -1209,11 +1372,15 @@ export class LocalAgentHost {
|
|
|
1209
1372
|
const live = this.liveSessions.get(localThreadId);
|
|
1210
1373
|
if (!live)
|
|
1211
1374
|
return false;
|
|
1375
|
+
const ready = live.ready.then(() => true);
|
|
1376
|
+
const failed = live.startupFailed.then(() => false);
|
|
1377
|
+
if (timeoutMs === null)
|
|
1378
|
+
return Promise.race([ready, failed]);
|
|
1212
1379
|
let timer;
|
|
1213
1380
|
const timeout = new Promise((resolve) => {
|
|
1214
1381
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1215
1382
|
});
|
|
1216
|
-
const ok = await Promise.race([
|
|
1383
|
+
const ok = await Promise.race([ready, failed, timeout]);
|
|
1217
1384
|
if (timer)
|
|
1218
1385
|
clearTimeout(timer);
|
|
1219
1386
|
return ok;
|
|
@@ -1230,6 +1397,8 @@ export class LocalAgentHost {
|
|
|
1230
1397
|
const live = this.liveSessions.get(localThreadId);
|
|
1231
1398
|
if (!live)
|
|
1232
1399
|
return false;
|
|
1400
|
+
if (timeoutMs === null)
|
|
1401
|
+
return live.terminalReady;
|
|
1233
1402
|
let timer;
|
|
1234
1403
|
const timeout = new Promise((resolve) => {
|
|
1235
1404
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1241,7 +1410,21 @@ export class LocalAgentHost {
|
|
|
1241
1410
|
}
|
|
1242
1411
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1243
1412
|
liveSessionError(localThreadId) {
|
|
1244
|
-
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1413
|
+
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1414
|
+
?? this.liveSessions.get(localThreadId)?.startupError
|
|
1415
|
+
?? this.liveStartupErrors.get(localThreadId);
|
|
1416
|
+
}
|
|
1417
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
1418
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
1419
|
+
* 30s discovery cause. */
|
|
1420
|
+
failLiveStartup(localThreadId, error) {
|
|
1421
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1422
|
+
if (!live || live.threadId || live.stopped)
|
|
1423
|
+
return false;
|
|
1424
|
+
live.startupError = error.message;
|
|
1425
|
+
this.liveStartupErrors.set(localThreadId, error.message);
|
|
1426
|
+
live.markStartupFailed();
|
|
1427
|
+
return true;
|
|
1245
1428
|
}
|
|
1246
1429
|
/**
|
|
1247
1430
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -1302,7 +1485,7 @@ export class LocalAgentHost {
|
|
|
1302
1485
|
return "failed";
|
|
1303
1486
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1304
1487
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1305
|
-
const bound = await this.waitLiveReady(localThreadId,
|
|
1488
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1306
1489
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1307
1490
|
if (!bound || !threadId)
|
|
1308
1491
|
return "notReady";
|
|
@@ -1316,6 +1499,7 @@ export class LocalAgentHost {
|
|
|
1316
1499
|
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1317
1500
|
};
|
|
1318
1501
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1502
|
+
let injectionMethod = "turn/start";
|
|
1319
1503
|
const forgetPendingInput = () => {
|
|
1320
1504
|
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1321
1505
|
if (index >= 0)
|
|
@@ -1326,6 +1510,7 @@ export class LocalAgentHost {
|
|
|
1326
1510
|
if (live.forwarder.isTurnOpen()) {
|
|
1327
1511
|
const turnId = live.forwarder.currentTurnId();
|
|
1328
1512
|
if (turnId) {
|
|
1513
|
+
injectionMethod = "turn/steer";
|
|
1329
1514
|
const steered = await live.injectClient.turnSteer({
|
|
1330
1515
|
threadId,
|
|
1331
1516
|
expectedTurnId: turnId,
|
|
@@ -1342,7 +1527,11 @@ export class LocalAgentHost {
|
|
|
1342
1527
|
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1343
1528
|
pendingInput.state = "optimistic";
|
|
1344
1529
|
}
|
|
1345
|
-
|
|
1530
|
+
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1531
|
+
if (!live.observerAvailable) {
|
|
1532
|
+
this.armObserverReconnectDeadline(live);
|
|
1533
|
+
}
|
|
1534
|
+
return "steered";
|
|
1346
1535
|
}
|
|
1347
1536
|
}
|
|
1348
1537
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
@@ -1366,13 +1555,22 @@ export class LocalAgentHost {
|
|
|
1366
1555
|
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1367
1556
|
pendingInput.state = "optimistic";
|
|
1368
1557
|
}
|
|
1558
|
+
// The injection RPC and active-turn write are one serialized operation.
|
|
1559
|
+
// Do not wait for the independent observer connection's `turn/started`:
|
|
1560
|
+
// a second message accepted in that window must steer, not double-start.
|
|
1561
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1562
|
+
if (!live.observerAvailable) {
|
|
1563
|
+
this.armObserverReconnectDeadline(live);
|
|
1564
|
+
}
|
|
1369
1565
|
return "injected";
|
|
1370
1566
|
}
|
|
1371
1567
|
catch (error) {
|
|
1372
1568
|
forgetPendingInput();
|
|
1373
1569
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1374
1570
|
// into the unactionable `live injection failed` string.
|
|
1375
|
-
const
|
|
1571
|
+
const baseDetail = codexRpcError(error, injectionMethod);
|
|
1572
|
+
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1573
|
+
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1376
1574
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1377
1575
|
throw new Error(detail, { cause: error });
|
|
1378
1576
|
}
|
|
@@ -1417,21 +1615,35 @@ export class LocalAgentHost {
|
|
|
1417
1615
|
const live = this.liveSessions.get(localThreadId);
|
|
1418
1616
|
if (!live)
|
|
1419
1617
|
return false;
|
|
1420
|
-
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
1421
|
-
// reference implementation no-ops (204) when no turn is active.
|
|
1422
|
-
if (!live.forwarder.isTurnOpen())
|
|
1423
|
-
return false;
|
|
1424
1618
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
return
|
|
1619
|
+
const pendingMcp = live.forwarder.cancelMcpStartup();
|
|
1620
|
+
const turnId = live.forwarder.isTurnOpen()
|
|
1621
|
+
? live.forwarder.currentTurnId()
|
|
1622
|
+
: null;
|
|
1623
|
+
if (!threadId)
|
|
1624
|
+
return pendingMcp.length > 0;
|
|
1625
|
+
let handled = pendingMcp.length > 0;
|
|
1626
|
+
if (pendingMcp.length > 0) {
|
|
1627
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
1628
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
1629
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
1630
|
+
try {
|
|
1631
|
+
await live.injectClient.turnInterrupt({ threadId, turnId: "" });
|
|
1632
|
+
}
|
|
1633
|
+
catch (error) {
|
|
1634
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
1635
|
+
}
|
|
1431
1636
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1637
|
+
if (turnId) {
|
|
1638
|
+
try {
|
|
1639
|
+
await live.injectClient.turnInterrupt({ threadId, turnId });
|
|
1640
|
+
handled = true;
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
1644
|
+
}
|
|
1434
1645
|
}
|
|
1646
|
+
return handled;
|
|
1435
1647
|
}
|
|
1436
1648
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
1437
1649
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
@@ -1457,6 +1669,11 @@ export class LocalAgentHost {
|
|
|
1457
1669
|
return;
|
|
1458
1670
|
this.liveSessions.delete(localThreadId);
|
|
1459
1671
|
live.stopped = true;
|
|
1672
|
+
if (!live.threadId) {
|
|
1673
|
+
live.startupError ??= "native Session stopped before thread discovery completed";
|
|
1674
|
+
live.markStartupFailed();
|
|
1675
|
+
}
|
|
1676
|
+
this.clearObserverReconnectDeadline(live);
|
|
1460
1677
|
live.releaseActive?.();
|
|
1461
1678
|
live.injectClient.cancelInteractions("session_stopped");
|
|
1462
1679
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
@@ -1522,7 +1739,7 @@ export class LocalAgentHost {
|
|
|
1522
1739
|
});
|
|
1523
1740
|
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1524
1741
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1525
|
-
// The live session carries the
|
|
1742
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1526
1743
|
const args = buildClaudeTuiArgs({
|
|
1527
1744
|
settingsJson: settingsPath,
|
|
1528
1745
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1530,7 +1747,10 @@ export class LocalAgentHost {
|
|
|
1530
1747
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1531
1748
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1532
1749
|
settingSources: "",
|
|
1533
|
-
model: live.execution.model,
|
|
1750
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
1751
|
+
...(live.execution.reasoningEffort
|
|
1752
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
1753
|
+
: {}),
|
|
1534
1754
|
...(live.execution.instructions
|
|
1535
1755
|
? { appendSystemPrompt: live.execution.instructions }
|
|
1536
1756
|
: {}),
|
|
@@ -1633,8 +1853,16 @@ export class LocalAgentHost {
|
|
|
1633
1853
|
throw failedPluginSkill.reason;
|
|
1634
1854
|
}
|
|
1635
1855
|
const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1856
|
+
const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
|
|
1857
|
+
const selectedNames = new Set();
|
|
1858
|
+
for (const skill of selectedSkills) {
|
|
1859
|
+
if (selectedNames.has(skill.name)) {
|
|
1860
|
+
throw new Error(`duplicate final Session skill name: ${skill.name}`);
|
|
1861
|
+
}
|
|
1862
|
+
selectedNames.add(skill.name);
|
|
1863
|
+
}
|
|
1636
1864
|
return {
|
|
1637
|
-
selectedSkills
|
|
1865
|
+
selectedSkills,
|
|
1638
1866
|
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1639
1867
|
};
|
|
1640
1868
|
}
|
|
@@ -1664,7 +1892,7 @@ export class LocalAgentHost {
|
|
|
1664
1892
|
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1665
1893
|
return false;
|
|
1666
1894
|
}
|
|
1667
|
-
const model = execution.model;
|
|
1895
|
+
const model = execution.model ?? "";
|
|
1668
1896
|
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1669
1897
|
void snapshotSkills.skillsCleanup();
|
|
1670
1898
|
const permissionMode = execution.permissionMode;
|
|
@@ -1695,7 +1923,7 @@ export class LocalAgentHost {
|
|
|
1695
1923
|
normalizer = new SessionNormalizer({
|
|
1696
1924
|
sessionId: currentSessionId,
|
|
1697
1925
|
responseId,
|
|
1698
|
-
model,
|
|
1926
|
+
model: model || "claude",
|
|
1699
1927
|
});
|
|
1700
1928
|
return normalizer;
|
|
1701
1929
|
};
|
|
@@ -1776,6 +2004,17 @@ export class LocalAgentHost {
|
|
|
1776
2004
|
emitCurrent(se);
|
|
1777
2005
|
},
|
|
1778
2006
|
onInteraction: forwardInteraction,
|
|
2007
|
+
onStatus: (status, blockedOn) => {
|
|
2008
|
+
const responseId = currentResponseId ??
|
|
2009
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
2010
|
+
emitCurrent({
|
|
2011
|
+
type: "session.status",
|
|
2012
|
+
sessionId: currentSessionId,
|
|
2013
|
+
...(responseId ? { responseId } : {}),
|
|
2014
|
+
status,
|
|
2015
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
2016
|
+
});
|
|
2017
|
+
},
|
|
1779
2018
|
onTurnEnd: (usage) => {
|
|
1780
2019
|
if (!normalizer)
|
|
1781
2020
|
return;
|
|
@@ -1816,16 +2055,20 @@ export class LocalAgentHost {
|
|
|
1816
2055
|
});
|
|
1817
2056
|
}
|
|
1818
2057
|
},
|
|
1819
|
-
onTurnError: () => {
|
|
2058
|
+
onTurnError: (error) => {
|
|
1820
2059
|
if (!normalizer)
|
|
1821
2060
|
return;
|
|
1822
2061
|
const rid = currentResponseId;
|
|
2062
|
+
const message = error.message || "Agent turn failed";
|
|
1823
2063
|
for (const se of normalizer.fail({
|
|
1824
2064
|
code: "agent_error",
|
|
1825
|
-
message
|
|
2065
|
+
message,
|
|
1826
2066
|
source: "execution",
|
|
1827
|
-
}))
|
|
1828
|
-
emitCurrent(se
|
|
2067
|
+
})) {
|
|
2068
|
+
emitCurrent(se.type === "session.status"
|
|
2069
|
+
? { ...se, status: "failed", note: message }
|
|
2070
|
+
: se);
|
|
2071
|
+
}
|
|
1829
2072
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1830
2073
|
normalizer = null;
|
|
1831
2074
|
currentResponseId = undefined;
|
|
@@ -1956,7 +2199,8 @@ export class LocalAgentHost {
|
|
|
1956
2199
|
});
|
|
1957
2200
|
return ok ? "injected" : "failed";
|
|
1958
2201
|
}
|
|
1959
|
-
catch {
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1960
2204
|
return "failed";
|
|
1961
2205
|
}
|
|
1962
2206
|
finally {
|
|
@@ -1983,8 +2227,25 @@ export class LocalAgentHost {
|
|
|
1983
2227
|
* (they inject via the app-server). Idempotent. */
|
|
1984
2228
|
attachTerminalInjector(localThreadId, injector) {
|
|
1985
2229
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1986
|
-
if (live)
|
|
2230
|
+
if (live) {
|
|
2231
|
+
if (live.injector === injector)
|
|
2232
|
+
return;
|
|
1987
2233
|
live.injector = injector;
|
|
2234
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
2238
|
+
failLiveSession(localThreadId, error) {
|
|
2239
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2240
|
+
if (claude)
|
|
2241
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
2242
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2243
|
+
if (!codex)
|
|
2244
|
+
return false;
|
|
2245
|
+
const failed = codex.forwarder.failOpenTurn(error);
|
|
2246
|
+
if (failed)
|
|
2247
|
+
this.clearObserverReconnectDeadline(codex);
|
|
2248
|
+
return failed;
|
|
1988
2249
|
}
|
|
1989
2250
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1990
2251
|
async listModels(runtime) {
|
|
@@ -2113,7 +2374,7 @@ export class LocalAgentHost {
|
|
|
2113
2374
|
threadId: record.codexSessionId,
|
|
2114
2375
|
excludeTurns: true,
|
|
2115
2376
|
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2116
|
-
model: execution.model,
|
|
2377
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2117
2378
|
approvalPolicy: execution.approvalPolicy,
|
|
2118
2379
|
});
|
|
2119
2380
|
}
|
|
@@ -2155,11 +2416,11 @@ export class LocalAgentHost {
|
|
|
2155
2416
|
}
|
|
2156
2417
|
}
|
|
2157
2418
|
}
|
|
2158
|
-
function
|
|
2419
|
+
function codexRpcError(error, method) {
|
|
2159
2420
|
if (error instanceof CodexTransportError) {
|
|
2160
|
-
return `Codex app-server rejected
|
|
2421
|
+
return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
|
|
2161
2422
|
}
|
|
2162
|
-
return `Codex
|
|
2423
|
+
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2163
2424
|
}
|
|
2164
2425
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2165
2426
|
const normalized = output.trim();
|
|
@@ -2184,15 +2445,18 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
2184
2445
|
issues: normalized ? [normalized] : [],
|
|
2185
2446
|
};
|
|
2186
2447
|
}
|
|
2187
|
-
/** Race a live session's `ready` promise against failure
|
|
2448
|
+
/** Race a live session's `ready` promise against failure and, when supplied,
|
|
2449
|
+
* a timeout. */
|
|
2188
2450
|
function raceReady(ready, timeoutMs, failed) {
|
|
2189
2451
|
let timer;
|
|
2190
|
-
const
|
|
2191
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2192
|
-
});
|
|
2193
|
-
const outcomes = [ready.then(() => true), timeout];
|
|
2452
|
+
const outcomes = [ready.then(() => true)];
|
|
2194
2453
|
if (failed)
|
|
2195
2454
|
outcomes.push(failed.then(() => false));
|
|
2455
|
+
if (timeoutMs !== null) {
|
|
2456
|
+
outcomes.push(new Promise((resolve) => {
|
|
2457
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2458
|
+
}));
|
|
2459
|
+
}
|
|
2196
2460
|
return Promise.race(outcomes).finally(() => {
|
|
2197
2461
|
if (timer)
|
|
2198
2462
|
clearTimeout(timer);
|