@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.31
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 +1333 -468
- 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 +49 -21
- package/dist/runner/child.js +629 -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,12 +9,14 @@ 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";
|
|
@@ -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,34 @@ 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
|
+
publishTurnAdmission: () => undefined,
|
|
877
|
+
publishTurnAdmissionFailure: () => undefined,
|
|
878
|
+
publishInterrupted: () => undefined,
|
|
879
|
+
interruptedResponseId: null,
|
|
671
880
|
subscribing: false,
|
|
672
881
|
rotationPending: false,
|
|
673
882
|
stopped: false,
|
|
883
|
+
observerAvailable: !coldResume,
|
|
884
|
+
threadActive: false,
|
|
674
885
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
675
|
-
interactionOwners: new Map(),
|
|
676
|
-
interactionStandbys: new Map(),
|
|
677
|
-
interactionSubmissions: new Map(),
|
|
678
886
|
canonicalInteractions: new Set(),
|
|
679
887
|
settledInteractions: new Set(),
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
disconnectedClients: new Set(),
|
|
888
|
+
syntheticInteractions: new Map(),
|
|
889
|
+
cancelSyntheticInteractions: () => undefined,
|
|
683
890
|
};
|
|
684
891
|
let currentSessionId = localThreadId;
|
|
685
892
|
let pendingRotationEvents = null;
|
|
@@ -690,11 +897,23 @@ export class LocalAgentHost {
|
|
|
690
897
|
}
|
|
691
898
|
emit(event);
|
|
692
899
|
};
|
|
900
|
+
live.publishInterrupted = (responseId) => {
|
|
901
|
+
if (live.interruptedResponseId === responseId)
|
|
902
|
+
return;
|
|
903
|
+
live.interruptedResponseId = responseId;
|
|
904
|
+
emitCurrent({
|
|
905
|
+
type: "session.interrupted",
|
|
906
|
+
sessionId: currentSessionId,
|
|
907
|
+
responseId,
|
|
908
|
+
});
|
|
909
|
+
};
|
|
693
910
|
let normalizer = null;
|
|
694
911
|
let currentResponseId = null;
|
|
912
|
+
/** State for content-only events, keyed by Provider response so consecutive
|
|
913
|
+
* late deltas retain one canonical item id without owning lifecycle. */
|
|
914
|
+
const contentNormalizers = new Map();
|
|
695
915
|
const startNormalizer = (turnId) => {
|
|
696
|
-
const responseId =
|
|
697
|
-
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
916
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
698
917
|
if (normalizer && currentResponseId === responseId)
|
|
699
918
|
return normalizer;
|
|
700
919
|
currentResponseId = responseId;
|
|
@@ -704,26 +923,68 @@ export class LocalAgentHost {
|
|
|
704
923
|
// `turn/started` land on ONE response instead of splitting into random
|
|
705
924
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
706
925
|
responseId,
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
// label, so report the runtime when the exact model is unknown.
|
|
926
|
+
// A null Session model intentionally leaves selection to the Provider
|
|
927
|
+
// CLI. Canonical events still require a printable label.
|
|
710
928
|
model: model || runtime,
|
|
711
929
|
});
|
|
712
930
|
return normalizer;
|
|
713
931
|
};
|
|
714
|
-
live.
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
932
|
+
live.publishTurnAdmission = () => {
|
|
933
|
+
emitCurrent({
|
|
934
|
+
type: "session.status",
|
|
935
|
+
sessionId: currentSessionId,
|
|
936
|
+
status: "running",
|
|
937
|
+
});
|
|
938
|
+
};
|
|
939
|
+
live.publishTurnAdmissionFailure = (message) => {
|
|
940
|
+
emitCurrent({
|
|
941
|
+
type: "session.status",
|
|
942
|
+
sessionId: currentSessionId,
|
|
943
|
+
status: "failed",
|
|
944
|
+
note: message,
|
|
945
|
+
});
|
|
721
946
|
};
|
|
722
947
|
const clearPendingInputsForResponse = (responseId) => {
|
|
723
948
|
if (!responseId)
|
|
724
949
|
return;
|
|
725
950
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
726
951
|
};
|
|
952
|
+
const turnContentEvents = (turnId, produce) => {
|
|
953
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
954
|
+
let contentNormalizer = contentNormalizers.get(responseId);
|
|
955
|
+
if (!contentNormalizer) {
|
|
956
|
+
contentNormalizer = new SessionNormalizer({
|
|
957
|
+
sessionId: currentSessionId,
|
|
958
|
+
responseId,
|
|
959
|
+
model: live.model || live.runtime,
|
|
960
|
+
});
|
|
961
|
+
contentNormalizers.set(responseId, contentNormalizer);
|
|
962
|
+
}
|
|
963
|
+
for (const event of produce(contentNormalizer)) {
|
|
964
|
+
// Item/transient content is attached to the Turn's response id
|
|
965
|
+
// independently of lifecycle status. Reuse Rynx's canonical item
|
|
966
|
+
// normalization but suppress its synthetic lifecycle edges.
|
|
967
|
+
if (event.type !== "response.created" && event.type !== "session.status") {
|
|
968
|
+
emitCurrent(event);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
const observedUserContent = (content) => {
|
|
973
|
+
const normalizedContent = typeof content === "string"
|
|
974
|
+
? [{ type: "input_text", text: content }]
|
|
975
|
+
: content;
|
|
976
|
+
// The Provider does not echo a caller id and may reformat the text, so the
|
|
977
|
+
// first native user record drains the oldest injected input instead of
|
|
978
|
+
// comparing message text.
|
|
979
|
+
const pending = live.pendingInjectedInputs[0];
|
|
980
|
+
if (pending) {
|
|
981
|
+
pending.observed = true;
|
|
982
|
+
if (pending.state === "accepted") {
|
|
983
|
+
live.pendingInjectedInputs.shift();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return normalizedContent;
|
|
987
|
+
};
|
|
727
988
|
const rememberSettledInteraction = (interactionId) => {
|
|
728
989
|
live.settledInteractions.add(interactionId);
|
|
729
990
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -732,11 +993,174 @@ export class LocalAgentHost {
|
|
|
732
993
|
if (oldest)
|
|
733
994
|
live.settledInteractions.delete(oldest);
|
|
734
995
|
};
|
|
996
|
+
const cancelSyntheticInteractions = (reason) => {
|
|
997
|
+
for (const interaction of live.syntheticInteractions.values())
|
|
998
|
+
interaction.cancel(reason);
|
|
999
|
+
};
|
|
1000
|
+
live.cancelSyntheticInteractions = cancelSyntheticInteractions;
|
|
1001
|
+
const publishPlanImplementationPrompt = (prompt) => {
|
|
1002
|
+
const suffix = createHash("sha256")
|
|
1003
|
+
.update(`${prompt.threadId}\0${prompt.turnId}`)
|
|
1004
|
+
.digest("hex")
|
|
1005
|
+
.slice(0, 24);
|
|
1006
|
+
const interactionId = `codex_plan_implementation_${suffix}`;
|
|
1007
|
+
if (live.syntheticInteractions.has(interactionId) ||
|
|
1008
|
+
live.settledInteractions.has(interactionId))
|
|
1009
|
+
return;
|
|
1010
|
+
const responseId = `resp_codex_plan_approval_${suffix}`;
|
|
1011
|
+
const request = codexPlanImplementationRequest(interactionId);
|
|
1012
|
+
const interactionNormalizer = new SessionNormalizer({
|
|
1013
|
+
sessionId: currentSessionId,
|
|
1014
|
+
responseId,
|
|
1015
|
+
model: live.model || live.runtime,
|
|
1016
|
+
});
|
|
1017
|
+
let submitted = false;
|
|
1018
|
+
const finish = (kind, resolution, reason) => {
|
|
1019
|
+
if (!live.syntheticInteractions.delete(interactionId))
|
|
1020
|
+
return;
|
|
1021
|
+
rememberSettledInteraction(interactionId);
|
|
1022
|
+
const event = kind === "resolved"
|
|
1023
|
+
? {
|
|
1024
|
+
type: "interaction_resolved",
|
|
1025
|
+
interactionId,
|
|
1026
|
+
resolution: resolution,
|
|
1027
|
+
}
|
|
1028
|
+
: {
|
|
1029
|
+
type: "interaction_cancelled",
|
|
1030
|
+
interactionId,
|
|
1031
|
+
...(reason ? { reason } : {}),
|
|
1032
|
+
};
|
|
1033
|
+
// This interaction belongs to the Session that produced the Plan. A
|
|
1034
|
+
// clear-context choice can rotate the native thread before turn/start
|
|
1035
|
+
// returns, so never retarget its resolution to the new Session.
|
|
1036
|
+
for (const sessionEvent of interactionNormalizer.next(event))
|
|
1037
|
+
emit(sessionEvent);
|
|
1038
|
+
for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
|
|
1039
|
+
emit(sessionEvent);
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
live.syntheticInteractions.set(interactionId, {
|
|
1043
|
+
cancel: (reason) => {
|
|
1044
|
+
// The selected implementation itself starts a native Turn and the
|
|
1045
|
+
// clear-context variant also starts a new thread. Their observer
|
|
1046
|
+
// notifications can beat the short-lived RPC response; they are not
|
|
1047
|
+
// competing user actions and must not cancel the card being resolved.
|
|
1048
|
+
if (submitted &&
|
|
1049
|
+
(reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
|
|
1050
|
+
return;
|
|
1051
|
+
finish("cancelled", undefined, reason);
|
|
1052
|
+
},
|
|
1053
|
+
resolve: async (resolution) => {
|
|
1054
|
+
if (submitted)
|
|
1055
|
+
return { disposition: "already_resolved" };
|
|
1056
|
+
const invalid = validateInteractionResolution(request, resolution);
|
|
1057
|
+
if (invalid)
|
|
1058
|
+
return { disposition: "invalid", message: invalid };
|
|
1059
|
+
const choice = resolution.answers?.plan_implementation;
|
|
1060
|
+
if (typeof choice !== "string") {
|
|
1061
|
+
return { disposition: "invalid", message: "plan implementation choice is required" };
|
|
1062
|
+
}
|
|
1063
|
+
submitted = true;
|
|
1064
|
+
const run = live.injectLock.then(async () => {
|
|
1065
|
+
if (live.stopped || live.rotationPending) {
|
|
1066
|
+
throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
|
|
1067
|
+
}
|
|
1068
|
+
const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
|
|
1069
|
+
!live.rotationPending &&
|
|
1070
|
+
live.syntheticInteractions.has(interactionId) &&
|
|
1071
|
+
live.forwarder.threadId() === prompt.threadId &&
|
|
1072
|
+
!live.forwarder.isTurnOpen());
|
|
1073
|
+
if (promptDismissal === "failed") {
|
|
1074
|
+
console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
|
|
1075
|
+
}
|
|
1076
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
|
|
1077
|
+
finish("resolved", resolution);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const client = this.injectionClientFactory(live.appServerUrl);
|
|
1081
|
+
try {
|
|
1082
|
+
await client.ensureInitialized();
|
|
1083
|
+
const collaborationMode = await resolveLiveCollaborationMode(client, live, "default");
|
|
1084
|
+
let threadId = prompt.threadId;
|
|
1085
|
+
let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
|
|
1086
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
|
|
1087
|
+
live.nextThreadCollaborationMode = "default";
|
|
1088
|
+
let startedThread;
|
|
1089
|
+
try {
|
|
1090
|
+
startedThread = await client.threadStart({
|
|
1091
|
+
...(live.model ? { model: live.model } : {}),
|
|
1092
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1093
|
+
approvalPolicy: live.approvalPolicy,
|
|
1094
|
+
...(live.instructions ? { developerInstructions: live.instructions } : {}),
|
|
1095
|
+
sessionStartSource: "clear",
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1100
|
+
throw error;
|
|
1101
|
+
}
|
|
1102
|
+
threadId = startedThread.threadId;
|
|
1103
|
+
text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
|
|
1104
|
+
}
|
|
1105
|
+
else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
|
|
1106
|
+
throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
|
|
1107
|
+
}
|
|
1108
|
+
const started = await client.turnStart({
|
|
1109
|
+
threadId,
|
|
1110
|
+
input: [{ type: "text", text }],
|
|
1111
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1112
|
+
approvalPolicy: live.approvalPolicy,
|
|
1113
|
+
collaborationMode,
|
|
1114
|
+
});
|
|
1115
|
+
if (!live.model)
|
|
1116
|
+
live.model = collaborationMode.settings.model;
|
|
1117
|
+
live.mayUseProviderDefaultModel = false;
|
|
1118
|
+
live.appliedModel = collaborationMode.settings.model;
|
|
1119
|
+
live.appliedReasoningEffort =
|
|
1120
|
+
collaborationMode.settings.reasoning_effort ?? undefined;
|
|
1121
|
+
live.execution = { ...live.execution, collaborationMode: "default" };
|
|
1122
|
+
live.appliedCollaborationMode = "default";
|
|
1123
|
+
emitCurrent({
|
|
1124
|
+
type: "session.collaboration_mode",
|
|
1125
|
+
sessionId: currentSessionId,
|
|
1126
|
+
mode: "default",
|
|
1127
|
+
});
|
|
1128
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1129
|
+
finish("resolved", resolution);
|
|
1130
|
+
}
|
|
1131
|
+
finally {
|
|
1132
|
+
await client.stop().catch(() => undefined);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
1136
|
+
try {
|
|
1137
|
+
await run;
|
|
1138
|
+
return { disposition: "applied" };
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
submitted = false;
|
|
1142
|
+
return {
|
|
1143
|
+
disposition: "invalid",
|
|
1144
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
for (const sessionEvent of interactionNormalizer.next({
|
|
1150
|
+
type: "interaction_requested",
|
|
1151
|
+
interaction: request,
|
|
1152
|
+
}))
|
|
1153
|
+
emit(sessionEvent);
|
|
1154
|
+
};
|
|
735
1155
|
const forwardInteraction = (event) => {
|
|
736
1156
|
const interactionId = event.type === "requested"
|
|
737
1157
|
? event.request.interactionId
|
|
738
1158
|
: event.interactionId;
|
|
739
1159
|
if (event.type === "requested") {
|
|
1160
|
+
if (event.request.fields.some((field) => field.id === "plan_implementation")) {
|
|
1161
|
+
live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
|
|
1162
|
+
cancelSyntheticInteractions("superseded_by_native_plan_prompt");
|
|
1163
|
+
}
|
|
740
1164
|
if (live.canonicalInteractions.has(interactionId) ||
|
|
741
1165
|
live.settledInteractions.has(interactionId))
|
|
742
1166
|
return;
|
|
@@ -768,223 +1192,179 @@ export class LocalAgentHost {
|
|
|
768
1192
|
for (const se of n.next(agentEvent))
|
|
769
1193
|
emitCurrent(se);
|
|
770
1194
|
};
|
|
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
|
-
});
|
|
1195
|
+
const bindObserverClient = (client) => {
|
|
1196
|
+
client.setInteractionListener(forwardInteraction);
|
|
902
1197
|
client.setConnectionListener((state) => {
|
|
903
|
-
if (state === "connected")
|
|
904
|
-
live.disconnectedClients.delete(client);
|
|
1198
|
+
if (state === "connected" || live.stopped)
|
|
905
1199
|
return;
|
|
1200
|
+
live.observerAvailable = false;
|
|
1201
|
+
for (const interactionId of [...live.canonicalInteractions]) {
|
|
1202
|
+
forwardInteraction({
|
|
1203
|
+
type: "cancelled",
|
|
1204
|
+
interactionId,
|
|
1205
|
+
reason: "app_server_disconnected",
|
|
1206
|
+
});
|
|
906
1207
|
}
|
|
907
|
-
|
|
908
|
-
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
909
|
-
if (allInteractionClientsUnavailable(interactionId)) {
|
|
910
|
-
finishRecovery(interactionId, recovery.event);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
1208
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
|
|
913
1209
|
});
|
|
914
1210
|
};
|
|
915
1211
|
const closeCanonicalInteractions = () => {
|
|
916
1212
|
for (const interactionId of live.canonicalInteractions) {
|
|
917
1213
|
rememberSettledInteraction(interactionId);
|
|
918
|
-
clearInteractionRouting(interactionId);
|
|
919
1214
|
}
|
|
920
1215
|
live.canonicalInteractions.clear();
|
|
921
1216
|
};
|
|
1217
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
1218
|
+
closeCanonicalInteractions();
|
|
1219
|
+
if (!normalizer)
|
|
1220
|
+
return;
|
|
1221
|
+
const completedResponseId = currentResponseId;
|
|
1222
|
+
if (usage) {
|
|
1223
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1224
|
+
emitCurrent(se);
|
|
1225
|
+
}
|
|
1226
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
1227
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
1228
|
+
continue;
|
|
1229
|
+
emitCurrent(se);
|
|
1230
|
+
}
|
|
1231
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
1232
|
+
normalizer = null;
|
|
1233
|
+
currentResponseId = null;
|
|
1234
|
+
};
|
|
1235
|
+
const failCurrentTurn = (error) => {
|
|
1236
|
+
closeCanonicalInteractions();
|
|
1237
|
+
if (!normalizer)
|
|
1238
|
+
return;
|
|
1239
|
+
const failedResponseId = currentResponseId;
|
|
1240
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
1241
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
1242
|
+
const message = responseStopped
|
|
1243
|
+
? "Response stopped"
|
|
1244
|
+
: runtime === "traex"
|
|
1245
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
1246
|
+
: error.message;
|
|
1247
|
+
for (const se of normalizer.fail({
|
|
1248
|
+
code: responseStopped
|
|
1249
|
+
? "response_stopped"
|
|
1250
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
1251
|
+
message,
|
|
1252
|
+
source: "execution",
|
|
1253
|
+
})) {
|
|
1254
|
+
emitCurrent(se.type === "session.status"
|
|
1255
|
+
? { ...se, status: "failed", note: message }
|
|
1256
|
+
: se);
|
|
1257
|
+
}
|
|
1258
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
1259
|
+
normalizer = null;
|
|
1260
|
+
currentResponseId = null;
|
|
1261
|
+
};
|
|
1262
|
+
const interruptCurrentTurn = () => {
|
|
1263
|
+
closeCanonicalInteractions();
|
|
1264
|
+
if (!normalizer)
|
|
1265
|
+
return;
|
|
1266
|
+
const interruptedResponseId = currentResponseId;
|
|
1267
|
+
for (const se of normalizer.interrupt()) {
|
|
1268
|
+
if (se.type === "session.interrupted" &&
|
|
1269
|
+
live.interruptedResponseId === interruptedResponseId)
|
|
1270
|
+
continue;
|
|
1271
|
+
emitCurrent(se);
|
|
1272
|
+
}
|
|
1273
|
+
live.interruptedResponseId = null;
|
|
1274
|
+
clearPendingInputsForResponse(interruptedResponseId);
|
|
1275
|
+
normalizer = null;
|
|
1276
|
+
currentResponseId = null;
|
|
1277
|
+
};
|
|
922
1278
|
const sink = {
|
|
923
1279
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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);
|
|
932
|
-
return;
|
|
1280
|
+
onTurnObserved: (turnId) => {
|
|
1281
|
+
cancelSyntheticInteractions("superseded_by_native_turn");
|
|
1282
|
+
const n = startNormalizer(turnId);
|
|
1283
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
1284
|
+
emitCurrent(event);
|
|
933
1285
|
}
|
|
934
|
-
|
|
935
|
-
|
|
1286
|
+
},
|
|
1287
|
+
onUserMessage: (content) => {
|
|
1288
|
+
const normalizedContent = observedUserContent(content);
|
|
936
1289
|
const n = normalizer ?? startNormalizer();
|
|
937
1290
|
for (const se of n.userInput(normalizedContent))
|
|
938
1291
|
emitCurrent(se);
|
|
939
1292
|
},
|
|
1293
|
+
onTurnContentUserMessage: (turnId, content) => {
|
|
1294
|
+
const normalizedContent = observedUserContent(content);
|
|
1295
|
+
turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
|
|
1296
|
+
},
|
|
940
1297
|
onEvent: (event) => {
|
|
941
1298
|
const n = normalizer ?? startNormalizer();
|
|
942
1299
|
for (const se of n.next(event))
|
|
943
1300
|
emitCurrent(se);
|
|
944
1301
|
},
|
|
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;
|
|
1302
|
+
onTurnContentEvent: (turnId, event) => {
|
|
1303
|
+
turnContentEvents(turnId, (n) => n.next(event));
|
|
959
1304
|
},
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1305
|
+
onStatus: (note, statusKind) => {
|
|
1306
|
+
const responseId = currentResponseId ??
|
|
1307
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
1308
|
+
emitCurrent({
|
|
1309
|
+
type: "session.status",
|
|
1310
|
+
sessionId: currentSessionId,
|
|
1311
|
+
...(responseId ? { responseId } : {}),
|
|
1312
|
+
status: "running",
|
|
1313
|
+
...(note ? { note } : {}),
|
|
1314
|
+
...(statusKind ? { statusKind } : {}),
|
|
1315
|
+
});
|
|
1316
|
+
},
|
|
1317
|
+
// A newer authoritative turn/started completes the old canonical
|
|
1318
|
+
// response without publishing a false idle edge in a running→running
|
|
1319
|
+
// transition.
|
|
1320
|
+
onTurnEnd: completeCurrentTurn,
|
|
1321
|
+
onTurnInterrupted: interruptCurrentTurn,
|
|
1322
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1323
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1324
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
1325
|
+
if (status === "failed")
|
|
1326
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
1327
|
+
else
|
|
1328
|
+
completeCurrentTurn();
|
|
963
1329
|
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;
|
|
1330
|
+
}
|
|
1331
|
+
emitCurrent({
|
|
1332
|
+
type: "session.status",
|
|
1333
|
+
sessionId: currentSessionId,
|
|
1334
|
+
...(responseId ? { responseId } : {}),
|
|
1335
|
+
status,
|
|
1336
|
+
...(error ? { note: error.message } : {}),
|
|
1337
|
+
});
|
|
983
1338
|
},
|
|
1339
|
+
onTurnError: failCurrentTurn,
|
|
984
1340
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
985
1341
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
986
1342
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
987
|
-
onThreadActive: () =>
|
|
1343
|
+
onThreadActive: () => {
|
|
1344
|
+
live.threadActive = true;
|
|
1345
|
+
live.releaseActive?.();
|
|
1346
|
+
},
|
|
1347
|
+
onCollaborationModeChanged: (mode) => {
|
|
1348
|
+
live.appliedCollaborationMode = mode;
|
|
1349
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
1350
|
+
emitCurrent({
|
|
1351
|
+
type: "session.collaboration_mode",
|
|
1352
|
+
sessionId: currentSessionId,
|
|
1353
|
+
mode,
|
|
1354
|
+
});
|
|
1355
|
+
},
|
|
1356
|
+
onThreadSettingsChanged: ({ model: reportedModel, reasoningEffort: reportedEffort }) => {
|
|
1357
|
+
if (reportedModel) {
|
|
1358
|
+
live.model = reportedModel;
|
|
1359
|
+
live.appliedModel = reportedModel;
|
|
1360
|
+
live.mayUseProviderDefaultModel = false;
|
|
1361
|
+
}
|
|
1362
|
+
if (reportedEffort !== undefined) {
|
|
1363
|
+
live.reasoningEffort = reportedEffort ?? undefined;
|
|
1364
|
+
live.appliedReasoningEffort = reportedEffort ?? undefined;
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
onPlanImplementationPrompt: publishPlanImplementationPrompt,
|
|
988
1368
|
};
|
|
989
1369
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
990
1370
|
// Traex 0.200 can publish a final reasoning item immediately after
|
|
@@ -995,6 +1375,7 @@ export class LocalAgentHost {
|
|
|
995
1375
|
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
996
1376
|
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
997
1377
|
surfaceQueueStatus: runtime === "traex",
|
|
1378
|
+
mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
|
|
998
1379
|
});
|
|
999
1380
|
live.forwarder = forwarder;
|
|
1000
1381
|
live.onNativeThreadRotated = (threadId, kind) => {
|
|
@@ -1002,12 +1383,20 @@ export class LocalAgentHost {
|
|
|
1002
1383
|
live.stopped = true;
|
|
1003
1384
|
return;
|
|
1004
1385
|
}
|
|
1386
|
+
cancelSyntheticInteractions("native_thread_rotated");
|
|
1005
1387
|
const previousSessionId = currentSessionId;
|
|
1006
1388
|
const newSessionId = makeSessionId();
|
|
1389
|
+
const rotationMode = live.nextThreadCollaborationMode;
|
|
1390
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1391
|
+
live.appliedCollaborationMode = undefined;
|
|
1392
|
+
if (rotationMode) {
|
|
1393
|
+
live.execution = { ...live.execution, collaborationMode: rotationMode };
|
|
1394
|
+
}
|
|
1007
1395
|
live.rotationPending = true;
|
|
1008
1396
|
currentSessionId = newSessionId;
|
|
1009
1397
|
normalizer = null;
|
|
1010
1398
|
currentResponseId = null;
|
|
1399
|
+
contentNormalizers.clear();
|
|
1011
1400
|
live.pendingInjectedInputs = [];
|
|
1012
1401
|
pendingRotationEvents = [];
|
|
1013
1402
|
void this.sessionStore
|
|
@@ -1040,7 +1429,15 @@ export class LocalAgentHost {
|
|
|
1040
1429
|
live.rotationPending = false;
|
|
1041
1430
|
for (const event of queued)
|
|
1042
1431
|
emit(event);
|
|
1043
|
-
|
|
1432
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1433
|
+
if (desiredMode) {
|
|
1434
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1435
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1436
|
+
void restore.catch((error) => {
|
|
1437
|
+
this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1044
1441
|
})
|
|
1045
1442
|
.catch(() => {
|
|
1046
1443
|
pendingRotationEvents = null;
|
|
@@ -1048,8 +1445,7 @@ export class LocalAgentHost {
|
|
|
1048
1445
|
live.stopped = true;
|
|
1049
1446
|
});
|
|
1050
1447
|
};
|
|
1051
|
-
|
|
1052
|
-
bindInteractionClient(forwarderClient);
|
|
1448
|
+
bindObserverClient(forwarderClient);
|
|
1053
1449
|
this.liveSessions.set(localThreadId, live);
|
|
1054
1450
|
forwarder.start();
|
|
1055
1451
|
// Existing bindings resume through the public app-server API. A fresh thread
|
|
@@ -1059,36 +1455,56 @@ export class LocalAgentHost {
|
|
|
1059
1455
|
// turn-less app-server thread resumable by the TUI.
|
|
1060
1456
|
try {
|
|
1061
1457
|
if (record?.codexSessionId) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1458
|
+
// Match reference implementation's cold-resume preload exactly: one history-free
|
|
1459
|
+
// `thread/resume` on a short-lived, separately initialized connection.
|
|
1460
|
+
// The backend owner's handshake above is the app-server readiness
|
|
1461
|
+
// probe; reusing that first connection here races Codex's startup state
|
|
1462
|
+
// and can return `Not initialized`. A failure stays attached to the
|
|
1463
|
+
// original binding and is reported by this phase; it is not polled.
|
|
1464
|
+
const preloadClient = this.preloadClientFactory(appServerUrl);
|
|
1465
|
+
try {
|
|
1466
|
+
let resumed;
|
|
1066
1467
|
try {
|
|
1067
|
-
|
|
1468
|
+
await preloadClient.ensureInitialized();
|
|
1469
|
+
resumed = await preloadClient.threadResume({
|
|
1068
1470
|
threadId: record.codexSessionId,
|
|
1069
1471
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1070
1472
|
approvalPolicy,
|
|
1071
1473
|
excludeTurns: true,
|
|
1072
1474
|
});
|
|
1073
|
-
|
|
1074
|
-
|
|
1475
|
+
adoptResumedThreadSettings(live, resumed);
|
|
1476
|
+
await this.applyLiveThreadSettings(live, resumed.threadId, execution.collaborationMode, preloadClient);
|
|
1075
1477
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
if (attempt + 1 < attempts) {
|
|
1081
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1082
|
-
}
|
|
1478
|
+
finally {
|
|
1479
|
+
await preloadClient.stop().catch((error) => {
|
|
1480
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1481
|
+
});
|
|
1083
1482
|
}
|
|
1483
|
+
forwarder.noteThreadBound(resumed.threadId);
|
|
1484
|
+
this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
|
|
1084
1485
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1486
|
+
catch (error) {
|
|
1487
|
+
if (!isThreadNotReadyError(error) ||
|
|
1488
|
+
record.parentSessionId ||
|
|
1489
|
+
record.runtimeHomeOwnerSessionId) {
|
|
1490
|
+
throw error;
|
|
1491
|
+
}
|
|
1492
|
+
// Preserve the existing turn-less binding recovery without polling:
|
|
1493
|
+
// an ordinary native id with no rollout/turn has no Provider history
|
|
1494
|
+
// to retain. Fork bindings remain strict, and `thread not found` is
|
|
1495
|
+
// not classified here because it does not prove the history is empty.
|
|
1496
|
+
await this.sessionStore.delete(localThreadId);
|
|
1497
|
+
live.threadId = null;
|
|
1498
|
+
live.mayUseProviderDefaultModel = !live.model;
|
|
1499
|
+
try {
|
|
1500
|
+
await forwarderClient.ensureInitialized();
|
|
1501
|
+
}
|
|
1502
|
+
catch (listenerError) {
|
|
1503
|
+
throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
|
|
1504
|
+
}
|
|
1505
|
+
live.observerAvailable = true;
|
|
1506
|
+
live.markTerminalReady(true);
|
|
1507
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
|
|
1092
1508
|
}
|
|
1093
1509
|
}
|
|
1094
1510
|
else {
|
|
@@ -1103,15 +1519,25 @@ export class LocalAgentHost {
|
|
|
1103
1519
|
live.stopped = true;
|
|
1104
1520
|
forwarder.stop();
|
|
1105
1521
|
void forwarderClient.stop().catch(() => undefined);
|
|
1522
|
+
const failure = err instanceof CodexRuntimeError
|
|
1523
|
+
? err
|
|
1524
|
+
: nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
|
|
1525
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1106
1526
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1107
1527
|
return abandon();
|
|
1108
1528
|
}
|
|
1529
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1109
1530
|
return true;
|
|
1110
1531
|
}
|
|
1111
1532
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
1112
1533
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
1113
1534
|
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1114
1535
|
const previousThreadId = live.threadId;
|
|
1536
|
+
if (previousThreadId !== threadId) {
|
|
1537
|
+
live.threadActive = false;
|
|
1538
|
+
if (!live.model)
|
|
1539
|
+
live.mayUseProviderDefaultModel = true;
|
|
1540
|
+
}
|
|
1115
1541
|
if (previousThreadId && previousThreadId !== threadId) {
|
|
1116
1542
|
live.threadId = threadId;
|
|
1117
1543
|
live.subscribing = true;
|
|
@@ -1140,8 +1566,75 @@ export class LocalAgentHost {
|
|
|
1140
1566
|
});
|
|
1141
1567
|
})
|
|
1142
1568
|
.catch(() => undefined);
|
|
1143
|
-
|
|
1144
|
-
|
|
1569
|
+
const finishBinding = () => {
|
|
1570
|
+
if (live.stopped)
|
|
1571
|
+
return;
|
|
1572
|
+
live.markReady(); // thread id + explicit Session mode are ready for turns
|
|
1573
|
+
if (live.observerAvailable) {
|
|
1574
|
+
void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
// Known-thread cold resume has already completed the dedicated preload. Its
|
|
1578
|
+
// observer is deliberately post-TUI background work, not Terminal
|
|
1579
|
+
// readiness. RunnerSession calls startLiveCodexObserver after launch.
|
|
1580
|
+
live.markTerminalReady(true);
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1584
|
+
if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
|
|
1585
|
+
finishBinding();
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
// A fresh thread does not exist until the TUI broadcasts thread/started.
|
|
1589
|
+
// Apply the Session-owned mode immediately at that boundary, before Web
|
|
1590
|
+
// injection is released. This also narrows the window in which a user can
|
|
1591
|
+
// type into the native pane before its explicit startup mode is installed.
|
|
1592
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1593
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1594
|
+
void restore.then(finishBinding).catch((error) => {
|
|
1595
|
+
const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
|
|
1596
|
+
live.startupError = failure;
|
|
1597
|
+
live.markStartupFailed();
|
|
1598
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1599
|
+
this.teardownLiveCodexSession(localThreadId, failure);
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
/** Start the known-thread observer after the replacement TUI has launched.
|
|
1603
|
+
* Fresh sessions already connected their discovery listener before launch;
|
|
1604
|
+
* this is therefore an idempotent no-op for them and for a healthy observer. */
|
|
1605
|
+
startLiveCodexObserver(localThreadId) {
|
|
1606
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1607
|
+
if (!live ||
|
|
1608
|
+
live.stopped ||
|
|
1609
|
+
live.observerAvailable ||
|
|
1610
|
+
!live.threadId)
|
|
1611
|
+
return;
|
|
1612
|
+
void (async () => {
|
|
1613
|
+
try {
|
|
1614
|
+
await live.forwarderClient.ensureInitialized();
|
|
1615
|
+
if (live.stopped)
|
|
1616
|
+
return;
|
|
1617
|
+
live.observerAvailable = true;
|
|
1618
|
+
// reference implementation starts subscription as a sibling task after the transport
|
|
1619
|
+
// connects. A no/empty rollout remains event-driven retryable; any
|
|
1620
|
+
// other subscription rejection is diagnostic only and must not tear
|
|
1621
|
+
// down the otherwise usable Terminal/injection lifecycle.
|
|
1622
|
+
void this.subscribeUntilReady(localThreadId, live, live.threadId);
|
|
1623
|
+
}
|
|
1624
|
+
catch (error) {
|
|
1625
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
|
|
1626
|
+
}
|
|
1627
|
+
})();
|
|
1628
|
+
}
|
|
1629
|
+
/** reference implementation treats the forwarder as a required component of one native
|
|
1630
|
+
* lifecycle: if its transport dies, it closes the app-server instead of
|
|
1631
|
+
* accepting turns that can no longer reach the canonical mirror. */
|
|
1632
|
+
failObserverLifecycle(localThreadId, live, error) {
|
|
1633
|
+
if (live.stopped)
|
|
1634
|
+
return;
|
|
1635
|
+
live.startupError = error;
|
|
1636
|
+
this.liveStartupErrors.set(localThreadId, error);
|
|
1637
|
+
this.teardownLiveCodexSession(localThreadId, error);
|
|
1145
1638
|
}
|
|
1146
1639
|
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1147
1640
|
const pending = live.managedFork;
|
|
@@ -1165,11 +1658,13 @@ export class LocalAgentHost {
|
|
|
1165
1658
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1166
1659
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1167
1660
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1168
|
-
* thread active, then retry
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1661
|
+
* thread active, then retry. Like reference implementation, the first attempt always uses
|
|
1662
|
+
* `excludeTurns`: a known-session cold resume therefore never reconstructs
|
|
1663
|
+
* historical running/completed state. Only a fresh thread whose first attempt
|
|
1664
|
+
* failed as not-ready retries without `excludeTurns`, backfilling the newly
|
|
1665
|
+
* materialized first turn and de-duplicating it against live notifications.
|
|
1171
1666
|
*/
|
|
1172
|
-
async subscribeUntilReady(live, threadId) {
|
|
1667
|
+
async subscribeUntilReady(localThreadId, live, threadId) {
|
|
1173
1668
|
let sawNotReady = false;
|
|
1174
1669
|
while (!live.stopped) {
|
|
1175
1670
|
try {
|
|
@@ -1177,30 +1672,43 @@ export class LocalAgentHost {
|
|
|
1177
1672
|
threadId,
|
|
1178
1673
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1179
1674
|
approvalPolicy: live.approvalPolicy,
|
|
1180
|
-
excludeTurns:
|
|
1675
|
+
...(!sawNotReady ? { excludeTurns: true } : {}),
|
|
1181
1676
|
});
|
|
1182
|
-
|
|
1183
|
-
|
|
1677
|
+
adoptResumedThreadSettings(live, resp);
|
|
1678
|
+
if (sawNotReady) {
|
|
1679
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1680
|
+
? resp.thread.turns
|
|
1681
|
+
: [];
|
|
1682
|
+
live.forwarder.replayBackfill(turns);
|
|
1184
1683
|
}
|
|
1185
1684
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1186
1685
|
}
|
|
1187
1686
|
catch (error) {
|
|
1188
1687
|
if (!isThreadNotReadyError(error)) {
|
|
1189
|
-
|
|
1688
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1689
|
+
return false;
|
|
1190
1690
|
}
|
|
1191
1691
|
sawNotReady = true;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1692
|
+
if (!live.threadActive) {
|
|
1693
|
+
// An idle fresh thread has no rollout until its first Turn. Wait only
|
|
1694
|
+
// for live activity instead of polling the app-server indefinitely.
|
|
1695
|
+
await new Promise((resolve) => {
|
|
1696
|
+
live.releaseActive = resolve;
|
|
1697
|
+
});
|
|
1698
|
+
live.releaseActive = undefined;
|
|
1699
|
+
}
|
|
1700
|
+
else {
|
|
1701
|
+
// Activity can precede rollout persistence by a short interval.
|
|
1702
|
+
await new Promise((resolve) => {
|
|
1703
|
+
setTimeout(resolve, CODEX_SUBSCRIBE_FLUSH_RETRY_MS);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1199
1706
|
}
|
|
1200
1707
|
}
|
|
1201
1708
|
return false;
|
|
1202
1709
|
}
|
|
1203
|
-
/** Await a live session's thread binding
|
|
1710
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1711
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1204
1712
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1205
1713
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1206
1714
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
@@ -1209,18 +1717,22 @@ export class LocalAgentHost {
|
|
|
1209
1717
|
const live = this.liveSessions.get(localThreadId);
|
|
1210
1718
|
if (!live)
|
|
1211
1719
|
return false;
|
|
1720
|
+
const ready = live.ready.then(() => true);
|
|
1721
|
+
const failed = live.startupFailed.then(() => false);
|
|
1722
|
+
if (timeoutMs === null)
|
|
1723
|
+
return Promise.race([ready, failed]);
|
|
1212
1724
|
let timer;
|
|
1213
1725
|
const timeout = new Promise((resolve) => {
|
|
1214
1726
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1215
1727
|
});
|
|
1216
|
-
const ok = await Promise.race([
|
|
1728
|
+
const ok = await Promise.race([ready, failed, timeout]);
|
|
1217
1729
|
if (timer)
|
|
1218
1730
|
clearTimeout(timer);
|
|
1219
1731
|
return ok;
|
|
1220
1732
|
}
|
|
1221
|
-
/** Await the
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1733
|
+
/** Await the observer subscription diagnostic. Codex/Traex Terminal startup
|
|
1734
|
+
* deliberately does not gate on this promise: the dedicated preload owns resume,
|
|
1735
|
+
* while the independent observer attaches in the background. */
|
|
1224
1736
|
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1225
1737
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1226
1738
|
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
@@ -1230,6 +1742,8 @@ export class LocalAgentHost {
|
|
|
1230
1742
|
const live = this.liveSessions.get(localThreadId);
|
|
1231
1743
|
if (!live)
|
|
1232
1744
|
return false;
|
|
1745
|
+
if (timeoutMs === null)
|
|
1746
|
+
return live.terminalReady;
|
|
1233
1747
|
let timer;
|
|
1234
1748
|
const timeout = new Promise((resolve) => {
|
|
1235
1749
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1241,7 +1755,40 @@ export class LocalAgentHost {
|
|
|
1241
1755
|
}
|
|
1242
1756
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1243
1757
|
liveSessionError(localThreadId) {
|
|
1244
|
-
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1758
|
+
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1759
|
+
?? this.liveSessions.get(localThreadId)?.startupError?.message
|
|
1760
|
+
?? this.liveStartupErrors.get(localThreadId)?.message;
|
|
1761
|
+
}
|
|
1762
|
+
/** Structured phase + concrete cause used by the runner wire protocol. */
|
|
1763
|
+
liveSessionFailure(localThreadId) {
|
|
1764
|
+
const failure = this.liveSessions.get(localThreadId)?.startupError
|
|
1765
|
+
?? this.liveStartupErrors.get(localThreadId);
|
|
1766
|
+
if (failure) {
|
|
1767
|
+
return {
|
|
1768
|
+
message: failure.message,
|
|
1769
|
+
code: failure.code,
|
|
1770
|
+
statusCode: failure.statusCode,
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1774
|
+
return claudeError
|
|
1775
|
+
? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
|
|
1776
|
+
: undefined;
|
|
1777
|
+
}
|
|
1778
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
1779
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
1780
|
+
* 30s discovery cause. */
|
|
1781
|
+
failLiveStartup(localThreadId, error) {
|
|
1782
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1783
|
+
if (!live || live.threadId || live.stopped)
|
|
1784
|
+
return false;
|
|
1785
|
+
const failure = error instanceof CodexRuntimeError
|
|
1786
|
+
? error
|
|
1787
|
+
: new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
|
|
1788
|
+
live.startupError = failure;
|
|
1789
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1790
|
+
live.markStartupFailed();
|
|
1791
|
+
return true;
|
|
1245
1792
|
}
|
|
1246
1793
|
/**
|
|
1247
1794
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -1250,13 +1797,13 @@ export class LocalAgentHost {
|
|
|
1250
1797
|
* resumed from the persisted native id. Serialized per session so two
|
|
1251
1798
|
* injects can't double-open a turn.
|
|
1252
1799
|
*
|
|
1253
|
-
* Returns an {@link
|
|
1800
|
+
* Returns an {@link InjectResult}: `notLive` when this session has no live
|
|
1254
1801
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
1255
1802
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
1256
1803
|
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
1257
1804
|
* the bridge instead of a short race that falls back to a second output path.
|
|
1258
1805
|
*/
|
|
1259
|
-
async injectMessage(localThreadId, input) {
|
|
1806
|
+
async injectMessage(localThreadId, input, options) {
|
|
1260
1807
|
const runtimeInput = typeof input === "string"
|
|
1261
1808
|
? { content: [{ type: "text", text: input }] }
|
|
1262
1809
|
: input;
|
|
@@ -1265,11 +1812,8 @@ export class LocalAgentHost {
|
|
|
1265
1812
|
const text = claudeInputText(runtimeInput);
|
|
1266
1813
|
const content = runtimeUserContent(runtimeInput);
|
|
1267
1814
|
const pendingInput = {
|
|
1268
|
-
|
|
1269
|
-
signature: JSON.stringify(content),
|
|
1270
|
-
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1815
|
+
state: "awaiting",
|
|
1271
1816
|
observed: false,
|
|
1272
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1273
1817
|
};
|
|
1274
1818
|
claude.pendingInjectedInputs.push(pendingInput);
|
|
1275
1819
|
const forgetPendingInput = () => {
|
|
@@ -1283,98 +1827,179 @@ export class LocalAgentHost {
|
|
|
1283
1827
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1284
1828
|
expiry.unref?.();
|
|
1285
1829
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1830
|
+
let result;
|
|
1831
|
+
try {
|
|
1832
|
+
result = await this.injectClaude(claude, localThreadId, text, pendingInput);
|
|
1833
|
+
}
|
|
1834
|
+
catch (error) {
|
|
1288
1835
|
forgetPendingInput();
|
|
1289
1836
|
if (token)
|
|
1290
1837
|
claude.pendingImageInputs.delete(token);
|
|
1838
|
+
throw error;
|
|
1291
1839
|
}
|
|
1292
|
-
|
|
1840
|
+
if (result.outcome !== "injected" && result.outcome !== "steered") {
|
|
1293
1841
|
forgetPendingInput();
|
|
1842
|
+
if (token)
|
|
1843
|
+
claude.pendingImageInputs.delete(token);
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
pendingInput.responseId = result.responseId;
|
|
1847
|
+
if (pendingInput.observed)
|
|
1848
|
+
forgetPendingInput();
|
|
1849
|
+
else
|
|
1850
|
+
pendingInput.state = "accepted";
|
|
1294
1851
|
}
|
|
1295
|
-
return
|
|
1852
|
+
return result;
|
|
1296
1853
|
}
|
|
1297
1854
|
const live = this.liveSessions.get(localThreadId);
|
|
1298
1855
|
if (!live)
|
|
1299
|
-
return "notLive";
|
|
1856
|
+
return { outcome: "notLive" };
|
|
1300
1857
|
const run = live.injectLock.then(async () => {
|
|
1301
1858
|
if (live.rotationPending || live.stopped)
|
|
1302
|
-
return "failed";
|
|
1859
|
+
return { outcome: "failed" };
|
|
1303
1860
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1304
1861
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1305
|
-
const bound = await this.waitLiveReady(localThreadId,
|
|
1862
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1306
1863
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1307
1864
|
if (!bound || !threadId)
|
|
1308
|
-
return "notReady";
|
|
1865
|
+
return { outcome: "notReady" };
|
|
1309
1866
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1310
|
-
const content = runtimeUserContent(runtimeInput);
|
|
1311
1867
|
const pendingInput = {
|
|
1312
|
-
|
|
1313
|
-
signature: JSON.stringify(content),
|
|
1314
|
-
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1868
|
+
state: "awaiting",
|
|
1315
1869
|
observed: false,
|
|
1316
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1317
1870
|
};
|
|
1318
1871
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1872
|
+
let injectionMethod = "turn/start";
|
|
1873
|
+
let admissionPublished = false;
|
|
1874
|
+
const publishAdmission = () => {
|
|
1875
|
+
if (admissionPublished)
|
|
1876
|
+
return;
|
|
1877
|
+
admissionPublished = true;
|
|
1878
|
+
live.publishTurnAdmission();
|
|
1879
|
+
};
|
|
1319
1880
|
const forgetPendingInput = () => {
|
|
1320
1881
|
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1321
1882
|
if (index >= 0)
|
|
1322
1883
|
live.pendingInjectedInputs.splice(index, 1);
|
|
1323
1884
|
};
|
|
1885
|
+
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1324
1886
|
try {
|
|
1325
|
-
//
|
|
1887
|
+
// reference implementation publishes codex-native running only after the native Terminal
|
|
1888
|
+
// is ready and the runner has accepted the message, but before the
|
|
1889
|
+
// short-lived app-server client initializes and starts the Turn. Claude
|
|
1890
|
+
// deliberately has no matching synthesized edge.
|
|
1891
|
+
if (!live.forwarder.isTurnOpen())
|
|
1892
|
+
publishAdmission();
|
|
1893
|
+
await injectionClient.ensureInitialized();
|
|
1894
|
+
// Match reference implementation's executor: each message uses one initialized client
|
|
1895
|
+
// that closes as soon as turn/start or turn/steer is acknowledged.
|
|
1326
1896
|
if (live.forwarder.isTurnOpen()) {
|
|
1327
1897
|
const turnId = live.forwarder.currentTurnId();
|
|
1328
1898
|
if (turnId) {
|
|
1329
|
-
|
|
1899
|
+
injectionMethod = "turn/steer";
|
|
1900
|
+
const steered = await injectionClient.turnSteer({
|
|
1330
1901
|
threadId,
|
|
1331
1902
|
expectedTurnId: turnId,
|
|
1332
1903
|
input: nativeInput,
|
|
1333
1904
|
});
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
// before waiting for the native Terminal to become ready.
|
|
1337
|
-
}
|
|
1338
|
-
else if (pendingInput.observed) {
|
|
1905
|
+
pendingInput.responseId = `resp_codex_${steered.turnId}`;
|
|
1906
|
+
if (pendingInput.observed)
|
|
1339
1907
|
forgetPendingInput();
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1345
|
-
return "injected";
|
|
1908
|
+
else
|
|
1909
|
+
pendingInput.state = "accepted";
|
|
1910
|
+
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1911
|
+
return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
|
|
1346
1912
|
}
|
|
1347
1913
|
}
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
|
|
1914
|
+
// Match reference implementation's turn boundary: change the native thread settings
|
|
1915
|
+
// under the same lock immediately before starting the next Turn. Never
|
|
1916
|
+
// put settings on turn/start or mutate a Turn that is already open.
|
|
1917
|
+
const desiredModel = live.model;
|
|
1918
|
+
const desiredReasoningEffort = live.reasoningEffort;
|
|
1919
|
+
// A Session-owned mode survives runner restarts. Normal Web messages do
|
|
1920
|
+
// not carry per-Turn options, so a cold-resumed native thread must fall
|
|
1921
|
+
// back to the persisted execution snapshot before its first new Turn.
|
|
1922
|
+
// An explicit session.run option still wins for that invocation.
|
|
1923
|
+
const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
|
|
1924
|
+
const collaborationMode = desiredCollaborationMode &&
|
|
1925
|
+
desiredCollaborationMode !== live.appliedCollaborationMode
|
|
1926
|
+
? await resolveLiveCollaborationMode(injectionClient, live, desiredCollaborationMode)
|
|
1927
|
+
: undefined;
|
|
1928
|
+
const settings = {
|
|
1929
|
+
threadId,
|
|
1930
|
+
...(desiredModel !== live.appliedModel
|
|
1931
|
+
? { model: desiredModel || null }
|
|
1932
|
+
: {}),
|
|
1933
|
+
...(desiredReasoningEffort !== live.appliedReasoningEffort
|
|
1934
|
+
? { effort: desiredReasoningEffort ?? null }
|
|
1935
|
+
: {}),
|
|
1936
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
1937
|
+
};
|
|
1938
|
+
if (Object.keys(settings).length > 1) {
|
|
1939
|
+
await injectionClient.threadSettingsUpdate(settings);
|
|
1940
|
+
// Record exactly what this RPC carried. A concurrent settings save may
|
|
1941
|
+
// already have advanced the desired fields and must remain pending for
|
|
1942
|
+
// the following new Turn.
|
|
1943
|
+
if (collaborationMode && !live.model) {
|
|
1944
|
+
live.model = collaborationMode.settings.model;
|
|
1945
|
+
}
|
|
1946
|
+
if (collaborationMode)
|
|
1947
|
+
live.mayUseProviderDefaultModel = false;
|
|
1948
|
+
live.appliedModel = collaborationMode?.settings.model ?? desiredModel;
|
|
1949
|
+
live.appliedReasoningEffort = collaborationMode
|
|
1950
|
+
? collaborationMode.settings.reasoning_effort ?? undefined
|
|
1951
|
+
: desiredReasoningEffort;
|
|
1952
|
+
if (desiredCollaborationMode) {
|
|
1953
|
+
live.appliedCollaborationMode = desiredCollaborationMode;
|
|
1954
|
+
live.execution = {
|
|
1955
|
+
...live.execution,
|
|
1956
|
+
collaborationMode: desiredCollaborationMode,
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
publishAdmission();
|
|
1961
|
+
const started = await injectionClient.turnStart({
|
|
1351
1962
|
threadId,
|
|
1352
1963
|
input: nativeInput,
|
|
1353
1964
|
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1354
1965
|
approvalPolicy: live.approvalPolicy,
|
|
1355
|
-
...(live.model ? { model: live.model } : {}),
|
|
1356
|
-
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1357
1966
|
});
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
// waiting for the native Terminal to become ready.
|
|
1361
|
-
}
|
|
1362
|
-
else if (pendingInput.observed) {
|
|
1967
|
+
pendingInput.responseId = `resp_codex_${started.turnId}`;
|
|
1968
|
+
if (pendingInput.observed)
|
|
1363
1969
|
forgetPendingInput();
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1970
|
+
else
|
|
1971
|
+
pendingInput.state = "accepted";
|
|
1972
|
+
// The injection RPC and active-turn write are one serialized operation.
|
|
1973
|
+
// Do not wait for the independent observer connection's `turn/started`:
|
|
1974
|
+
// a second message accepted in that window must steer, not double-start.
|
|
1975
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1976
|
+
return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
|
|
1370
1977
|
}
|
|
1371
1978
|
catch (error) {
|
|
1372
1979
|
forgetPendingInput();
|
|
1980
|
+
if (error instanceof CodexRuntimeError &&
|
|
1981
|
+
error.code === "collaboration_mode_model_unknown") {
|
|
1982
|
+
throw error;
|
|
1983
|
+
}
|
|
1373
1984
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1374
1985
|
// into the unactionable `live injection failed` string.
|
|
1375
|
-
const
|
|
1986
|
+
const baseDetail = codexRpcError(error, injectionMethod);
|
|
1987
|
+
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1988
|
+
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1989
|
+
// A turn may open on the observer while this short-lived client is
|
|
1990
|
+
// initializing, converting the submission to steer. A rejected steer
|
|
1991
|
+
// does not fail that already-running Turn; only a new-Turn admission
|
|
1992
|
+
// owns the terminal failed edge.
|
|
1993
|
+
if (admissionPublished && injectionMethod === "turn/start") {
|
|
1994
|
+
live.publishTurnAdmissionFailure(detail);
|
|
1995
|
+
}
|
|
1376
1996
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1377
|
-
throw
|
|
1997
|
+
throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
|
|
1998
|
+
}
|
|
1999
|
+
finally {
|
|
2000
|
+
await injectionClient.stop().catch((stopError) => {
|
|
2001
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
2002
|
+
});
|
|
1378
2003
|
}
|
|
1379
2004
|
});
|
|
1380
2005
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1417,20 +2042,42 @@ export class LocalAgentHost {
|
|
|
1417
2042
|
const live = this.liveSessions.get(localThreadId);
|
|
1418
2043
|
if (!live)
|
|
1419
2044
|
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
2045
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
2046
|
+
const pendingMcp = live.forwarder.cancelMcpStartup();
|
|
2047
|
+
const turnId = live.forwarder.isTurnOpen()
|
|
2048
|
+
? live.forwarder.currentTurnId()
|
|
2049
|
+
: null;
|
|
2050
|
+
if (!threadId)
|
|
2051
|
+
return pendingMcp.length > 0;
|
|
2052
|
+
let handled = pendingMcp.length > 0;
|
|
2053
|
+
const interruptClient = this.injectionClientFactory(live.appServerUrl);
|
|
1428
2054
|
try {
|
|
1429
|
-
await
|
|
1430
|
-
|
|
2055
|
+
await interruptClient.ensureInitialized();
|
|
2056
|
+
if (pendingMcp.length > 0) {
|
|
2057
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
2058
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
2059
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
2060
|
+
try {
|
|
2061
|
+
await interruptClient.turnInterrupt({ threadId, turnId: "" });
|
|
2062
|
+
}
|
|
2063
|
+
catch (error) {
|
|
2064
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
if (turnId) {
|
|
2068
|
+
try {
|
|
2069
|
+
await interruptClient.turnInterrupt({ threadId, turnId });
|
|
2070
|
+
live.publishInterrupted(`resp_codex_${turnId}`);
|
|
2071
|
+
handled = true;
|
|
2072
|
+
}
|
|
2073
|
+
catch {
|
|
2074
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
return handled;
|
|
1431
2078
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
2079
|
+
finally {
|
|
2080
|
+
await interruptClient.stop().catch(() => undefined);
|
|
1434
2081
|
}
|
|
1435
2082
|
}
|
|
1436
2083
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
@@ -1448,8 +2095,6 @@ export class LocalAgentHost {
|
|
|
1448
2095
|
claude.forwarder.finalizeStop();
|
|
1449
2096
|
}
|
|
1450
2097
|
removeManagedClaudeSettings(claude.bridgeDir);
|
|
1451
|
-
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
1452
|
-
void claude.skillCleanup?.();
|
|
1453
2098
|
return;
|
|
1454
2099
|
}
|
|
1455
2100
|
const live = this.liveSessions.get(localThreadId);
|
|
@@ -1457,26 +2102,47 @@ export class LocalAgentHost {
|
|
|
1457
2102
|
return;
|
|
1458
2103
|
this.liveSessions.delete(localThreadId);
|
|
1459
2104
|
live.stopped = true;
|
|
2105
|
+
if (!live.threadId) {
|
|
2106
|
+
live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
|
|
2107
|
+
live.markStartupFailed();
|
|
2108
|
+
}
|
|
1460
2109
|
live.releaseActive?.();
|
|
1461
|
-
live.
|
|
2110
|
+
live.cancelSyntheticInteractions("session_stopped");
|
|
2111
|
+
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1462
2112
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1463
|
-
live.
|
|
2113
|
+
live.appServerOwner.setInteractionListener(null);
|
|
1464
2114
|
live.forwarderClient.setInteractionListener(null);
|
|
1465
|
-
live.
|
|
2115
|
+
live.appServerOwner.setConnectionListener(null);
|
|
1466
2116
|
live.forwarderClient.setConnectionListener(null);
|
|
1467
|
-
live.interactionOwners.clear();
|
|
1468
|
-
live.interactionStandbys.clear();
|
|
1469
|
-
live.interactionSubmissions.clear();
|
|
1470
2117
|
live.forwarder.stop();
|
|
1471
|
-
live.interactionRecoveries.clear();
|
|
1472
|
-
live.interactionFailedClients.clear();
|
|
1473
|
-
live.disconnectedClients.clear();
|
|
1474
2118
|
live.canonicalInteractions.clear();
|
|
1475
2119
|
live.settledInteractions.clear();
|
|
2120
|
+
live.syntheticInteractions.clear();
|
|
1476
2121
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
1477
2122
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
1478
2123
|
void live.skillsCleanup?.();
|
|
1479
2124
|
}
|
|
2125
|
+
/** Tear down one codex-lineage native runtime without deleting its durable
|
|
2126
|
+
* session-store binding. reference implementation couples its auxiliary Terminal, observer,
|
|
2127
|
+
* forwarder and per-session app-server as one disposable runtime envelope;
|
|
2128
|
+
* the next message recreates that envelope and cold-resumes the native id. */
|
|
2129
|
+
teardownLiveCodexSession(localThreadId, error) {
|
|
2130
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2131
|
+
if (!live)
|
|
2132
|
+
return false;
|
|
2133
|
+
const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
|
|
2134
|
+
const backendKey = codexBackendKey(live.runtime, live.execution.budget ?? undefined);
|
|
2135
|
+
const backend = this.backends.get(backendKey);
|
|
2136
|
+
const appServerOwner = live.appServerOwner;
|
|
2137
|
+
this.stopLiveCodexSession(localThreadId);
|
|
2138
|
+
if (backend?.appServerClient === appServerOwner) {
|
|
2139
|
+
this.backends.delete(backendKey);
|
|
2140
|
+
}
|
|
2141
|
+
void appServerOwner.stop().catch((stopError) => {
|
|
2142
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
2143
|
+
});
|
|
2144
|
+
return turnFailed;
|
|
2145
|
+
}
|
|
1480
2146
|
/** Complete the second shutdown phase after the runner has killed all native
|
|
1481
2147
|
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1482
2148
|
finalizeStoppedLiveSessions() {
|
|
@@ -1522,7 +2188,7 @@ export class LocalAgentHost {
|
|
|
1522
2188
|
});
|
|
1523
2189
|
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1524
2190
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1525
|
-
// The live session carries the
|
|
2191
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1526
2192
|
const args = buildClaudeTuiArgs({
|
|
1527
2193
|
settingsJson: settingsPath,
|
|
1528
2194
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1530,7 +2196,10 @@ export class LocalAgentHost {
|
|
|
1530
2196
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1531
2197
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1532
2198
|
settingSources: "",
|
|
1533
|
-
model: live.execution.model,
|
|
2199
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
2200
|
+
...(live.execution.reasoningEffort
|
|
2201
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
2202
|
+
: {}),
|
|
1534
2203
|
...(live.execution.instructions
|
|
1535
2204
|
? { appendSystemPrompt: live.execution.instructions }
|
|
1536
2205
|
: {}),
|
|
@@ -1664,17 +2333,27 @@ export class LocalAgentHost {
|
|
|
1664
2333
|
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1665
2334
|
}
|
|
1666
2335
|
const cwd = workspace.cwd;
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
2336
|
+
const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
|
|
2337
|
+
let skillPlugin = hasSkillSnapshot
|
|
2338
|
+
? await reuseClaudePlugin(localThreadId)
|
|
2339
|
+
: null;
|
|
2340
|
+
if (!skillPlugin && hasSkillSnapshot) {
|
|
2341
|
+
let snapshotSkills;
|
|
2342
|
+
try {
|
|
2343
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
2344
|
+
}
|
|
2345
|
+
catch (err) {
|
|
2346
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2347
|
+
return false;
|
|
2348
|
+
}
|
|
2349
|
+
try {
|
|
2350
|
+
skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
|
|
2351
|
+
}
|
|
2352
|
+
finally {
|
|
2353
|
+
await snapshotSkills.skillsCleanup();
|
|
2354
|
+
}
|
|
1674
2355
|
}
|
|
1675
|
-
const model = execution.model;
|
|
1676
|
-
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1677
|
-
void snapshotSkills.skillsCleanup();
|
|
2356
|
+
const model = execution.model ?? "";
|
|
1678
2357
|
const permissionMode = execution.permissionMode;
|
|
1679
2358
|
const launchExtraArgs = [
|
|
1680
2359
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
@@ -1695,15 +2374,14 @@ export class LocalAgentHost {
|
|
|
1695
2374
|
};
|
|
1696
2375
|
const startNormalizer = (turnId) => {
|
|
1697
2376
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1698
|
-
const responseId =
|
|
1699
|
-
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
2377
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1700
2378
|
if (normalizer && currentResponseId === responseId)
|
|
1701
2379
|
return normalizer;
|
|
1702
2380
|
currentResponseId = responseId;
|
|
1703
2381
|
normalizer = new SessionNormalizer({
|
|
1704
2382
|
sessionId: currentSessionId,
|
|
1705
2383
|
responseId,
|
|
1706
|
-
model,
|
|
2384
|
+
model: model || "claude",
|
|
1707
2385
|
});
|
|
1708
2386
|
return normalizer;
|
|
1709
2387
|
};
|
|
@@ -1741,6 +2419,8 @@ export class LocalAgentHost {
|
|
|
1741
2419
|
injectLock: Promise.resolve(),
|
|
1742
2420
|
pendingImageInputs: new Map(),
|
|
1743
2421
|
pendingInjectedInputs: [],
|
|
2422
|
+
currentResponseId: () => currentResponseId,
|
|
2423
|
+
publishInterrupted: () => undefined,
|
|
1744
2424
|
ready,
|
|
1745
2425
|
markReady,
|
|
1746
2426
|
failed,
|
|
@@ -1750,7 +2430,58 @@ export class LocalAgentHost {
|
|
|
1750
2430
|
launchExtraArgs,
|
|
1751
2431
|
permissionMode,
|
|
1752
2432
|
...(forkIntent ? { forkIntent } : {}),
|
|
1753
|
-
|
|
2433
|
+
};
|
|
2434
|
+
live.publishInterrupted = (responseId) => {
|
|
2435
|
+
if (live.interruptedResponseId === responseId)
|
|
2436
|
+
return;
|
|
2437
|
+
live.interruptedResponseId = responseId;
|
|
2438
|
+
emitCurrent({
|
|
2439
|
+
type: "session.interrupted",
|
|
2440
|
+
sessionId: currentSessionId,
|
|
2441
|
+
responseId,
|
|
2442
|
+
});
|
|
2443
|
+
};
|
|
2444
|
+
const settleClaudeTurn = (interrupted, usage, backgroundTaskCount) => {
|
|
2445
|
+
if (!normalizer)
|
|
2446
|
+
return;
|
|
2447
|
+
const rid = currentResponseId;
|
|
2448
|
+
if (interrupted) {
|
|
2449
|
+
for (const se of normalizer.interrupt()) {
|
|
2450
|
+
if (se.type === "session.interrupted" &&
|
|
2451
|
+
live.interruptedResponseId === rid)
|
|
2452
|
+
continue;
|
|
2453
|
+
emitCurrent(se);
|
|
2454
|
+
}
|
|
2455
|
+
live.interruptedResponseId = undefined;
|
|
2456
|
+
}
|
|
2457
|
+
else {
|
|
2458
|
+
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
2459
|
+
if (usage) {
|
|
2460
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
2461
|
+
emitCurrent(se);
|
|
2462
|
+
}
|
|
2463
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
2464
|
+
emitCurrent(se.type === "session.status" && backgroundTaskCount !== undefined
|
|
2465
|
+
? { ...se, backgroundTaskCount }
|
|
2466
|
+
: se);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2470
|
+
normalizer = null;
|
|
2471
|
+
currentResponseId = undefined;
|
|
2472
|
+
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
2473
|
+
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
2474
|
+
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
2475
|
+
if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
2476
|
+
live.contextWarned = true;
|
|
2477
|
+
emitCurrent({
|
|
2478
|
+
type: "session.status",
|
|
2479
|
+
sessionId: currentSessionId,
|
|
2480
|
+
responseId: rid,
|
|
2481
|
+
status: "idle",
|
|
2482
|
+
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
1754
2485
|
};
|
|
1755
2486
|
const sink = {
|
|
1756
2487
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
@@ -1761,14 +2492,14 @@ export class LocalAgentHost {
|
|
|
1761
2492
|
if (token && content)
|
|
1762
2493
|
live.pendingImageInputs.delete(token);
|
|
1763
2494
|
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1764
|
-
const
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1768
|
-
return;
|
|
1769
|
-
}
|
|
1770
|
-
if (pending)
|
|
2495
|
+
const pending = live.pendingInjectedInputs[0];
|
|
2496
|
+
if (pending) {
|
|
2497
|
+
pending.responseId = currentResponseId;
|
|
1771
2498
|
pending.observed = true;
|
|
2499
|
+
if (pending.state === "accepted") {
|
|
2500
|
+
live.pendingInjectedInputs.shift();
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
1772
2503
|
for (const se of n.userInput(normalizedContent))
|
|
1773
2504
|
emitCurrent(se);
|
|
1774
2505
|
},
|
|
@@ -1784,56 +2515,62 @@ export class LocalAgentHost {
|
|
|
1784
2515
|
emitCurrent(se);
|
|
1785
2516
|
},
|
|
1786
2517
|
onInteraction: forwardInteraction,
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
emitCurrent(se);
|
|
1798
|
-
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1799
|
-
normalizer = null;
|
|
1800
|
-
currentResponseId = undefined;
|
|
1801
|
-
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1802
|
-
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1803
|
-
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
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
|
-
}
|
|
2518
|
+
onStatus: (status, blockedOn) => {
|
|
2519
|
+
const responseId = currentResponseId ??
|
|
2520
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
2521
|
+
emitCurrent({
|
|
2522
|
+
type: "session.status",
|
|
2523
|
+
sessionId: currentSessionId,
|
|
2524
|
+
...(responseId ? { responseId } : {}),
|
|
2525
|
+
status,
|
|
2526
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
2527
|
+
});
|
|
1814
2528
|
},
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2529
|
+
onTurnEnd: (usage, backgroundTaskCount) => settleClaudeTurn(false, usage, backgroundTaskCount),
|
|
2530
|
+
onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
|
|
2531
|
+
onTurnInterruptRequested: () => {
|
|
2532
|
+
const responseId = currentResponseId;
|
|
2533
|
+
if (responseId)
|
|
2534
|
+
live.publishInterrupted(responseId);
|
|
2535
|
+
},
|
|
2536
|
+
onIdle: (backgroundTaskCount) => {
|
|
2537
|
+
// Surface Stop even after its Turn has already closed: a later
|
|
2538
|
+
// authoritative zero is what clears a sticky background-shell tally.
|
|
2539
|
+
// When the Turn is still open, retain its identity without finalizing it
|
|
2540
|
+
// so a late assistant record continues to join the same Response.
|
|
2541
|
+
emitCurrent({
|
|
2542
|
+
type: "session.status",
|
|
2543
|
+
sessionId: currentSessionId,
|
|
2544
|
+
...(currentResponseId ? { responseId: currentResponseId } : {}),
|
|
2545
|
+
status: "idle",
|
|
2546
|
+
...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
|
|
2547
|
+
});
|
|
2548
|
+
},
|
|
2549
|
+
onTurnError: (error) => {
|
|
2550
|
+
const message = error.message || "Agent turn failed";
|
|
2551
|
+
if (!normalizer) {
|
|
2552
|
+
// Native pane/forwarder death is Session-level even when Claude is
|
|
2553
|
+
// between Turns. Emit a bare failed edge; the
|
|
2554
|
+
// explicit zero also retires any sticky background-shell tally.
|
|
1819
2555
|
emitCurrent({
|
|
1820
2556
|
type: "session.status",
|
|
1821
2557
|
sessionId: currentSessionId,
|
|
1822
|
-
|
|
1823
|
-
|
|
2558
|
+
status: "failed",
|
|
2559
|
+
backgroundTaskCount: 0,
|
|
2560
|
+
note: message,
|
|
1824
2561
|
});
|
|
1825
|
-
}
|
|
1826
|
-
},
|
|
1827
|
-
onTurnError: () => {
|
|
1828
|
-
if (!normalizer)
|
|
1829
2562
|
return;
|
|
2563
|
+
}
|
|
1830
2564
|
const rid = currentResponseId;
|
|
1831
2565
|
for (const se of normalizer.fail({
|
|
1832
2566
|
code: "agent_error",
|
|
1833
|
-
message
|
|
2567
|
+
message,
|
|
1834
2568
|
source: "execution",
|
|
1835
|
-
}))
|
|
1836
|
-
emitCurrent(se
|
|
2569
|
+
})) {
|
|
2570
|
+
emitCurrent(se.type === "session.status"
|
|
2571
|
+
? { ...se, status: "failed", note: message }
|
|
2572
|
+
: se);
|
|
2573
|
+
}
|
|
1837
2574
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1838
2575
|
normalizer = null;
|
|
1839
2576
|
currentResponseId = undefined;
|
|
@@ -1941,17 +2678,18 @@ export class LocalAgentHost {
|
|
|
1941
2678
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1942
2679
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1943
2680
|
* 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) {
|
|
2681
|
+
* fall-through-to-run signal. Returns {@link InjectResult}. */
|
|
2682
|
+
injectClaude(live, localThreadId, text, pendingInput) {
|
|
1946
2683
|
const run = live.injectLock.then(async () => {
|
|
1947
2684
|
const ready = await this.waitLiveReady(localThreadId, 60_000);
|
|
1948
2685
|
if (!ready)
|
|
1949
|
-
return "notReady";
|
|
2686
|
+
return { outcome: "notReady" };
|
|
1950
2687
|
// Pane may have just relaunched — park until its injector re-attaches
|
|
1951
2688
|
// (attachTerminalInjector resets it) instead of returning false → fallback.
|
|
1952
2689
|
const injector = await this.waitInjector(live, 60_000);
|
|
1953
2690
|
if (!injector)
|
|
1954
|
-
return "notReady";
|
|
2691
|
+
return { outcome: "notReady" };
|
|
2692
|
+
const steered = live.forwarder.isTurnOpen();
|
|
1955
2693
|
// Abortable: the web Stop button cancels an in-flight paste/submit (before
|
|
1956
2694
|
// the message reaches claude) via interruptLive → injectAbort.abort().
|
|
1957
2695
|
const abort = new AbortController();
|
|
@@ -1962,10 +2700,18 @@ export class LocalAgentHost {
|
|
|
1962
2700
|
signal: abort.signal,
|
|
1963
2701
|
submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
|
|
1964
2702
|
});
|
|
1965
|
-
|
|
2703
|
+
if (!ok)
|
|
2704
|
+
return { outcome: "failed" };
|
|
2705
|
+
const responseId = pendingInput.responseId ?? live.currentResponseId();
|
|
2706
|
+
if (!responseId) {
|
|
2707
|
+
live.error = "Claude accepted the message but did not publish its native Turn identity";
|
|
2708
|
+
return { outcome: "failed" };
|
|
2709
|
+
}
|
|
2710
|
+
return { outcome: steered ? "steered" : "injected", responseId };
|
|
1966
2711
|
}
|
|
1967
|
-
catch {
|
|
1968
|
-
|
|
2712
|
+
catch (error) {
|
|
2713
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
2714
|
+
return { outcome: "failed" };
|
|
1969
2715
|
}
|
|
1970
2716
|
finally {
|
|
1971
2717
|
if (live.injectAbort === abort)
|
|
@@ -1987,12 +2733,31 @@ export class LocalAgentHost {
|
|
|
1987
2733
|
return live.injector ?? null;
|
|
1988
2734
|
}
|
|
1989
2735
|
/** Attach a session's tmux pane injector (from the runner-child, which owns the
|
|
1990
|
-
* terminal registry — the host does not hold tmux).
|
|
1991
|
-
*
|
|
2736
|
+
* terminal registry — the host does not hold tmux). Claude uses it for normal
|
|
2737
|
+
* message injection; Codex uses it only to dismiss the exact TUI-local Plan
|
|
2738
|
+
* picker after its synthetic Web form is submitted. Idempotent. */
|
|
1992
2739
|
attachTerminalInjector(localThreadId, injector) {
|
|
1993
2740
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1994
|
-
if (live)
|
|
2741
|
+
if (live) {
|
|
2742
|
+
if (live.injector === injector)
|
|
2743
|
+
return;
|
|
1995
2744
|
live.injector = injector;
|
|
2745
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2749
|
+
if (codex)
|
|
2750
|
+
codex.injector = injector;
|
|
2751
|
+
}
|
|
2752
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
2753
|
+
failLiveSession(localThreadId, error) {
|
|
2754
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2755
|
+
if (claude)
|
|
2756
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
2757
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2758
|
+
if (!codex)
|
|
2759
|
+
return false;
|
|
2760
|
+
return codex.forwarder.failOpenTurn(error);
|
|
1996
2761
|
}
|
|
1997
2762
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1998
2763
|
async listModels(runtime) {
|
|
@@ -2022,7 +2787,7 @@ export class LocalAgentHost {
|
|
|
2022
2787
|
return { ok: false, reason: "unsupported" };
|
|
2023
2788
|
}
|
|
2024
2789
|
try {
|
|
2025
|
-
return { ok: true, data: await fn(live.
|
|
2790
|
+
return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
|
|
2026
2791
|
}
|
|
2027
2792
|
catch (error) {
|
|
2028
2793
|
return {
|
|
@@ -2094,7 +2859,7 @@ export class LocalAgentHost {
|
|
|
2094
2859
|
return { ok: true, data: undefined };
|
|
2095
2860
|
}
|
|
2096
2861
|
const runtime = execution.provider;
|
|
2097
|
-
const client = live?.
|
|
2862
|
+
const client = live?.appServerOwner ??
|
|
2098
2863
|
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2099
2864
|
if (!client) {
|
|
2100
2865
|
return { ok: false, reason: "unsupported" };
|
|
@@ -2121,7 +2886,7 @@ export class LocalAgentHost {
|
|
|
2121
2886
|
threadId: record.codexSessionId,
|
|
2122
2887
|
excludeTurns: true,
|
|
2123
2888
|
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2124
|
-
model: execution.model,
|
|
2889
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2125
2890
|
approvalPolicy: execution.approvalPolicy,
|
|
2126
2891
|
});
|
|
2127
2892
|
}
|
|
@@ -2163,11 +2928,11 @@ export class LocalAgentHost {
|
|
|
2163
2928
|
}
|
|
2164
2929
|
}
|
|
2165
2930
|
}
|
|
2166
|
-
function
|
|
2931
|
+
function codexRpcError(error, method) {
|
|
2167
2932
|
if (error instanceof CodexTransportError) {
|
|
2168
|
-
return `Codex app-server rejected
|
|
2933
|
+
return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
|
|
2169
2934
|
}
|
|
2170
|
-
return `Codex
|
|
2935
|
+
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2171
2936
|
}
|
|
2172
2937
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2173
2938
|
const normalized = output.trim();
|
|
@@ -2192,20 +2957,83 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
2192
2957
|
issues: normalized ? [normalized] : [],
|
|
2193
2958
|
};
|
|
2194
2959
|
}
|
|
2195
|
-
/** Race a live session's `ready` promise against failure
|
|
2960
|
+
/** Race a live session's `ready` promise against failure and, when supplied,
|
|
2961
|
+
* a timeout. */
|
|
2196
2962
|
function raceReady(ready, timeoutMs, failed) {
|
|
2197
2963
|
let timer;
|
|
2198
|
-
const
|
|
2199
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2200
|
-
});
|
|
2201
|
-
const outcomes = [ready.then(() => true), timeout];
|
|
2964
|
+
const outcomes = [ready.then(() => true)];
|
|
2202
2965
|
if (failed)
|
|
2203
2966
|
outcomes.push(failed.then(() => false));
|
|
2967
|
+
if (timeoutMs !== null) {
|
|
2968
|
+
outcomes.push(new Promise((resolve) => {
|
|
2969
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2970
|
+
}));
|
|
2971
|
+
}
|
|
2204
2972
|
return Promise.race(outcomes).finally(() => {
|
|
2205
2973
|
if (timer)
|
|
2206
2974
|
clearTimeout(timer);
|
|
2207
2975
|
});
|
|
2208
2976
|
}
|
|
2977
|
+
/** Record the authoritative settings returned while binding an existing
|
|
2978
|
+
* native thread. A Session-level explicit selection remains a pending desired
|
|
2979
|
+
* override; otherwise the resumed thread itself is the source of truth. */
|
|
2980
|
+
function adoptResumedThreadSettings(live, settings) {
|
|
2981
|
+
const model = settings.model?.trim();
|
|
2982
|
+
if (model) {
|
|
2983
|
+
live.mayUseProviderDefaultModel = false;
|
|
2984
|
+
live.appliedModel = model;
|
|
2985
|
+
if (!live.execution.model)
|
|
2986
|
+
live.model = model;
|
|
2987
|
+
}
|
|
2988
|
+
if (settings.reasoningEffort !== undefined) {
|
|
2989
|
+
live.appliedReasoningEffort = settings.reasoningEffort ?? undefined;
|
|
2990
|
+
if (!live.execution.reasoningEffort) {
|
|
2991
|
+
live.reasoningEffort = settings.reasoningEffort ?? undefined;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
/** Resolve the model required by a full collaboration-mode snapshot. Only a
|
|
2996
|
+
* fresh thread with no explicit/config model may use the catalog default; a
|
|
2997
|
+
* resumed thread must report its own authoritative model. */
|
|
2998
|
+
async function resolveLiveCollaborationMode(client, live, mode) {
|
|
2999
|
+
let model = live.model.trim();
|
|
3000
|
+
if (!model && live.mayUseProviderDefaultModel) {
|
|
3001
|
+
let catalog;
|
|
3002
|
+
try {
|
|
3003
|
+
catalog = await client.modelList();
|
|
3004
|
+
}
|
|
3005
|
+
catch (error) {
|
|
3006
|
+
throw new CodexRuntimeError(`collaboration mode requires the Provider default model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
|
|
3007
|
+
}
|
|
3008
|
+
const defaults = [...new Set(catalog.data
|
|
3009
|
+
.filter((candidate) => candidate.isDefault === true)
|
|
3010
|
+
.map((candidate) => (candidate.model || candidate.id).trim())
|
|
3011
|
+
.filter(Boolean))];
|
|
3012
|
+
if (defaults.length !== 1) {
|
|
3013
|
+
throw new CodexRuntimeError(`collaboration mode requires exactly one Provider default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
|
|
3014
|
+
}
|
|
3015
|
+
model = defaults[0];
|
|
3016
|
+
}
|
|
3017
|
+
return buildCollaborationMode(mode, model, live.reasoningEffort);
|
|
3018
|
+
}
|
|
3019
|
+
/** Expand the public mode enum into the App Server's required settings
|
|
3020
|
+
* snapshot using the Session's tracked effective model. */
|
|
3021
|
+
function buildCollaborationMode(mode, desiredModel, desiredReasoningEffort) {
|
|
3022
|
+
const model = desiredModel.trim();
|
|
3023
|
+
if (!model) {
|
|
3024
|
+
throw new CodexRuntimeError("collaboration mode requires the current model, but the Provider did not report one", 503, "collaboration_mode_model_unknown");
|
|
3025
|
+
}
|
|
3026
|
+
return {
|
|
3027
|
+
mode,
|
|
3028
|
+
settings: {
|
|
3029
|
+
model,
|
|
3030
|
+
reasoning_effort: desiredReasoningEffort ?? null,
|
|
3031
|
+
// `null` asks the App Server to install the built-in instructions for
|
|
3032
|
+
// the selected mode; thread-level Agent instructions remain separate.
|
|
3033
|
+
developer_instructions: null,
|
|
3034
|
+
},
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
2209
3037
|
function sameSessionSnapshots(workspace, execution, opts) {
|
|
2210
3038
|
return JSON.stringify(stableValue({
|
|
2211
3039
|
workspace,
|
|
@@ -2215,6 +3043,40 @@ function sameSessionSnapshots(workspace, execution, opts) {
|
|
|
2215
3043
|
execution: opts.execution,
|
|
2216
3044
|
}));
|
|
2217
3045
|
}
|
|
3046
|
+
function sameImmutableSessionSnapshots(workspace, execution, opts) {
|
|
3047
|
+
const current = {
|
|
3048
|
+
...execution,
|
|
3049
|
+
model: null,
|
|
3050
|
+
reasoningEffort: null,
|
|
3051
|
+
collaborationMode: undefined,
|
|
3052
|
+
};
|
|
3053
|
+
const requested = {
|
|
3054
|
+
...opts.execution,
|
|
3055
|
+
model: null,
|
|
3056
|
+
reasoningEffort: null,
|
|
3057
|
+
collaborationMode: undefined,
|
|
3058
|
+
};
|
|
3059
|
+
return JSON.stringify(stableValue({ workspace, execution: current })) ===
|
|
3060
|
+
JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
|
|
3061
|
+
}
|
|
3062
|
+
function updateLiveCodexTurnSettings(live, opts) {
|
|
3063
|
+
if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
|
|
3064
|
+
return false;
|
|
3065
|
+
}
|
|
3066
|
+
const previousModel = live.execution.model;
|
|
3067
|
+
const previousReasoningEffort = live.execution.reasoningEffort;
|
|
3068
|
+
live.execution = structuredClone(opts.execution);
|
|
3069
|
+
// Repeated calls normally carry the same immutable Session snapshot. Preserve
|
|
3070
|
+
// a newer native `/model` or effort change unless Core explicitly changed the
|
|
3071
|
+
// corresponding Session setting between calls.
|
|
3072
|
+
if (opts.execution.model !== previousModel) {
|
|
3073
|
+
live.model = opts.execution.model?.trim() ?? "";
|
|
3074
|
+
}
|
|
3075
|
+
if (opts.execution.reasoningEffort !== previousReasoningEffort) {
|
|
3076
|
+
live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
|
|
3077
|
+
}
|
|
3078
|
+
return true;
|
|
3079
|
+
}
|
|
2218
3080
|
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2219
3081
|
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2220
3082
|
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
@@ -2267,10 +3129,13 @@ export function isThreadNotReadyError(error) {
|
|
|
2267
3129
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2268
3130
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
2269
3131
|
}
|
|
2270
|
-
/**
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
2273
|
-
|
|
2274
|
-
const
|
|
2275
|
-
|
|
3132
|
+
/** Build the user-facing native lifecycle failure once, at the phase that owns
|
|
3133
|
+
* it. Downstream layers preserve this code/message verbatim instead of replacing
|
|
3134
|
+
* it with a generic "failed to start; see logs" wrapper. */
|
|
3135
|
+
function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
|
|
3136
|
+
const provider = runtime === "traex" ? "Traex" : "Codex";
|
|
3137
|
+
const detail = cause === undefined
|
|
3138
|
+
? ""
|
|
3139
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
3140
|
+
return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
|
|
2276
3141
|
}
|