@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30
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/executor.d.ts +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- 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 +50 -10
- package/dist/claude/native-integration.js +262 -37
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript.js +27 -17
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +509 -56
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +174 -28
- 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 +64 -7
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +64 -21
- package/dist/host.js +1330 -441
- package/dist/index.d.ts +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +48 -21
- package/dist/runner/child.js +550 -48
- package/dist/runner/manager.d.ts +54 -13
- package/dist/runner/manager.js +479 -114
- package/dist/runner/protocol.d.ts +62 -19
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/registry.js +3 -2
- package/dist/terminal/tmux.d.ts +50 -7
- package/dist/terminal/tmux.js +168 -47
- package/package.json +4 -3
package/dist/host.js
CHANGED
|
@@ -9,17 +9,19 @@ import { getRuntimeProfile, } from "@rynx-ai/core";
|
|
|
9
9
|
import { resolveRuntimeBinary, } from "@rynx-ai/core";
|
|
10
10
|
import { createCodexChildEnv } from "./codex-child-env.js";
|
|
11
11
|
import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
|
|
12
|
-
import {
|
|
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";
|
|
17
18
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
19
|
+
import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
|
|
18
20
|
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
19
21
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
20
22
|
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
21
23
|
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
22
|
-
import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
|
|
24
|
+
import { claudeAttachmentToken, claudeInputText, codexUserEchoContent, runtimeUserContent, } from "./input-resources.js";
|
|
23
25
|
import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
24
26
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
25
27
|
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
@@ -181,6 +183,8 @@ export class SpawnCodexCommandRunner {
|
|
|
181
183
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
182
184
|
* signal is the lever consumers surface to the user. */
|
|
183
185
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
186
|
+
/** Wait for native bridge/thread binding before injection gives up. */
|
|
187
|
+
const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
|
|
184
188
|
function providerPluginSkillName(namespace, name) {
|
|
185
189
|
const candidate = `${namespace}-${name}`;
|
|
186
190
|
if (candidate.length <= 128)
|
|
@@ -189,6 +193,86 @@ function providerPluginSkillName(namespace, name) {
|
|
|
189
193
|
return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
|
|
190
194
|
}
|
|
191
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_SUBSCRIBE_FLUSH_RETRY_MS = 1_500;
|
|
203
|
+
const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = "A previous agent produced the plan below to accomplish the user's task. " +
|
|
204
|
+
"Implement the plan in a fresh context. Treat the plan as the source of " +
|
|
205
|
+
"user intent, re-read files as needed, and carry the work through " +
|
|
206
|
+
"implementation and verification.";
|
|
207
|
+
function codexPlanImplementationRequest(interactionId) {
|
|
208
|
+
return {
|
|
209
|
+
interactionId,
|
|
210
|
+
kind: "question",
|
|
211
|
+
title: "Plan",
|
|
212
|
+
fields: [{
|
|
213
|
+
id: "plan_implementation",
|
|
214
|
+
type: "select",
|
|
215
|
+
label: "Implement this plan?",
|
|
216
|
+
required: true,
|
|
217
|
+
options: [
|
|
218
|
+
{
|
|
219
|
+
value: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
220
|
+
label: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
221
|
+
description: "Switch to Default and start coding.",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
value: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
225
|
+
label: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
226
|
+
description: "Fresh thread with this plan.",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
value: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
230
|
+
label: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
231
|
+
description: "Continue planning with the model.",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}],
|
|
235
|
+
actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
|
|
236
|
+
createdAt: Date.now(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function codexPlanImplementationPromptVisible(pane) {
|
|
240
|
+
return pane.includes("Implement this plan?") &&
|
|
241
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_YES) &&
|
|
242
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_NO);
|
|
243
|
+
}
|
|
244
|
+
/** Codex's final Plan picker is owned by the TUI and is not an app-server
|
|
245
|
+
* request that Web can resolve. Once the synthetic Web form takes ownership,
|
|
246
|
+
* close only that exact picker with its documented Escape action. The owner
|
|
247
|
+
* predicate is checked both before and after capture: app-server notifications
|
|
248
|
+
* cannot interleave between the two synchronous tmux calls, so a newly observed
|
|
249
|
+
* Turn cannot receive a blind Escape. This is deliberately best-effort — a
|
|
250
|
+
* cosmetic Terminal redraw failure must not make an otherwise valid Web Plan
|
|
251
|
+
* decision fail when the structured app-server path can still proceed. */
|
|
252
|
+
async function dismissCodexPlanImplementationPrompt(injector, ownsPrompt) {
|
|
253
|
+
if (!injector || !ownsPrompt())
|
|
254
|
+
return "not_visible";
|
|
255
|
+
try {
|
|
256
|
+
const pane = injector.capturePane();
|
|
257
|
+
if (!ownsPrompt())
|
|
258
|
+
return "superseded";
|
|
259
|
+
if (!codexPlanImplementationPromptVisible(pane))
|
|
260
|
+
return "not_visible";
|
|
261
|
+
injector.interrupt();
|
|
262
|
+
const deadline = Date.now() + CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS;
|
|
263
|
+
while (ownsPrompt()) {
|
|
264
|
+
if (!codexPlanImplementationPromptVisible(injector.capturePane()))
|
|
265
|
+
return "dismissed";
|
|
266
|
+
if (Date.now() >= deadline)
|
|
267
|
+
return "failed";
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, CODEX_PLAN_PROMPT_DISMISS_POLL_MS));
|
|
269
|
+
}
|
|
270
|
+
return "superseded";
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return "failed";
|
|
274
|
+
}
|
|
275
|
+
}
|
|
192
276
|
/**
|
|
193
277
|
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
194
278
|
* when the budget declares no retry or the runtime is claude. claude applies
|
|
@@ -244,6 +328,13 @@ export class LocalAgentHost {
|
|
|
244
328
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
245
329
|
// the backend's app-server; tests inject a fake.
|
|
246
330
|
forwarderClientFactory;
|
|
331
|
+
// Builds reference implementation's short-lived cold-resume preload connection. This must be
|
|
332
|
+
// distinct from both the app-server owner/injector and the background
|
|
333
|
+
// observer: the owner's initialize handshake is the readiness probe, then a
|
|
334
|
+
// freshly initialized connection performs the one resume and closes.
|
|
335
|
+
preloadClientFactory;
|
|
336
|
+
/** Builds one short-lived message/interrupt client. */
|
|
337
|
+
injectionClientFactory;
|
|
247
338
|
// Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
|
|
248
339
|
// a `${runtime}::${retryHash}` composite for a per-agent retry budget.
|
|
249
340
|
backends = new Map();
|
|
@@ -263,6 +354,10 @@ export class LocalAgentHost {
|
|
|
263
354
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
264
355
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
265
356
|
liveClaudeSessions = new Map();
|
|
357
|
+
/** Exact pre-live startup failure, retained after the partial handle has been
|
|
358
|
+
* cleaned up so Core/plugin callers receive the failed phase, not a generic
|
|
359
|
+
* `live session unavailable` wrapper. */
|
|
360
|
+
liveStartupErrors = new Map();
|
|
266
361
|
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
267
362
|
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
268
363
|
pendingClaudeFinalizers = new Set();
|
|
@@ -273,7 +368,7 @@ export class LocalAgentHost {
|
|
|
273
368
|
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
274
369
|
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
275
370
|
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, }) {
|
|
371
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, preloadClientFactory, injectionClientFactory, sessionId, runtimeHomeSessionId, }) {
|
|
277
372
|
this.config = config;
|
|
278
373
|
this.sessionId = sessionId ?? "__default__";
|
|
279
374
|
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
@@ -284,9 +379,10 @@ export class LocalAgentHost {
|
|
|
284
379
|
this.injectedAppServerClient = appServerClient;
|
|
285
380
|
this.clock = now;
|
|
286
381
|
this.backendIdleTtlMs = backendIdleTtlMs;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
382
|
+
const defaultAttachedClientFactory = (appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) });
|
|
383
|
+
this.forwarderClientFactory = forwarderClientFactory ?? defaultAttachedClientFactory;
|
|
384
|
+
this.preloadClientFactory = preloadClientFactory ?? defaultAttachedClientFactory;
|
|
385
|
+
this.injectionClientFactory = injectionClientFactory ?? defaultAttachedClientFactory;
|
|
290
386
|
}
|
|
291
387
|
getBackend(runtime, budget) {
|
|
292
388
|
this.reapIdleBackends();
|
|
@@ -403,36 +499,81 @@ export class LocalAgentHost {
|
|
|
403
499
|
if (live.settledInteractions.has(interactionId)) {
|
|
404
500
|
return { disposition: "already_resolved" };
|
|
405
501
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
502
|
+
const synthetic = live.syntheticInteractions.get(interactionId);
|
|
503
|
+
if (synthetic)
|
|
504
|
+
return synthetic.resolve(resolution);
|
|
505
|
+
return live.forwarderClient.resolveInteraction(interactionId, resolution);
|
|
506
|
+
}
|
|
507
|
+
/** Apply collaboration mode to an already-loaded Codex-lineage thread. The
|
|
508
|
+
* caller persists only after this native RPC succeeds. */
|
|
509
|
+
async updateCollaborationMode(localThreadId, mode) {
|
|
510
|
+
const live = this.liveSessions.get(localThreadId);
|
|
511
|
+
if (!live || live.stopped || live.rotationPending) {
|
|
512
|
+
throw new CodexRuntimeError("Native Session is not live", 409, "native_session_not_live");
|
|
411
513
|
}
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const previous = live.interactionSubmissions.get(interactionId);
|
|
418
|
-
live.interactionSubmissions.set(interactionId, resolution);
|
|
419
|
-
const result = client.resolveInteraction(interactionId, resolution);
|
|
420
|
-
if (result.disposition !== "applied") {
|
|
421
|
-
if (hadPrevious && previous)
|
|
422
|
-
live.interactionSubmissions.set(interactionId, previous);
|
|
423
|
-
else
|
|
424
|
-
live.interactionSubmissions.delete(interactionId);
|
|
514
|
+
const run = live.injectLock.then(async () => {
|
|
515
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
516
|
+
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
517
|
+
if (!bound || !threadId) {
|
|
518
|
+
throw new CodexRuntimeError("Native thread is not ready", 503, "native_thread_not_ready");
|
|
425
519
|
}
|
|
426
|
-
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
520
|
+
await this.applyLiveCollaborationMode(live, threadId, mode);
|
|
521
|
+
});
|
|
522
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
523
|
+
await run;
|
|
524
|
+
}
|
|
525
|
+
/** Apply one explicit collaboration mode to the current native thread and
|
|
526
|
+
* remember only a successful RPC. A supplied client is already initialized
|
|
527
|
+
* and remains owned by its caller (used by cold-resume preload). */
|
|
528
|
+
async applyLiveCollaborationMode(live, threadId, mode, suppliedClient) {
|
|
529
|
+
await this.applyLiveThreadSettings(live, threadId, mode, suppliedClient);
|
|
530
|
+
}
|
|
531
|
+
/** Apply pending explicit model/effort settings and, when requested, one
|
|
532
|
+
* collaboration mode in a single native update. */
|
|
533
|
+
async applyLiveThreadSettings(live, threadId, mode, suppliedClient) {
|
|
534
|
+
const client = suppliedClient ?? this.injectionClientFactory(live.appServerUrl);
|
|
535
|
+
try {
|
|
536
|
+
if (!suppliedClient)
|
|
537
|
+
await client.ensureInitialized();
|
|
538
|
+
const desiredModel = live.model;
|
|
539
|
+
const desiredReasoningEffort = live.reasoningEffort;
|
|
540
|
+
const modelChanged = desiredModel !== live.appliedModel;
|
|
541
|
+
const effortChanged = desiredReasoningEffort !== live.appliedReasoningEffort;
|
|
542
|
+
const collaborationMode = mode && live.appliedCollaborationMode !== mode
|
|
543
|
+
? await resolveLiveCollaborationMode(client, live, mode)
|
|
544
|
+
: undefined;
|
|
545
|
+
const settings = {
|
|
546
|
+
threadId,
|
|
547
|
+
...(modelChanged ? { model: desiredModel || null } : {}),
|
|
548
|
+
...(effortChanged
|
|
549
|
+
? { effort: desiredReasoningEffort ?? null }
|
|
550
|
+
: {}),
|
|
551
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
552
|
+
};
|
|
553
|
+
if (Object.keys(settings).length === 1)
|
|
554
|
+
return;
|
|
555
|
+
await client.threadSettingsUpdate(settings);
|
|
556
|
+
if (collaborationMode) {
|
|
557
|
+
if (!live.model)
|
|
558
|
+
live.model = collaborationMode.settings.model;
|
|
559
|
+
live.mayUseProviderDefaultModel = false;
|
|
560
|
+
live.appliedModel = collaborationMode.settings.model;
|
|
561
|
+
live.appliedReasoningEffort =
|
|
562
|
+
collaborationMode.settings.reasoning_effort ?? undefined;
|
|
563
|
+
live.appliedCollaborationMode = mode;
|
|
564
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
if (modelChanged)
|
|
568
|
+
live.appliedModel = desiredModel;
|
|
569
|
+
if (effortChanged)
|
|
570
|
+
live.appliedReasoningEffort = desiredReasoningEffort;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
if (!suppliedClient)
|
|
575
|
+
await client.stop().catch(() => undefined);
|
|
434
576
|
}
|
|
435
|
-
return submit(live.forwarderClient);
|
|
436
577
|
}
|
|
437
578
|
/**
|
|
438
579
|
* The command to run in a session's live terminal so it co-drives the codex
|
|
@@ -472,6 +613,9 @@ export class LocalAgentHost {
|
|
|
472
613
|
// to the same effective sandbox.
|
|
473
614
|
const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
|
|
474
615
|
const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
|
|
616
|
+
const traexYolo = runtime === "traex" &&
|
|
617
|
+
sandbox === "danger-full-access" &&
|
|
618
|
+
approvalPolicy === "never";
|
|
475
619
|
const configOverrides = sandbox === "workspace-write"
|
|
476
620
|
? ["sandbox_workspace_write.network_access=true"]
|
|
477
621
|
: [];
|
|
@@ -500,9 +644,11 @@ export class LocalAgentHost {
|
|
|
500
644
|
remoteUrl,
|
|
501
645
|
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
502
646
|
configOverrides,
|
|
647
|
+
codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
|
|
503
648
|
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
504
649
|
}),
|
|
505
650
|
cwd: live.workspace.cwd,
|
|
651
|
+
...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
|
|
506
652
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
507
653
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
508
654
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -538,16 +684,21 @@ export class LocalAgentHost {
|
|
|
538
684
|
return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
|
|
539
685
|
}
|
|
540
686
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
541
|
-
// session (two would double-mirror every turn).
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// the thread id instead of launching a bare `codex --remote` pane.
|
|
687
|
+
// session (two would double-mirror every turn). startLiveCodexSession
|
|
688
|
+
// publishes its partially initialized session before thread/start completes,
|
|
689
|
+
// so wait for that startup and then adopt any newer mutable Turn settings.
|
|
545
690
|
const inflight = this.liveEnsuring.get(localThreadId);
|
|
546
|
-
if (inflight)
|
|
547
|
-
|
|
691
|
+
if (inflight) {
|
|
692
|
+
if (!await inflight)
|
|
693
|
+
return false;
|
|
694
|
+
const startedLive = this.liveSessions.get(localThreadId);
|
|
695
|
+
return startedLive
|
|
696
|
+
? updateLiveCodexTurnSettings(startedLive, snapshotOpts)
|
|
697
|
+
: false;
|
|
698
|
+
}
|
|
548
699
|
const live = this.liveSessions.get(localThreadId);
|
|
549
700
|
if (live)
|
|
550
|
-
return
|
|
701
|
+
return updateLiveCodexTurnSettings(live, snapshotOpts);
|
|
551
702
|
const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
|
|
552
703
|
this.liveEnsuring.set(localThreadId, started);
|
|
553
704
|
try {
|
|
@@ -558,6 +709,7 @@ export class LocalAgentHost {
|
|
|
558
709
|
}
|
|
559
710
|
}
|
|
560
711
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
712
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
561
713
|
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
562
714
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
563
715
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
@@ -566,6 +718,7 @@ export class LocalAgentHost {
|
|
|
566
718
|
this.stopLiveCodexSession(localThreadId);
|
|
567
719
|
}
|
|
568
720
|
const record = await this.sessionStore.get(localThreadId);
|
|
721
|
+
const coldResume = Boolean(record?.codexSessionId);
|
|
569
722
|
const retargetMirror = opts.retargetMirror;
|
|
570
723
|
const workspace = structuredClone(opts.workspace);
|
|
571
724
|
const execution = structuredClone(opts.execution);
|
|
@@ -581,6 +734,7 @@ export class LocalAgentHost {
|
|
|
581
734
|
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
582
735
|
}
|
|
583
736
|
catch (err) {
|
|
737
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_preparation_failed", "skill preparation failed", err));
|
|
584
738
|
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
585
739
|
return false;
|
|
586
740
|
}
|
|
@@ -593,6 +747,7 @@ export class LocalAgentHost {
|
|
|
593
747
|
}
|
|
594
748
|
catch (err) {
|
|
595
749
|
void snapshotSkills.skillsCleanup();
|
|
750
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_persistence_failed", "skill persistence failed", err));
|
|
596
751
|
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
597
752
|
return false;
|
|
598
753
|
}
|
|
@@ -610,46 +765,91 @@ export class LocalAgentHost {
|
|
|
610
765
|
void snapshotSkills.skillsCleanup();
|
|
611
766
|
return false;
|
|
612
767
|
};
|
|
613
|
-
const
|
|
614
|
-
if (!
|
|
768
|
+
const appServerOwner = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
769
|
+
if (!appServerOwner) {
|
|
770
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_unavailable", "app-server is unavailable for this Session"));
|
|
615
771
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
616
772
|
return abandon();
|
|
617
773
|
}
|
|
618
774
|
try {
|
|
619
|
-
await
|
|
775
|
+
await appServerOwner.ensureInitialized();
|
|
620
776
|
}
|
|
621
777
|
catch (err) {
|
|
778
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_start_failed", "app-server initialization failed during its 10s readiness phase", err));
|
|
622
779
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
623
780
|
return abandon();
|
|
624
781
|
}
|
|
625
|
-
|
|
782
|
+
// A fresh remote TUI creates the thread, so Rynx does not receive its
|
|
783
|
+
// `thread/start` response. Ask the already-initialized app-server for the
|
|
784
|
+
// effective model instead of parsing Traex's version-dependent YAML/TOML
|
|
785
|
+
// files or confusing `model/list.isDefault` with this Session's model.
|
|
786
|
+
let configModel;
|
|
787
|
+
let configReasoningEffort;
|
|
788
|
+
if (!coldResume && (!execution.model || !execution.reasoningEffort)) {
|
|
789
|
+
try {
|
|
790
|
+
const effective = await appServerOwner.configRead({
|
|
791
|
+
includeLayers: false,
|
|
792
|
+
cwd: workspace.cwd,
|
|
793
|
+
});
|
|
794
|
+
const reportedModel = effective.config.model;
|
|
795
|
+
if (typeof reportedModel === "string" && reportedModel.trim()) {
|
|
796
|
+
configModel = reportedModel.trim();
|
|
797
|
+
}
|
|
798
|
+
const reportedEffort = effective.config.model_reasoning_effort;
|
|
799
|
+
if (typeof reportedEffort === "string" && reportedEffort) {
|
|
800
|
+
configReasoningEffort = reportedEffort;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
// Explicit Session settings remain usable on an older Provider. If a
|
|
805
|
+
// requested collaboration mode later needs an unknown model, that
|
|
806
|
+
// update fails clearly without mutating the native thread.
|
|
807
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} effective config unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const appServerUrl = appServerOwner.terminalRemoteUrl();
|
|
626
811
|
if (!appServerUrl) {
|
|
812
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_endpoint_missing", "app-server started without a Terminal remote endpoint"));
|
|
627
813
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
628
814
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
629
815
|
}
|
|
630
816
|
// reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
|
|
631
817
|
// app-server, which `thread/resume`s to subscribe to the thread's item/turn
|
|
632
818
|
// notifications (the backend client injects; this one only observes).
|
|
819
|
+
// Fresh sessions must connect this listener before the TUI launches so its
|
|
820
|
+
// one-shot `thread/started` cannot race discovery. A known-thread cold resume
|
|
821
|
+
// already has its durable id: like reference implementation's `_codex_forward_known_thread`,
|
|
822
|
+
// its observer starts in the background only after the replacement TUI has
|
|
823
|
+
// launched and is never part of the resume admission boundary.
|
|
633
824
|
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
825
|
+
if (!coldResume) {
|
|
826
|
+
try {
|
|
827
|
+
await forwarderClient.ensureInitialized();
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach to the ready app-server", err));
|
|
831
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} fresh-thread listener init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
832
|
+
return abandon();
|
|
833
|
+
}
|
|
640
834
|
}
|
|
641
|
-
const model = execution.model;
|
|
642
|
-
const reasoningEffort = execution.reasoningEffort ??
|
|
835
|
+
const model = execution.model?.trim() || (!coldResume ? configModel : undefined) || "";
|
|
836
|
+
const reasoningEffort = execution.reasoningEffort ??
|
|
837
|
+
(!coldResume ? configReasoningEffort : undefined);
|
|
643
838
|
let markReady;
|
|
644
839
|
const ready = new Promise((resolve) => {
|
|
645
840
|
markReady = resolve;
|
|
646
841
|
});
|
|
842
|
+
let markStartupFailed;
|
|
843
|
+
const startupFailed = new Promise((resolve) => {
|
|
844
|
+
markStartupFailed = resolve;
|
|
845
|
+
});
|
|
647
846
|
let markTerminalReady;
|
|
648
847
|
const terminalReady = new Promise((resolve) => {
|
|
649
848
|
markTerminalReady = resolve;
|
|
650
849
|
});
|
|
651
850
|
const live = {
|
|
652
|
-
|
|
851
|
+
appServerOwner,
|
|
852
|
+
appServerUrl,
|
|
653
853
|
forwarderClient,
|
|
654
854
|
forwarder: null,
|
|
655
855
|
runtime,
|
|
@@ -659,27 +859,35 @@ export class LocalAgentHost {
|
|
|
659
859
|
approvalPolicy,
|
|
660
860
|
model,
|
|
661
861
|
reasoningEffort,
|
|
862
|
+
mayUseProviderDefaultModel: !coldResume && !model,
|
|
863
|
+
appliedModel: coldResume ? "" : model,
|
|
864
|
+
appliedReasoningEffort: coldResume ? undefined : reasoningEffort,
|
|
865
|
+
appliedCollaborationMode: undefined,
|
|
662
866
|
...(execution.instructions ? { instructions: execution.instructions } : {}),
|
|
663
867
|
threadId: record?.codexSessionId ?? null,
|
|
664
868
|
ready,
|
|
665
869
|
markReady,
|
|
870
|
+
startupFailed,
|
|
871
|
+
markStartupFailed,
|
|
666
872
|
terminalReady,
|
|
667
873
|
markTerminalReady,
|
|
668
874
|
injectLock: Promise.resolve(),
|
|
669
875
|
pendingInjectedInputs: [],
|
|
670
876
|
publishInjectedInput: () => undefined,
|
|
877
|
+
publishTurnAdmission: () => undefined,
|
|
878
|
+
publishTurnAdmissionFailure: () => undefined,
|
|
879
|
+
publishInterrupted: () => undefined,
|
|
880
|
+
interruptedResponseId: null,
|
|
671
881
|
subscribing: false,
|
|
672
882
|
rotationPending: false,
|
|
673
883
|
stopped: false,
|
|
884
|
+
observerAvailable: !coldResume,
|
|
885
|
+
threadActive: false,
|
|
674
886
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
675
|
-
interactionOwners: new Map(),
|
|
676
|
-
interactionStandbys: new Map(),
|
|
677
|
-
interactionSubmissions: new Map(),
|
|
678
887
|
canonicalInteractions: new Set(),
|
|
679
888
|
settledInteractions: new Set(),
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
disconnectedClients: new Set(),
|
|
889
|
+
syntheticInteractions: new Map(),
|
|
890
|
+
cancelSyntheticInteractions: () => undefined,
|
|
683
891
|
};
|
|
684
892
|
let currentSessionId = localThreadId;
|
|
685
893
|
let pendingRotationEvents = null;
|
|
@@ -690,11 +898,23 @@ export class LocalAgentHost {
|
|
|
690
898
|
}
|
|
691
899
|
emit(event);
|
|
692
900
|
};
|
|
901
|
+
live.publishInterrupted = (responseId) => {
|
|
902
|
+
if (live.interruptedResponseId === responseId)
|
|
903
|
+
return;
|
|
904
|
+
live.interruptedResponseId = responseId;
|
|
905
|
+
emitCurrent({
|
|
906
|
+
type: "session.interrupted",
|
|
907
|
+
sessionId: currentSessionId,
|
|
908
|
+
responseId,
|
|
909
|
+
});
|
|
910
|
+
};
|
|
693
911
|
let normalizer = null;
|
|
694
912
|
let currentResponseId = null;
|
|
913
|
+
/** State for content-only events, keyed by Provider response so consecutive
|
|
914
|
+
* late deltas retain one canonical item id without owning lifecycle. */
|
|
915
|
+
const contentNormalizers = new Map();
|
|
695
916
|
const startNormalizer = (turnId) => {
|
|
696
|
-
const responseId =
|
|
697
|
-
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
917
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
698
918
|
if (normalizer && currentResponseId === responseId)
|
|
699
919
|
return normalizer;
|
|
700
920
|
currentResponseId = responseId;
|
|
@@ -704,9 +924,8 @@ export class LocalAgentHost {
|
|
|
704
924
|
// `turn/started` land on ONE response instead of splitting into random
|
|
705
925
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
706
926
|
responseId,
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
// label, so report the runtime when the exact model is unknown.
|
|
927
|
+
// A null Session model intentionally leaves selection to the Provider
|
|
928
|
+
// CLI. Canonical events still require a printable label.
|
|
710
929
|
model: model || runtime,
|
|
711
930
|
});
|
|
712
931
|
return normalizer;
|
|
@@ -719,11 +938,60 @@ export class LocalAgentHost {
|
|
|
719
938
|
for (const se of n.userInput(content))
|
|
720
939
|
emitCurrent(se);
|
|
721
940
|
};
|
|
941
|
+
live.publishTurnAdmission = () => {
|
|
942
|
+
emitCurrent({
|
|
943
|
+
type: "session.status",
|
|
944
|
+
sessionId: currentSessionId,
|
|
945
|
+
status: "running",
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
live.publishTurnAdmissionFailure = (message) => {
|
|
949
|
+
emitCurrent({
|
|
950
|
+
type: "session.status",
|
|
951
|
+
sessionId: currentSessionId,
|
|
952
|
+
status: "failed",
|
|
953
|
+
note: message,
|
|
954
|
+
});
|
|
955
|
+
};
|
|
722
956
|
const clearPendingInputsForResponse = (responseId) => {
|
|
723
957
|
if (!responseId)
|
|
724
958
|
return;
|
|
725
959
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
726
960
|
};
|
|
961
|
+
const turnContentEvents = (turnId, produce) => {
|
|
962
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
963
|
+
let contentNormalizer = contentNormalizers.get(responseId);
|
|
964
|
+
if (!contentNormalizer) {
|
|
965
|
+
contentNormalizer = new SessionNormalizer({
|
|
966
|
+
sessionId: currentSessionId,
|
|
967
|
+
responseId,
|
|
968
|
+
model: live.model || live.runtime,
|
|
969
|
+
});
|
|
970
|
+
contentNormalizers.set(responseId, contentNormalizer);
|
|
971
|
+
}
|
|
972
|
+
for (const event of produce(contentNormalizer)) {
|
|
973
|
+
// Omnigent posts item/transient content under the Turn's response id
|
|
974
|
+
// independently of lifecycle status. Reuse Rynx's canonical item
|
|
975
|
+
// normalization but suppress its synthetic lifecycle edges.
|
|
976
|
+
if (event.type !== "response.created" && event.type !== "session.status") {
|
|
977
|
+
emitCurrent(event);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
const observedUserContent = (content) => {
|
|
982
|
+
const normalizedContent = typeof content === "string"
|
|
983
|
+
? [{ type: "input_text", text: content }]
|
|
984
|
+
: content;
|
|
985
|
+
const signature = JSON.stringify(normalizedContent);
|
|
986
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature || entry.providerEchoSignature === signature);
|
|
987
|
+
if (pending?.state === "optimistic") {
|
|
988
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
if (pending)
|
|
992
|
+
pending.observed = true;
|
|
993
|
+
return normalizedContent;
|
|
994
|
+
};
|
|
727
995
|
const rememberSettledInteraction = (interactionId) => {
|
|
728
996
|
live.settledInteractions.add(interactionId);
|
|
729
997
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -732,11 +1000,174 @@ export class LocalAgentHost {
|
|
|
732
1000
|
if (oldest)
|
|
733
1001
|
live.settledInteractions.delete(oldest);
|
|
734
1002
|
};
|
|
1003
|
+
const cancelSyntheticInteractions = (reason) => {
|
|
1004
|
+
for (const interaction of live.syntheticInteractions.values())
|
|
1005
|
+
interaction.cancel(reason);
|
|
1006
|
+
};
|
|
1007
|
+
live.cancelSyntheticInteractions = cancelSyntheticInteractions;
|
|
1008
|
+
const publishPlanImplementationPrompt = (prompt) => {
|
|
1009
|
+
const suffix = createHash("sha256")
|
|
1010
|
+
.update(`${prompt.threadId}\0${prompt.turnId}`)
|
|
1011
|
+
.digest("hex")
|
|
1012
|
+
.slice(0, 24);
|
|
1013
|
+
const interactionId = `codex_plan_implementation_${suffix}`;
|
|
1014
|
+
if (live.syntheticInteractions.has(interactionId) ||
|
|
1015
|
+
live.settledInteractions.has(interactionId))
|
|
1016
|
+
return;
|
|
1017
|
+
const responseId = `resp_codex_plan_approval_${suffix}`;
|
|
1018
|
+
const request = codexPlanImplementationRequest(interactionId);
|
|
1019
|
+
const interactionNormalizer = new SessionNormalizer({
|
|
1020
|
+
sessionId: currentSessionId,
|
|
1021
|
+
responseId,
|
|
1022
|
+
model: live.model || live.runtime,
|
|
1023
|
+
});
|
|
1024
|
+
let submitted = false;
|
|
1025
|
+
const finish = (kind, resolution, reason) => {
|
|
1026
|
+
if (!live.syntheticInteractions.delete(interactionId))
|
|
1027
|
+
return;
|
|
1028
|
+
rememberSettledInteraction(interactionId);
|
|
1029
|
+
const event = kind === "resolved"
|
|
1030
|
+
? {
|
|
1031
|
+
type: "interaction_resolved",
|
|
1032
|
+
interactionId,
|
|
1033
|
+
resolution: resolution,
|
|
1034
|
+
}
|
|
1035
|
+
: {
|
|
1036
|
+
type: "interaction_cancelled",
|
|
1037
|
+
interactionId,
|
|
1038
|
+
...(reason ? { reason } : {}),
|
|
1039
|
+
};
|
|
1040
|
+
// This interaction belongs to the Session that produced the Plan. A
|
|
1041
|
+
// clear-context choice can rotate the native thread before turn/start
|
|
1042
|
+
// returns, so never retarget its resolution to the new Session.
|
|
1043
|
+
for (const sessionEvent of interactionNormalizer.next(event))
|
|
1044
|
+
emit(sessionEvent);
|
|
1045
|
+
for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
|
|
1046
|
+
emit(sessionEvent);
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
live.syntheticInteractions.set(interactionId, {
|
|
1050
|
+
cancel: (reason) => {
|
|
1051
|
+
// The selected implementation itself starts a native Turn and the
|
|
1052
|
+
// clear-context variant also starts a new thread. Their observer
|
|
1053
|
+
// notifications can beat the short-lived RPC response; they are not
|
|
1054
|
+
// competing user actions and must not cancel the card being resolved.
|
|
1055
|
+
if (submitted &&
|
|
1056
|
+
(reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
|
|
1057
|
+
return;
|
|
1058
|
+
finish("cancelled", undefined, reason);
|
|
1059
|
+
},
|
|
1060
|
+
resolve: async (resolution) => {
|
|
1061
|
+
if (submitted)
|
|
1062
|
+
return { disposition: "already_resolved" };
|
|
1063
|
+
const invalid = validateInteractionResolution(request, resolution);
|
|
1064
|
+
if (invalid)
|
|
1065
|
+
return { disposition: "invalid", message: invalid };
|
|
1066
|
+
const choice = resolution.answers?.plan_implementation;
|
|
1067
|
+
if (typeof choice !== "string") {
|
|
1068
|
+
return { disposition: "invalid", message: "plan implementation choice is required" };
|
|
1069
|
+
}
|
|
1070
|
+
submitted = true;
|
|
1071
|
+
const run = live.injectLock.then(async () => {
|
|
1072
|
+
if (live.stopped || live.rotationPending) {
|
|
1073
|
+
throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
|
|
1074
|
+
}
|
|
1075
|
+
const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
|
|
1076
|
+
!live.rotationPending &&
|
|
1077
|
+
live.syntheticInteractions.has(interactionId) &&
|
|
1078
|
+
live.forwarder.threadId() === prompt.threadId &&
|
|
1079
|
+
!live.forwarder.isTurnOpen());
|
|
1080
|
+
if (promptDismissal === "failed") {
|
|
1081
|
+
console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
|
|
1082
|
+
}
|
|
1083
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
|
|
1084
|
+
finish("resolved", resolution);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const client = this.injectionClientFactory(live.appServerUrl);
|
|
1088
|
+
try {
|
|
1089
|
+
await client.ensureInitialized();
|
|
1090
|
+
const collaborationMode = await resolveLiveCollaborationMode(client, live, "default");
|
|
1091
|
+
let threadId = prompt.threadId;
|
|
1092
|
+
let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
|
|
1093
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
|
|
1094
|
+
live.nextThreadCollaborationMode = "default";
|
|
1095
|
+
let startedThread;
|
|
1096
|
+
try {
|
|
1097
|
+
startedThread = await client.threadStart({
|
|
1098
|
+
...(live.model ? { model: live.model } : {}),
|
|
1099
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1100
|
+
approvalPolicy: live.approvalPolicy,
|
|
1101
|
+
...(live.instructions ? { developerInstructions: live.instructions } : {}),
|
|
1102
|
+
sessionStartSource: "clear",
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1107
|
+
throw error;
|
|
1108
|
+
}
|
|
1109
|
+
threadId = startedThread.threadId;
|
|
1110
|
+
text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
|
|
1111
|
+
}
|
|
1112
|
+
else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
|
|
1113
|
+
throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
|
|
1114
|
+
}
|
|
1115
|
+
const started = await client.turnStart({
|
|
1116
|
+
threadId,
|
|
1117
|
+
input: [{ type: "text", text }],
|
|
1118
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1119
|
+
approvalPolicy: live.approvalPolicy,
|
|
1120
|
+
collaborationMode,
|
|
1121
|
+
});
|
|
1122
|
+
if (!live.model)
|
|
1123
|
+
live.model = collaborationMode.settings.model;
|
|
1124
|
+
live.mayUseProviderDefaultModel = false;
|
|
1125
|
+
live.appliedModel = collaborationMode.settings.model;
|
|
1126
|
+
live.appliedReasoningEffort =
|
|
1127
|
+
collaborationMode.settings.reasoning_effort ?? undefined;
|
|
1128
|
+
live.execution = { ...live.execution, collaborationMode: "default" };
|
|
1129
|
+
live.appliedCollaborationMode = "default";
|
|
1130
|
+
emitCurrent({
|
|
1131
|
+
type: "session.collaboration_mode",
|
|
1132
|
+
sessionId: currentSessionId,
|
|
1133
|
+
mode: "default",
|
|
1134
|
+
});
|
|
1135
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1136
|
+
finish("resolved", resolution);
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
await client.stop().catch(() => undefined);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
1143
|
+
try {
|
|
1144
|
+
await run;
|
|
1145
|
+
return { disposition: "applied" };
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
submitted = false;
|
|
1149
|
+
return {
|
|
1150
|
+
disposition: "invalid",
|
|
1151
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
});
|
|
1156
|
+
for (const sessionEvent of interactionNormalizer.next({
|
|
1157
|
+
type: "interaction_requested",
|
|
1158
|
+
interaction: request,
|
|
1159
|
+
}))
|
|
1160
|
+
emit(sessionEvent);
|
|
1161
|
+
};
|
|
735
1162
|
const forwardInteraction = (event) => {
|
|
736
1163
|
const interactionId = event.type === "requested"
|
|
737
1164
|
? event.request.interactionId
|
|
738
1165
|
: event.interactionId;
|
|
739
1166
|
if (event.type === "requested") {
|
|
1167
|
+
if (event.request.fields.some((field) => field.id === "plan_implementation")) {
|
|
1168
|
+
live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
|
|
1169
|
+
cancelSyntheticInteractions("superseded_by_native_plan_prompt");
|
|
1170
|
+
}
|
|
740
1171
|
if (live.canonicalInteractions.has(interactionId) ||
|
|
741
1172
|
live.settledInteractions.has(interactionId))
|
|
742
1173
|
return;
|
|
@@ -768,223 +1199,183 @@ export class LocalAgentHost {
|
|
|
768
1199
|
for (const se of n.next(agentEvent))
|
|
769
1200
|
emitCurrent(se);
|
|
770
1201
|
};
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
};
|
|
774
|
-
const clearInteractionRouting = (interactionId) => {
|
|
775
|
-
clearRecovery(interactionId);
|
|
776
|
-
live.interactionOwners.delete(interactionId);
|
|
777
|
-
live.interactionStandbys.delete(interactionId);
|
|
778
|
-
live.interactionSubmissions.delete(interactionId);
|
|
779
|
-
live.interactionFailedClients.delete(interactionId);
|
|
780
|
-
};
|
|
781
|
-
const markInteractionClientFailed = (interactionId, client) => {
|
|
782
|
-
const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
|
|
783
|
-
failed.add(client);
|
|
784
|
-
live.interactionFailedClients.set(interactionId, failed);
|
|
785
|
-
};
|
|
786
|
-
const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
|
|
787
|
-
live.interactionFailedClients.get(interactionId)?.has(client) === true;
|
|
788
|
-
const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
|
|
789
|
-
const finishRecovery = (interactionId, event) => {
|
|
790
|
-
clearInteractionRouting(interactionId);
|
|
791
|
-
forwardInteraction(event);
|
|
792
|
-
};
|
|
793
|
-
const beginRecovery = (interactionId, event) => {
|
|
794
|
-
live.interactionOwners.delete(interactionId);
|
|
795
|
-
live.interactionRecoveries.set(interactionId, { event });
|
|
796
|
-
};
|
|
797
|
-
const promoteInteractionOwner = (interactionId, replacement) => {
|
|
798
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
799
|
-
live.interactionOwners.set(interactionId, replacement);
|
|
800
|
-
const submission = live.interactionSubmissions.get(interactionId);
|
|
801
|
-
if (!submission) {
|
|
802
|
-
clearRecovery(interactionId);
|
|
803
|
-
return true;
|
|
804
|
-
}
|
|
805
|
-
const replayed = replacement.resolveInteraction(interactionId, submission);
|
|
806
|
-
// A write failure can synchronously re-enter the listener and remove this
|
|
807
|
-
// owner. Only clear recovery when the replacement still owns the request.
|
|
808
|
-
if (replayed.disposition === "applied" &&
|
|
809
|
-
live.interactionOwners.get(interactionId) === replacement) {
|
|
810
|
-
clearRecovery(interactionId);
|
|
811
|
-
return true;
|
|
812
|
-
}
|
|
813
|
-
if (live.interactionOwners.get(interactionId) === replacement) {
|
|
814
|
-
live.interactionOwners.delete(interactionId);
|
|
815
|
-
}
|
|
816
|
-
if (recovery &&
|
|
817
|
-
live.canonicalInteractions.has(interactionId) &&
|
|
818
|
-
!live.settledInteractions.has(interactionId) &&
|
|
819
|
-
!live.interactionRecoveries.has(interactionId)) {
|
|
820
|
-
live.interactionRecoveries.set(interactionId, recovery);
|
|
821
|
-
}
|
|
822
|
-
return false;
|
|
823
|
-
};
|
|
824
|
-
const bindInteractionClient = (client) => {
|
|
825
|
-
client.setInteractionListener((event) => {
|
|
826
|
-
const interactionId = event.type === "requested"
|
|
827
|
-
? event.request.interactionId
|
|
828
|
-
: event.interactionId;
|
|
829
|
-
const owner = live.interactionOwners.get(interactionId);
|
|
830
|
-
if (event.type === "requested") {
|
|
831
|
-
// Receiving a native request proves this connection is alive and can
|
|
832
|
-
// be retried even if an earlier copy failed on it.
|
|
833
|
-
live.disconnectedClients.delete(client);
|
|
834
|
-
const failed = live.interactionFailedClients.get(interactionId);
|
|
835
|
-
failed?.delete(client);
|
|
836
|
-
if (failed?.size === 0)
|
|
837
|
-
live.interactionFailedClients.delete(interactionId);
|
|
838
|
-
if (live.settledInteractions.has(interactionId))
|
|
839
|
-
return;
|
|
840
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
841
|
-
if (recovery) {
|
|
842
|
-
promoteInteractionOwner(interactionId, client);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
if (owner && owner !== client) {
|
|
846
|
-
const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
|
|
847
|
-
standbys.add(client);
|
|
848
|
-
live.interactionStandbys.set(interactionId, standbys);
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
live.interactionOwners.set(interactionId, client);
|
|
852
|
-
forwardInteraction(event);
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
const recoverableOwnerFailure = event.type === "cancelled" &&
|
|
856
|
-
(event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
|
|
857
|
-
if (recoverableOwnerFailure)
|
|
858
|
-
markInteractionClientFailed(interactionId, client);
|
|
859
|
-
if (!owner || owner !== client) {
|
|
860
|
-
const standbys = live.interactionStandbys.get(interactionId);
|
|
861
|
-
standbys?.delete(client);
|
|
862
|
-
if (standbys?.size === 0)
|
|
863
|
-
live.interactionStandbys.delete(interactionId);
|
|
864
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
865
|
-
if (recoverableOwnerFailure &&
|
|
866
|
-
live.canonicalInteractions.has(interactionId) &&
|
|
867
|
-
allInteractionClientsUnavailable(interactionId)) {
|
|
868
|
-
finishRecovery(interactionId, recovery?.event ?? event);
|
|
869
|
-
}
|
|
870
|
-
return;
|
|
871
|
-
}
|
|
872
|
-
if (recoverableOwnerFailure) {
|
|
873
|
-
const standbys = live.interactionStandbys.get(interactionId);
|
|
874
|
-
while (standbys && standbys.size > 0) {
|
|
875
|
-
const replacement = standbys.values().next().value;
|
|
876
|
-
if (!replacement)
|
|
877
|
-
break;
|
|
878
|
-
standbys.delete(replacement);
|
|
879
|
-
if (interactionClientUnavailable(interactionId, replacement))
|
|
880
|
-
continue;
|
|
881
|
-
if (promoteInteractionOwner(interactionId, replacement)) {
|
|
882
|
-
if (standbys.size === 0)
|
|
883
|
-
live.interactionStandbys.delete(interactionId);
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
if (live.settledInteractions.has(interactionId))
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
if (!live.settledInteractions.has(interactionId)) {
|
|
890
|
-
if (allInteractionClientsUnavailable(interactionId)) {
|
|
891
|
-
finishRecovery(interactionId, event);
|
|
892
|
-
}
|
|
893
|
-
else {
|
|
894
|
-
beginRecovery(interactionId, event);
|
|
895
|
-
}
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
clearInteractionRouting(interactionId);
|
|
900
|
-
forwardInteraction(event);
|
|
901
|
-
});
|
|
1202
|
+
const bindObserverClient = (client) => {
|
|
1203
|
+
client.setInteractionListener(forwardInteraction);
|
|
902
1204
|
client.setConnectionListener((state) => {
|
|
903
|
-
if (state === "connected")
|
|
904
|
-
live.disconnectedClients.delete(client);
|
|
1205
|
+
if (state === "connected" || live.stopped)
|
|
905
1206
|
return;
|
|
1207
|
+
live.observerAvailable = false;
|
|
1208
|
+
for (const interactionId of [...live.canonicalInteractions]) {
|
|
1209
|
+
forwardInteraction({
|
|
1210
|
+
type: "cancelled",
|
|
1211
|
+
interactionId,
|
|
1212
|
+
reason: "app_server_disconnected",
|
|
1213
|
+
});
|
|
906
1214
|
}
|
|
907
|
-
|
|
908
|
-
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
909
|
-
if (allInteractionClientsUnavailable(interactionId)) {
|
|
910
|
-
finishRecovery(interactionId, recovery.event);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
1215
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
|
|
913
1216
|
});
|
|
914
1217
|
};
|
|
915
1218
|
const closeCanonicalInteractions = () => {
|
|
916
1219
|
for (const interactionId of live.canonicalInteractions) {
|
|
917
1220
|
rememberSettledInteraction(interactionId);
|
|
918
|
-
clearInteractionRouting(interactionId);
|
|
919
1221
|
}
|
|
920
1222
|
live.canonicalInteractions.clear();
|
|
921
1223
|
};
|
|
1224
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
1225
|
+
closeCanonicalInteractions();
|
|
1226
|
+
if (!normalizer)
|
|
1227
|
+
return;
|
|
1228
|
+
const completedResponseId = currentResponseId;
|
|
1229
|
+
if (usage) {
|
|
1230
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1231
|
+
emitCurrent(se);
|
|
1232
|
+
}
|
|
1233
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
1234
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
1235
|
+
continue;
|
|
1236
|
+
emitCurrent(se);
|
|
1237
|
+
}
|
|
1238
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
1239
|
+
normalizer = null;
|
|
1240
|
+
currentResponseId = null;
|
|
1241
|
+
};
|
|
1242
|
+
const failCurrentTurn = (error) => {
|
|
1243
|
+
closeCanonicalInteractions();
|
|
1244
|
+
if (!normalizer)
|
|
1245
|
+
return;
|
|
1246
|
+
const failedResponseId = currentResponseId;
|
|
1247
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
1248
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
1249
|
+
const message = responseStopped
|
|
1250
|
+
? "Response stopped"
|
|
1251
|
+
: runtime === "traex"
|
|
1252
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
1253
|
+
: error.message;
|
|
1254
|
+
for (const se of normalizer.fail({
|
|
1255
|
+
code: responseStopped
|
|
1256
|
+
? "response_stopped"
|
|
1257
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
1258
|
+
message,
|
|
1259
|
+
source: "execution",
|
|
1260
|
+
})) {
|
|
1261
|
+
emitCurrent(se.type === "session.status"
|
|
1262
|
+
? { ...se, status: "failed", note: message }
|
|
1263
|
+
: se);
|
|
1264
|
+
}
|
|
1265
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
1266
|
+
normalizer = null;
|
|
1267
|
+
currentResponseId = null;
|
|
1268
|
+
};
|
|
1269
|
+
const interruptCurrentTurn = () => {
|
|
1270
|
+
closeCanonicalInteractions();
|
|
1271
|
+
if (!normalizer)
|
|
1272
|
+
return;
|
|
1273
|
+
const interruptedResponseId = currentResponseId;
|
|
1274
|
+
for (const se of normalizer.interrupt()) {
|
|
1275
|
+
if (se.type === "session.interrupted" &&
|
|
1276
|
+
live.interruptedResponseId === interruptedResponseId)
|
|
1277
|
+
continue;
|
|
1278
|
+
emitCurrent(se);
|
|
1279
|
+
}
|
|
1280
|
+
live.interruptedResponseId = null;
|
|
1281
|
+
clearPendingInputsForResponse(interruptedResponseId);
|
|
1282
|
+
normalizer = null;
|
|
1283
|
+
currentResponseId = null;
|
|
1284
|
+
};
|
|
922
1285
|
const sink = {
|
|
923
1286
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1287
|
+
onTurnObserved: (turnId) => {
|
|
1288
|
+
cancelSyntheticInteractions("superseded_by_native_turn");
|
|
1289
|
+
const n = startNormalizer(turnId);
|
|
1290
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
1291
|
+
emitCurrent(event);
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
924
1294
|
onUserMessage: (content) => {
|
|
925
|
-
const normalizedContent =
|
|
926
|
-
|
|
927
|
-
: content;
|
|
928
|
-
const signature = JSON.stringify(normalizedContent);
|
|
929
|
-
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
930
|
-
if (pending?.state === "optimistic" || pending?.state === "prepublished") {
|
|
931
|
-
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1295
|
+
const normalizedContent = observedUserContent(content);
|
|
1296
|
+
if (!normalizedContent)
|
|
932
1297
|
return;
|
|
933
|
-
}
|
|
934
|
-
if (pending)
|
|
935
|
-
pending.observed = true;
|
|
936
1298
|
const n = normalizer ?? startNormalizer();
|
|
937
1299
|
for (const se of n.userInput(normalizedContent))
|
|
938
1300
|
emitCurrent(se);
|
|
939
1301
|
},
|
|
1302
|
+
onTurnContentUserMessage: (turnId, content) => {
|
|
1303
|
+
const normalizedContent = observedUserContent(content);
|
|
1304
|
+
if (!normalizedContent)
|
|
1305
|
+
return;
|
|
1306
|
+
turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
|
|
1307
|
+
},
|
|
940
1308
|
onEvent: (event) => {
|
|
941
1309
|
const n = normalizer ?? startNormalizer();
|
|
942
1310
|
for (const se of n.next(event))
|
|
943
1311
|
emitCurrent(se);
|
|
944
1312
|
},
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (!normalizer)
|
|
948
|
-
return;
|
|
949
|
-
const completedResponseId = currentResponseId;
|
|
950
|
-
if (usage) {
|
|
951
|
-
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
952
|
-
emitCurrent(se);
|
|
953
|
-
}
|
|
954
|
-
for (const se of normalizer.next({ type: "done" }))
|
|
955
|
-
emitCurrent(se);
|
|
956
|
-
clearPendingInputsForResponse(completedResponseId);
|
|
957
|
-
normalizer = null;
|
|
958
|
-
currentResponseId = null;
|
|
1313
|
+
onTurnContentEvent: (turnId, event) => {
|
|
1314
|
+
turnContentEvents(turnId, (n) => n.next(event));
|
|
959
1315
|
},
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1316
|
+
onStatus: (note, statusKind) => {
|
|
1317
|
+
const responseId = currentResponseId ??
|
|
1318
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
1319
|
+
emitCurrent({
|
|
1320
|
+
type: "session.status",
|
|
1321
|
+
sessionId: currentSessionId,
|
|
1322
|
+
...(responseId ? { responseId } : {}),
|
|
1323
|
+
status: "running",
|
|
1324
|
+
...(note ? { note } : {}),
|
|
1325
|
+
...(statusKind ? { statusKind } : {}),
|
|
1326
|
+
});
|
|
1327
|
+
},
|
|
1328
|
+
// A newer authoritative turn/started completes the old canonical
|
|
1329
|
+
// response without publishing a false idle edge in a running→running
|
|
1330
|
+
// transition.
|
|
1331
|
+
onTurnEnd: completeCurrentTurn,
|
|
1332
|
+
onTurnInterrupted: interruptCurrentTurn,
|
|
1333
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1334
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1335
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
1336
|
+
if (status === "failed")
|
|
1337
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
1338
|
+
else
|
|
1339
|
+
completeCurrentTurn();
|
|
963
1340
|
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;
|
|
1341
|
+
}
|
|
1342
|
+
emitCurrent({
|
|
1343
|
+
type: "session.status",
|
|
1344
|
+
sessionId: currentSessionId,
|
|
1345
|
+
...(responseId ? { responseId } : {}),
|
|
1346
|
+
status,
|
|
1347
|
+
...(error ? { note: error.message } : {}),
|
|
1348
|
+
});
|
|
983
1349
|
},
|
|
1350
|
+
onTurnError: failCurrentTurn,
|
|
984
1351
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
985
1352
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
986
1353
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
987
|
-
onThreadActive: () =>
|
|
1354
|
+
onThreadActive: () => {
|
|
1355
|
+
live.threadActive = true;
|
|
1356
|
+
live.releaseActive?.();
|
|
1357
|
+
},
|
|
1358
|
+
onCollaborationModeChanged: (mode) => {
|
|
1359
|
+
live.appliedCollaborationMode = mode;
|
|
1360
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
1361
|
+
emitCurrent({
|
|
1362
|
+
type: "session.collaboration_mode",
|
|
1363
|
+
sessionId: currentSessionId,
|
|
1364
|
+
mode,
|
|
1365
|
+
});
|
|
1366
|
+
},
|
|
1367
|
+
onThreadSettingsChanged: ({ model: reportedModel, reasoningEffort: reportedEffort }) => {
|
|
1368
|
+
if (reportedModel) {
|
|
1369
|
+
live.model = reportedModel;
|
|
1370
|
+
live.appliedModel = reportedModel;
|
|
1371
|
+
live.mayUseProviderDefaultModel = false;
|
|
1372
|
+
}
|
|
1373
|
+
if (reportedEffort !== undefined) {
|
|
1374
|
+
live.reasoningEffort = reportedEffort ?? undefined;
|
|
1375
|
+
live.appliedReasoningEffort = reportedEffort ?? undefined;
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
onPlanImplementationPrompt: publishPlanImplementationPrompt,
|
|
988
1379
|
};
|
|
989
1380
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
990
1381
|
// Traex 0.200 can publish a final reasoning item immediately after
|
|
@@ -995,6 +1386,7 @@ export class LocalAgentHost {
|
|
|
995
1386
|
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
996
1387
|
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
997
1388
|
surfaceQueueStatus: runtime === "traex",
|
|
1389
|
+
mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
|
|
998
1390
|
});
|
|
999
1391
|
live.forwarder = forwarder;
|
|
1000
1392
|
live.onNativeThreadRotated = (threadId, kind) => {
|
|
@@ -1002,12 +1394,20 @@ export class LocalAgentHost {
|
|
|
1002
1394
|
live.stopped = true;
|
|
1003
1395
|
return;
|
|
1004
1396
|
}
|
|
1397
|
+
cancelSyntheticInteractions("native_thread_rotated");
|
|
1005
1398
|
const previousSessionId = currentSessionId;
|
|
1006
1399
|
const newSessionId = makeSessionId();
|
|
1400
|
+
const rotationMode = live.nextThreadCollaborationMode;
|
|
1401
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1402
|
+
live.appliedCollaborationMode = undefined;
|
|
1403
|
+
if (rotationMode) {
|
|
1404
|
+
live.execution = { ...live.execution, collaborationMode: rotationMode };
|
|
1405
|
+
}
|
|
1007
1406
|
live.rotationPending = true;
|
|
1008
1407
|
currentSessionId = newSessionId;
|
|
1009
1408
|
normalizer = null;
|
|
1010
1409
|
currentResponseId = null;
|
|
1410
|
+
contentNormalizers.clear();
|
|
1011
1411
|
live.pendingInjectedInputs = [];
|
|
1012
1412
|
pendingRotationEvents = [];
|
|
1013
1413
|
void this.sessionStore
|
|
@@ -1040,7 +1440,15 @@ export class LocalAgentHost {
|
|
|
1040
1440
|
live.rotationPending = false;
|
|
1041
1441
|
for (const event of queued)
|
|
1042
1442
|
emit(event);
|
|
1043
|
-
|
|
1443
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1444
|
+
if (desiredMode) {
|
|
1445
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1446
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1447
|
+
void restore.catch((error) => {
|
|
1448
|
+
this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1044
1452
|
})
|
|
1045
1453
|
.catch(() => {
|
|
1046
1454
|
pendingRotationEvents = null;
|
|
@@ -1048,8 +1456,7 @@ export class LocalAgentHost {
|
|
|
1048
1456
|
live.stopped = true;
|
|
1049
1457
|
});
|
|
1050
1458
|
};
|
|
1051
|
-
|
|
1052
|
-
bindInteractionClient(forwarderClient);
|
|
1459
|
+
bindObserverClient(forwarderClient);
|
|
1053
1460
|
this.liveSessions.set(localThreadId, live);
|
|
1054
1461
|
forwarder.start();
|
|
1055
1462
|
// Existing bindings resume through the public app-server API. A fresh thread
|
|
@@ -1059,36 +1466,56 @@ export class LocalAgentHost {
|
|
|
1059
1466
|
// turn-less app-server thread resumable by the TUI.
|
|
1060
1467
|
try {
|
|
1061
1468
|
if (record?.codexSessionId) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1469
|
+
// Match reference implementation's cold-resume preload exactly: one history-free
|
|
1470
|
+
// `thread/resume` on a short-lived, separately initialized connection.
|
|
1471
|
+
// The backend owner's handshake above is the app-server readiness
|
|
1472
|
+
// probe; reusing that first connection here races Codex's startup state
|
|
1473
|
+
// and can return `Not initialized`. A failure stays attached to the
|
|
1474
|
+
// original binding and is reported by this phase; it is not polled.
|
|
1475
|
+
const preloadClient = this.preloadClientFactory(appServerUrl);
|
|
1476
|
+
try {
|
|
1477
|
+
let resumed;
|
|
1066
1478
|
try {
|
|
1067
|
-
|
|
1479
|
+
await preloadClient.ensureInitialized();
|
|
1480
|
+
resumed = await preloadClient.threadResume({
|
|
1068
1481
|
threadId: record.codexSessionId,
|
|
1069
1482
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1070
1483
|
approvalPolicy,
|
|
1071
1484
|
excludeTurns: true,
|
|
1072
1485
|
});
|
|
1073
|
-
|
|
1074
|
-
|
|
1486
|
+
adoptResumedThreadSettings(live, resumed);
|
|
1487
|
+
await this.applyLiveThreadSettings(live, resumed.threadId, execution.collaborationMode, preloadClient);
|
|
1075
1488
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
if (attempt + 1 < attempts) {
|
|
1081
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1082
|
-
}
|
|
1489
|
+
finally {
|
|
1490
|
+
await preloadClient.stop().catch((error) => {
|
|
1491
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1492
|
+
});
|
|
1083
1493
|
}
|
|
1494
|
+
forwarder.noteThreadBound(resumed.threadId);
|
|
1495
|
+
this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
|
|
1084
1496
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1497
|
+
catch (error) {
|
|
1498
|
+
if (!isThreadNotReadyError(error) ||
|
|
1499
|
+
record.parentSessionId ||
|
|
1500
|
+
record.runtimeHomeOwnerSessionId) {
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
// Preserve the existing turn-less binding recovery without polling:
|
|
1504
|
+
// an ordinary native id with no rollout/turn has no Provider history
|
|
1505
|
+
// to retain. Fork bindings remain strict, and `thread not found` is
|
|
1506
|
+
// not classified here because it does not prove the history is empty.
|
|
1507
|
+
await this.sessionStore.delete(localThreadId);
|
|
1508
|
+
live.threadId = null;
|
|
1509
|
+
live.mayUseProviderDefaultModel = !live.model;
|
|
1510
|
+
try {
|
|
1511
|
+
await forwarderClient.ensureInitialized();
|
|
1512
|
+
}
|
|
1513
|
+
catch (listenerError) {
|
|
1514
|
+
throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
|
|
1515
|
+
}
|
|
1516
|
+
live.observerAvailable = true;
|
|
1517
|
+
live.markTerminalReady(true);
|
|
1518
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
|
|
1092
1519
|
}
|
|
1093
1520
|
}
|
|
1094
1521
|
else {
|
|
@@ -1103,15 +1530,25 @@ export class LocalAgentHost {
|
|
|
1103
1530
|
live.stopped = true;
|
|
1104
1531
|
forwarder.stop();
|
|
1105
1532
|
void forwarderClient.stop().catch(() => undefined);
|
|
1533
|
+
const failure = err instanceof CodexRuntimeError
|
|
1534
|
+
? err
|
|
1535
|
+
: nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
|
|
1536
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1106
1537
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1107
1538
|
return abandon();
|
|
1108
1539
|
}
|
|
1540
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1109
1541
|
return true;
|
|
1110
1542
|
}
|
|
1111
1543
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
1112
1544
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
1113
1545
|
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1114
1546
|
const previousThreadId = live.threadId;
|
|
1547
|
+
if (previousThreadId !== threadId) {
|
|
1548
|
+
live.threadActive = false;
|
|
1549
|
+
if (!live.model)
|
|
1550
|
+
live.mayUseProviderDefaultModel = true;
|
|
1551
|
+
}
|
|
1115
1552
|
if (previousThreadId && previousThreadId !== threadId) {
|
|
1116
1553
|
live.threadId = threadId;
|
|
1117
1554
|
live.subscribing = true;
|
|
@@ -1140,8 +1577,75 @@ export class LocalAgentHost {
|
|
|
1140
1577
|
});
|
|
1141
1578
|
})
|
|
1142
1579
|
.catch(() => undefined);
|
|
1143
|
-
|
|
1144
|
-
|
|
1580
|
+
const finishBinding = () => {
|
|
1581
|
+
if (live.stopped)
|
|
1582
|
+
return;
|
|
1583
|
+
live.markReady(); // thread id + explicit Session mode are ready for turns
|
|
1584
|
+
if (live.observerAvailable) {
|
|
1585
|
+
void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
// Known-thread cold resume has already completed the dedicated preload. Its
|
|
1589
|
+
// observer is deliberately post-TUI background work, not Terminal
|
|
1590
|
+
// readiness. RunnerSession calls startLiveCodexObserver after launch.
|
|
1591
|
+
live.markTerminalReady(true);
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1595
|
+
if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
|
|
1596
|
+
finishBinding();
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
// A fresh thread does not exist until the TUI broadcasts thread/started.
|
|
1600
|
+
// Apply the Session-owned mode immediately at that boundary, before Web
|
|
1601
|
+
// injection is released. This also narrows the window in which a user can
|
|
1602
|
+
// type into the native pane before its explicit startup mode is installed.
|
|
1603
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1604
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1605
|
+
void restore.then(finishBinding).catch((error) => {
|
|
1606
|
+
const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
|
|
1607
|
+
live.startupError = failure;
|
|
1608
|
+
live.markStartupFailed();
|
|
1609
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1610
|
+
this.teardownLiveCodexSession(localThreadId, failure);
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
/** Start the known-thread observer after the replacement TUI has launched.
|
|
1614
|
+
* Fresh sessions already connected their discovery listener before launch;
|
|
1615
|
+
* this is therefore an idempotent no-op for them and for a healthy observer. */
|
|
1616
|
+
startLiveCodexObserver(localThreadId) {
|
|
1617
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1618
|
+
if (!live ||
|
|
1619
|
+
live.stopped ||
|
|
1620
|
+
live.observerAvailable ||
|
|
1621
|
+
!live.threadId)
|
|
1622
|
+
return;
|
|
1623
|
+
void (async () => {
|
|
1624
|
+
try {
|
|
1625
|
+
await live.forwarderClient.ensureInitialized();
|
|
1626
|
+
if (live.stopped)
|
|
1627
|
+
return;
|
|
1628
|
+
live.observerAvailable = true;
|
|
1629
|
+
// reference implementation starts subscription as a sibling task after the transport
|
|
1630
|
+
// connects. A no/empty rollout remains event-driven retryable; any
|
|
1631
|
+
// other subscription rejection is diagnostic only and must not tear
|
|
1632
|
+
// down the otherwise usable Terminal/injection lifecycle.
|
|
1633
|
+
void this.subscribeUntilReady(localThreadId, live, live.threadId);
|
|
1634
|
+
}
|
|
1635
|
+
catch (error) {
|
|
1636
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
|
|
1637
|
+
}
|
|
1638
|
+
})();
|
|
1639
|
+
}
|
|
1640
|
+
/** reference implementation treats the forwarder as a required component of one native
|
|
1641
|
+
* lifecycle: if its transport dies, it closes the app-server instead of
|
|
1642
|
+
* accepting turns that can no longer reach the canonical mirror. */
|
|
1643
|
+
failObserverLifecycle(localThreadId, live, error) {
|
|
1644
|
+
if (live.stopped)
|
|
1645
|
+
return;
|
|
1646
|
+
live.startupError = error;
|
|
1647
|
+
this.liveStartupErrors.set(localThreadId, error);
|
|
1648
|
+
this.teardownLiveCodexSession(localThreadId, error);
|
|
1145
1649
|
}
|
|
1146
1650
|
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1147
1651
|
const pending = live.managedFork;
|
|
@@ -1165,11 +1669,13 @@ export class LocalAgentHost {
|
|
|
1165
1669
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1166
1670
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1167
1671
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1168
|
-
* thread active, then retry
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1672
|
+
* thread active, then retry. Like reference implementation, the first attempt always uses
|
|
1673
|
+
* `excludeTurns`: a known-session cold resume therefore never reconstructs
|
|
1674
|
+
* historical running/completed state. Only a fresh thread whose first attempt
|
|
1675
|
+
* failed as not-ready retries without `excludeTurns`, backfilling the newly
|
|
1676
|
+
* materialized first turn and de-duplicating it against live notifications.
|
|
1171
1677
|
*/
|
|
1172
|
-
async subscribeUntilReady(live, threadId) {
|
|
1678
|
+
async subscribeUntilReady(localThreadId, live, threadId) {
|
|
1173
1679
|
let sawNotReady = false;
|
|
1174
1680
|
while (!live.stopped) {
|
|
1175
1681
|
try {
|
|
@@ -1177,30 +1683,43 @@ export class LocalAgentHost {
|
|
|
1177
1683
|
threadId,
|
|
1178
1684
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1179
1685
|
approvalPolicy: live.approvalPolicy,
|
|
1180
|
-
excludeTurns:
|
|
1686
|
+
...(!sawNotReady ? { excludeTurns: true } : {}),
|
|
1181
1687
|
});
|
|
1182
|
-
|
|
1183
|
-
|
|
1688
|
+
adoptResumedThreadSettings(live, resp);
|
|
1689
|
+
if (sawNotReady) {
|
|
1690
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1691
|
+
? resp.thread.turns
|
|
1692
|
+
: [];
|
|
1693
|
+
live.forwarder.replayBackfill(turns);
|
|
1184
1694
|
}
|
|
1185
1695
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1186
1696
|
}
|
|
1187
1697
|
catch (error) {
|
|
1188
1698
|
if (!isThreadNotReadyError(error)) {
|
|
1189
|
-
|
|
1699
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1700
|
+
return false;
|
|
1190
1701
|
}
|
|
1191
1702
|
sawNotReady = true;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1703
|
+
if (!live.threadActive) {
|
|
1704
|
+
// An idle fresh thread has no rollout until its first Turn. Wait only
|
|
1705
|
+
// for live activity instead of polling the app-server indefinitely.
|
|
1706
|
+
await new Promise((resolve) => {
|
|
1707
|
+
live.releaseActive = resolve;
|
|
1708
|
+
});
|
|
1709
|
+
live.releaseActive = undefined;
|
|
1710
|
+
}
|
|
1711
|
+
else {
|
|
1712
|
+
// Activity can precede rollout persistence by a short interval.
|
|
1713
|
+
await new Promise((resolve) => {
|
|
1714
|
+
setTimeout(resolve, CODEX_SUBSCRIBE_FLUSH_RETRY_MS);
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1199
1717
|
}
|
|
1200
1718
|
}
|
|
1201
1719
|
return false;
|
|
1202
1720
|
}
|
|
1203
|
-
/** Await a live session's thread binding
|
|
1721
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1722
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1204
1723
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1205
1724
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1206
1725
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
@@ -1209,18 +1728,22 @@ export class LocalAgentHost {
|
|
|
1209
1728
|
const live = this.liveSessions.get(localThreadId);
|
|
1210
1729
|
if (!live)
|
|
1211
1730
|
return false;
|
|
1731
|
+
const ready = live.ready.then(() => true);
|
|
1732
|
+
const failed = live.startupFailed.then(() => false);
|
|
1733
|
+
if (timeoutMs === null)
|
|
1734
|
+
return Promise.race([ready, failed]);
|
|
1212
1735
|
let timer;
|
|
1213
1736
|
const timeout = new Promise((resolve) => {
|
|
1214
1737
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1215
1738
|
});
|
|
1216
|
-
const ok = await Promise.race([
|
|
1739
|
+
const ok = await Promise.race([ready, failed, timeout]);
|
|
1217
1740
|
if (timer)
|
|
1218
1741
|
clearTimeout(timer);
|
|
1219
1742
|
return ok;
|
|
1220
1743
|
}
|
|
1221
|
-
/** Await the
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1744
|
+
/** Await the observer subscription diagnostic. Codex/Traex Terminal startup
|
|
1745
|
+
* deliberately does not gate on this promise: the dedicated preload owns resume,
|
|
1746
|
+
* while the independent observer attaches in the background. */
|
|
1224
1747
|
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1225
1748
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1226
1749
|
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
@@ -1230,6 +1753,8 @@ export class LocalAgentHost {
|
|
|
1230
1753
|
const live = this.liveSessions.get(localThreadId);
|
|
1231
1754
|
if (!live)
|
|
1232
1755
|
return false;
|
|
1756
|
+
if (timeoutMs === null)
|
|
1757
|
+
return live.terminalReady;
|
|
1233
1758
|
let timer;
|
|
1234
1759
|
const timeout = new Promise((resolve) => {
|
|
1235
1760
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1241,7 +1766,40 @@ export class LocalAgentHost {
|
|
|
1241
1766
|
}
|
|
1242
1767
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1243
1768
|
liveSessionError(localThreadId) {
|
|
1244
|
-
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1769
|
+
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1770
|
+
?? this.liveSessions.get(localThreadId)?.startupError?.message
|
|
1771
|
+
?? this.liveStartupErrors.get(localThreadId)?.message;
|
|
1772
|
+
}
|
|
1773
|
+
/** Structured phase + concrete cause used by the runner wire protocol. */
|
|
1774
|
+
liveSessionFailure(localThreadId) {
|
|
1775
|
+
const failure = this.liveSessions.get(localThreadId)?.startupError
|
|
1776
|
+
?? this.liveStartupErrors.get(localThreadId);
|
|
1777
|
+
if (failure) {
|
|
1778
|
+
return {
|
|
1779
|
+
message: failure.message,
|
|
1780
|
+
code: failure.code,
|
|
1781
|
+
statusCode: failure.statusCode,
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1785
|
+
return claudeError
|
|
1786
|
+
? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
|
|
1787
|
+
: undefined;
|
|
1788
|
+
}
|
|
1789
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
1790
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
1791
|
+
* 30s discovery cause. */
|
|
1792
|
+
failLiveStartup(localThreadId, error) {
|
|
1793
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1794
|
+
if (!live || live.threadId || live.stopped)
|
|
1795
|
+
return false;
|
|
1796
|
+
const failure = error instanceof CodexRuntimeError
|
|
1797
|
+
? error
|
|
1798
|
+
: new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
|
|
1799
|
+
live.startupError = failure;
|
|
1800
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1801
|
+
live.markStartupFailed();
|
|
1802
|
+
return true;
|
|
1245
1803
|
}
|
|
1246
1804
|
/**
|
|
1247
1805
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -1250,13 +1808,13 @@ export class LocalAgentHost {
|
|
|
1250
1808
|
* resumed from the persisted native id. Serialized per session so two
|
|
1251
1809
|
* injects can't double-open a turn.
|
|
1252
1810
|
*
|
|
1253
|
-
* Returns an {@link
|
|
1811
|
+
* Returns an {@link InjectResult}: `notLive` when this session has no live
|
|
1254
1812
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
1255
1813
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
1256
1814
|
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
1257
1815
|
* the bridge instead of a short race that falls back to a second output path.
|
|
1258
1816
|
*/
|
|
1259
|
-
async injectMessage(localThreadId, input) {
|
|
1817
|
+
async injectMessage(localThreadId, input, options) {
|
|
1260
1818
|
const runtimeInput = typeof input === "string"
|
|
1261
1819
|
? { content: [{ type: "text", text: input }] }
|
|
1262
1820
|
: input;
|
|
@@ -1267,9 +1825,8 @@ export class LocalAgentHost {
|
|
|
1267
1825
|
const pendingInput = {
|
|
1268
1826
|
content,
|
|
1269
1827
|
signature: JSON.stringify(content),
|
|
1270
|
-
state:
|
|
1828
|
+
state: "awaiting",
|
|
1271
1829
|
observed: false,
|
|
1272
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1273
1830
|
};
|
|
1274
1831
|
claude.pendingInjectedInputs.push(pendingInput);
|
|
1275
1832
|
const forgetPendingInput = () => {
|
|
@@ -1283,8 +1840,17 @@ export class LocalAgentHost {
|
|
|
1283
1840
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1284
1841
|
expiry.unref?.();
|
|
1285
1842
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1843
|
+
let result;
|
|
1844
|
+
try {
|
|
1845
|
+
result = await this.injectClaude(claude, localThreadId, text, pendingInput);
|
|
1846
|
+
}
|
|
1847
|
+
catch (error) {
|
|
1848
|
+
forgetPendingInput();
|
|
1849
|
+
if (token)
|
|
1850
|
+
claude.pendingImageInputs.delete(token);
|
|
1851
|
+
throw error;
|
|
1852
|
+
}
|
|
1853
|
+
if (result.outcome !== "injected" && result.outcome !== "steered") {
|
|
1288
1854
|
forgetPendingInput();
|
|
1289
1855
|
if (token)
|
|
1290
1856
|
claude.pendingImageInputs.delete(token);
|
|
@@ -1292,89 +1858,171 @@ export class LocalAgentHost {
|
|
|
1292
1858
|
else if (pendingInput.observed) {
|
|
1293
1859
|
forgetPendingInput();
|
|
1294
1860
|
}
|
|
1295
|
-
return
|
|
1861
|
+
return result;
|
|
1296
1862
|
}
|
|
1297
1863
|
const live = this.liveSessions.get(localThreadId);
|
|
1298
1864
|
if (!live)
|
|
1299
|
-
return "notLive";
|
|
1865
|
+
return { outcome: "notLive" };
|
|
1300
1866
|
const run = live.injectLock.then(async () => {
|
|
1301
1867
|
if (live.rotationPending || live.stopped)
|
|
1302
|
-
return "failed";
|
|
1868
|
+
return { outcome: "failed" };
|
|
1303
1869
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1304
1870
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1305
|
-
const bound = await this.waitLiveReady(localThreadId,
|
|
1871
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1306
1872
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1307
1873
|
if (!bound || !threadId)
|
|
1308
|
-
return "notReady";
|
|
1874
|
+
return { outcome: "notReady" };
|
|
1309
1875
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1310
1876
|
const content = runtimeUserContent(runtimeInput);
|
|
1877
|
+
const providerEcho = codexUserEchoContent(nativeInput);
|
|
1878
|
+
const providerEchoContent = typeof providerEcho === "string"
|
|
1879
|
+
? [{ type: "input_text", text: providerEcho }]
|
|
1880
|
+
: providerEcho;
|
|
1311
1881
|
const pendingInput = {
|
|
1312
1882
|
content,
|
|
1313
1883
|
signature: JSON.stringify(content),
|
|
1314
|
-
|
|
1884
|
+
...(providerEchoContent
|
|
1885
|
+
? { providerEchoSignature: JSON.stringify(providerEchoContent) }
|
|
1886
|
+
: {}),
|
|
1887
|
+
state: "awaiting",
|
|
1315
1888
|
observed: false,
|
|
1316
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1317
1889
|
};
|
|
1318
1890
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1891
|
+
let injectionMethod = "turn/start";
|
|
1892
|
+
let admissionPublished = false;
|
|
1893
|
+
const publishAdmission = () => {
|
|
1894
|
+
if (admissionPublished)
|
|
1895
|
+
return;
|
|
1896
|
+
admissionPublished = true;
|
|
1897
|
+
live.publishTurnAdmission();
|
|
1898
|
+
};
|
|
1319
1899
|
const forgetPendingInput = () => {
|
|
1320
1900
|
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1321
1901
|
if (index >= 0)
|
|
1322
1902
|
live.pendingInjectedInputs.splice(index, 1);
|
|
1323
1903
|
};
|
|
1904
|
+
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1324
1905
|
try {
|
|
1325
|
-
//
|
|
1906
|
+
// reference implementation publishes codex-native running only after the native Terminal
|
|
1907
|
+
// is ready and the runner has accepted the message, but before the
|
|
1908
|
+
// short-lived app-server client initializes and starts the Turn. Claude
|
|
1909
|
+
// deliberately has no matching synthesized edge.
|
|
1910
|
+
if (!live.forwarder.isTurnOpen())
|
|
1911
|
+
publishAdmission();
|
|
1912
|
+
await injectionClient.ensureInitialized();
|
|
1913
|
+
// Match reference implementation's executor: each message uses one initialized client
|
|
1914
|
+
// that closes as soon as turn/start or turn/steer is acknowledged.
|
|
1326
1915
|
if (live.forwarder.isTurnOpen()) {
|
|
1327
1916
|
const turnId = live.forwarder.currentTurnId();
|
|
1328
1917
|
if (turnId) {
|
|
1329
|
-
|
|
1918
|
+
injectionMethod = "turn/steer";
|
|
1919
|
+
const steered = await injectionClient.turnSteer({
|
|
1330
1920
|
threadId,
|
|
1331
1921
|
expectedTurnId: turnId,
|
|
1332
1922
|
input: nativeInput,
|
|
1333
1923
|
});
|
|
1334
|
-
if (pendingInput.
|
|
1335
|
-
// The caller already persisted and published this user input
|
|
1336
|
-
// before waiting for the native Terminal to become ready.
|
|
1337
|
-
}
|
|
1338
|
-
else if (pendingInput.observed) {
|
|
1924
|
+
if (pendingInput.observed) {
|
|
1339
1925
|
forgetPendingInput();
|
|
1340
1926
|
}
|
|
1341
1927
|
else {
|
|
1342
1928
|
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1343
1929
|
pendingInput.state = "optimistic";
|
|
1344
1930
|
}
|
|
1345
|
-
|
|
1931
|
+
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1932
|
+
return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
// Match reference implementation's turn boundary: change the native thread settings
|
|
1936
|
+
// under the same lock immediately before starting the next Turn. Never
|
|
1937
|
+
// put settings on turn/start or mutate a Turn that is already open.
|
|
1938
|
+
const desiredModel = live.model;
|
|
1939
|
+
const desiredReasoningEffort = live.reasoningEffort;
|
|
1940
|
+
// A Session-owned mode survives runner restarts. Normal Web messages do
|
|
1941
|
+
// not carry per-Turn options, so a cold-resumed native thread must fall
|
|
1942
|
+
// back to the persisted execution snapshot before its first new Turn.
|
|
1943
|
+
// An explicit session.run option still wins for that invocation.
|
|
1944
|
+
const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
|
|
1945
|
+
const collaborationMode = desiredCollaborationMode &&
|
|
1946
|
+
desiredCollaborationMode !== live.appliedCollaborationMode
|
|
1947
|
+
? await resolveLiveCollaborationMode(injectionClient, live, desiredCollaborationMode)
|
|
1948
|
+
: undefined;
|
|
1949
|
+
const settings = {
|
|
1950
|
+
threadId,
|
|
1951
|
+
...(desiredModel !== live.appliedModel
|
|
1952
|
+
? { model: desiredModel || null }
|
|
1953
|
+
: {}),
|
|
1954
|
+
...(desiredReasoningEffort !== live.appliedReasoningEffort
|
|
1955
|
+
? { effort: desiredReasoningEffort ?? null }
|
|
1956
|
+
: {}),
|
|
1957
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
1958
|
+
};
|
|
1959
|
+
if (Object.keys(settings).length > 1) {
|
|
1960
|
+
await injectionClient.threadSettingsUpdate(settings);
|
|
1961
|
+
// Record exactly what this RPC carried. A concurrent settings save may
|
|
1962
|
+
// already have advanced the desired fields and must remain pending for
|
|
1963
|
+
// the following new Turn.
|
|
1964
|
+
if (collaborationMode && !live.model) {
|
|
1965
|
+
live.model = collaborationMode.settings.model;
|
|
1966
|
+
}
|
|
1967
|
+
if (collaborationMode)
|
|
1968
|
+
live.mayUseProviderDefaultModel = false;
|
|
1969
|
+
live.appliedModel = collaborationMode?.settings.model ?? desiredModel;
|
|
1970
|
+
live.appliedReasoningEffort = collaborationMode
|
|
1971
|
+
? collaborationMode.settings.reasoning_effort ?? undefined
|
|
1972
|
+
: desiredReasoningEffort;
|
|
1973
|
+
if (desiredCollaborationMode) {
|
|
1974
|
+
live.appliedCollaborationMode = desiredCollaborationMode;
|
|
1975
|
+
live.execution = {
|
|
1976
|
+
...live.execution,
|
|
1977
|
+
collaborationMode: desiredCollaborationMode,
|
|
1978
|
+
};
|
|
1346
1979
|
}
|
|
1347
1980
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const started = await live.injectClient.turnStart({
|
|
1981
|
+
publishAdmission();
|
|
1982
|
+
const started = await injectionClient.turnStart({
|
|
1351
1983
|
threadId,
|
|
1352
1984
|
input: nativeInput,
|
|
1353
1985
|
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1354
1986
|
approvalPolicy: live.approvalPolicy,
|
|
1355
|
-
...(live.model ? { model: live.model } : {}),
|
|
1356
|
-
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1357
1987
|
});
|
|
1358
|
-
if (pendingInput.
|
|
1359
|
-
// The caller already persisted and published this user input before
|
|
1360
|
-
// waiting for the native Terminal to become ready.
|
|
1361
|
-
}
|
|
1362
|
-
else if (pendingInput.observed) {
|
|
1988
|
+
if (pendingInput.observed) {
|
|
1363
1989
|
forgetPendingInput();
|
|
1364
1990
|
}
|
|
1365
1991
|
else {
|
|
1366
1992
|
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1367
1993
|
pendingInput.state = "optimistic";
|
|
1368
1994
|
}
|
|
1369
|
-
|
|
1995
|
+
// The injection RPC and active-turn write are one serialized operation.
|
|
1996
|
+
// Do not wait for the independent observer connection's `turn/started`:
|
|
1997
|
+
// a second message accepted in that window must steer, not double-start.
|
|
1998
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1999
|
+
return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
|
|
1370
2000
|
}
|
|
1371
2001
|
catch (error) {
|
|
1372
2002
|
forgetPendingInput();
|
|
2003
|
+
if (error instanceof CodexRuntimeError &&
|
|
2004
|
+
error.code === "collaboration_mode_model_unknown") {
|
|
2005
|
+
throw error;
|
|
2006
|
+
}
|
|
1373
2007
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1374
2008
|
// into the unactionable `live injection failed` string.
|
|
1375
|
-
const
|
|
2009
|
+
const baseDetail = codexRpcError(error, injectionMethod);
|
|
2010
|
+
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
2011
|
+
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
2012
|
+
// A turn may open on the observer while this short-lived client is
|
|
2013
|
+
// initializing, converting the submission to steer. A rejected steer
|
|
2014
|
+
// does not fail that already-running Turn; only a new-Turn admission
|
|
2015
|
+
// owns the terminal failed edge.
|
|
2016
|
+
if (admissionPublished && injectionMethod === "turn/start") {
|
|
2017
|
+
live.publishTurnAdmissionFailure(detail);
|
|
2018
|
+
}
|
|
1376
2019
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1377
|
-
throw
|
|
2020
|
+
throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
|
|
2021
|
+
}
|
|
2022
|
+
finally {
|
|
2023
|
+
await injectionClient.stop().catch((stopError) => {
|
|
2024
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
2025
|
+
});
|
|
1378
2026
|
}
|
|
1379
2027
|
});
|
|
1380
2028
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1417,20 +2065,42 @@ export class LocalAgentHost {
|
|
|
1417
2065
|
const live = this.liveSessions.get(localThreadId);
|
|
1418
2066
|
if (!live)
|
|
1419
2067
|
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
2068
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
2069
|
+
const pendingMcp = live.forwarder.cancelMcpStartup();
|
|
2070
|
+
const turnId = live.forwarder.isTurnOpen()
|
|
2071
|
+
? live.forwarder.currentTurnId()
|
|
2072
|
+
: null;
|
|
2073
|
+
if (!threadId)
|
|
2074
|
+
return pendingMcp.length > 0;
|
|
2075
|
+
let handled = pendingMcp.length > 0;
|
|
2076
|
+
const interruptClient = this.injectionClientFactory(live.appServerUrl);
|
|
1428
2077
|
try {
|
|
1429
|
-
await
|
|
1430
|
-
|
|
2078
|
+
await interruptClient.ensureInitialized();
|
|
2079
|
+
if (pendingMcp.length > 0) {
|
|
2080
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
2081
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
2082
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
2083
|
+
try {
|
|
2084
|
+
await interruptClient.turnInterrupt({ threadId, turnId: "" });
|
|
2085
|
+
}
|
|
2086
|
+
catch (error) {
|
|
2087
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
if (turnId) {
|
|
2091
|
+
try {
|
|
2092
|
+
await interruptClient.turnInterrupt({ threadId, turnId });
|
|
2093
|
+
live.publishInterrupted(`resp_codex_${turnId}`);
|
|
2094
|
+
handled = true;
|
|
2095
|
+
}
|
|
2096
|
+
catch {
|
|
2097
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return handled;
|
|
1431
2101
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
2102
|
+
finally {
|
|
2103
|
+
await interruptClient.stop().catch(() => undefined);
|
|
1434
2104
|
}
|
|
1435
2105
|
}
|
|
1436
2106
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
@@ -1448,8 +2118,6 @@ export class LocalAgentHost {
|
|
|
1448
2118
|
claude.forwarder.finalizeStop();
|
|
1449
2119
|
}
|
|
1450
2120
|
removeManagedClaudeSettings(claude.bridgeDir);
|
|
1451
|
-
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
1452
|
-
void claude.skillCleanup?.();
|
|
1453
2121
|
return;
|
|
1454
2122
|
}
|
|
1455
2123
|
const live = this.liveSessions.get(localThreadId);
|
|
@@ -1457,26 +2125,47 @@ export class LocalAgentHost {
|
|
|
1457
2125
|
return;
|
|
1458
2126
|
this.liveSessions.delete(localThreadId);
|
|
1459
2127
|
live.stopped = true;
|
|
2128
|
+
if (!live.threadId) {
|
|
2129
|
+
live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
|
|
2130
|
+
live.markStartupFailed();
|
|
2131
|
+
}
|
|
1460
2132
|
live.releaseActive?.();
|
|
1461
|
-
live.
|
|
2133
|
+
live.cancelSyntheticInteractions("session_stopped");
|
|
2134
|
+
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1462
2135
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1463
|
-
live.
|
|
2136
|
+
live.appServerOwner.setInteractionListener(null);
|
|
1464
2137
|
live.forwarderClient.setInteractionListener(null);
|
|
1465
|
-
live.
|
|
2138
|
+
live.appServerOwner.setConnectionListener(null);
|
|
1466
2139
|
live.forwarderClient.setConnectionListener(null);
|
|
1467
|
-
live.interactionOwners.clear();
|
|
1468
|
-
live.interactionStandbys.clear();
|
|
1469
|
-
live.interactionSubmissions.clear();
|
|
1470
2140
|
live.forwarder.stop();
|
|
1471
|
-
live.interactionRecoveries.clear();
|
|
1472
|
-
live.interactionFailedClients.clear();
|
|
1473
|
-
live.disconnectedClients.clear();
|
|
1474
2141
|
live.canonicalInteractions.clear();
|
|
1475
2142
|
live.settledInteractions.clear();
|
|
2143
|
+
live.syntheticInteractions.clear();
|
|
1476
2144
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
1477
2145
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
1478
2146
|
void live.skillsCleanup?.();
|
|
1479
2147
|
}
|
|
2148
|
+
/** Tear down one codex-lineage native runtime without deleting its durable
|
|
2149
|
+
* session-store binding. reference implementation couples its auxiliary Terminal, observer,
|
|
2150
|
+
* forwarder and per-session app-server as one disposable runtime envelope;
|
|
2151
|
+
* the next message recreates that envelope and cold-resumes the native id. */
|
|
2152
|
+
teardownLiveCodexSession(localThreadId, error) {
|
|
2153
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2154
|
+
if (!live)
|
|
2155
|
+
return false;
|
|
2156
|
+
const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
|
|
2157
|
+
const backendKey = codexBackendKey(live.runtime, live.execution.budget ?? undefined);
|
|
2158
|
+
const backend = this.backends.get(backendKey);
|
|
2159
|
+
const appServerOwner = live.appServerOwner;
|
|
2160
|
+
this.stopLiveCodexSession(localThreadId);
|
|
2161
|
+
if (backend?.appServerClient === appServerOwner) {
|
|
2162
|
+
this.backends.delete(backendKey);
|
|
2163
|
+
}
|
|
2164
|
+
void appServerOwner.stop().catch((stopError) => {
|
|
2165
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
2166
|
+
});
|
|
2167
|
+
return turnFailed;
|
|
2168
|
+
}
|
|
1480
2169
|
/** Complete the second shutdown phase after the runner has killed all native
|
|
1481
2170
|
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1482
2171
|
finalizeStoppedLiveSessions() {
|
|
@@ -1522,7 +2211,7 @@ export class LocalAgentHost {
|
|
|
1522
2211
|
});
|
|
1523
2212
|
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1524
2213
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1525
|
-
// The live session carries the
|
|
2214
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1526
2215
|
const args = buildClaudeTuiArgs({
|
|
1527
2216
|
settingsJson: settingsPath,
|
|
1528
2217
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1530,7 +2219,10 @@ export class LocalAgentHost {
|
|
|
1530
2219
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1531
2220
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1532
2221
|
settingSources: "",
|
|
1533
|
-
model: live.execution.model,
|
|
2222
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
2223
|
+
...(live.execution.reasoningEffort
|
|
2224
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
2225
|
+
: {}),
|
|
1534
2226
|
...(live.execution.instructions
|
|
1535
2227
|
? { appendSystemPrompt: live.execution.instructions }
|
|
1536
2228
|
: {}),
|
|
@@ -1664,17 +2356,27 @@ export class LocalAgentHost {
|
|
|
1664
2356
|
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1665
2357
|
}
|
|
1666
2358
|
const cwd = workspace.cwd;
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
2359
|
+
const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
|
|
2360
|
+
let skillPlugin = hasSkillSnapshot
|
|
2361
|
+
? await reuseClaudePlugin(localThreadId)
|
|
2362
|
+
: null;
|
|
2363
|
+
if (!skillPlugin && hasSkillSnapshot) {
|
|
2364
|
+
let snapshotSkills;
|
|
2365
|
+
try {
|
|
2366
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
2367
|
+
}
|
|
2368
|
+
catch (err) {
|
|
2369
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2370
|
+
return false;
|
|
2371
|
+
}
|
|
2372
|
+
try {
|
|
2373
|
+
skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
|
|
2374
|
+
}
|
|
2375
|
+
finally {
|
|
2376
|
+
await snapshotSkills.skillsCleanup();
|
|
2377
|
+
}
|
|
1674
2378
|
}
|
|
1675
|
-
const model = execution.model;
|
|
1676
|
-
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1677
|
-
void snapshotSkills.skillsCleanup();
|
|
2379
|
+
const model = execution.model ?? "";
|
|
1678
2380
|
const permissionMode = execution.permissionMode;
|
|
1679
2381
|
const launchExtraArgs = [
|
|
1680
2382
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
@@ -1695,15 +2397,14 @@ export class LocalAgentHost {
|
|
|
1695
2397
|
};
|
|
1696
2398
|
const startNormalizer = (turnId) => {
|
|
1697
2399
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1698
|
-
const responseId =
|
|
1699
|
-
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
2400
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1700
2401
|
if (normalizer && currentResponseId === responseId)
|
|
1701
2402
|
return normalizer;
|
|
1702
2403
|
currentResponseId = responseId;
|
|
1703
2404
|
normalizer = new SessionNormalizer({
|
|
1704
2405
|
sessionId: currentSessionId,
|
|
1705
2406
|
responseId,
|
|
1706
|
-
model,
|
|
2407
|
+
model: model || "claude",
|
|
1707
2408
|
});
|
|
1708
2409
|
return normalizer;
|
|
1709
2410
|
};
|
|
@@ -1741,6 +2442,8 @@ export class LocalAgentHost {
|
|
|
1741
2442
|
injectLock: Promise.resolve(),
|
|
1742
2443
|
pendingImageInputs: new Map(),
|
|
1743
2444
|
pendingInjectedInputs: [],
|
|
2445
|
+
currentResponseId: () => currentResponseId,
|
|
2446
|
+
publishInterrupted: () => undefined,
|
|
1744
2447
|
ready,
|
|
1745
2448
|
markReady,
|
|
1746
2449
|
failed,
|
|
@@ -1750,7 +2453,58 @@ export class LocalAgentHost {
|
|
|
1750
2453
|
launchExtraArgs,
|
|
1751
2454
|
permissionMode,
|
|
1752
2455
|
...(forkIntent ? { forkIntent } : {}),
|
|
1753
|
-
|
|
2456
|
+
};
|
|
2457
|
+
live.publishInterrupted = (responseId) => {
|
|
2458
|
+
if (live.interruptedResponseId === responseId)
|
|
2459
|
+
return;
|
|
2460
|
+
live.interruptedResponseId = responseId;
|
|
2461
|
+
emitCurrent({
|
|
2462
|
+
type: "session.interrupted",
|
|
2463
|
+
sessionId: currentSessionId,
|
|
2464
|
+
responseId,
|
|
2465
|
+
});
|
|
2466
|
+
};
|
|
2467
|
+
const settleClaudeTurn = (interrupted, usage, backgroundTaskCount) => {
|
|
2468
|
+
if (!normalizer)
|
|
2469
|
+
return;
|
|
2470
|
+
const rid = currentResponseId;
|
|
2471
|
+
if (interrupted) {
|
|
2472
|
+
for (const se of normalizer.interrupt()) {
|
|
2473
|
+
if (se.type === "session.interrupted" &&
|
|
2474
|
+
live.interruptedResponseId === rid)
|
|
2475
|
+
continue;
|
|
2476
|
+
emitCurrent(se);
|
|
2477
|
+
}
|
|
2478
|
+
live.interruptedResponseId = undefined;
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
2482
|
+
if (usage) {
|
|
2483
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
2484
|
+
emitCurrent(se);
|
|
2485
|
+
}
|
|
2486
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
2487
|
+
emitCurrent(se.type === "session.status" && backgroundTaskCount !== undefined
|
|
2488
|
+
? { ...se, backgroundTaskCount }
|
|
2489
|
+
: se);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2493
|
+
normalizer = null;
|
|
2494
|
+
currentResponseId = undefined;
|
|
2495
|
+
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
2496
|
+
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
2497
|
+
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
2498
|
+
if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
2499
|
+
live.contextWarned = true;
|
|
2500
|
+
emitCurrent({
|
|
2501
|
+
type: "session.status",
|
|
2502
|
+
sessionId: currentSessionId,
|
|
2503
|
+
responseId: rid,
|
|
2504
|
+
status: "idle",
|
|
2505
|
+
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
1754
2508
|
};
|
|
1755
2509
|
const sink = {
|
|
1756
2510
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
@@ -1763,12 +2517,13 @@ export class LocalAgentHost {
|
|
|
1763
2517
|
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1764
2518
|
const signature = JSON.stringify(normalizedContent);
|
|
1765
2519
|
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1766
|
-
if (pending
|
|
2520
|
+
if (pending) {
|
|
2521
|
+
pending.responseId = currentResponseId;
|
|
1767
2522
|
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
if (pending)
|
|
2523
|
+
if (pending.state === "optimistic")
|
|
2524
|
+
return;
|
|
1771
2525
|
pending.observed = true;
|
|
2526
|
+
}
|
|
1772
2527
|
for (const se of n.userInput(normalizedContent))
|
|
1773
2528
|
emitCurrent(se);
|
|
1774
2529
|
},
|
|
@@ -1784,56 +2539,62 @@ export class LocalAgentHost {
|
|
|
1784
2539
|
emitCurrent(se);
|
|
1785
2540
|
},
|
|
1786
2541
|
onInteraction: forwardInteraction,
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1805
|
-
live.contextWarned = true;
|
|
1806
|
-
emitCurrent({
|
|
1807
|
-
type: "session.status",
|
|
1808
|
-
sessionId: currentSessionId,
|
|
1809
|
-
responseId: rid,
|
|
1810
|
-
status: "idle",
|
|
1811
|
-
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
1812
|
-
});
|
|
1813
|
-
}
|
|
2542
|
+
onStatus: (status, blockedOn) => {
|
|
2543
|
+
const responseId = currentResponseId ??
|
|
2544
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
2545
|
+
emitCurrent({
|
|
2546
|
+
type: "session.status",
|
|
2547
|
+
sessionId: currentSessionId,
|
|
2548
|
+
...(responseId ? { responseId } : {}),
|
|
2549
|
+
status,
|
|
2550
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
2551
|
+
});
|
|
2552
|
+
},
|
|
2553
|
+
onTurnEnd: (usage, backgroundTaskCount) => settleClaudeTurn(false, usage, backgroundTaskCount),
|
|
2554
|
+
onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
|
|
2555
|
+
onTurnInterruptRequested: () => {
|
|
2556
|
+
const responseId = currentResponseId;
|
|
2557
|
+
if (responseId)
|
|
2558
|
+
live.publishInterrupted(responseId);
|
|
1814
2559
|
},
|
|
1815
|
-
onIdle: () => {
|
|
1816
|
-
// Surface
|
|
1817
|
-
//
|
|
1818
|
-
|
|
2560
|
+
onIdle: (backgroundTaskCount) => {
|
|
2561
|
+
// Surface Stop even after its Turn has already closed: a later
|
|
2562
|
+
// authoritative zero is what clears a sticky background-shell tally.
|
|
2563
|
+
// When the Turn is still open, retain its identity without finalizing it
|
|
2564
|
+
// so a late assistant record continues to join the same Response.
|
|
2565
|
+
emitCurrent({
|
|
2566
|
+
type: "session.status",
|
|
2567
|
+
sessionId: currentSessionId,
|
|
2568
|
+
...(currentResponseId ? { responseId: currentResponseId } : {}),
|
|
2569
|
+
status: "idle",
|
|
2570
|
+
...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
|
|
2571
|
+
});
|
|
2572
|
+
},
|
|
2573
|
+
onTurnError: (error) => {
|
|
2574
|
+
const message = error.message || "Agent turn failed";
|
|
2575
|
+
if (!normalizer) {
|
|
2576
|
+
// Native pane/forwarder death is Session-level even when Claude is
|
|
2577
|
+
// between Turns. Omnigent publishes the same bare failed edge; the
|
|
2578
|
+
// explicit zero also retires any sticky background-shell tally.
|
|
1819
2579
|
emitCurrent({
|
|
1820
2580
|
type: "session.status",
|
|
1821
2581
|
sessionId: currentSessionId,
|
|
1822
|
-
|
|
1823
|
-
|
|
2582
|
+
status: "failed",
|
|
2583
|
+
backgroundTaskCount: 0,
|
|
2584
|
+
note: message,
|
|
1824
2585
|
});
|
|
1825
|
-
}
|
|
1826
|
-
},
|
|
1827
|
-
onTurnError: () => {
|
|
1828
|
-
if (!normalizer)
|
|
1829
2586
|
return;
|
|
2587
|
+
}
|
|
1830
2588
|
const rid = currentResponseId;
|
|
1831
2589
|
for (const se of normalizer.fail({
|
|
1832
2590
|
code: "agent_error",
|
|
1833
|
-
message
|
|
2591
|
+
message,
|
|
1834
2592
|
source: "execution",
|
|
1835
|
-
}))
|
|
1836
|
-
emitCurrent(se
|
|
2593
|
+
})) {
|
|
2594
|
+
emitCurrent(se.type === "session.status"
|
|
2595
|
+
? { ...se, status: "failed", note: message }
|
|
2596
|
+
: se);
|
|
2597
|
+
}
|
|
1837
2598
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1838
2599
|
normalizer = null;
|
|
1839
2600
|
currentResponseId = undefined;
|
|
@@ -1941,17 +2702,18 @@ export class LocalAgentHost {
|
|
|
1941
2702
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1942
2703
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1943
2704
|
* appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
|
|
1944
|
-
* fall-through-to-run signal. Returns {@link
|
|
1945
|
-
injectClaude(live, localThreadId, text) {
|
|
2705
|
+
* fall-through-to-run signal. Returns {@link InjectResult}. */
|
|
2706
|
+
injectClaude(live, localThreadId, text, pendingInput) {
|
|
1946
2707
|
const run = live.injectLock.then(async () => {
|
|
1947
2708
|
const ready = await this.waitLiveReady(localThreadId, 60_000);
|
|
1948
2709
|
if (!ready)
|
|
1949
|
-
return "notReady";
|
|
2710
|
+
return { outcome: "notReady" };
|
|
1950
2711
|
// Pane may have just relaunched — park until its injector re-attaches
|
|
1951
2712
|
// (attachTerminalInjector resets it) instead of returning false → fallback.
|
|
1952
2713
|
const injector = await this.waitInjector(live, 60_000);
|
|
1953
2714
|
if (!injector)
|
|
1954
|
-
return "notReady";
|
|
2715
|
+
return { outcome: "notReady" };
|
|
2716
|
+
const steered = live.forwarder.isTurnOpen();
|
|
1955
2717
|
// Abortable: the web Stop button cancels an in-flight paste/submit (before
|
|
1956
2718
|
// the message reaches claude) via interruptLive → injectAbort.abort().
|
|
1957
2719
|
const abort = new AbortController();
|
|
@@ -1962,10 +2724,18 @@ export class LocalAgentHost {
|
|
|
1962
2724
|
signal: abort.signal,
|
|
1963
2725
|
submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
|
|
1964
2726
|
});
|
|
1965
|
-
|
|
2727
|
+
if (!ok)
|
|
2728
|
+
return { outcome: "failed" };
|
|
2729
|
+
const responseId = pendingInput.responseId ?? live.currentResponseId();
|
|
2730
|
+
if (!responseId) {
|
|
2731
|
+
live.error = "Claude accepted the message but did not publish its native Turn identity";
|
|
2732
|
+
return { outcome: "failed" };
|
|
2733
|
+
}
|
|
2734
|
+
return { outcome: steered ? "steered" : "injected", responseId };
|
|
1966
2735
|
}
|
|
1967
|
-
catch {
|
|
1968
|
-
|
|
2736
|
+
catch (error) {
|
|
2737
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
2738
|
+
return { outcome: "failed" };
|
|
1969
2739
|
}
|
|
1970
2740
|
finally {
|
|
1971
2741
|
if (live.injectAbort === abort)
|
|
@@ -1987,12 +2757,31 @@ export class LocalAgentHost {
|
|
|
1987
2757
|
return live.injector ?? null;
|
|
1988
2758
|
}
|
|
1989
2759
|
/** Attach a session's tmux pane injector (from the runner-child, which owns the
|
|
1990
|
-
* terminal registry — the host does not hold tmux).
|
|
1991
|
-
*
|
|
2760
|
+
* terminal registry — the host does not hold tmux). Claude uses it for normal
|
|
2761
|
+
* message injection; Codex uses it only to dismiss the exact TUI-local Plan
|
|
2762
|
+
* picker after its synthetic Web form is submitted. Idempotent. */
|
|
1992
2763
|
attachTerminalInjector(localThreadId, injector) {
|
|
1993
2764
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1994
|
-
if (live)
|
|
2765
|
+
if (live) {
|
|
2766
|
+
if (live.injector === injector)
|
|
2767
|
+
return;
|
|
1995
2768
|
live.injector = injector;
|
|
2769
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2773
|
+
if (codex)
|
|
2774
|
+
codex.injector = injector;
|
|
2775
|
+
}
|
|
2776
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
2777
|
+
failLiveSession(localThreadId, error) {
|
|
2778
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2779
|
+
if (claude)
|
|
2780
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
2781
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2782
|
+
if (!codex)
|
|
2783
|
+
return false;
|
|
2784
|
+
return codex.forwarder.failOpenTurn(error);
|
|
1996
2785
|
}
|
|
1997
2786
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1998
2787
|
async listModels(runtime) {
|
|
@@ -2022,7 +2811,7 @@ export class LocalAgentHost {
|
|
|
2022
2811
|
return { ok: false, reason: "unsupported" };
|
|
2023
2812
|
}
|
|
2024
2813
|
try {
|
|
2025
|
-
return { ok: true, data: await fn(live.
|
|
2814
|
+
return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
|
|
2026
2815
|
}
|
|
2027
2816
|
catch (error) {
|
|
2028
2817
|
return {
|
|
@@ -2094,7 +2883,7 @@ export class LocalAgentHost {
|
|
|
2094
2883
|
return { ok: true, data: undefined };
|
|
2095
2884
|
}
|
|
2096
2885
|
const runtime = execution.provider;
|
|
2097
|
-
const client = live?.
|
|
2886
|
+
const client = live?.appServerOwner ??
|
|
2098
2887
|
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2099
2888
|
if (!client) {
|
|
2100
2889
|
return { ok: false, reason: "unsupported" };
|
|
@@ -2121,7 +2910,7 @@ export class LocalAgentHost {
|
|
|
2121
2910
|
threadId: record.codexSessionId,
|
|
2122
2911
|
excludeTurns: true,
|
|
2123
2912
|
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2124
|
-
model: execution.model,
|
|
2913
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2125
2914
|
approvalPolicy: execution.approvalPolicy,
|
|
2126
2915
|
});
|
|
2127
2916
|
}
|
|
@@ -2163,11 +2952,11 @@ export class LocalAgentHost {
|
|
|
2163
2952
|
}
|
|
2164
2953
|
}
|
|
2165
2954
|
}
|
|
2166
|
-
function
|
|
2955
|
+
function codexRpcError(error, method) {
|
|
2167
2956
|
if (error instanceof CodexTransportError) {
|
|
2168
|
-
return `Codex app-server rejected
|
|
2957
|
+
return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
|
|
2169
2958
|
}
|
|
2170
|
-
return `Codex
|
|
2959
|
+
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2171
2960
|
}
|
|
2172
2961
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2173
2962
|
const normalized = output.trim();
|
|
@@ -2192,20 +2981,83 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
2192
2981
|
issues: normalized ? [normalized] : [],
|
|
2193
2982
|
};
|
|
2194
2983
|
}
|
|
2195
|
-
/** Race a live session's `ready` promise against failure
|
|
2984
|
+
/** Race a live session's `ready` promise against failure and, when supplied,
|
|
2985
|
+
* a timeout. */
|
|
2196
2986
|
function raceReady(ready, timeoutMs, failed) {
|
|
2197
2987
|
let timer;
|
|
2198
|
-
const
|
|
2199
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2200
|
-
});
|
|
2201
|
-
const outcomes = [ready.then(() => true), timeout];
|
|
2988
|
+
const outcomes = [ready.then(() => true)];
|
|
2202
2989
|
if (failed)
|
|
2203
2990
|
outcomes.push(failed.then(() => false));
|
|
2991
|
+
if (timeoutMs !== null) {
|
|
2992
|
+
outcomes.push(new Promise((resolve) => {
|
|
2993
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2994
|
+
}));
|
|
2995
|
+
}
|
|
2204
2996
|
return Promise.race(outcomes).finally(() => {
|
|
2205
2997
|
if (timer)
|
|
2206
2998
|
clearTimeout(timer);
|
|
2207
2999
|
});
|
|
2208
3000
|
}
|
|
3001
|
+
/** Record the authoritative settings returned while binding an existing
|
|
3002
|
+
* native thread. A Session-level explicit selection remains a pending desired
|
|
3003
|
+
* override; otherwise the resumed thread itself is the source of truth. */
|
|
3004
|
+
function adoptResumedThreadSettings(live, settings) {
|
|
3005
|
+
const model = settings.model?.trim();
|
|
3006
|
+
if (model) {
|
|
3007
|
+
live.mayUseProviderDefaultModel = false;
|
|
3008
|
+
live.appliedModel = model;
|
|
3009
|
+
if (!live.execution.model)
|
|
3010
|
+
live.model = model;
|
|
3011
|
+
}
|
|
3012
|
+
if (settings.reasoningEffort !== undefined) {
|
|
3013
|
+
live.appliedReasoningEffort = settings.reasoningEffort ?? undefined;
|
|
3014
|
+
if (!live.execution.reasoningEffort) {
|
|
3015
|
+
live.reasoningEffort = settings.reasoningEffort ?? undefined;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
/** Resolve the model required by a full collaboration-mode snapshot. Only a
|
|
3020
|
+
* fresh thread with no explicit/config model may use the catalog default; a
|
|
3021
|
+
* resumed thread must report its own authoritative model. */
|
|
3022
|
+
async function resolveLiveCollaborationMode(client, live, mode) {
|
|
3023
|
+
let model = live.model.trim();
|
|
3024
|
+
if (!model && live.mayUseProviderDefaultModel) {
|
|
3025
|
+
let catalog;
|
|
3026
|
+
try {
|
|
3027
|
+
catalog = await client.modelList();
|
|
3028
|
+
}
|
|
3029
|
+
catch (error) {
|
|
3030
|
+
throw new CodexRuntimeError(`collaboration mode requires the Provider default model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
|
|
3031
|
+
}
|
|
3032
|
+
const defaults = [...new Set(catalog.data
|
|
3033
|
+
.filter((candidate) => candidate.isDefault === true)
|
|
3034
|
+
.map((candidate) => (candidate.model || candidate.id).trim())
|
|
3035
|
+
.filter(Boolean))];
|
|
3036
|
+
if (defaults.length !== 1) {
|
|
3037
|
+
throw new CodexRuntimeError(`collaboration mode requires exactly one Provider default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
|
|
3038
|
+
}
|
|
3039
|
+
model = defaults[0];
|
|
3040
|
+
}
|
|
3041
|
+
return buildCollaborationMode(mode, model, live.reasoningEffort);
|
|
3042
|
+
}
|
|
3043
|
+
/** Expand the public mode enum into the App Server's required settings
|
|
3044
|
+
* snapshot using the Session's tracked effective model. */
|
|
3045
|
+
function buildCollaborationMode(mode, desiredModel, desiredReasoningEffort) {
|
|
3046
|
+
const model = desiredModel.trim();
|
|
3047
|
+
if (!model) {
|
|
3048
|
+
throw new CodexRuntimeError("collaboration mode requires the current model, but the Provider did not report one", 503, "collaboration_mode_model_unknown");
|
|
3049
|
+
}
|
|
3050
|
+
return {
|
|
3051
|
+
mode,
|
|
3052
|
+
settings: {
|
|
3053
|
+
model,
|
|
3054
|
+
reasoning_effort: desiredReasoningEffort ?? null,
|
|
3055
|
+
// `null` asks the App Server to install the built-in instructions for
|
|
3056
|
+
// the selected mode; thread-level Agent instructions remain separate.
|
|
3057
|
+
developer_instructions: null,
|
|
3058
|
+
},
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
2209
3061
|
function sameSessionSnapshots(workspace, execution, opts) {
|
|
2210
3062
|
return JSON.stringify(stableValue({
|
|
2211
3063
|
workspace,
|
|
@@ -2215,6 +3067,40 @@ function sameSessionSnapshots(workspace, execution, opts) {
|
|
|
2215
3067
|
execution: opts.execution,
|
|
2216
3068
|
}));
|
|
2217
3069
|
}
|
|
3070
|
+
function sameImmutableSessionSnapshots(workspace, execution, opts) {
|
|
3071
|
+
const current = {
|
|
3072
|
+
...execution,
|
|
3073
|
+
model: null,
|
|
3074
|
+
reasoningEffort: null,
|
|
3075
|
+
collaborationMode: undefined,
|
|
3076
|
+
};
|
|
3077
|
+
const requested = {
|
|
3078
|
+
...opts.execution,
|
|
3079
|
+
model: null,
|
|
3080
|
+
reasoningEffort: null,
|
|
3081
|
+
collaborationMode: undefined,
|
|
3082
|
+
};
|
|
3083
|
+
return JSON.stringify(stableValue({ workspace, execution: current })) ===
|
|
3084
|
+
JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
|
|
3085
|
+
}
|
|
3086
|
+
function updateLiveCodexTurnSettings(live, opts) {
|
|
3087
|
+
if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
|
|
3088
|
+
return false;
|
|
3089
|
+
}
|
|
3090
|
+
const previousModel = live.execution.model;
|
|
3091
|
+
const previousReasoningEffort = live.execution.reasoningEffort;
|
|
3092
|
+
live.execution = structuredClone(opts.execution);
|
|
3093
|
+
// Repeated calls normally carry the same immutable Session snapshot. Preserve
|
|
3094
|
+
// a newer native `/model` or effort change unless Core explicitly changed the
|
|
3095
|
+
// corresponding Session setting between calls.
|
|
3096
|
+
if (opts.execution.model !== previousModel) {
|
|
3097
|
+
live.model = opts.execution.model?.trim() ?? "";
|
|
3098
|
+
}
|
|
3099
|
+
if (opts.execution.reasoningEffort !== previousReasoningEffort) {
|
|
3100
|
+
live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
|
|
3101
|
+
}
|
|
3102
|
+
return true;
|
|
3103
|
+
}
|
|
2218
3104
|
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2219
3105
|
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2220
3106
|
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
@@ -2267,10 +3153,13 @@ export function isThreadNotReadyError(error) {
|
|
|
2267
3153
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2268
3154
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
2269
3155
|
}
|
|
2270
|
-
/**
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
2273
|
-
|
|
2274
|
-
const
|
|
2275
|
-
|
|
3156
|
+
/** Build the user-facing native lifecycle failure once, at the phase that owns
|
|
3157
|
+
* it. Downstream layers preserve this code/message verbatim instead of replacing
|
|
3158
|
+
* it with a generic "failed to start; see logs" wrapper. */
|
|
3159
|
+
function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
|
|
3160
|
+
const provider = runtime === "traex" ? "Traex" : "Codex";
|
|
3161
|
+
const detail = cause === undefined
|
|
3162
|
+
? ""
|
|
3163
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
3164
|
+
return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
|
|
2276
3165
|
}
|