@rynx-ai/runtime 0.1.11-beta.24 → 0.1.11-beta.26
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-bridge.d.ts +2 -0
- package/dist/claude/native-bridge.js +23 -0
- package/dist/claude/native-hook-main.js +62 -0
- package/dist/claude/native-integration.d.ts +8 -3
- package/dist/claude/native-integration.js +38 -8
- package/dist/claude/transcript.js +27 -17
- package/dist/codex-app-server/client.js +11 -1
- package/dist/codex-app-server/forwarder.d.ts +25 -1
- package/dist/codex-app-server/forwarder.js +216 -55
- package/dist/codex-app-server/mapping.d.ts +0 -6
- package/dist/codex-app-server/mapping.js +70 -5
- package/dist/codex-app-server/protocol.d.ts +35 -2
- package/dist/host.d.ts +15 -7
- package/dist/host.js +517 -50
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/runner/child.d.ts +13 -13
- package/dist/runner/child.js +183 -13
- package/dist/runner/manager.d.ts +12 -4
- package/dist/runner/manager.js +204 -20
- package/dist/runner/protocol.d.ts +47 -1
- package/dist/runner/protocol.js +5 -0
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +5 -5
- package/dist/terminal/tmux.js +6 -6
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createCodexChildEnv } from "./codex-child-env.js";
|
|
|
11
11
|
import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
|
|
12
12
|
import { materializeClaudePlugin, reuseClaudePlugin, } from "./claude/executor.js";
|
|
13
13
|
import { listClaudeModels } from "./claude/models.js";
|
|
14
|
+
import { validateInteractionResolution } from "./interactions.js";
|
|
14
15
|
import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
|
|
15
16
|
import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
|
|
16
17
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
@@ -20,7 +21,7 @@ import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
|
20
21
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
21
22
|
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
22
23
|
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
23
|
-
import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
|
|
24
|
+
import { claudeAttachmentToken, claudeInputText, codexUserEchoContent, runtimeUserContent, } from "./input-resources.js";
|
|
24
25
|
import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
25
26
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
26
27
|
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
@@ -192,6 +193,85 @@ function providerPluginSkillName(namespace, name) {
|
|
|
192
193
|
return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
|
|
193
194
|
}
|
|
194
195
|
const MAX_CODEX_SETTLED_INTERACTIONS = 512;
|
|
196
|
+
const CODEX_PLAN_IMPLEMENTATION_YES = "Yes, implement this plan";
|
|
197
|
+
const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT = "Yes, clear context and implement";
|
|
198
|
+
const CODEX_PLAN_IMPLEMENTATION_NO = "No, stay in Plan mode";
|
|
199
|
+
const CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE = "Implement the plan.";
|
|
200
|
+
const CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS = 1_500;
|
|
201
|
+
const CODEX_PLAN_PROMPT_DISMISS_POLL_MS = 50;
|
|
202
|
+
const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = "A previous agent produced the plan below to accomplish the user's task. " +
|
|
203
|
+
"Implement the plan in a fresh context. Treat the plan as the source of " +
|
|
204
|
+
"user intent, re-read files as needed, and carry the work through " +
|
|
205
|
+
"implementation and verification.";
|
|
206
|
+
function codexPlanImplementationRequest(interactionId) {
|
|
207
|
+
return {
|
|
208
|
+
interactionId,
|
|
209
|
+
kind: "question",
|
|
210
|
+
title: "Plan",
|
|
211
|
+
fields: [{
|
|
212
|
+
id: "plan_implementation",
|
|
213
|
+
type: "select",
|
|
214
|
+
label: "Implement this plan?",
|
|
215
|
+
required: true,
|
|
216
|
+
options: [
|
|
217
|
+
{
|
|
218
|
+
value: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
219
|
+
label: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
220
|
+
description: "Switch to Default and start coding.",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
value: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
224
|
+
label: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
225
|
+
description: "Fresh thread with this plan.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
value: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
229
|
+
label: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
230
|
+
description: "Continue planning with the model.",
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
}],
|
|
234
|
+
actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
|
|
235
|
+
createdAt: Date.now(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function codexPlanImplementationPromptVisible(pane) {
|
|
239
|
+
return pane.includes("Implement this plan?") &&
|
|
240
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_YES) &&
|
|
241
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_NO);
|
|
242
|
+
}
|
|
243
|
+
/** Codex's final Plan picker is owned by the TUI and is not an app-server
|
|
244
|
+
* request that Web can resolve. Once the synthetic Web form takes ownership,
|
|
245
|
+
* close only that exact picker with its documented Escape action. The owner
|
|
246
|
+
* predicate is checked both before and after capture: app-server notifications
|
|
247
|
+
* cannot interleave between the two synchronous tmux calls, so a newly observed
|
|
248
|
+
* Turn cannot receive a blind Escape. This is deliberately best-effort — a
|
|
249
|
+
* cosmetic Terminal redraw failure must not make an otherwise valid Web Plan
|
|
250
|
+
* decision fail when the structured app-server path can still proceed. */
|
|
251
|
+
async function dismissCodexPlanImplementationPrompt(injector, ownsPrompt) {
|
|
252
|
+
if (!injector || !ownsPrompt())
|
|
253
|
+
return "not_visible";
|
|
254
|
+
try {
|
|
255
|
+
const pane = injector.capturePane();
|
|
256
|
+
if (!ownsPrompt())
|
|
257
|
+
return "superseded";
|
|
258
|
+
if (!codexPlanImplementationPromptVisible(pane))
|
|
259
|
+
return "not_visible";
|
|
260
|
+
injector.interrupt();
|
|
261
|
+
const deadline = Date.now() + CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS;
|
|
262
|
+
while (ownsPrompt()) {
|
|
263
|
+
if (!codexPlanImplementationPromptVisible(injector.capturePane()))
|
|
264
|
+
return "dismissed";
|
|
265
|
+
if (Date.now() >= deadline)
|
|
266
|
+
return "failed";
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, CODEX_PLAN_PROMPT_DISMISS_POLL_MS));
|
|
268
|
+
}
|
|
269
|
+
return "superseded";
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return "failed";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
195
275
|
/**
|
|
196
276
|
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
197
277
|
* when the budget declares no retry or the runtime is claude. claude applies
|
|
@@ -247,7 +327,7 @@ export class LocalAgentHost {
|
|
|
247
327
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
248
328
|
// the backend's app-server; tests inject a fake.
|
|
249
329
|
forwarderClientFactory;
|
|
250
|
-
// Builds
|
|
330
|
+
// Builds reference implementation's short-lived cold-resume preload connection. This must be
|
|
251
331
|
// distinct from both the app-server owner/injector and the background
|
|
252
332
|
// observer: the owner's initialize handshake is the readiness probe, then a
|
|
253
333
|
// freshly initialized connection performs the one resume and closes.
|
|
@@ -418,8 +498,51 @@ export class LocalAgentHost {
|
|
|
418
498
|
if (live.settledInteractions.has(interactionId)) {
|
|
419
499
|
return { disposition: "already_resolved" };
|
|
420
500
|
}
|
|
501
|
+
const synthetic = live.syntheticInteractions.get(interactionId);
|
|
502
|
+
if (synthetic)
|
|
503
|
+
return synthetic.resolve(resolution);
|
|
421
504
|
return live.forwarderClient.resolveInteraction(interactionId, resolution);
|
|
422
505
|
}
|
|
506
|
+
/** Apply collaboration mode to an already-loaded Codex-lineage thread. The
|
|
507
|
+
* caller persists only after this native RPC succeeds. */
|
|
508
|
+
async updateCollaborationMode(localThreadId, mode) {
|
|
509
|
+
const live = this.liveSessions.get(localThreadId);
|
|
510
|
+
if (!live || live.stopped || live.rotationPending) {
|
|
511
|
+
throw new CodexRuntimeError("Native Session is not live", 409, "native_session_not_live");
|
|
512
|
+
}
|
|
513
|
+
const run = live.injectLock.then(async () => {
|
|
514
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
515
|
+
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
516
|
+
if (!bound || !threadId) {
|
|
517
|
+
throw new CodexRuntimeError("Native thread is not ready", 503, "native_thread_not_ready");
|
|
518
|
+
}
|
|
519
|
+
await this.applyLiveCollaborationMode(live, threadId, mode);
|
|
520
|
+
});
|
|
521
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
522
|
+
await run;
|
|
523
|
+
}
|
|
524
|
+
/** Apply one explicit collaboration mode to the current native thread and
|
|
525
|
+
* remember only a successful RPC. A supplied client is already initialized
|
|
526
|
+
* and remains owned by its caller (used by cold-resume preload). */
|
|
527
|
+
async applyLiveCollaborationMode(live, threadId, mode, suppliedClient) {
|
|
528
|
+
if (live.appliedCollaborationMode === mode)
|
|
529
|
+
return;
|
|
530
|
+
const client = suppliedClient ?? this.injectionClientFactory(live.appServerUrl);
|
|
531
|
+
try {
|
|
532
|
+
if (!suppliedClient)
|
|
533
|
+
await client.ensureInitialized();
|
|
534
|
+
await client.threadSettingsUpdate({
|
|
535
|
+
threadId,
|
|
536
|
+
collaborationMode: await buildCollaborationMode(client, mode, live.model, live.reasoningEffort),
|
|
537
|
+
});
|
|
538
|
+
live.appliedCollaborationMode = mode;
|
|
539
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
if (!suppliedClient)
|
|
543
|
+
await client.stop().catch(() => undefined);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
423
546
|
/**
|
|
424
547
|
* The command to run in a session's live terminal so it co-drives the codex
|
|
425
548
|
* app-server thread (Phase D). Returns `null` when live-terminal is off, the
|
|
@@ -634,7 +757,7 @@ export class LocalAgentHost {
|
|
|
634
757
|
// notifications (the backend client injects; this one only observes).
|
|
635
758
|
// Fresh sessions must connect this listener before the TUI launches so its
|
|
636
759
|
// one-shot `thread/started` cannot race discovery. A known-thread cold resume
|
|
637
|
-
// already has its durable id: like
|
|
760
|
+
// already has its durable id: like reference implementation's `_codex_forward_known_thread`,
|
|
638
761
|
// its observer starts in the background only after the replacement TUI has
|
|
639
762
|
// launched and is never part of the resume admission boundary.
|
|
640
763
|
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
@@ -677,6 +800,7 @@ export class LocalAgentHost {
|
|
|
677
800
|
reasoningEffort,
|
|
678
801
|
appliedModel: model,
|
|
679
802
|
appliedReasoningEffort: reasoningEffort,
|
|
803
|
+
appliedCollaborationMode: undefined,
|
|
680
804
|
...(execution.instructions ? { instructions: execution.instructions } : {}),
|
|
681
805
|
threadId: record?.codexSessionId ?? null,
|
|
682
806
|
ready,
|
|
@@ -699,6 +823,8 @@ export class LocalAgentHost {
|
|
|
699
823
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
700
824
|
canonicalInteractions: new Set(),
|
|
701
825
|
settledInteractions: new Set(),
|
|
826
|
+
syntheticInteractions: new Map(),
|
|
827
|
+
cancelSyntheticInteractions: () => undefined,
|
|
702
828
|
};
|
|
703
829
|
let currentSessionId = localThreadId;
|
|
704
830
|
let pendingRotationEvents = null;
|
|
@@ -721,6 +847,9 @@ export class LocalAgentHost {
|
|
|
721
847
|
};
|
|
722
848
|
let normalizer = null;
|
|
723
849
|
let currentResponseId = null;
|
|
850
|
+
/** State for content-only events, keyed by Provider response so consecutive
|
|
851
|
+
* late deltas retain one canonical item id without owning lifecycle. */
|
|
852
|
+
const contentNormalizers = new Map();
|
|
724
853
|
const startNormalizer = (turnId) => {
|
|
725
854
|
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
726
855
|
if (normalizer && currentResponseId === responseId)
|
|
@@ -766,6 +895,40 @@ export class LocalAgentHost {
|
|
|
766
895
|
return;
|
|
767
896
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
768
897
|
};
|
|
898
|
+
const turnContentEvents = (turnId, produce) => {
|
|
899
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
900
|
+
let contentNormalizer = contentNormalizers.get(responseId);
|
|
901
|
+
if (!contentNormalizer) {
|
|
902
|
+
contentNormalizer = new SessionNormalizer({
|
|
903
|
+
sessionId: currentSessionId,
|
|
904
|
+
responseId,
|
|
905
|
+
model: live.model || live.runtime,
|
|
906
|
+
});
|
|
907
|
+
contentNormalizers.set(responseId, contentNormalizer);
|
|
908
|
+
}
|
|
909
|
+
for (const event of produce(contentNormalizer)) {
|
|
910
|
+
// Omnigent posts item/transient content under the Turn's response id
|
|
911
|
+
// independently of lifecycle status. Reuse Rynx's canonical item
|
|
912
|
+
// normalization but suppress its synthetic lifecycle edges.
|
|
913
|
+
if (event.type !== "response.created" && event.type !== "session.status") {
|
|
914
|
+
emitCurrent(event);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
const observedUserContent = (content) => {
|
|
919
|
+
const normalizedContent = typeof content === "string"
|
|
920
|
+
? [{ type: "input_text", text: content }]
|
|
921
|
+
: content;
|
|
922
|
+
const signature = JSON.stringify(normalizedContent);
|
|
923
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature || entry.providerEchoSignature === signature);
|
|
924
|
+
if (pending?.state === "optimistic") {
|
|
925
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
if (pending)
|
|
929
|
+
pending.observed = true;
|
|
930
|
+
return normalizedContent;
|
|
931
|
+
};
|
|
769
932
|
const rememberSettledInteraction = (interactionId) => {
|
|
770
933
|
live.settledInteractions.add(interactionId);
|
|
771
934
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -774,11 +937,168 @@ export class LocalAgentHost {
|
|
|
774
937
|
if (oldest)
|
|
775
938
|
live.settledInteractions.delete(oldest);
|
|
776
939
|
};
|
|
940
|
+
const cancelSyntheticInteractions = (reason) => {
|
|
941
|
+
for (const interaction of live.syntheticInteractions.values())
|
|
942
|
+
interaction.cancel(reason);
|
|
943
|
+
};
|
|
944
|
+
live.cancelSyntheticInteractions = cancelSyntheticInteractions;
|
|
945
|
+
const publishPlanImplementationPrompt = (prompt) => {
|
|
946
|
+
const suffix = createHash("sha256")
|
|
947
|
+
.update(`${prompt.threadId}\0${prompt.turnId}`)
|
|
948
|
+
.digest("hex")
|
|
949
|
+
.slice(0, 24);
|
|
950
|
+
const interactionId = `codex_plan_implementation_${suffix}`;
|
|
951
|
+
if (live.syntheticInteractions.has(interactionId) ||
|
|
952
|
+
live.settledInteractions.has(interactionId))
|
|
953
|
+
return;
|
|
954
|
+
const responseId = `resp_codex_plan_approval_${suffix}`;
|
|
955
|
+
const request = codexPlanImplementationRequest(interactionId);
|
|
956
|
+
const interactionNormalizer = new SessionNormalizer({
|
|
957
|
+
sessionId: currentSessionId,
|
|
958
|
+
responseId,
|
|
959
|
+
model: live.model || live.runtime,
|
|
960
|
+
});
|
|
961
|
+
let submitted = false;
|
|
962
|
+
const finish = (kind, resolution, reason) => {
|
|
963
|
+
if (!live.syntheticInteractions.delete(interactionId))
|
|
964
|
+
return;
|
|
965
|
+
rememberSettledInteraction(interactionId);
|
|
966
|
+
const event = kind === "resolved"
|
|
967
|
+
? {
|
|
968
|
+
type: "interaction_resolved",
|
|
969
|
+
interactionId,
|
|
970
|
+
resolution: resolution,
|
|
971
|
+
}
|
|
972
|
+
: {
|
|
973
|
+
type: "interaction_cancelled",
|
|
974
|
+
interactionId,
|
|
975
|
+
...(reason ? { reason } : {}),
|
|
976
|
+
};
|
|
977
|
+
// This interaction belongs to the Session that produced the Plan. A
|
|
978
|
+
// clear-context choice can rotate the native thread before turn/start
|
|
979
|
+
// returns, so never retarget its resolution to the new Session.
|
|
980
|
+
for (const sessionEvent of interactionNormalizer.next(event))
|
|
981
|
+
emit(sessionEvent);
|
|
982
|
+
for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
|
|
983
|
+
emit(sessionEvent);
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
live.syntheticInteractions.set(interactionId, {
|
|
987
|
+
cancel: (reason) => {
|
|
988
|
+
// The selected implementation itself starts a native Turn and the
|
|
989
|
+
// clear-context variant also starts a new thread. Their observer
|
|
990
|
+
// notifications can beat the short-lived RPC response; they are not
|
|
991
|
+
// competing user actions and must not cancel the card being resolved.
|
|
992
|
+
if (submitted &&
|
|
993
|
+
(reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
|
|
994
|
+
return;
|
|
995
|
+
finish("cancelled", undefined, reason);
|
|
996
|
+
},
|
|
997
|
+
resolve: async (resolution) => {
|
|
998
|
+
if (submitted)
|
|
999
|
+
return { disposition: "already_resolved" };
|
|
1000
|
+
const invalid = validateInteractionResolution(request, resolution);
|
|
1001
|
+
if (invalid)
|
|
1002
|
+
return { disposition: "invalid", message: invalid };
|
|
1003
|
+
const choice = resolution.answers?.plan_implementation;
|
|
1004
|
+
if (typeof choice !== "string") {
|
|
1005
|
+
return { disposition: "invalid", message: "plan implementation choice is required" };
|
|
1006
|
+
}
|
|
1007
|
+
submitted = true;
|
|
1008
|
+
const run = live.injectLock.then(async () => {
|
|
1009
|
+
if (live.stopped || live.rotationPending) {
|
|
1010
|
+
throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
|
|
1011
|
+
}
|
|
1012
|
+
const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
|
|
1013
|
+
!live.rotationPending &&
|
|
1014
|
+
live.syntheticInteractions.has(interactionId) &&
|
|
1015
|
+
live.forwarder.threadId() === prompt.threadId &&
|
|
1016
|
+
!live.forwarder.isTurnOpen());
|
|
1017
|
+
if (promptDismissal === "failed") {
|
|
1018
|
+
console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
|
|
1019
|
+
}
|
|
1020
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
|
|
1021
|
+
finish("resolved", resolution);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const client = this.injectionClientFactory(live.appServerUrl);
|
|
1025
|
+
try {
|
|
1026
|
+
await client.ensureInitialized();
|
|
1027
|
+
const collaborationMode = await buildCollaborationMode(client, "default", live.model, live.reasoningEffort);
|
|
1028
|
+
let threadId = prompt.threadId;
|
|
1029
|
+
let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
|
|
1030
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
|
|
1031
|
+
live.nextThreadCollaborationMode = "default";
|
|
1032
|
+
let startedThread;
|
|
1033
|
+
try {
|
|
1034
|
+
startedThread = await client.threadStart({
|
|
1035
|
+
...(live.model ? { model: live.model } : {}),
|
|
1036
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1037
|
+
approvalPolicy: live.approvalPolicy,
|
|
1038
|
+
...(live.instructions ? { developerInstructions: live.instructions } : {}),
|
|
1039
|
+
sessionStartSource: "clear",
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1046
|
+
threadId = startedThread.threadId;
|
|
1047
|
+
text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
|
|
1048
|
+
}
|
|
1049
|
+
else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
|
|
1050
|
+
throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
|
|
1051
|
+
}
|
|
1052
|
+
const started = await client.turnStart({
|
|
1053
|
+
threadId,
|
|
1054
|
+
input: [{ type: "text", text }],
|
|
1055
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1056
|
+
approvalPolicy: live.approvalPolicy,
|
|
1057
|
+
collaborationMode,
|
|
1058
|
+
});
|
|
1059
|
+
live.execution = { ...live.execution, collaborationMode: "default" };
|
|
1060
|
+
live.appliedCollaborationMode = "default";
|
|
1061
|
+
emitCurrent({
|
|
1062
|
+
type: "session.collaboration_mode",
|
|
1063
|
+
sessionId: currentSessionId,
|
|
1064
|
+
mode: "default",
|
|
1065
|
+
});
|
|
1066
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1067
|
+
finish("resolved", resolution);
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
await client.stop().catch(() => undefined);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
1074
|
+
try {
|
|
1075
|
+
await run;
|
|
1076
|
+
return { disposition: "applied" };
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
submitted = false;
|
|
1080
|
+
return {
|
|
1081
|
+
disposition: "invalid",
|
|
1082
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
for (const sessionEvent of interactionNormalizer.next({
|
|
1088
|
+
type: "interaction_requested",
|
|
1089
|
+
interaction: request,
|
|
1090
|
+
}))
|
|
1091
|
+
emit(sessionEvent);
|
|
1092
|
+
};
|
|
777
1093
|
const forwardInteraction = (event) => {
|
|
778
1094
|
const interactionId = event.type === "requested"
|
|
779
1095
|
? event.request.interactionId
|
|
780
1096
|
: event.interactionId;
|
|
781
1097
|
if (event.type === "requested") {
|
|
1098
|
+
if (event.request.fields.some((field) => field.id === "plan_implementation")) {
|
|
1099
|
+
live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
|
|
1100
|
+
cancelSyntheticInteractions("superseded_by_native_plan_prompt");
|
|
1101
|
+
}
|
|
782
1102
|
if (live.canonicalInteractions.has(interactionId) ||
|
|
783
1103
|
live.settledInteractions.has(interactionId))
|
|
784
1104
|
return;
|
|
@@ -896,32 +1216,34 @@ export class LocalAgentHost {
|
|
|
896
1216
|
const sink = {
|
|
897
1217
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
898
1218
|
onTurnObserved: (turnId) => {
|
|
1219
|
+
cancelSyntheticInteractions("superseded_by_native_turn");
|
|
899
1220
|
const n = startNormalizer(turnId);
|
|
900
1221
|
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
901
1222
|
emitCurrent(event);
|
|
902
1223
|
}
|
|
903
1224
|
},
|
|
904
1225
|
onUserMessage: (content) => {
|
|
905
|
-
const normalizedContent =
|
|
906
|
-
|
|
907
|
-
: content;
|
|
908
|
-
const signature = JSON.stringify(normalizedContent);
|
|
909
|
-
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
910
|
-
if (pending?.state === "optimistic") {
|
|
911
|
-
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1226
|
+
const normalizedContent = observedUserContent(content);
|
|
1227
|
+
if (!normalizedContent)
|
|
912
1228
|
return;
|
|
913
|
-
}
|
|
914
|
-
if (pending)
|
|
915
|
-
pending.observed = true;
|
|
916
1229
|
const n = normalizer ?? startNormalizer();
|
|
917
1230
|
for (const se of n.userInput(normalizedContent))
|
|
918
1231
|
emitCurrent(se);
|
|
919
1232
|
},
|
|
1233
|
+
onTurnContentUserMessage: (turnId, content) => {
|
|
1234
|
+
const normalizedContent = observedUserContent(content);
|
|
1235
|
+
if (!normalizedContent)
|
|
1236
|
+
return;
|
|
1237
|
+
turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
|
|
1238
|
+
},
|
|
920
1239
|
onEvent: (event) => {
|
|
921
1240
|
const n = normalizer ?? startNormalizer();
|
|
922
1241
|
for (const se of n.next(event))
|
|
923
1242
|
emitCurrent(se);
|
|
924
1243
|
},
|
|
1244
|
+
onTurnContentEvent: (turnId, event) => {
|
|
1245
|
+
turnContentEvents(turnId, (n) => n.next(event));
|
|
1246
|
+
},
|
|
925
1247
|
onStatus: (note, statusKind) => {
|
|
926
1248
|
const responseId = currentResponseId ??
|
|
927
1249
|
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
@@ -961,6 +1283,16 @@ export class LocalAgentHost {
|
|
|
961
1283
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
962
1284
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
963
1285
|
onThreadActive: () => live.releaseActive?.(),
|
|
1286
|
+
onCollaborationModeChanged: (mode) => {
|
|
1287
|
+
live.appliedCollaborationMode = mode;
|
|
1288
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
1289
|
+
emitCurrent({
|
|
1290
|
+
type: "session.collaboration_mode",
|
|
1291
|
+
sessionId: currentSessionId,
|
|
1292
|
+
mode,
|
|
1293
|
+
});
|
|
1294
|
+
},
|
|
1295
|
+
onPlanImplementationPrompt: publishPlanImplementationPrompt,
|
|
964
1296
|
};
|
|
965
1297
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
966
1298
|
// Traex 0.200 can publish a final reasoning item immediately after
|
|
@@ -979,12 +1311,20 @@ export class LocalAgentHost {
|
|
|
979
1311
|
live.stopped = true;
|
|
980
1312
|
return;
|
|
981
1313
|
}
|
|
1314
|
+
cancelSyntheticInteractions("native_thread_rotated");
|
|
982
1315
|
const previousSessionId = currentSessionId;
|
|
983
1316
|
const newSessionId = makeSessionId();
|
|
1317
|
+
const rotationMode = live.nextThreadCollaborationMode;
|
|
1318
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1319
|
+
live.appliedCollaborationMode = undefined;
|
|
1320
|
+
if (rotationMode) {
|
|
1321
|
+
live.execution = { ...live.execution, collaborationMode: rotationMode };
|
|
1322
|
+
}
|
|
984
1323
|
live.rotationPending = true;
|
|
985
1324
|
currentSessionId = newSessionId;
|
|
986
1325
|
normalizer = null;
|
|
987
1326
|
currentResponseId = null;
|
|
1327
|
+
contentNormalizers.clear();
|
|
988
1328
|
live.pendingInjectedInputs = [];
|
|
989
1329
|
pendingRotationEvents = [];
|
|
990
1330
|
void this.sessionStore
|
|
@@ -1017,6 +1357,14 @@ export class LocalAgentHost {
|
|
|
1017
1357
|
live.rotationPending = false;
|
|
1018
1358
|
for (const event of queued)
|
|
1019
1359
|
emit(event);
|
|
1360
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1361
|
+
if (desiredMode) {
|
|
1362
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1363
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1364
|
+
void restore.catch((error) => {
|
|
1365
|
+
this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1020
1368
|
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1021
1369
|
})
|
|
1022
1370
|
.catch(() => {
|
|
@@ -1035,7 +1383,7 @@ export class LocalAgentHost {
|
|
|
1035
1383
|
// turn-less app-server thread resumable by the TUI.
|
|
1036
1384
|
try {
|
|
1037
1385
|
if (record?.codexSessionId) {
|
|
1038
|
-
// Match
|
|
1386
|
+
// Match reference implementation's cold-resume preload exactly: one history-free
|
|
1039
1387
|
// `thread/resume` on a short-lived, separately initialized connection.
|
|
1040
1388
|
// The backend owner's handshake above is the app-server readiness
|
|
1041
1389
|
// probe; reusing that first connection here races Codex's startup state
|
|
@@ -1052,6 +1400,9 @@ export class LocalAgentHost {
|
|
|
1052
1400
|
approvalPolicy,
|
|
1053
1401
|
excludeTurns: true,
|
|
1054
1402
|
});
|
|
1403
|
+
if (execution.collaborationMode) {
|
|
1404
|
+
await this.applyLiveCollaborationMode(live, resumed.threadId, execution.collaborationMode, preloadClient);
|
|
1405
|
+
}
|
|
1055
1406
|
}
|
|
1056
1407
|
finally {
|
|
1057
1408
|
await preloadClient.stop().catch((error) => {
|
|
@@ -1138,16 +1489,38 @@ export class LocalAgentHost {
|
|
|
1138
1489
|
});
|
|
1139
1490
|
})
|
|
1140
1491
|
.catch(() => undefined);
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1492
|
+
const finishBinding = () => {
|
|
1493
|
+
if (live.stopped)
|
|
1494
|
+
return;
|
|
1495
|
+
live.markReady(); // thread id + explicit Session mode are ready for turns
|
|
1496
|
+
if (live.observerAvailable) {
|
|
1497
|
+
void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
// Known-thread cold resume has already completed the dedicated preload. Its
|
|
1501
|
+
// observer is deliberately post-TUI background work, not Terminal
|
|
1502
|
+
// readiness. RunnerSession calls startLiveCodexObserver after launch.
|
|
1503
|
+
live.markTerminalReady(true);
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1507
|
+
if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
|
|
1508
|
+
finishBinding();
|
|
1509
|
+
return;
|
|
1150
1510
|
}
|
|
1511
|
+
// A fresh thread does not exist until the TUI broadcasts thread/started.
|
|
1512
|
+
// Apply the Session-owned mode immediately at that boundary, before Web
|
|
1513
|
+
// injection is released. This also narrows the window in which a user can
|
|
1514
|
+
// type into the native pane before its explicit startup mode is installed.
|
|
1515
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1516
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1517
|
+
void restore.then(finishBinding).catch((error) => {
|
|
1518
|
+
const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
|
|
1519
|
+
live.startupError = failure;
|
|
1520
|
+
live.markStartupFailed();
|
|
1521
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1522
|
+
this.teardownLiveCodexSession(localThreadId, failure);
|
|
1523
|
+
});
|
|
1151
1524
|
}
|
|
1152
1525
|
/** Start the known-thread observer after the replacement TUI has launched.
|
|
1153
1526
|
* Fresh sessions already connected their discovery listener before launch;
|
|
@@ -1165,7 +1538,7 @@ export class LocalAgentHost {
|
|
|
1165
1538
|
if (live.stopped)
|
|
1166
1539
|
return;
|
|
1167
1540
|
live.observerAvailable = true;
|
|
1168
|
-
//
|
|
1541
|
+
// reference implementation starts subscription as a sibling task after the transport
|
|
1169
1542
|
// connects. A no/empty rollout remains event-driven retryable; any
|
|
1170
1543
|
// other subscription rejection is diagnostic only and must not tear
|
|
1171
1544
|
// down the otherwise usable Terminal/injection lifecycle.
|
|
@@ -1176,7 +1549,7 @@ export class LocalAgentHost {
|
|
|
1176
1549
|
}
|
|
1177
1550
|
})();
|
|
1178
1551
|
}
|
|
1179
|
-
/**
|
|
1552
|
+
/** reference implementation treats the forwarder as a required component of one native
|
|
1180
1553
|
* lifecycle: if its transport dies, it closes the app-server instead of
|
|
1181
1554
|
* accepting turns that can no longer reach the canonical mirror. */
|
|
1182
1555
|
failObserverLifecycle(localThreadId, live, error) {
|
|
@@ -1208,7 +1581,7 @@ export class LocalAgentHost {
|
|
|
1208
1581
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1209
1582
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1210
1583
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1211
|
-
* thread active, then retry. Like
|
|
1584
|
+
* thread active, then retry. Like reference implementation, the first attempt always uses
|
|
1212
1585
|
* `excludeTurns`: a known-session cold resume therefore never reconstructs
|
|
1213
1586
|
* historical running/completed state. Only a fresh thread whose first attempt
|
|
1214
1587
|
* failed as not-ready retries without `excludeTurns`, backfilling the newly
|
|
@@ -1345,7 +1718,7 @@ export class LocalAgentHost {
|
|
|
1345
1718
|
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
1346
1719
|
* the bridge instead of a short race that falls back to a second output path.
|
|
1347
1720
|
*/
|
|
1348
|
-
async injectMessage(localThreadId, input) {
|
|
1721
|
+
async injectMessage(localThreadId, input, options) {
|
|
1349
1722
|
const runtimeInput = typeof input === "string"
|
|
1350
1723
|
? { content: [{ type: "text", text: input }] }
|
|
1351
1724
|
: input;
|
|
@@ -1405,9 +1778,16 @@ export class LocalAgentHost {
|
|
|
1405
1778
|
return { outcome: "notReady" };
|
|
1406
1779
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1407
1780
|
const content = runtimeUserContent(runtimeInput);
|
|
1781
|
+
const providerEcho = codexUserEchoContent(nativeInput);
|
|
1782
|
+
const providerEchoContent = typeof providerEcho === "string"
|
|
1783
|
+
? [{ type: "input_text", text: providerEcho }]
|
|
1784
|
+
: providerEcho;
|
|
1408
1785
|
const pendingInput = {
|
|
1409
1786
|
content,
|
|
1410
1787
|
signature: JSON.stringify(content),
|
|
1788
|
+
...(providerEchoContent
|
|
1789
|
+
? { providerEchoSignature: JSON.stringify(providerEchoContent) }
|
|
1790
|
+
: {}),
|
|
1411
1791
|
state: "awaiting",
|
|
1412
1792
|
observed: false,
|
|
1413
1793
|
};
|
|
@@ -1427,14 +1807,14 @@ export class LocalAgentHost {
|
|
|
1427
1807
|
};
|
|
1428
1808
|
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1429
1809
|
try {
|
|
1430
|
-
//
|
|
1810
|
+
// reference implementation publishes codex-native running only after the native Terminal
|
|
1431
1811
|
// is ready and the runner has accepted the message, but before the
|
|
1432
1812
|
// short-lived app-server client initializes and starts the Turn. Claude
|
|
1433
1813
|
// deliberately has no matching synthesized edge.
|
|
1434
1814
|
if (!live.forwarder.isTurnOpen())
|
|
1435
1815
|
publishAdmission();
|
|
1436
1816
|
await injectionClient.ensureInitialized();
|
|
1437
|
-
// Match
|
|
1817
|
+
// Match reference implementation's executor: each message uses one initialized client
|
|
1438
1818
|
// that closes as soon as turn/start or turn/steer is acknowledged.
|
|
1439
1819
|
if (live.forwarder.isTurnOpen()) {
|
|
1440
1820
|
const turnId = live.forwarder.currentTurnId();
|
|
@@ -1456,11 +1836,20 @@ export class LocalAgentHost {
|
|
|
1456
1836
|
return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
|
|
1457
1837
|
}
|
|
1458
1838
|
}
|
|
1459
|
-
// Match
|
|
1839
|
+
// Match reference implementation's turn boundary: change the native thread settings
|
|
1460
1840
|
// under the same lock immediately before starting the next Turn. Never
|
|
1461
1841
|
// put settings on turn/start or mutate a Turn that is already open.
|
|
1462
1842
|
const desiredModel = live.model;
|
|
1463
1843
|
const desiredReasoningEffort = live.reasoningEffort;
|
|
1844
|
+
// A Session-owned mode survives runner restarts. Normal Web messages do
|
|
1845
|
+
// not carry per-Turn options, so a cold-resumed native thread must fall
|
|
1846
|
+
// back to the persisted execution snapshot before its first new Turn.
|
|
1847
|
+
// An explicit session.run option still wins for that invocation.
|
|
1848
|
+
const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
|
|
1849
|
+
const collaborationMode = desiredCollaborationMode &&
|
|
1850
|
+
desiredCollaborationMode !== live.appliedCollaborationMode
|
|
1851
|
+
? await buildCollaborationMode(injectionClient, desiredCollaborationMode, desiredModel, desiredReasoningEffort)
|
|
1852
|
+
: undefined;
|
|
1464
1853
|
const settings = {
|
|
1465
1854
|
threadId,
|
|
1466
1855
|
...(desiredModel !== live.appliedModel
|
|
@@ -1469,6 +1858,7 @@ export class LocalAgentHost {
|
|
|
1469
1858
|
...(desiredReasoningEffort !== live.appliedReasoningEffort
|
|
1470
1859
|
? { effort: desiredReasoningEffort ?? null }
|
|
1471
1860
|
: {}),
|
|
1861
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
1472
1862
|
};
|
|
1473
1863
|
if (Object.keys(settings).length > 1) {
|
|
1474
1864
|
await injectionClient.threadSettingsUpdate(settings);
|
|
@@ -1477,6 +1867,13 @@ export class LocalAgentHost {
|
|
|
1477
1867
|
// the following new Turn.
|
|
1478
1868
|
live.appliedModel = desiredModel;
|
|
1479
1869
|
live.appliedReasoningEffort = desiredReasoningEffort;
|
|
1870
|
+
if (desiredCollaborationMode) {
|
|
1871
|
+
live.appliedCollaborationMode = desiredCollaborationMode;
|
|
1872
|
+
live.execution = {
|
|
1873
|
+
...live.execution,
|
|
1874
|
+
collaborationMode: desiredCollaborationMode,
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1480
1877
|
}
|
|
1481
1878
|
publishAdmission();
|
|
1482
1879
|
const started = await injectionClient.turnStart({
|
|
@@ -1500,6 +1897,10 @@ export class LocalAgentHost {
|
|
|
1500
1897
|
}
|
|
1501
1898
|
catch (error) {
|
|
1502
1899
|
forgetPendingInput();
|
|
1900
|
+
if (error instanceof CodexRuntimeError &&
|
|
1901
|
+
error.code === "collaboration_mode_model_unknown") {
|
|
1902
|
+
throw error;
|
|
1903
|
+
}
|
|
1503
1904
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1504
1905
|
// into the unactionable `live injection failed` string.
|
|
1505
1906
|
const baseDetail = codexRpcError(error, injectionMethod);
|
|
@@ -1626,6 +2027,7 @@ export class LocalAgentHost {
|
|
|
1626
2027
|
live.markStartupFailed();
|
|
1627
2028
|
}
|
|
1628
2029
|
live.releaseActive?.();
|
|
2030
|
+
live.cancelSyntheticInteractions("session_stopped");
|
|
1629
2031
|
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1630
2032
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1631
2033
|
live.appServerOwner.setInteractionListener(null);
|
|
@@ -1635,12 +2037,13 @@ export class LocalAgentHost {
|
|
|
1635
2037
|
live.forwarder.stop();
|
|
1636
2038
|
live.canonicalInteractions.clear();
|
|
1637
2039
|
live.settledInteractions.clear();
|
|
2040
|
+
live.syntheticInteractions.clear();
|
|
1638
2041
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
1639
2042
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
1640
2043
|
void live.skillsCleanup?.();
|
|
1641
2044
|
}
|
|
1642
2045
|
/** Tear down one codex-lineage native runtime without deleting its durable
|
|
1643
|
-
* session-store binding.
|
|
2046
|
+
* session-store binding. reference implementation couples its auxiliary Terminal, observer,
|
|
1644
2047
|
* forwarder and per-session app-server as one disposable runtime envelope;
|
|
1645
2048
|
* the next message recreates that envelope and cold-resumes the native id. */
|
|
1646
2049
|
teardownLiveCodexSession(localThreadId, error) {
|
|
@@ -1958,7 +2361,7 @@ export class LocalAgentHost {
|
|
|
1958
2361
|
responseId,
|
|
1959
2362
|
});
|
|
1960
2363
|
};
|
|
1961
|
-
const settleClaudeTurn = (interrupted, usage) => {
|
|
2364
|
+
const settleClaudeTurn = (interrupted, usage, backgroundTaskCount) => {
|
|
1962
2365
|
if (!normalizer)
|
|
1963
2366
|
return;
|
|
1964
2367
|
const rid = currentResponseId;
|
|
@@ -1977,8 +2380,11 @@ export class LocalAgentHost {
|
|
|
1977
2380
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1978
2381
|
emitCurrent(se);
|
|
1979
2382
|
}
|
|
1980
|
-
for (const se of normalizer.next({ type: "done" }))
|
|
1981
|
-
emitCurrent(se
|
|
2383
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
2384
|
+
emitCurrent(se.type === "session.status" && backgroundTaskCount !== undefined
|
|
2385
|
+
? { ...se, backgroundTaskCount }
|
|
2386
|
+
: se);
|
|
2387
|
+
}
|
|
1982
2388
|
}
|
|
1983
2389
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1984
2390
|
normalizer = null;
|
|
@@ -2041,30 +2447,42 @@ export class LocalAgentHost {
|
|
|
2041
2447
|
...(blockedOn ? { note: blockedOn } : {}),
|
|
2042
2448
|
});
|
|
2043
2449
|
},
|
|
2044
|
-
onTurnEnd: (usage) => settleClaudeTurn(false, usage),
|
|
2450
|
+
onTurnEnd: (usage, backgroundTaskCount) => settleClaudeTurn(false, usage, backgroundTaskCount),
|
|
2045
2451
|
onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
|
|
2046
2452
|
onTurnInterruptRequested: () => {
|
|
2047
2453
|
const responseId = currentResponseId;
|
|
2048
2454
|
if (responseId)
|
|
2049
2455
|
live.publishInterrupted(responseId);
|
|
2050
2456
|
},
|
|
2051
|
-
onIdle: () => {
|
|
2052
|
-
// Surface
|
|
2053
|
-
//
|
|
2054
|
-
|
|
2457
|
+
onIdle: (backgroundTaskCount) => {
|
|
2458
|
+
// Surface Stop even after its Turn has already closed: a later
|
|
2459
|
+
// authoritative zero is what clears a sticky background-shell tally.
|
|
2460
|
+
// When the Turn is still open, retain its identity without finalizing it
|
|
2461
|
+
// so a late assistant record continues to join the same Response.
|
|
2462
|
+
emitCurrent({
|
|
2463
|
+
type: "session.status",
|
|
2464
|
+
sessionId: currentSessionId,
|
|
2465
|
+
...(currentResponseId ? { responseId: currentResponseId } : {}),
|
|
2466
|
+
status: "idle",
|
|
2467
|
+
...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
|
|
2468
|
+
});
|
|
2469
|
+
},
|
|
2470
|
+
onTurnError: (error) => {
|
|
2471
|
+
const message = error.message || "Agent turn failed";
|
|
2472
|
+
if (!normalizer) {
|
|
2473
|
+
// Native pane/forwarder death is Session-level even when Claude is
|
|
2474
|
+
// between Turns. Omnigent publishes the same bare failed edge; the
|
|
2475
|
+
// explicit zero also retires any sticky background-shell tally.
|
|
2055
2476
|
emitCurrent({
|
|
2056
2477
|
type: "session.status",
|
|
2057
2478
|
sessionId: currentSessionId,
|
|
2058
|
-
|
|
2059
|
-
|
|
2479
|
+
status: "failed",
|
|
2480
|
+
backgroundTaskCount: 0,
|
|
2481
|
+
note: message,
|
|
2060
2482
|
});
|
|
2061
|
-
}
|
|
2062
|
-
},
|
|
2063
|
-
onTurnError: (error) => {
|
|
2064
|
-
if (!normalizer)
|
|
2065
2483
|
return;
|
|
2484
|
+
}
|
|
2066
2485
|
const rid = currentResponseId;
|
|
2067
|
-
const message = error.message || "Agent turn failed";
|
|
2068
2486
|
for (const se of normalizer.fail({
|
|
2069
2487
|
code: "agent_error",
|
|
2070
2488
|
message,
|
|
@@ -2236,8 +2654,9 @@ export class LocalAgentHost {
|
|
|
2236
2654
|
return live.injector ?? null;
|
|
2237
2655
|
}
|
|
2238
2656
|
/** Attach a session's tmux pane injector (from the runner-child, which owns the
|
|
2239
|
-
* terminal registry — the host does not hold tmux).
|
|
2240
|
-
*
|
|
2657
|
+
* terminal registry — the host does not hold tmux). Claude uses it for normal
|
|
2658
|
+
* message injection; Codex uses it only to dismiss the exact TUI-local Plan
|
|
2659
|
+
* picker after its synthetic Web form is submitted. Idempotent. */
|
|
2241
2660
|
attachTerminalInjector(localThreadId, injector) {
|
|
2242
2661
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
2243
2662
|
if (live) {
|
|
@@ -2245,7 +2664,11 @@ export class LocalAgentHost {
|
|
|
2245
2664
|
return;
|
|
2246
2665
|
live.injector = injector;
|
|
2247
2666
|
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2667
|
+
return;
|
|
2248
2668
|
}
|
|
2669
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2670
|
+
if (codex)
|
|
2671
|
+
codex.injector = injector;
|
|
2249
2672
|
}
|
|
2250
2673
|
/** Close an active native response when its terminal or runner disappears. */
|
|
2251
2674
|
failLiveSession(localThreadId, error) {
|
|
@@ -2472,6 +2895,40 @@ function raceReady(ready, timeoutMs, failed) {
|
|
|
2472
2895
|
clearTimeout(timer);
|
|
2473
2896
|
});
|
|
2474
2897
|
}
|
|
2898
|
+
/** Expand the public mode enum into the App Server's required settings
|
|
2899
|
+
* snapshot. This mirrors Omnigent: an explicit Session model wins; otherwise
|
|
2900
|
+
* the Provider's advertised native default is required, and an unknown model
|
|
2901
|
+
* fails the update instead of silently dropping collaboration mode. */
|
|
2902
|
+
async function buildCollaborationMode(client, mode, desiredModel, desiredReasoningEffort) {
|
|
2903
|
+
let model = desiredModel.trim();
|
|
2904
|
+
if (!model) {
|
|
2905
|
+
let catalog;
|
|
2906
|
+
try {
|
|
2907
|
+
catalog = await client.modelList();
|
|
2908
|
+
}
|
|
2909
|
+
catch (error) {
|
|
2910
|
+
throw new CodexRuntimeError(`collaboration mode requires the current model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
|
|
2911
|
+
}
|
|
2912
|
+
const defaults = [...new Set(catalog.data
|
|
2913
|
+
.filter((candidate) => candidate.isDefault === true)
|
|
2914
|
+
.map((candidate) => (candidate.model || candidate.id).trim())
|
|
2915
|
+
.filter(Boolean))];
|
|
2916
|
+
if (defaults.length !== 1) {
|
|
2917
|
+
throw new CodexRuntimeError(`collaboration mode requires exactly one current default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
|
|
2918
|
+
}
|
|
2919
|
+
model = defaults[0];
|
|
2920
|
+
}
|
|
2921
|
+
return {
|
|
2922
|
+
mode,
|
|
2923
|
+
settings: {
|
|
2924
|
+
model,
|
|
2925
|
+
reasoning_effort: desiredReasoningEffort ?? null,
|
|
2926
|
+
// `null` asks the App Server to install the built-in instructions for
|
|
2927
|
+
// the selected mode; thread-level Agent instructions remain separate.
|
|
2928
|
+
developer_instructions: null,
|
|
2929
|
+
},
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2475
2932
|
function sameSessionSnapshots(workspace, execution, opts) {
|
|
2476
2933
|
return JSON.stringify(stableValue({
|
|
2477
2934
|
workspace,
|
|
@@ -2482,8 +2939,18 @@ function sameSessionSnapshots(workspace, execution, opts) {
|
|
|
2482
2939
|
}));
|
|
2483
2940
|
}
|
|
2484
2941
|
function sameImmutableSessionSnapshots(workspace, execution, opts) {
|
|
2485
|
-
const current = {
|
|
2486
|
-
|
|
2942
|
+
const current = {
|
|
2943
|
+
...execution,
|
|
2944
|
+
model: null,
|
|
2945
|
+
reasoningEffort: null,
|
|
2946
|
+
collaborationMode: undefined,
|
|
2947
|
+
};
|
|
2948
|
+
const requested = {
|
|
2949
|
+
...opts.execution,
|
|
2950
|
+
model: null,
|
|
2951
|
+
reasoningEffort: null,
|
|
2952
|
+
collaborationMode: undefined,
|
|
2953
|
+
};
|
|
2487
2954
|
return JSON.stringify(stableValue({ workspace, execution: current })) ===
|
|
2488
2955
|
JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
|
|
2489
2956
|
}
|