@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30

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