@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41

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 (65) 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 +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
package/dist/host.js CHANGED
@@ -4,29 +4,54 @@ import { cp, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
- import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, } from "@rynx-ai/core";
7
+ import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, newSessionItemId, } from "@rynx-ai/core";
8
8
  import { getRuntimeProfile, } from "@rynx-ai/core";
9
9
  import { resolveRuntimeBinary, } from "@rynx-ai/core";
10
10
  import { createCodexChildEnv } from "./codex-child-env.js";
11
- import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
12
- import { materializeSkillPlugin } from "./claude/executor.js";
11
+ import { applyCodexProviderRetryPolicy, codexLaunchRequiresLogin, prepareRuntimeHome, populateCodexSkills, readCodexDeveloperInstructionsState, runtimeHomePath, syncCodexDeveloperInstructions, trustCodexProject, } from "./codex-home.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";
18
+ import { reapRuntimeProcessesForStateDir } from "./codex-app-server/process-registry.js";
17
19
  import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
20
+ import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
21
+ import { runtimeSessionStateDir } from "./runtime-state-paths.js";
18
22
  import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
19
23
  import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
20
24
  import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
21
25
  import { ensureProjectTrusted } from "./claude/trust.js";
22
26
  import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
23
- import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
27
+ import { prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
24
28
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
25
29
  import { buildClaudeHookSettings } from "./claude/native-hooks.js";
26
30
  import { buildManagedClaudeSettings } from "./claude/settings.js";
27
31
  import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
28
32
  import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
29
33
  import { newSessionId as makeSessionId } from "@rynx-ai/core";
34
+ function isDurableSessionEvent(event) {
35
+ return event.type === "response.output_item.done" ||
36
+ event.type === "session.input.consumed" ||
37
+ event.type === "session.interaction.requested" ||
38
+ event.type === "session.interaction.resolved" ||
39
+ event.type === "session.interaction.cancelled";
40
+ }
41
+ /**
42
+ * Events produced while a native Provider is crossing a logical Session
43
+ * rotation are normalized before the new Rynx id is known. Re-stamp both the
44
+ * envelope and any durable item before releasing that buffer into the target.
45
+ */
46
+ function retargetSessionEvent(event, sessionId) {
47
+ const retargeted = structuredClone(event);
48
+ if (typeof retargeted.sessionId === "string")
49
+ retargeted.sessionId = sessionId;
50
+ if (retargeted.item && typeof retargeted.item.sessionId === "string") {
51
+ retargeted.item.sessionId = sessionId;
52
+ }
53
+ return retargeted;
54
+ }
30
55
  import { resolveAllowedRoots, } from "@rynx-ai/core";
31
56
  export { FileCodexSessionStore, resolveCodexSessionStorePath, };
32
57
  // The runtime error type lives in @rynx-ai/core (as AgentRuntimeError) so channels
@@ -181,6 +206,8 @@ export class SpawnCodexCommandRunner {
181
206
  * budget (set proactively); claude has no programmatic compaction, so this
182
207
  * signal is the lever consumers surface to the user. */
183
208
  const CONTEXT_WARN_RATIO = 0.8;
209
+ /** Wait for native bridge/thread binding before injection gives up. */
210
+ const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
184
211
  function providerPluginSkillName(namespace, name) {
185
212
  const candidate = `${namespace}-${name}`;
186
213
  if (candidate.length <= 128)
@@ -189,44 +216,85 @@ function providerPluginSkillName(namespace, name) {
189
216
  return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
190
217
  }
191
218
  const MAX_CODEX_SETTLED_INTERACTIONS = 512;
192
- /**
193
- * The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
194
- * when the budget declares no retry or the runtime is claude. claude applies
195
- * its retry budget per-run in `buildOptions` (see the claude executor), so it
196
- * never needs a dedicated app-server.
197
- */
198
- export function codexRetryEnv(runtime, budget) {
199
- if (runtime === "claude") {
200
- return undefined;
201
- }
202
- const retry = budget?.retry;
203
- if (!retry) {
204
- return undefined;
205
- }
206
- const env = {};
207
- if (retry.maxRetries != null) {
208
- env.OPENAI_MAX_RETRIES = String(retry.maxRetries);
209
- }
210
- if (retry.timeoutPerRequestSeconds != null) {
211
- env.OPENAI_TIMEOUT = String(Math.round(retry.timeoutPerRequestSeconds));
212
- }
213
- return Object.keys(env).length > 0 ? env : undefined;
219
+ const CODEX_PLAN_IMPLEMENTATION_YES = "Yes, implement this plan";
220
+ const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT = "Yes, clear context and implement";
221
+ const CODEX_PLAN_IMPLEMENTATION_NO = "No, stay in Plan mode";
222
+ const CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE = "Implement the plan.";
223
+ const CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS = 1_500;
224
+ const CODEX_PLAN_PROMPT_DISMISS_POLL_MS = 50;
225
+ const CODEX_SUBSCRIBE_FLUSH_RETRY_MS = 1_500;
226
+ const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = "A previous agent produced the plan below to accomplish the user's task. " +
227
+ "Implement the plan in a fresh context. Treat the plan as the source of " +
228
+ "user intent, re-read files as needed, and carry the work through " +
229
+ "implementation and verification.";
230
+ function codexPlanImplementationRequest(interactionId) {
231
+ return {
232
+ interactionId,
233
+ kind: "question",
234
+ title: "Plan",
235
+ fields: [{
236
+ id: "plan_implementation",
237
+ type: "select",
238
+ label: "Implement this plan?",
239
+ required: true,
240
+ options: [
241
+ {
242
+ value: CODEX_PLAN_IMPLEMENTATION_YES,
243
+ label: CODEX_PLAN_IMPLEMENTATION_YES,
244
+ description: "Switch to Default and start coding.",
245
+ },
246
+ {
247
+ value: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
248
+ label: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
249
+ description: "Fresh thread with this plan.",
250
+ },
251
+ {
252
+ value: CODEX_PLAN_IMPLEMENTATION_NO,
253
+ label: CODEX_PLAN_IMPLEMENTATION_NO,
254
+ description: "Continue planning with the model.",
255
+ },
256
+ ],
257
+ }],
258
+ actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
259
+ createdAt: Date.now(),
260
+ };
214
261
  }
215
- /**
216
- * The cache key for a runtime backend. A retry budget on a codex-lineage
217
- * runtime forks a dedicated app-server (its own retry env) under a composite
218
- * `${runtime}::${hash}` key; everything else shares the base `${runtime}`
219
- * backend — so budget-less agents keep today's shared-process behaviour.
220
- */
221
- function hashRetryEnv(env) {
222
- return Object.keys(env)
223
- .sort()
224
- .map((key) => `${key}=${env[key]}`)
225
- .join("&");
262
+ function codexPlanImplementationPromptVisible(pane) {
263
+ return pane.includes("Implement this plan?") &&
264
+ pane.includes(CODEX_PLAN_IMPLEMENTATION_YES) &&
265
+ pane.includes(CODEX_PLAN_IMPLEMENTATION_NO);
226
266
  }
227
- export function codexBackendKey(runtime, budget) {
228
- const env = codexRetryEnv(runtime, budget);
229
- return env ? `${runtime}::${hashRetryEnv(env)}` : runtime;
267
+ /** Codex's final Plan picker is owned by the TUI and is not an app-server
268
+ * request that Web can resolve. Once the synthetic Web form takes ownership,
269
+ * close only that exact picker with its documented Escape action. The owner
270
+ * predicate is checked both before and after capture: app-server notifications
271
+ * cannot interleave between the two synchronous tmux calls, so a newly observed
272
+ * Turn cannot receive a blind Escape. This is deliberately best-effort — a
273
+ * cosmetic Terminal redraw failure must not make an otherwise valid Web Plan
274
+ * decision fail when the structured app-server path can still proceed. */
275
+ async function dismissCodexPlanImplementationPrompt(injector, ownsPrompt) {
276
+ if (!injector || !ownsPrompt())
277
+ return "not_visible";
278
+ try {
279
+ const pane = injector.capturePane();
280
+ if (!ownsPrompt())
281
+ return "superseded";
282
+ if (!codexPlanImplementationPromptVisible(pane))
283
+ return "not_visible";
284
+ injector.interrupt();
285
+ const deadline = Date.now() + CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS;
286
+ while (ownsPrompt()) {
287
+ if (!codexPlanImplementationPromptVisible(injector.capturePane()))
288
+ return "dismissed";
289
+ if (Date.now() >= deadline)
290
+ return "failed";
291
+ await new Promise((resolve) => setTimeout(resolve, CODEX_PLAN_PROMPT_DISMISS_POLL_MS));
292
+ }
293
+ return "superseded";
294
+ }
295
+ catch {
296
+ return "failed";
297
+ }
230
298
  }
231
299
  export class LocalAgentHost {
232
300
  config;
@@ -238,14 +306,19 @@ export class LocalAgentHost {
238
306
  // auto-create. The command runner override backs the status probe.
239
307
  injectedCommandRunner;
240
308
  injectedAppServerClient;
241
- clock;
242
- backendIdleTtlMs;
243
309
  // Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
244
310
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
245
311
  // the backend's app-server; tests inject a fake.
246
312
  forwarderClientFactory;
247
- // Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
248
- // a `${runtime}::${retryHash}` composite for a per-agent retry budget.
313
+ // Builds reference implementation's short-lived cold-resume preload connection. This must be
314
+ // distinct from both the app-server owner/injector and the background
315
+ // observer: the owner's initialize handshake is the readiness probe, then a
316
+ // freshly initialized connection performs the one resume and closes.
317
+ preloadClientFactory;
318
+ /** Builds one short-lived message/interrupt client. */
319
+ injectionClientFactory;
320
+ // One backend per runtime. The host belongs to one Session runner, and Codex
321
+ // retry settings live in that Session's private config rather than process env.
249
322
  backends = new Map();
250
323
  // The rynx Session this host serves (set from `RYNX_RUNNER_SESSION`).
251
324
  sessionId;
@@ -263,9 +336,14 @@ export class LocalAgentHost {
263
336
  // Per-session claude-native live forwarders (parallel to liveSessions; claude
264
337
  // has no app-server, so it tails the bridge + transcript instead of RPC).
265
338
  liveClaudeSessions = new Map();
339
+ /** Exact pre-live startup failure, retained after the partial handle has been
340
+ * cleaned up so Core/plugin callers receive the failed phase, not a generic
341
+ * `live session unavailable` wrapper. */
342
+ liveStartupErrors = new Map();
266
343
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
267
344
  * finalizes these synchronously afterwards to scrub raw interaction answers. */
268
345
  pendingClaudeFinalizers = new Set();
346
+ pendingCleanupTasks = new Set();
269
347
  // In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
270
348
  // `live.ensure` racing a `term.open`) share ONE forwarder — never two.
271
349
  liveEnsuring = new Map();
@@ -273,7 +351,7 @@ export class LocalAgentHost {
273
351
  /** Short-lived dedupe for managed fork notifications delivered after the
274
352
  * `thread/fork` response. Values are expected source Provider thread ids. */
275
353
  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, }) {
354
+ constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, forwarderClientFactory, preloadClientFactory, injectionClientFactory, sessionId, runtimeHomeSessionId, }) {
277
355
  this.config = config;
278
356
  this.sessionId = sessionId ?? "__default__";
279
357
  this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
@@ -282,61 +360,22 @@ export class LocalAgentHost {
282
360
  this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
283
361
  this.injectedCommandRunner = commandRunner;
284
362
  this.injectedAppServerClient = appServerClient;
285
- this.clock = now;
286
- this.backendIdleTtlMs = backendIdleTtlMs;
287
- this.forwarderClientFactory =
288
- forwarderClientFactory ??
289
- ((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
290
- }
291
- getBackend(runtime, budget) {
292
- this.reapIdleBackends();
293
- // In forced exec mode (the default runtime's injected app-server is null) a
294
- // retry budget must NOT fork a composite backend: there is no app-server to
295
- // carry the retry env, and forking one would spawn a real app-server and
296
- // defeat the forced-exec override. Fall back to the base key — codex retry is
297
- // then a no-op in exec mode (consistent with the broader codex-retry caveat).
298
- const execMode = runtime === this.defaultRuntime && this.injectedAppServerClient === null;
299
- const retryEnv = execMode ? undefined : codexRetryEnv(runtime, budget);
300
- const key = retryEnv ? `${runtime}::${hashRetryEnv(retryEnv)}` : runtime;
301
- const existing = this.backends.get(key);
302
- if (existing) {
303
- existing.lastUsedAt = this.clock();
363
+ const defaultAttachedClientFactory = (appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) });
364
+ this.forwarderClientFactory = forwarderClientFactory ?? defaultAttachedClientFactory;
365
+ this.preloadClientFactory = preloadClientFactory ?? defaultAttachedClientFactory;
366
+ this.injectionClientFactory = injectionClientFactory ?? defaultAttachedClientFactory;
367
+ }
368
+ getBackend(runtime) {
369
+ const existing = this.backends.get(runtime);
370
+ if (existing)
304
371
  return existing;
305
- }
306
- const backend = this.createBackend(runtime, retryEnv);
307
- backend.lastUsedAt = this.clock();
308
- this.backends.set(key, backend);
372
+ const backend = this.createBackend(runtime);
373
+ this.backends.set(runtime, backend);
309
374
  return backend;
310
375
  }
311
- /**
312
- * Stop + drop per-budget composite backends that have been idle longer than
313
- * the TTL and have no in-flight run. Base per-runtime backends (key === the
314
- * bare runtime id) are never reaped — they live for the process lifetime, as
315
- * before. There are no per-conversation close hooks, so this lazy sweep (run
316
- * on every `getBackend`) is how dedicated app-servers get reclaimed.
317
- */
318
- reapIdleBackends() {
319
- const now = this.clock();
320
- for (const [key, backend] of this.backends) {
321
- if (!key.includes("::")) {
322
- continue;
323
- }
324
- if ((backend.inFlight ?? 0) > 0 || backend.lastUsedAt === undefined) {
325
- continue;
326
- }
327
- if (now - backend.lastUsedAt < this.backendIdleTtlMs) {
328
- continue;
329
- }
330
- void backend.appServerClient?.stop();
331
- this.backends.delete(key);
332
- }
333
- }
334
- createBackend(runtime, retryEnv) {
376
+ createBackend(runtime) {
335
377
  const profile = getRuntimeProfile(runtime);
336
- // A composite (per-budget) backend is inherently non-default: it gets a
337
- // fresh app-server with the retry env, never the test-injected default deps.
338
- const isComposite = retryEnv !== undefined;
339
- const isDefault = runtime === this.defaultRuntime && !isComposite;
378
+ const isDefault = runtime === this.defaultRuntime;
340
379
  const commandRunner = (isDefault && this.injectedCommandRunner) ||
341
380
  new SpawnCodexCommandRunner(resolveRuntimeBinary(runtime));
342
381
  // The claude runtime is not codex-lineage: it has no app-server (its live
@@ -351,7 +390,7 @@ export class LocalAgentHost {
351
390
  ? this.injectedAppServerClient === null
352
391
  ? null
353
392
  : this.injectedAppServerClient ?? this.createAppServerClient(runtime)
354
- : this.createAppServerClient(runtime, retryEnv);
393
+ : this.createAppServerClient(runtime);
355
394
  return { runtime, profile, appServerClient, commandRunner };
356
395
  }
357
396
  /** This session's private native home, shared by its app-server + TUI. */
@@ -365,7 +404,7 @@ export class LocalAgentHost {
365
404
  this.runtimeHomes.set(runtime, prepared);
366
405
  return prepared;
367
406
  }
368
- createAppServerClient(runtime, extraEnv) {
407
+ createAppServerClient(runtime) {
369
408
  const cliPath = resolveRuntimeBinary(runtime);
370
409
  // Per-agent knobs (resolved at live-session start) win over the daemon
371
410
  // default — the app-server is private to this session's agent.
@@ -381,8 +420,8 @@ export class LocalAgentHost {
381
420
  const channel = new WsRpcChannel({
382
421
  cliPath,
383
422
  baseArgs: buildAppServerBaseArgs(sandbox),
423
+ stateDir: runtimeSessionStateDir(this.runtimeHomeSessionId),
384
424
  extraEnv: {
385
- ...(extraEnv ?? {}),
386
425
  [profile.homeEnvVar]: this.runtimeHome(runtime),
387
426
  },
388
427
  });
@@ -403,36 +442,81 @@ export class LocalAgentHost {
403
442
  if (live.settledInteractions.has(interactionId)) {
404
443
  return { disposition: "already_resolved" };
405
444
  }
406
- if (live.interactionRecoveries.has(interactionId)) {
407
- return {
408
- disposition: "invalid",
409
- message: "interaction delivery is recovering; retry shortly",
410
- };
445
+ const synthetic = live.syntheticInteractions.get(interactionId);
446
+ if (synthetic)
447
+ return synthetic.resolve(resolution);
448
+ return live.forwarderClient.resolveInteraction(interactionId, resolution);
449
+ }
450
+ /** Apply collaboration mode to an already-loaded Codex-lineage thread. The
451
+ * caller persists only after this native RPC succeeds. */
452
+ async updateCollaborationMode(localThreadId, mode) {
453
+ const live = this.liveSessions.get(localThreadId);
454
+ if (!live || live.stopped || live.rotationPending) {
455
+ throw new CodexRuntimeError("Native Session is not live", 409, "native_session_not_live");
411
456
  }
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);
457
+ const run = live.injectLock.then(async () => {
458
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
459
+ const threadId = live.threadId ?? live.forwarder.threadId();
460
+ if (!bound || !threadId) {
461
+ throw new CodexRuntimeError("Native thread is not ready", 503, "native_thread_not_ready");
425
462
  }
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;
463
+ await this.applyLiveCollaborationMode(live, threadId, mode);
464
+ });
465
+ live.injectLock = run.then(() => undefined, () => undefined);
466
+ await run;
467
+ }
468
+ /** Apply one explicit collaboration mode to the current native thread and
469
+ * remember only a successful RPC. A supplied client is already initialized
470
+ * and remains owned by its caller (used by cold-resume preload). */
471
+ async applyLiveCollaborationMode(live, threadId, mode, suppliedClient) {
472
+ await this.applyLiveThreadSettings(live, threadId, mode, suppliedClient);
473
+ }
474
+ /** Apply pending explicit model/effort settings and, when requested, one
475
+ * collaboration mode in a single native update. */
476
+ async applyLiveThreadSettings(live, threadId, mode, suppliedClient) {
477
+ const client = suppliedClient ?? this.injectionClientFactory(live.appServerUrl);
478
+ try {
479
+ if (!suppliedClient)
480
+ await client.ensureInitialized();
481
+ const desiredModel = live.model;
482
+ const desiredReasoningEffort = live.reasoningEffort;
483
+ const modelChanged = desiredModel !== live.appliedModel;
484
+ const effortChanged = desiredReasoningEffort !== live.appliedReasoningEffort;
485
+ const collaborationMode = mode && live.appliedCollaborationMode !== mode
486
+ ? await resolveLiveCollaborationMode(client, live, mode, live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined)
487
+ : undefined;
488
+ const settings = {
489
+ threadId,
490
+ ...(modelChanged ? { model: desiredModel || null } : {}),
491
+ ...(effortChanged
492
+ ? { effort: desiredReasoningEffort ?? null }
493
+ : {}),
494
+ ...(collaborationMode ? { collaborationMode } : {}),
495
+ };
496
+ if (Object.keys(settings).length === 1)
497
+ return;
498
+ await client.threadSettingsUpdate(settings);
499
+ if (collaborationMode) {
500
+ if (!live.model)
501
+ live.model = collaborationMode.settings.model;
502
+ live.mayUseProviderDefaultModel = false;
503
+ live.appliedModel = collaborationMode.settings.model;
504
+ live.appliedReasoningEffort =
505
+ collaborationMode.settings.reasoning_effort ?? undefined;
506
+ live.appliedCollaborationMode = mode;
507
+ live.execution = { ...live.execution, collaborationMode: mode };
508
+ }
509
+ else {
510
+ if (modelChanged)
511
+ live.appliedModel = desiredModel;
512
+ if (effortChanged)
513
+ live.appliedReasoningEffort = desiredReasoningEffort;
514
+ }
515
+ }
516
+ finally {
517
+ if (!suppliedClient)
518
+ await client.stop().catch(() => undefined);
434
519
  }
435
- return submit(live.forwarderClient);
436
520
  }
437
521
  /**
438
522
  * The command to run in a session's live terminal so it co-drives the codex
@@ -455,7 +539,7 @@ export class LocalAgentHost {
455
539
  throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
456
540
  }
457
541
  const runtime = live.runtime;
458
- const backend = this.getBackend(runtime, live.execution.budget ?? undefined);
542
+ const backend = this.getBackend(runtime);
459
543
  const client = backend.appServerClient;
460
544
  if (!client) {
461
545
  throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
@@ -475,6 +559,10 @@ export class LocalAgentHost {
475
559
  const traexYolo = runtime === "traex" &&
476
560
  sandbox === "danger-full-access" &&
477
561
  approvalPolicy === "never";
562
+ // Runner-owned Codex terminals start detached, so nobody can answer the
563
+ // interactive hook-review prompt. The private CODEX_HOME is provisioned
564
+ // before launch; use Codex's native bypass flag for this managed TUI.
565
+ const bypassHookTrust = runtime === "codex" || traexYolo;
478
566
  const configOverrides = sandbox === "workspace-write"
479
567
  ? ["sandbox_workspace_write.network_access=true"]
480
568
  : [];
@@ -492,21 +580,26 @@ export class LocalAgentHost {
492
580
  const launchEffort = live.reasoningEffort;
493
581
  if (launchEffort)
494
582
  configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
495
- if (live?.instructions) {
583
+ if (runtime === "traex" && live?.instructions) {
496
584
  configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
497
585
  }
498
586
  const profile = getRuntimeProfile(runtime);
499
587
  const activeThreadId = live?.threadId ?? record?.codexSessionId;
500
588
  return {
589
+ lifecycle: "auxiliary",
501
590
  command: resolveRuntimeBinary(runtime),
502
591
  args: buildCodexRemoteArgs({
503
592
  remoteUrl,
504
593
  ...(activeThreadId ? { threadId: activeThreadId } : {}),
505
594
  configOverrides,
506
- codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
595
+ codexArgs: bypassHookTrust ? ["--dangerously-bypass-hook-trust"] : [],
507
596
  additionalDirs: providerAdditionalDirs(live.workspace),
508
597
  }),
509
598
  cwd: live.workspace.cwd,
599
+ scrollback: 100_000,
600
+ tmuxAllowPassthrough: true,
601
+ tmuxStartOnAttach: false,
602
+ keepAliveAfterExit: false,
510
603
  ...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
511
604
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
512
605
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
@@ -543,16 +636,21 @@ export class LocalAgentHost {
543
636
  return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
544
637
  }
545
638
  // Dedupe concurrent triggers so only one forwarder is ever created per
546
- // session (two would double-mirror every turn). Check this before the live
547
- // map: startLiveCodexSession publishes its partially initialized session
548
- // before thread/start completes, so a concurrent Terminal start must await
549
- // the thread id instead of launching a bare `codex --remote` pane.
639
+ // session (two would double-mirror every turn). startLiveCodexSession
640
+ // publishes its partially initialized session before thread/start completes,
641
+ // so wait for that startup and then adopt any newer mutable Turn settings.
550
642
  const inflight = this.liveEnsuring.get(localThreadId);
551
- if (inflight)
552
- return inflight;
643
+ if (inflight) {
644
+ if (!await inflight)
645
+ return false;
646
+ const startedLive = this.liveSessions.get(localThreadId);
647
+ return startedLive
648
+ ? updateLiveCodexTurnSettings(startedLive, snapshotOpts)
649
+ : false;
650
+ }
553
651
  const live = this.liveSessions.get(localThreadId);
554
652
  if (live)
555
- return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
653
+ return updateLiveCodexTurnSettings(live, snapshotOpts);
556
654
  const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
557
655
  this.liveEnsuring.set(localThreadId, started);
558
656
  try {
@@ -563,6 +661,7 @@ export class LocalAgentHost {
563
661
  }
564
662
  }
565
663
  async startLiveCodexSession(localThreadId, emit, opts) {
664
+ this.liveStartupErrors.delete(localThreadId);
566
665
  // cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
567
666
  // never run a new forwarder alongside a stale one for this id. ensureLive's has()
568
667
  // fast-path normally makes this a no-op; it closes the stop→re-ensure race that
@@ -571,6 +670,7 @@ export class LocalAgentHost {
571
670
  this.stopLiveCodexSession(localThreadId);
572
671
  }
573
672
  const record = await this.sessionStore.get(localThreadId);
673
+ const coldResume = Boolean(record?.codexSessionId);
574
674
  const retargetMirror = opts.retargetMirror;
575
675
  const workspace = structuredClone(opts.workspace);
576
676
  const execution = structuredClone(opts.execution);
@@ -586,18 +686,34 @@ export class LocalAgentHost {
586
686
  snapshotSkills = await this.prepareExecutionSkills(execution);
587
687
  }
588
688
  catch (err) {
689
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_preparation_failed", "skill preparation failed", err));
589
690
  console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
590
691
  return false;
591
692
  }
592
693
  }
593
- // Persist the owner's skills before the app-server boots. Fork targets reuse
594
- // the owner's immutable copies and never mutate the shared Provider home.
694
+ // Persist headless project trust before the app-server boots. Fork targets
695
+ // reuse the owner's immutable Provider home.
595
696
  const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
697
+ const runtimeHome = this.runtimeHome(runtime);
698
+ if (runtime === "codex") {
699
+ try {
700
+ trustCodexProject(runtimeHome, workspace.cwd);
701
+ syncCodexDeveloperInstructions(runtimeHome, nativeAuthoredInstructions(execution));
702
+ applyCodexProviderRetryPolicy(runtimeHome, execution.budget?.retry);
703
+ }
704
+ catch (err) {
705
+ void snapshotSkills.skillsCleanup();
706
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_project_trust_failed", "project trust preparation failed", err));
707
+ console.error(`[session-snapshot] session=${localThreadId} project trust preparation failed: ${err instanceof Error ? err.message : String(err)}`);
708
+ return false;
709
+ }
710
+ }
596
711
  try {
597
- populateCodexSkills(this.runtimeHome(runtime), liveSkills);
712
+ populateCodexSkills(runtimeHome, liveSkills);
598
713
  }
599
714
  catch (err) {
600
715
  void snapshotSkills.skillsCleanup();
716
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_persistence_failed", "skill persistence failed", err));
601
717
  console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
602
718
  return false;
603
719
  }
@@ -615,46 +731,99 @@ export class LocalAgentHost {
615
731
  void snapshotSkills.skillsCleanup();
616
732
  return false;
617
733
  };
618
- const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
619
- if (!injectClient) {
734
+ reapRuntimeProcessesForStateDir(runtimeSessionStateDir(this.runtimeHomeSessionId));
735
+ const backend = this.getBackend(runtime);
736
+ const appServerOwner = backend.appServerClient;
737
+ if (!appServerOwner) {
738
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_unavailable", "app-server is unavailable for this Session"));
620
739
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
621
740
  return abandon();
622
741
  }
742
+ let loginRequired;
743
+ if (!coldResume &&
744
+ runtime === "codex" &&
745
+ codexLaunchRequiresLogin(runtimeHome, createCodexChildEnv({ ...process.env, CODEX_HOME: runtimeHome }))) {
746
+ loginRequired = new CodexRuntimeError("Codex is not signed in and no configured Provider can run this Session, so the Terminal is waiting at the sign-in screen. Sign in from the Session Terminal, then send the message again.", 401, "login_required");
747
+ }
623
748
  try {
624
- await injectClient.ensureInitialized();
749
+ await appServerOwner.ensureInitialized();
625
750
  }
626
751
  catch (err) {
752
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_start_failed", "app-server initialization failed during its 10s readiness phase", err));
627
753
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
628
754
  return abandon();
629
755
  }
630
- const appServerUrl = injectClient.terminalRemoteUrl();
756
+ // A fresh remote TUI creates the thread, so Rynx does not receive its
757
+ // `thread/start` response. Ask the already-initialized app-server for the
758
+ // effective model instead of parsing Traex's version-dependent YAML/TOML
759
+ // files or confusing `model/list.isDefault` with this Session's model.
760
+ let configModel;
761
+ let configReasoningEffort;
762
+ if (!coldResume && (!execution.model || !execution.reasoningEffort)) {
763
+ try {
764
+ const effective = await appServerOwner.configRead({
765
+ includeLayers: false,
766
+ cwd: workspace.cwd,
767
+ });
768
+ const reportedModel = effective.config.model;
769
+ if (typeof reportedModel === "string" && reportedModel.trim()) {
770
+ configModel = reportedModel.trim();
771
+ }
772
+ const reportedEffort = effective.config.model_reasoning_effort;
773
+ if (typeof reportedEffort === "string" && reportedEffort) {
774
+ configReasoningEffort = reportedEffort;
775
+ }
776
+ }
777
+ catch (error) {
778
+ // Explicit Session settings remain usable on an older Provider. If a
779
+ // requested collaboration mode later needs an unknown model, that
780
+ // update fails clearly without mutating the native thread.
781
+ console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} effective config unavailable: ${error instanceof Error ? error.message : String(error)}`);
782
+ }
783
+ }
784
+ const appServerUrl = appServerOwner.terminalRemoteUrl();
631
785
  if (!appServerUrl) {
786
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_endpoint_missing", "app-server started without a Terminal remote endpoint"));
632
787
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
633
788
  return abandon(); // needs the ws app-server (live-terminal mode)
634
789
  }
635
790
  // reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
636
791
  // app-server, which `thread/resume`s to subscribe to the thread's item/turn
637
792
  // notifications (the backend client injects; this one only observes).
793
+ // Fresh sessions must connect this listener before the TUI launches so its
794
+ // one-shot `thread/started` cannot race discovery. A known-thread cold resume
795
+ // already has its durable id: like reference implementation's `_codex_forward_known_thread`,
796
+ // its observer starts in the background only after the replacement TUI has
797
+ // launched and is never part of the resume admission boundary.
638
798
  const forwarderClient = this.forwarderClientFactory(appServerUrl);
639
- try {
640
- await forwarderClient.ensureInitialized();
641
- }
642
- catch (err) {
643
- console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
644
- return abandon();
799
+ if (!coldResume) {
800
+ try {
801
+ await forwarderClient.ensureInitialized();
802
+ }
803
+ catch (err) {
804
+ this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach to the ready app-server", err));
805
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} fresh-thread listener init failed: ${err instanceof Error ? err.message : String(err)}`);
806
+ return abandon();
807
+ }
645
808
  }
646
- const model = execution.model;
647
- const reasoningEffort = execution.reasoningEffort ?? undefined;
809
+ const model = execution.model?.trim() || (!coldResume ? configModel : undefined) || "";
810
+ const reasoningEffort = execution.reasoningEffort ??
811
+ (!coldResume ? configReasoningEffort : undefined);
648
812
  let markReady;
649
813
  const ready = new Promise((resolve) => {
650
814
  markReady = resolve;
651
815
  });
816
+ let markStartupFailed;
817
+ const startupFailed = new Promise((resolve) => {
818
+ markStartupFailed = resolve;
819
+ });
652
820
  let markTerminalReady;
653
821
  const terminalReady = new Promise((resolve) => {
654
822
  markTerminalReady = resolve;
655
823
  });
656
824
  const live = {
657
- injectClient,
825
+ appServerOwner,
826
+ appServerUrl,
658
827
  forwarderClient,
659
828
  forwarder: null,
660
829
  runtime,
@@ -664,27 +833,37 @@ export class LocalAgentHost {
664
833
  approvalPolicy,
665
834
  model,
666
835
  reasoningEffort,
667
- ...(execution.instructions ? { instructions: execution.instructions } : {}),
836
+ mayUseProviderDefaultModel: !coldResume && !model,
837
+ appliedModel: coldResume ? "" : model,
838
+ appliedReasoningEffort: coldResume ? undefined : reasoningEffort,
839
+ appliedCollaborationMode: undefined,
840
+ ...(runtime === "traex" && execution.instructions
841
+ ? { instructions: execution.instructions }
842
+ : {}),
668
843
  threadId: record?.codexSessionId ?? null,
669
844
  ready,
670
845
  markReady,
846
+ startupFailed,
847
+ markStartupFailed,
848
+ ...(loginRequired ? { startupError: loginRequired } : {}),
671
849
  terminalReady,
672
850
  markTerminalReady,
673
851
  injectLock: Promise.resolve(),
674
852
  pendingInjectedInputs: [],
675
- publishInjectedInput: () => undefined,
853
+ publishTurnAdmission: () => undefined,
854
+ publishTurnAdmissionFailure: () => undefined,
855
+ publishInterrupted: () => undefined,
856
+ interruptedResponseId: null,
676
857
  subscribing: false,
677
858
  rotationPending: false,
678
859
  stopped: false,
860
+ observerAvailable: !coldResume,
861
+ threadActive: false,
679
862
  skillsCleanup: snapshotSkills.skillsCleanup,
680
- interactionOwners: new Map(),
681
- interactionStandbys: new Map(),
682
- interactionSubmissions: new Map(),
683
863
  canonicalInteractions: new Set(),
684
864
  settledInteractions: new Set(),
685
- interactionRecoveries: new Map(),
686
- interactionFailedClients: new Map(),
687
- disconnectedClients: new Set(),
865
+ syntheticInteractions: new Map(),
866
+ cancelSyntheticInteractions: () => undefined,
688
867
  };
689
868
  let currentSessionId = localThreadId;
690
869
  let pendingRotationEvents = null;
@@ -695,11 +874,23 @@ export class LocalAgentHost {
695
874
  }
696
875
  emit(event);
697
876
  };
877
+ live.publishInterrupted = (responseId) => {
878
+ if (live.interruptedResponseId === responseId)
879
+ return;
880
+ live.interruptedResponseId = responseId;
881
+ emitCurrent({
882
+ type: "session.interrupted",
883
+ sessionId: currentSessionId,
884
+ responseId,
885
+ });
886
+ };
698
887
  let normalizer = null;
699
888
  let currentResponseId = null;
889
+ /** State for content-only events, keyed by Provider response so consecutive
890
+ * late deltas retain one canonical item id without owning lifecycle. */
891
+ const contentNormalizers = new Map();
700
892
  const startNormalizer = (turnId) => {
701
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
702
- (turnId ? `resp_codex_${turnId}` : "resp_codex_native");
893
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
703
894
  if (normalizer && currentResponseId === responseId)
704
895
  return normalizer;
705
896
  currentResponseId = responseId;
@@ -709,26 +900,68 @@ export class LocalAgentHost {
709
900
  // `turn/started` land on ONE response instead of splitting into random
710
901
  // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
711
902
  responseId,
712
- // Traex intentionally leaves the launch model empty to use its own
713
- // config. Canonical/Direct events still require a printable producer
714
- // label, so report the runtime when the exact model is unknown.
903
+ // A null Session model intentionally leaves selection to the Provider
904
+ // CLI. Canonical events still require a printable label.
715
905
  model: model || runtime,
716
906
  });
717
907
  return normalizer;
718
908
  };
719
- live.publishInjectedInput = (turnId, content) => {
720
- const n = startNormalizer(turnId);
721
- const pending = live.pendingInjectedInputs.find((entry) => entry.signature === JSON.stringify(content) && entry.responseId === undefined);
722
- if (pending && currentResponseId)
723
- pending.responseId = currentResponseId;
724
- for (const se of n.userInput(content))
725
- emitCurrent(se);
909
+ live.publishTurnAdmission = () => {
910
+ emitCurrent({
911
+ type: "session.status",
912
+ sessionId: currentSessionId,
913
+ status: "running",
914
+ });
915
+ };
916
+ live.publishTurnAdmissionFailure = (message) => {
917
+ emitCurrent({
918
+ type: "session.status",
919
+ sessionId: currentSessionId,
920
+ status: "failed",
921
+ note: message,
922
+ });
726
923
  };
727
924
  const clearPendingInputsForResponse = (responseId) => {
728
925
  if (!responseId)
729
926
  return;
730
927
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
731
928
  };
929
+ const turnContentEvents = (turnId, produce) => {
930
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
931
+ let contentNormalizer = contentNormalizers.get(responseId);
932
+ if (!contentNormalizer) {
933
+ contentNormalizer = new SessionNormalizer({
934
+ sessionId: currentSessionId,
935
+ responseId,
936
+ model: live.model || live.runtime,
937
+ });
938
+ contentNormalizers.set(responseId, contentNormalizer);
939
+ }
940
+ for (const event of produce(contentNormalizer)) {
941
+ // Item/transient content is attached to the Turn's response id
942
+ // independently of lifecycle status. Reuse Rynx's canonical item
943
+ // normalization but suppress its synthetic lifecycle edges.
944
+ if (event.type !== "response.created" && event.type !== "session.status") {
945
+ emitCurrent(event);
946
+ }
947
+ }
948
+ };
949
+ const observedUserContent = (content) => {
950
+ const normalizedContent = typeof content === "string"
951
+ ? [{ type: "input_text", text: content }]
952
+ : content;
953
+ // The Provider does not echo a caller id and may reformat the text, so the
954
+ // first native user record drains the oldest injected input instead of
955
+ // comparing message text.
956
+ const pending = live.pendingInjectedInputs[0];
957
+ if (pending) {
958
+ pending.observed = true;
959
+ if (pending.state === "accepted") {
960
+ live.pendingInjectedInputs.shift();
961
+ }
962
+ }
963
+ return normalizedContent;
964
+ };
732
965
  const rememberSettledInteraction = (interactionId) => {
733
966
  live.settledInteractions.add(interactionId);
734
967
  if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
@@ -737,11 +970,174 @@ export class LocalAgentHost {
737
970
  if (oldest)
738
971
  live.settledInteractions.delete(oldest);
739
972
  };
973
+ const cancelSyntheticInteractions = (reason) => {
974
+ for (const interaction of live.syntheticInteractions.values())
975
+ interaction.cancel(reason);
976
+ };
977
+ live.cancelSyntheticInteractions = cancelSyntheticInteractions;
978
+ const publishPlanImplementationPrompt = (prompt) => {
979
+ const suffix = createHash("sha256")
980
+ .update(`${prompt.threadId}\0${prompt.turnId}`)
981
+ .digest("hex")
982
+ .slice(0, 24);
983
+ const interactionId = `codex_plan_implementation_${suffix}`;
984
+ if (live.syntheticInteractions.has(interactionId) ||
985
+ live.settledInteractions.has(interactionId))
986
+ return;
987
+ const responseId = `resp_codex_plan_approval_${suffix}`;
988
+ const request = codexPlanImplementationRequest(interactionId);
989
+ const interactionNormalizer = new SessionNormalizer({
990
+ sessionId: currentSessionId,
991
+ responseId,
992
+ model: live.model || live.runtime,
993
+ });
994
+ let submitted = false;
995
+ const finish = (kind, resolution, reason) => {
996
+ if (!live.syntheticInteractions.delete(interactionId))
997
+ return;
998
+ rememberSettledInteraction(interactionId);
999
+ const event = kind === "resolved"
1000
+ ? {
1001
+ type: "interaction_resolved",
1002
+ interactionId,
1003
+ resolution: resolution,
1004
+ }
1005
+ : {
1006
+ type: "interaction_cancelled",
1007
+ interactionId,
1008
+ ...(reason ? { reason } : {}),
1009
+ };
1010
+ // This interaction belongs to the Session that produced the Plan. A
1011
+ // clear-context choice can rotate the native thread before turn/start
1012
+ // returns, so never retarget its resolution to the new Session.
1013
+ for (const sessionEvent of interactionNormalizer.next(event))
1014
+ emit(sessionEvent);
1015
+ for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
1016
+ emit(sessionEvent);
1017
+ }
1018
+ };
1019
+ live.syntheticInteractions.set(interactionId, {
1020
+ cancel: (reason) => {
1021
+ // The selected implementation itself starts a native Turn and the
1022
+ // clear-context variant also starts a new thread. Their observer
1023
+ // notifications can beat the short-lived RPC response; they are not
1024
+ // competing user actions and must not cancel the card being resolved.
1025
+ if (submitted &&
1026
+ (reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
1027
+ return;
1028
+ finish("cancelled", undefined, reason);
1029
+ },
1030
+ resolve: async (resolution) => {
1031
+ if (submitted)
1032
+ return { disposition: "already_resolved" };
1033
+ const invalid = validateInteractionResolution(request, resolution);
1034
+ if (invalid)
1035
+ return { disposition: "invalid", message: invalid };
1036
+ const choice = resolution.answers?.plan_implementation;
1037
+ if (typeof choice !== "string") {
1038
+ return { disposition: "invalid", message: "plan implementation choice is required" };
1039
+ }
1040
+ submitted = true;
1041
+ const run = live.injectLock.then(async () => {
1042
+ if (live.stopped || live.rotationPending) {
1043
+ throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
1044
+ }
1045
+ const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
1046
+ !live.rotationPending &&
1047
+ live.syntheticInteractions.has(interactionId) &&
1048
+ live.forwarder.threadId() === prompt.threadId &&
1049
+ !live.forwarder.isTurnOpen());
1050
+ if (promptDismissal === "failed") {
1051
+ console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
1052
+ }
1053
+ if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
1054
+ finish("resolved", resolution);
1055
+ return;
1056
+ }
1057
+ const client = this.injectionClientFactory(live.appServerUrl);
1058
+ try {
1059
+ await client.ensureInitialized();
1060
+ const collaborationMode = await resolveLiveCollaborationMode(client, live, "default", live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined);
1061
+ let threadId = prompt.threadId;
1062
+ let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
1063
+ if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
1064
+ live.nextThreadCollaborationMode = "default";
1065
+ let startedThread;
1066
+ try {
1067
+ startedThread = await client.threadStart({
1068
+ ...(live.model ? { model: live.model } : {}),
1069
+ ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1070
+ approvalPolicy: live.approvalPolicy,
1071
+ ...(live.instructions ? { developerInstructions: live.instructions } : {}),
1072
+ sessionStartSource: "clear",
1073
+ });
1074
+ }
1075
+ catch (error) {
1076
+ live.nextThreadCollaborationMode = undefined;
1077
+ throw error;
1078
+ }
1079
+ threadId = startedThread.threadId;
1080
+ text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
1081
+ }
1082
+ else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
1083
+ throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
1084
+ }
1085
+ const started = await client.turnStart({
1086
+ threadId,
1087
+ input: [{ type: "text", text }],
1088
+ ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1089
+ approvalPolicy: live.approvalPolicy,
1090
+ collaborationMode,
1091
+ });
1092
+ if (!live.model)
1093
+ live.model = collaborationMode.settings.model;
1094
+ live.mayUseProviderDefaultModel = false;
1095
+ live.appliedModel = collaborationMode.settings.model;
1096
+ live.appliedReasoningEffort =
1097
+ collaborationMode.settings.reasoning_effort ?? undefined;
1098
+ live.execution = { ...live.execution, collaborationMode: "default" };
1099
+ live.appliedCollaborationMode = "default";
1100
+ emitCurrent({
1101
+ type: "session.collaboration_mode",
1102
+ sessionId: currentSessionId,
1103
+ mode: "default",
1104
+ });
1105
+ live.forwarder.noteTurnAccepted(started.turnId);
1106
+ finish("resolved", resolution);
1107
+ }
1108
+ finally {
1109
+ await client.stop().catch(() => undefined);
1110
+ }
1111
+ });
1112
+ live.injectLock = run.then(() => undefined, () => undefined);
1113
+ try {
1114
+ await run;
1115
+ return { disposition: "applied" };
1116
+ }
1117
+ catch (error) {
1118
+ submitted = false;
1119
+ return {
1120
+ disposition: "invalid",
1121
+ message: error instanceof Error ? error.message : String(error),
1122
+ };
1123
+ }
1124
+ },
1125
+ });
1126
+ for (const sessionEvent of interactionNormalizer.next({
1127
+ type: "interaction_requested",
1128
+ interaction: request,
1129
+ }))
1130
+ emit(sessionEvent);
1131
+ };
740
1132
  const forwardInteraction = (event) => {
741
1133
  const interactionId = event.type === "requested"
742
1134
  ? event.request.interactionId
743
1135
  : event.interactionId;
744
1136
  if (event.type === "requested") {
1137
+ if (event.request.fields.some((field) => field.id === "plan_implementation")) {
1138
+ live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
1139
+ cancelSyntheticInteractions("superseded_by_native_plan_prompt");
1140
+ }
745
1141
  if (live.canonicalInteractions.has(interactionId) ||
746
1142
  live.settledInteractions.has(interactionId))
747
1143
  return;
@@ -773,223 +1169,179 @@ export class LocalAgentHost {
773
1169
  for (const se of n.next(agentEvent))
774
1170
  emitCurrent(se);
775
1171
  };
776
- const clearRecovery = (interactionId) => {
777
- live.interactionRecoveries.delete(interactionId);
778
- };
779
- const clearInteractionRouting = (interactionId) => {
780
- clearRecovery(interactionId);
781
- live.interactionOwners.delete(interactionId);
782
- live.interactionStandbys.delete(interactionId);
783
- live.interactionSubmissions.delete(interactionId);
784
- live.interactionFailedClients.delete(interactionId);
785
- };
786
- const markInteractionClientFailed = (interactionId, client) => {
787
- const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
788
- failed.add(client);
789
- live.interactionFailedClients.set(interactionId, failed);
790
- };
791
- const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
792
- live.interactionFailedClients.get(interactionId)?.has(client) === true;
793
- const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
794
- const finishRecovery = (interactionId, event) => {
795
- clearInteractionRouting(interactionId);
796
- forwardInteraction(event);
797
- };
798
- const beginRecovery = (interactionId, event) => {
799
- live.interactionOwners.delete(interactionId);
800
- live.interactionRecoveries.set(interactionId, { event });
801
- };
802
- const promoteInteractionOwner = (interactionId, replacement) => {
803
- const recovery = live.interactionRecoveries.get(interactionId);
804
- live.interactionOwners.set(interactionId, replacement);
805
- const submission = live.interactionSubmissions.get(interactionId);
806
- if (!submission) {
807
- clearRecovery(interactionId);
808
- return true;
809
- }
810
- const replayed = replacement.resolveInteraction(interactionId, submission);
811
- // A write failure can synchronously re-enter the listener and remove this
812
- // owner. Only clear recovery when the replacement still owns the request.
813
- if (replayed.disposition === "applied" &&
814
- live.interactionOwners.get(interactionId) === replacement) {
815
- clearRecovery(interactionId);
816
- return true;
817
- }
818
- if (live.interactionOwners.get(interactionId) === replacement) {
819
- live.interactionOwners.delete(interactionId);
820
- }
821
- if (recovery &&
822
- live.canonicalInteractions.has(interactionId) &&
823
- !live.settledInteractions.has(interactionId) &&
824
- !live.interactionRecoveries.has(interactionId)) {
825
- live.interactionRecoveries.set(interactionId, recovery);
826
- }
827
- return false;
828
- };
829
- const bindInteractionClient = (client) => {
830
- client.setInteractionListener((event) => {
831
- const interactionId = event.type === "requested"
832
- ? event.request.interactionId
833
- : event.interactionId;
834
- const owner = live.interactionOwners.get(interactionId);
835
- if (event.type === "requested") {
836
- // Receiving a native request proves this connection is alive and can
837
- // be retried even if an earlier copy failed on it.
838
- live.disconnectedClients.delete(client);
839
- const failed = live.interactionFailedClients.get(interactionId);
840
- failed?.delete(client);
841
- if (failed?.size === 0)
842
- live.interactionFailedClients.delete(interactionId);
843
- if (live.settledInteractions.has(interactionId))
844
- return;
845
- const recovery = live.interactionRecoveries.get(interactionId);
846
- if (recovery) {
847
- promoteInteractionOwner(interactionId, client);
848
- return;
849
- }
850
- if (owner && owner !== client) {
851
- const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
852
- standbys.add(client);
853
- live.interactionStandbys.set(interactionId, standbys);
854
- return;
855
- }
856
- live.interactionOwners.set(interactionId, client);
857
- forwardInteraction(event);
858
- return;
859
- }
860
- const recoverableOwnerFailure = event.type === "cancelled" &&
861
- (event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
862
- if (recoverableOwnerFailure)
863
- markInteractionClientFailed(interactionId, client);
864
- if (!owner || owner !== client) {
865
- const standbys = live.interactionStandbys.get(interactionId);
866
- standbys?.delete(client);
867
- if (standbys?.size === 0)
868
- live.interactionStandbys.delete(interactionId);
869
- const recovery = live.interactionRecoveries.get(interactionId);
870
- if (recoverableOwnerFailure &&
871
- live.canonicalInteractions.has(interactionId) &&
872
- allInteractionClientsUnavailable(interactionId)) {
873
- finishRecovery(interactionId, recovery?.event ?? event);
874
- }
875
- return;
876
- }
877
- if (recoverableOwnerFailure) {
878
- const standbys = live.interactionStandbys.get(interactionId);
879
- while (standbys && standbys.size > 0) {
880
- const replacement = standbys.values().next().value;
881
- if (!replacement)
882
- break;
883
- standbys.delete(replacement);
884
- if (interactionClientUnavailable(interactionId, replacement))
885
- continue;
886
- if (promoteInteractionOwner(interactionId, replacement)) {
887
- if (standbys.size === 0)
888
- live.interactionStandbys.delete(interactionId);
889
- return;
890
- }
891
- if (live.settledInteractions.has(interactionId))
892
- return;
893
- }
894
- if (!live.settledInteractions.has(interactionId)) {
895
- if (allInteractionClientsUnavailable(interactionId)) {
896
- finishRecovery(interactionId, event);
897
- }
898
- else {
899
- beginRecovery(interactionId, event);
900
- }
901
- return;
902
- }
903
- }
904
- clearInteractionRouting(interactionId);
905
- forwardInteraction(event);
906
- });
1172
+ const bindObserverClient = (client) => {
1173
+ client.setInteractionListener(forwardInteraction);
907
1174
  client.setConnectionListener((state) => {
908
- if (state === "connected") {
909
- live.disconnectedClients.delete(client);
1175
+ if (state === "connected" || live.stopped)
910
1176
  return;
1177
+ live.observerAvailable = false;
1178
+ for (const interactionId of [...live.canonicalInteractions]) {
1179
+ forwardInteraction({
1180
+ type: "cancelled",
1181
+ interactionId,
1182
+ reason: "app_server_disconnected",
1183
+ });
911
1184
  }
912
- live.disconnectedClients.add(client);
913
- for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
914
- if (allInteractionClientsUnavailable(interactionId)) {
915
- finishRecovery(interactionId, recovery.event);
916
- }
917
- }
1185
+ this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
918
1186
  });
919
1187
  };
920
1188
  const closeCanonicalInteractions = () => {
921
1189
  for (const interactionId of live.canonicalInteractions) {
922
1190
  rememberSettledInteraction(interactionId);
923
- clearInteractionRouting(interactionId);
924
1191
  }
925
1192
  live.canonicalInteractions.clear();
926
1193
  };
1194
+ const completeCurrentTurn = (usage, reason) => {
1195
+ closeCanonicalInteractions();
1196
+ if (!normalizer)
1197
+ return;
1198
+ const completedResponseId = currentResponseId;
1199
+ if (usage) {
1200
+ for (const se of normalizer.next({ type: "turn_completed", usage }))
1201
+ emitCurrent(se);
1202
+ }
1203
+ for (const se of normalizer.next({ type: "done" })) {
1204
+ if (reason === "superseded" && se.type === "session.status")
1205
+ continue;
1206
+ emitCurrent(se);
1207
+ }
1208
+ clearPendingInputsForResponse(completedResponseId);
1209
+ normalizer = null;
1210
+ currentResponseId = null;
1211
+ };
1212
+ const failCurrentTurn = (error) => {
1213
+ closeCanonicalInteractions();
1214
+ if (!normalizer)
1215
+ return;
1216
+ const failedResponseId = currentResponseId;
1217
+ const responseStopped = error.message === "Codex turn was interrupted";
1218
+ const providerName = runtime === "traex" ? "Traex" : "Codex";
1219
+ const message = responseStopped
1220
+ ? "Response stopped"
1221
+ : runtime === "traex"
1222
+ ? error.message.replace(/^Codex\b/, providerName)
1223
+ : error.message;
1224
+ for (const se of normalizer.fail({
1225
+ code: responseStopped
1226
+ ? "response_stopped"
1227
+ : runtime === "traex" ? "traex_error" : "codex_error",
1228
+ message,
1229
+ source: "execution",
1230
+ })) {
1231
+ emitCurrent(se.type === "session.status"
1232
+ ? { ...se, status: "failed", note: message }
1233
+ : se);
1234
+ }
1235
+ clearPendingInputsForResponse(failedResponseId);
1236
+ normalizer = null;
1237
+ currentResponseId = null;
1238
+ };
1239
+ const interruptCurrentTurn = () => {
1240
+ closeCanonicalInteractions();
1241
+ if (!normalizer)
1242
+ return;
1243
+ const interruptedResponseId = currentResponseId;
1244
+ for (const se of normalizer.interrupt()) {
1245
+ if (se.type === "session.interrupted" &&
1246
+ live.interruptedResponseId === interruptedResponseId)
1247
+ continue;
1248
+ emitCurrent(se);
1249
+ }
1250
+ live.interruptedResponseId = null;
1251
+ clearPendingInputsForResponse(interruptedResponseId);
1252
+ normalizer = null;
1253
+ currentResponseId = null;
1254
+ };
927
1255
  const sink = {
928
1256
  onTurnStart: (turnId) => startNormalizer(turnId),
929
- onUserMessage: (content) => {
930
- const normalizedContent = typeof content === "string"
931
- ? [{ type: "input_text", text: content }]
932
- : content;
933
- const signature = JSON.stringify(normalizedContent);
934
- const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
935
- if (pending?.state === "optimistic" || pending?.state === "prepublished") {
936
- live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
937
- return;
1257
+ onTurnObserved: (turnId) => {
1258
+ cancelSyntheticInteractions("superseded_by_native_turn");
1259
+ const n = startNormalizer(turnId);
1260
+ for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
1261
+ emitCurrent(event);
938
1262
  }
939
- if (pending)
940
- pending.observed = true;
1263
+ },
1264
+ onUserMessage: (content) => {
1265
+ const normalizedContent = observedUserContent(content);
941
1266
  const n = normalizer ?? startNormalizer();
942
1267
  for (const se of n.userInput(normalizedContent))
943
1268
  emitCurrent(se);
944
1269
  },
1270
+ onTurnContentUserMessage: (turnId, content) => {
1271
+ const normalizedContent = observedUserContent(content);
1272
+ turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
1273
+ },
945
1274
  onEvent: (event) => {
946
1275
  const n = normalizer ?? startNormalizer();
947
1276
  for (const se of n.next(event))
948
1277
  emitCurrent(se);
949
1278
  },
950
- onTurnEnd: (usage) => {
951
- closeCanonicalInteractions();
952
- if (!normalizer)
953
- return;
954
- const completedResponseId = currentResponseId;
955
- if (usage) {
956
- for (const se of normalizer.next({ type: "turn_completed", usage }))
957
- emitCurrent(se);
958
- }
959
- for (const se of normalizer.next({ type: "done" }))
960
- emitCurrent(se);
961
- clearPendingInputsForResponse(completedResponseId);
962
- normalizer = null;
963
- currentResponseId = null;
1279
+ onTurnContentEvent: (turnId, event) => {
1280
+ turnContentEvents(turnId, (n) => n.next(event));
1281
+ },
1282
+ onStatus: (note, statusKind) => {
1283
+ const responseId = currentResponseId ??
1284
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
1285
+ emitCurrent({
1286
+ type: "session.status",
1287
+ sessionId: currentSessionId,
1288
+ ...(responseId ? { responseId } : {}),
1289
+ status: "running",
1290
+ ...(note ? { note } : {}),
1291
+ ...(statusKind ? { statusKind } : {}),
1292
+ });
964
1293
  },
965
- onTurnError: (error) => {
966
- closeCanonicalInteractions();
967
- if (!normalizer)
1294
+ // A newer authoritative turn/started completes the old canonical
1295
+ // response without publishing a false idle edge in a running→running
1296
+ // transition.
1297
+ onTurnEnd: completeCurrentTurn,
1298
+ onTurnInterrupted: interruptCurrentTurn,
1299
+ onRecoveredTurnStatus: (status, turnId, error) => {
1300
+ const responseId = turnId ? `resp_codex_${turnId}` : undefined;
1301
+ if (normalizer && (!responseId || currentResponseId === responseId)) {
1302
+ if (status === "failed")
1303
+ failCurrentTurn(error ?? new Error("Codex turn failed"));
1304
+ else
1305
+ completeCurrentTurn();
968
1306
  return;
969
- const failedResponseId = currentResponseId;
970
- const responseStopped = error.message === "Codex turn was interrupted";
971
- const providerName = runtime === "traex" ? "Traex" : "Codex";
972
- const message = responseStopped
973
- ? "Response stopped"
974
- : runtime === "traex"
975
- ? error.message.replace(/^Codex\b/, providerName)
976
- : error.message;
977
- for (const se of normalizer.fail({
978
- code: responseStopped
979
- ? "response_stopped"
980
- : runtime === "traex" ? "traex_error" : "codex_error",
981
- message,
982
- source: "execution",
983
- }))
984
- emitCurrent(se);
985
- clearPendingInputsForResponse(failedResponseId);
986
- normalizer = null;
987
- currentResponseId = null;
1307
+ }
1308
+ emitCurrent({
1309
+ type: "session.status",
1310
+ sessionId: currentSessionId,
1311
+ ...(responseId ? { responseId } : {}),
1312
+ status,
1313
+ ...(error ? { note: error.message } : {}),
1314
+ });
988
1315
  },
1316
+ onTurnError: failCurrentTurn,
989
1317
  // A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
990
1318
  shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
991
1319
  onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
992
- onThreadActive: () => live.releaseActive?.(),
1320
+ onThreadActive: () => {
1321
+ live.threadActive = true;
1322
+ live.releaseActive?.();
1323
+ },
1324
+ onCollaborationModeChanged: (mode) => {
1325
+ live.appliedCollaborationMode = mode;
1326
+ live.execution = { ...live.execution, collaborationMode: mode };
1327
+ emitCurrent({
1328
+ type: "session.collaboration_mode",
1329
+ sessionId: currentSessionId,
1330
+ mode,
1331
+ });
1332
+ },
1333
+ onThreadSettingsChanged: ({ model: reportedModel, reasoningEffort: reportedEffort }) => {
1334
+ if (reportedModel) {
1335
+ live.model = reportedModel;
1336
+ live.appliedModel = reportedModel;
1337
+ live.mayUseProviderDefaultModel = false;
1338
+ }
1339
+ if (reportedEffort !== undefined) {
1340
+ live.reasoningEffort = reportedEffort ?? undefined;
1341
+ live.appliedReasoningEffort = reportedEffort ?? undefined;
1342
+ }
1343
+ },
1344
+ onPlanImplementationPrompt: publishPlanImplementationPrompt,
993
1345
  };
994
1346
  const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
995
1347
  // Traex 0.200 can publish a final reasoning item immediately after
@@ -1000,6 +1352,7 @@ export class LocalAgentHost {
1000
1352
  turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
1001
1353
  assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
1002
1354
  surfaceQueueStatus: runtime === "traex",
1355
+ mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
1003
1356
  });
1004
1357
  live.forwarder = forwarder;
1005
1358
  live.onNativeThreadRotated = (threadId, kind) => {
@@ -1007,12 +1360,20 @@ export class LocalAgentHost {
1007
1360
  live.stopped = true;
1008
1361
  return;
1009
1362
  }
1363
+ cancelSyntheticInteractions("native_thread_rotated");
1010
1364
  const previousSessionId = currentSessionId;
1011
1365
  const newSessionId = makeSessionId();
1366
+ const rotationMode = live.nextThreadCollaborationMode;
1367
+ live.nextThreadCollaborationMode = undefined;
1368
+ live.appliedCollaborationMode = undefined;
1369
+ if (rotationMode) {
1370
+ live.execution = { ...live.execution, collaborationMode: rotationMode };
1371
+ }
1012
1372
  live.rotationPending = true;
1013
1373
  currentSessionId = newSessionId;
1014
1374
  normalizer = null;
1015
1375
  currentResponseId = null;
1376
+ contentNormalizers.clear();
1016
1377
  live.pendingInjectedInputs = [];
1017
1378
  pendingRotationEvents = [];
1018
1379
  void this.sessionStore
@@ -1024,6 +1385,9 @@ export class LocalAgentHost {
1024
1385
  ...(previous?.runtimeHomeOwnerSessionId
1025
1386
  ? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
1026
1387
  : {}),
1388
+ ...(previous?.bridgeOwnerSessionId
1389
+ ? { bridgeOwnerSessionId: previous.bridgeOwnerSessionId }
1390
+ : {}),
1027
1391
  updatedAt: new Date().toISOString(),
1028
1392
  }))
1029
1393
  .then(() => {
@@ -1034,6 +1398,7 @@ export class LocalAgentHost {
1034
1398
  kind,
1035
1399
  });
1036
1400
  this.liveSessions.set(newSessionId, live);
1401
+ this.liveSessions.delete(previousSessionId);
1037
1402
  retargetMirror?.(newSessionId, {
1038
1403
  kind,
1039
1404
  workspace: structuredClone(live.workspace),
@@ -1045,7 +1410,15 @@ export class LocalAgentHost {
1045
1410
  live.rotationPending = false;
1046
1411
  for (const event of queued)
1047
1412
  emit(event);
1048
- void this.subscribeUntilReady(live, threadId);
1413
+ const desiredMode = live.execution.collaborationMode;
1414
+ if (desiredMode) {
1415
+ const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
1416
+ live.injectLock = restore.then(() => undefined, () => undefined);
1417
+ void restore.catch((error) => {
1418
+ this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
1419
+ });
1420
+ }
1421
+ void this.subscribeUntilReady(newSessionId, live, threadId);
1049
1422
  })
1050
1423
  .catch(() => {
1051
1424
  pendingRotationEvents = null;
@@ -1053,9 +1426,10 @@ export class LocalAgentHost {
1053
1426
  live.stopped = true;
1054
1427
  });
1055
1428
  };
1056
- bindInteractionClient(injectClient);
1057
- bindInteractionClient(forwarderClient);
1429
+ bindObserverClient(forwarderClient);
1058
1430
  this.liveSessions.set(localThreadId, live);
1431
+ if (loginRequired)
1432
+ this.liveStartupErrors.set(localThreadId, loginRequired);
1059
1433
  forwarder.start();
1060
1434
  // Existing bindings resume through the public app-server API. A fresh thread
1061
1435
  // is created by the native remote TUI instead: its `thread/started`
@@ -1064,36 +1438,56 @@ export class LocalAgentHost {
1064
1438
  // turn-less app-server thread resumable by the TUI.
1065
1439
  try {
1066
1440
  if (record?.codexSessionId) {
1067
- let resumeError;
1068
- let resumedThreadId;
1069
- const attempts = 20;
1070
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1441
+ // Match reference implementation's cold-resume preload exactly: one history-free
1442
+ // `thread/resume` on a short-lived, separately initialized connection.
1443
+ // The backend owner's handshake above is the app-server readiness
1444
+ // probe; reusing that first connection here races Codex's startup state
1445
+ // and can return `Not initialized`. A failure stays attached to the
1446
+ // original binding and is reported by this phase; it is not polled.
1447
+ const preloadClient = this.preloadClientFactory(appServerUrl);
1448
+ try {
1449
+ let resumed;
1071
1450
  try {
1072
- const resumed = await injectClient.threadResume({
1451
+ await preloadClient.ensureInitialized();
1452
+ resumed = await preloadClient.threadResume({
1073
1453
  threadId: record.codexSessionId,
1074
1454
  ...threadWorkspaceParams(runtime, workspace, sandbox),
1075
1455
  approvalPolicy,
1076
1456
  excludeTurns: true,
1077
1457
  });
1078
- resumedThreadId = resumed.threadId;
1079
- break;
1458
+ adoptResumedThreadSettings(live, resumed);
1459
+ await this.applyLiveThreadSettings(live, resumed.threadId, execution.collaborationMode, preloadClient);
1080
1460
  }
1081
- catch (error) {
1082
- resumeError = error;
1083
- if (!isRetryableThreadResumeError(error))
1084
- throw error;
1085
- if (attempt + 1 < attempts) {
1086
- await new Promise((resolve) => setTimeout(resolve, 150));
1087
- }
1461
+ finally {
1462
+ await preloadClient.stop().catch((error) => {
1463
+ console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1464
+ });
1088
1465
  }
1466
+ forwarder.noteThreadBound(resumed.threadId);
1467
+ this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
1089
1468
  }
1090
- if (resumedThreadId) {
1091
- this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1092
- }
1093
- else {
1094
- // Never replace an existing binding. A delayed native index is
1095
- // retryable; a genuinely missing thread remains an explicit error.
1096
- throw resumeError;
1469
+ catch (error) {
1470
+ if (!isThreadNotReadyError(error) ||
1471
+ record.parentSessionId ||
1472
+ record.runtimeHomeOwnerSessionId) {
1473
+ throw error;
1474
+ }
1475
+ // Preserve the existing turn-less binding recovery without polling:
1476
+ // an ordinary native id with no rollout/turn has no Provider history
1477
+ // to retain. Fork bindings remain strict, and `thread not found` is
1478
+ // not classified here because it does not prove the history is empty.
1479
+ await this.sessionStore.delete(localThreadId);
1480
+ live.threadId = null;
1481
+ live.mayUseProviderDefaultModel = !live.model;
1482
+ try {
1483
+ await forwarderClient.ensureInitialized();
1484
+ }
1485
+ catch (listenerError) {
1486
+ throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
1487
+ }
1488
+ live.observerAvailable = true;
1489
+ live.markTerminalReady(true);
1490
+ console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
1097
1491
  }
1098
1492
  }
1099
1493
  else {
@@ -1108,15 +1502,32 @@ export class LocalAgentHost {
1108
1502
  live.stopped = true;
1109
1503
  forwarder.stop();
1110
1504
  void forwarderClient.stop().catch(() => undefined);
1505
+ const failure = err instanceof CodexRuntimeError
1506
+ ? err
1507
+ : nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
1508
+ this.liveStartupErrors.set(localThreadId, failure);
1111
1509
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1112
1510
  return abandon();
1113
1511
  }
1512
+ if (!loginRequired)
1513
+ this.liveStartupErrors.delete(localThreadId);
1114
1514
  return true;
1115
1515
  }
1116
1516
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
1117
1517
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
1118
1518
  onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
1519
+ if (live.startupError?.code === "login_required") {
1520
+ delete live.startupError;
1521
+ if (this.liveStartupErrors.get(localThreadId)?.code === "login_required") {
1522
+ this.liveStartupErrors.delete(localThreadId);
1523
+ }
1524
+ }
1119
1525
  const previousThreadId = live.threadId;
1526
+ if (previousThreadId !== threadId) {
1527
+ live.threadActive = false;
1528
+ if (!live.model)
1529
+ live.mayUseProviderDefaultModel = true;
1530
+ }
1120
1531
  if (previousThreadId && previousThreadId !== threadId) {
1121
1532
  live.threadId = threadId;
1122
1533
  live.subscribing = true;
@@ -1141,12 +1552,82 @@ export class LocalAgentHost {
1141
1552
  ...(existing?.runtimeHomeOwnerSessionId
1142
1553
  ? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
1143
1554
  : {}),
1555
+ ...(existing?.bridgeOwnerSessionId
1556
+ ? { bridgeOwnerSessionId: existing.bridgeOwnerSessionId }
1557
+ : {}),
1144
1558
  updatedAt: new Date().toISOString(),
1145
1559
  });
1146
1560
  })
1147
1561
  .catch(() => undefined);
1148
- live.markReady(); // thread id known → injection can turn/start
1149
- void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
1562
+ const finishBinding = () => {
1563
+ if (live.stopped)
1564
+ return;
1565
+ live.markReady(); // thread id + explicit Session mode are ready for turns
1566
+ if (live.observerAvailable) {
1567
+ void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
1568
+ }
1569
+ else {
1570
+ // Known-thread cold resume has already completed the dedicated preload. Its
1571
+ // observer is deliberately post-TUI background work, not Terminal
1572
+ // readiness. RunnerSession calls startLiveCodexObserver after launch.
1573
+ live.markTerminalReady(true);
1574
+ }
1575
+ };
1576
+ const desiredMode = live.execution.collaborationMode;
1577
+ if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
1578
+ finishBinding();
1579
+ return;
1580
+ }
1581
+ // A fresh thread does not exist until the TUI broadcasts thread/started.
1582
+ // Apply the Session-owned mode immediately at that boundary, before Web
1583
+ // injection is released. This also narrows the window in which a user can
1584
+ // type into the native pane before its explicit startup mode is installed.
1585
+ const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
1586
+ live.injectLock = restore.then(() => undefined, () => undefined);
1587
+ void restore.then(finishBinding).catch((error) => {
1588
+ const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
1589
+ live.startupError = failure;
1590
+ live.markStartupFailed();
1591
+ this.liveStartupErrors.set(localThreadId, failure);
1592
+ this.teardownLiveCodexSession(localThreadId, failure);
1593
+ });
1594
+ }
1595
+ /** Start the known-thread observer after the replacement TUI has launched.
1596
+ * Fresh sessions already connected their discovery listener before launch;
1597
+ * this is therefore an idempotent no-op for them and for a healthy observer. */
1598
+ startLiveCodexObserver(localThreadId) {
1599
+ const live = this.liveSessions.get(localThreadId);
1600
+ if (!live ||
1601
+ live.stopped ||
1602
+ live.observerAvailable ||
1603
+ !live.threadId)
1604
+ return;
1605
+ void (async () => {
1606
+ try {
1607
+ await live.forwarderClient.ensureInitialized();
1608
+ if (live.stopped)
1609
+ return;
1610
+ live.observerAvailable = true;
1611
+ // reference implementation starts subscription as a sibling task after the transport
1612
+ // connects. A no/empty rollout remains event-driven retryable; any
1613
+ // other subscription rejection is diagnostic only and must not tear
1614
+ // down the otherwise usable Terminal/injection lifecycle.
1615
+ void this.subscribeUntilReady(localThreadId, live, live.threadId);
1616
+ }
1617
+ catch (error) {
1618
+ this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
1619
+ }
1620
+ })();
1621
+ }
1622
+ /** reference implementation treats the forwarder as a required component of one native
1623
+ * lifecycle: if its transport dies, it closes the app-server instead of
1624
+ * accepting turns that can no longer reach the canonical mirror. */
1625
+ failObserverLifecycle(localThreadId, live, error) {
1626
+ if (live.stopped)
1627
+ return;
1628
+ live.startupError = error;
1629
+ this.liveStartupErrors.set(localThreadId, error);
1630
+ this.teardownLiveCodexSession(localThreadId, error);
1150
1631
  }
1151
1632
  shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1152
1633
  const pending = live.managedFork;
@@ -1170,11 +1651,13 @@ export class LocalAgentHost {
1170
1651
  * Subscribe the forwarder connection to a thread (reference implementation's
1171
1652
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1172
1653
  * turn, so `thread/resume` is retried: park until the forwarder observes the
1173
- * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
1174
- * first turn's items (replayed, deduped against the live stream). Once resume
1175
- * succeeds, subsequent turns arrive as live notifications on this connection.
1654
+ * thread active, then retry. Like reference implementation, the first attempt always uses
1655
+ * `excludeTurns`: a known-session cold resume therefore never reconstructs
1656
+ * historical running/completed state. Only a fresh thread whose first attempt
1657
+ * failed as not-ready retries without `excludeTurns`, backfilling the newly
1658
+ * materialized first turn and de-duplicating it against live notifications.
1176
1659
  */
1177
- async subscribeUntilReady(live, threadId) {
1660
+ async subscribeUntilReady(localThreadId, live, threadId) {
1178
1661
  let sawNotReady = false;
1179
1662
  while (!live.stopped) {
1180
1663
  try {
@@ -1182,30 +1665,43 @@ export class LocalAgentHost {
1182
1665
  threadId,
1183
1666
  ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1184
1667
  approvalPolicy: live.approvalPolicy,
1185
- excludeTurns: !sawNotReady,
1668
+ ...(!sawNotReady ? { excludeTurns: true } : {}),
1186
1669
  });
1187
- if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1188
- live.forwarder.replayBackfill(resp.thread.turns);
1670
+ adoptResumedThreadSettings(live, resp);
1671
+ if (sawNotReady) {
1672
+ const turns = Array.isArray(resp.thread.turns)
1673
+ ? resp.thread.turns
1674
+ : [];
1675
+ live.forwarder.replayBackfill(turns);
1189
1676
  }
1190
1677
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1191
1678
  }
1192
1679
  catch (error) {
1193
1680
  if (!isThreadNotReadyError(error)) {
1194
- return false; // other failure — injection still works via the backend client
1681
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
1682
+ return false;
1195
1683
  }
1196
1684
  sawNotReady = true;
1197
- // Park until the thread goes active (its first turn materializes the
1198
- // rollout); a short poll covers the flush race after "active".
1199
- await new Promise((resolve) => {
1200
- live.releaseActive = resolve;
1201
- setTimeout(resolve, 1500);
1202
- });
1203
- live.releaseActive = undefined;
1685
+ if (!live.threadActive) {
1686
+ // An idle fresh thread has no rollout until its first Turn. Wait only
1687
+ // for live activity instead of polling the app-server indefinitely.
1688
+ await new Promise((resolve) => {
1689
+ live.releaseActive = resolve;
1690
+ });
1691
+ live.releaseActive = undefined;
1692
+ }
1693
+ else {
1694
+ // Activity can precede rollout persistence by a short interval.
1695
+ await new Promise((resolve) => {
1696
+ setTimeout(resolve, CODEX_SUBSCRIBE_FLUSH_RETRY_MS);
1697
+ });
1698
+ }
1204
1699
  }
1205
1700
  }
1206
1701
  return false;
1207
1702
  }
1208
- /** Await a live session's thread binding (bounded). Returns false on timeout /
1703
+ /** Await a live session's thread binding. `null` leaves the deadline to the
1704
+ * caller; a number keeps the Provider-local bound. Returns false on timeout /
1209
1705
  * no live session. Injection and the runner's `live.ready` gate on this. */
1210
1706
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1211
1707
  const claude = this.liveClaudeSessions.get(localThreadId);
@@ -1214,18 +1710,22 @@ export class LocalAgentHost {
1214
1710
  const live = this.liveSessions.get(localThreadId);
1215
1711
  if (!live)
1216
1712
  return false;
1713
+ const ready = live.ready.then(() => true);
1714
+ const failed = live.startupFailed.then(() => false);
1715
+ if (timeoutMs === null)
1716
+ return Promise.race([ready, failed]);
1217
1717
  let timer;
1218
1718
  const timeout = new Promise((resolve) => {
1219
1719
  timer = setTimeout(() => resolve(false), timeoutMs);
1220
1720
  });
1221
- const ok = await Promise.race([live.ready.then(() => true), timeout]);
1721
+ const ok = await Promise.race([ready, failed, timeout]);
1222
1722
  if (timer)
1223
1723
  clearTimeout(timer);
1224
1724
  return ok;
1225
1725
  }
1226
- /** Await the stronger Terminal gate: another app-server connection has
1227
- * successfully resumed the thread, so the detached TUI cannot race rollout
1228
- * discovery or indexing. */
1726
+ /** Await the observer subscription diagnostic. Codex/Traex Terminal startup
1727
+ * deliberately does not gate on this promise: the dedicated preload owns resume,
1728
+ * while the independent observer attaches in the background. */
1229
1729
  async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
1230
1730
  const claude = this.liveClaudeSessions.get(localThreadId);
1231
1731
  // Claude has no separate app-server indexing gate. Its TUI must launch first;
@@ -1235,6 +1735,8 @@ export class LocalAgentHost {
1235
1735
  const live = this.liveSessions.get(localThreadId);
1236
1736
  if (!live)
1237
1737
  return false;
1738
+ if (timeoutMs === null)
1739
+ return live.terminalReady;
1238
1740
  let timer;
1239
1741
  const timeout = new Promise((resolve) => {
1240
1742
  timer = setTimeout(() => resolve(false), timeoutMs);
@@ -1246,7 +1748,40 @@ export class LocalAgentHost {
1246
1748
  }
1247
1749
  /** Diagnostic from the provider adapter when native discovery/resume failed. */
1248
1750
  liveSessionError(localThreadId) {
1249
- return this.liveClaudeSessions.get(localThreadId)?.error;
1751
+ return this.liveClaudeSessions.get(localThreadId)?.error
1752
+ ?? this.liveSessions.get(localThreadId)?.startupError?.message
1753
+ ?? this.liveStartupErrors.get(localThreadId)?.message;
1754
+ }
1755
+ /** Structured phase + concrete cause used by the runner wire protocol. */
1756
+ liveSessionFailure(localThreadId) {
1757
+ const failure = this.liveSessions.get(localThreadId)?.startupError
1758
+ ?? this.liveStartupErrors.get(localThreadId);
1759
+ if (failure) {
1760
+ return {
1761
+ message: failure.message,
1762
+ code: failure.code,
1763
+ statusCode: failure.statusCode,
1764
+ };
1765
+ }
1766
+ const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
1767
+ return claudeError
1768
+ ? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
1769
+ : undefined;
1770
+ }
1771
+ /** Publish the background TUI/thread discovery failure so an executor
1772
+ * already waiting in the 60s bridge window exits immediately with the exact
1773
+ * 30s discovery cause. */
1774
+ failLiveStartup(localThreadId, error) {
1775
+ const live = this.liveSessions.get(localThreadId);
1776
+ if (!live || live.threadId || live.stopped)
1777
+ return false;
1778
+ const failure = error instanceof CodexRuntimeError
1779
+ ? error
1780
+ : new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
1781
+ live.startupError = failure;
1782
+ this.liveStartupErrors.set(localThreadId, failure);
1783
+ live.markStartupFailed();
1784
+ return true;
1250
1785
  }
1251
1786
  /**
1252
1787
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -1255,13 +1790,13 @@ export class LocalAgentHost {
1255
1790
  * resumed from the persisted native id. Serialized per session so two
1256
1791
  * injects can't double-open a turn.
1257
1792
  *
1258
- * Returns an {@link InjectOutcome}: `notLive` when this session has no live
1793
+ * Returns an {@link InjectResult}: `notLive` when this session has no live
1259
1794
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
1260
1795
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
1261
1796
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
1262
1797
  * the bridge instead of a short race that falls back to a second output path.
1263
1798
  */
1264
- async injectMessage(localThreadId, input) {
1799
+ async injectMessage(localThreadId, input, options) {
1265
1800
  const runtimeInput = typeof input === "string"
1266
1801
  ? { content: [{ type: "text", text: input }] }
1267
1802
  : input;
@@ -1270,11 +1805,8 @@ export class LocalAgentHost {
1270
1805
  const text = claudeInputText(runtimeInput);
1271
1806
  const content = runtimeUserContent(runtimeInput);
1272
1807
  const pendingInput = {
1273
- content,
1274
- signature: JSON.stringify(content),
1275
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1808
+ state: "awaiting",
1276
1809
  observed: false,
1277
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1278
1810
  };
1279
1811
  claude.pendingInjectedInputs.push(pendingInput);
1280
1812
  const forgetPendingInput = () => {
@@ -1288,98 +1820,183 @@ export class LocalAgentHost {
1288
1820
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1289
1821
  expiry.unref?.();
1290
1822
  }
1291
- const outcome = await this.injectClaude(claude, localThreadId, text);
1292
- if (outcome !== "injected") {
1823
+ let result;
1824
+ try {
1825
+ result = await this.injectClaude(claude, localThreadId, text, pendingInput);
1826
+ }
1827
+ catch (error) {
1293
1828
  forgetPendingInput();
1294
1829
  if (token)
1295
1830
  claude.pendingImageInputs.delete(token);
1831
+ throw error;
1296
1832
  }
1297
- else if (pendingInput.observed) {
1833
+ if (result.outcome !== "injected" && result.outcome !== "steered") {
1298
1834
  forgetPendingInput();
1835
+ if (token)
1836
+ claude.pendingImageInputs.delete(token);
1837
+ }
1838
+ else {
1839
+ if (result.responseId)
1840
+ pendingInput.responseId = result.responseId;
1841
+ if (pendingInput.observed)
1842
+ forgetPendingInput();
1843
+ else
1844
+ pendingInput.state = "accepted";
1299
1845
  }
1300
- return outcome;
1846
+ return result;
1301
1847
  }
1302
1848
  const live = this.liveSessions.get(localThreadId);
1303
1849
  if (!live)
1304
- return "notLive";
1850
+ return { outcome: "notLive" };
1851
+ if (live.startupError?.code === "login_required")
1852
+ return { outcome: "notReady" };
1305
1853
  const run = live.injectLock.then(async () => {
1306
1854
  if (live.rotationPending || live.stopped)
1307
- return "failed";
1855
+ return { outcome: "failed" };
1308
1856
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1309
1857
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1310
- const bound = await this.waitLiveReady(localThreadId, 60_000);
1858
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1311
1859
  const threadId = live.threadId ?? live.forwarder.threadId();
1312
1860
  if (!bound || !threadId)
1313
- return "notReady";
1861
+ return { outcome: "notReady" };
1314
1862
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1315
- const content = runtimeUserContent(runtimeInput);
1316
1863
  const pendingInput = {
1317
- content,
1318
- signature: JSON.stringify(content),
1319
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1864
+ state: "awaiting",
1320
1865
  observed: false,
1321
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1322
1866
  };
1323
1867
  live.pendingInjectedInputs.push(pendingInput);
1868
+ let injectionMethod = "turn/start";
1869
+ let admissionPublished = false;
1870
+ const publishAdmission = () => {
1871
+ if (admissionPublished)
1872
+ return;
1873
+ admissionPublished = true;
1874
+ live.publishTurnAdmission();
1875
+ };
1324
1876
  const forgetPendingInput = () => {
1325
1877
  const index = live.pendingInjectedInputs.indexOf(pendingInput);
1326
1878
  if (index >= 0)
1327
1879
  live.pendingInjectedInputs.splice(index, 1);
1328
1880
  };
1881
+ const injectionClient = this.injectionClientFactory(live.appServerUrl);
1329
1882
  try {
1330
- // Inject via the BACKEND client (the forwarder connection only observes).
1883
+ // reference implementation publishes codex-native running only after the native Terminal
1884
+ // is ready and the runner has accepted the message, but before the
1885
+ // short-lived app-server client initializes and starts the Turn. Claude
1886
+ // deliberately has no matching synthesized edge.
1887
+ if (!live.forwarder.isTurnOpen())
1888
+ publishAdmission();
1889
+ await injectionClient.ensureInitialized();
1890
+ // Match reference implementation's executor: each message uses one initialized client
1891
+ // that closes as soon as turn/start or turn/steer is acknowledged.
1331
1892
  if (live.forwarder.isTurnOpen()) {
1332
1893
  const turnId = live.forwarder.currentTurnId();
1333
1894
  if (turnId) {
1334
- const steered = await live.injectClient.turnSteer({
1895
+ injectionMethod = "turn/steer";
1896
+ const steered = await injectionClient.turnSteer({
1335
1897
  threadId,
1336
1898
  expectedTurnId: turnId,
1337
1899
  input: nativeInput,
1338
1900
  });
1339
- if (pendingInput.state === "prepublished") {
1340
- // The caller already persisted and published this user input
1341
- // before waiting for the native Terminal to become ready.
1342
- }
1343
- else if (pendingInput.observed) {
1901
+ pendingInput.responseId = `resp_codex_${steered.turnId}`;
1902
+ if (pendingInput.observed)
1344
1903
  forgetPendingInput();
1345
- }
1346
- else {
1347
- live.publishInjectedInput(steered.turnId, pendingInput.content);
1348
- pendingInput.state = "optimistic";
1349
- }
1350
- return "injected";
1904
+ else
1905
+ pendingInput.state = "accepted";
1906
+ live.forwarder.noteTurnAccepted(steered.turnId);
1907
+ return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1351
1908
  }
1352
1909
  }
1353
- // Carry the agent-spec model on the turn so a web-injected turn runs the
1354
- // agent's model even if the TUI's config default differs.
1355
- const started = await live.injectClient.turnStart({
1910
+ // Match reference implementation's turn boundary: change the native thread settings
1911
+ // under the same lock immediately before starting the next Turn. Never
1912
+ // put settings on turn/start or mutate a Turn that is already open.
1913
+ const desiredModel = live.model;
1914
+ const desiredReasoningEffort = live.reasoningEffort;
1915
+ // A Session-owned mode survives runner restarts. Normal Web messages do
1916
+ // not carry per-Turn options, so a cold-resumed native thread must fall
1917
+ // back to the persisted execution snapshot before its first new Turn.
1918
+ // An explicit session.run option still wins for that invocation.
1919
+ const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
1920
+ const collaborationMode = desiredCollaborationMode &&
1921
+ desiredCollaborationMode !== live.appliedCollaborationMode
1922
+ ? await resolveLiveCollaborationMode(injectionClient, live, desiredCollaborationMode, live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined)
1923
+ : undefined;
1924
+ const settings = {
1925
+ threadId,
1926
+ ...(desiredModel !== live.appliedModel
1927
+ ? { model: desiredModel || null }
1928
+ : {}),
1929
+ ...(desiredReasoningEffort !== live.appliedReasoningEffort
1930
+ ? { effort: desiredReasoningEffort ?? null }
1931
+ : {}),
1932
+ ...(collaborationMode ? { collaborationMode } : {}),
1933
+ };
1934
+ if (Object.keys(settings).length > 1) {
1935
+ await injectionClient.threadSettingsUpdate(settings);
1936
+ // Record exactly what this RPC carried. A concurrent settings save may
1937
+ // already have advanced the desired fields and must remain pending for
1938
+ // the following new Turn.
1939
+ if (collaborationMode && !live.model) {
1940
+ live.model = collaborationMode.settings.model;
1941
+ }
1942
+ if (collaborationMode)
1943
+ live.mayUseProviderDefaultModel = false;
1944
+ live.appliedModel = collaborationMode?.settings.model ?? desiredModel;
1945
+ live.appliedReasoningEffort = collaborationMode
1946
+ ? collaborationMode.settings.reasoning_effort ?? undefined
1947
+ : desiredReasoningEffort;
1948
+ if (desiredCollaborationMode) {
1949
+ live.appliedCollaborationMode = desiredCollaborationMode;
1950
+ live.execution = {
1951
+ ...live.execution,
1952
+ collaborationMode: desiredCollaborationMode,
1953
+ };
1954
+ }
1955
+ }
1956
+ publishAdmission();
1957
+ const started = await injectionClient.turnStart({
1356
1958
  threadId,
1357
1959
  input: nativeInput,
1358
1960
  ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1359
1961
  approvalPolicy: live.approvalPolicy,
1360
- ...(live.model ? { model: live.model } : {}),
1361
- ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1362
1962
  });
1363
- if (pendingInput.state === "prepublished") {
1364
- // The caller already persisted and published this user input before
1365
- // waiting for the native Terminal to become ready.
1366
- }
1367
- else if (pendingInput.observed) {
1963
+ pendingInput.responseId = `resp_codex_${started.turnId}`;
1964
+ if (pendingInput.observed)
1368
1965
  forgetPendingInput();
1369
- }
1370
- else {
1371
- live.publishInjectedInput(started.turnId, pendingInput.content);
1372
- pendingInput.state = "optimistic";
1373
- }
1374
- return "injected";
1966
+ else
1967
+ pendingInput.state = "accepted";
1968
+ // The injection RPC and active-turn write are one serialized operation.
1969
+ // Do not wait for the independent observer connection's `turn/started`:
1970
+ // a second message accepted in that window must steer, not double-start.
1971
+ live.forwarder.noteTurnAccepted(started.turnId);
1972
+ return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
1375
1973
  }
1376
1974
  catch (error) {
1377
1975
  forgetPendingInput();
1976
+ if (error instanceof CodexRuntimeError &&
1977
+ (error.code === "collaboration_mode_model_unknown" ||
1978
+ error.code === "collaboration_mode_instructions_unknown")) {
1979
+ throw error;
1980
+ }
1378
1981
  // Preserve the app-server's error instead of collapsing every failure
1379
1982
  // into the unactionable `live injection failed` string.
1380
- const detail = codexInjectionError(error);
1983
+ const baseDetail = codexRpcError(error, injectionMethod);
1984
+ const startupDetail = live.forwarder.mcpStartupDetail();
1985
+ const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1986
+ // A turn may open on the observer while this short-lived client is
1987
+ // initializing, converting the submission to steer. A rejected steer
1988
+ // does not fail that already-running Turn; only a new-Turn admission
1989
+ // owns the terminal failed edge.
1990
+ if (admissionPublished && injectionMethod === "turn/start") {
1991
+ live.publishTurnAdmissionFailure(detail);
1992
+ }
1381
1993
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1382
- throw new Error(detail, { cause: error });
1994
+ throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
1995
+ }
1996
+ finally {
1997
+ await injectionClient.stop().catch((stopError) => {
1998
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1999
+ });
1383
2000
  }
1384
2001
  });
1385
2002
  live.injectLock = run.then(() => undefined, () => undefined);
@@ -1422,20 +2039,42 @@ export class LocalAgentHost {
1422
2039
  const live = this.liveSessions.get(localThreadId);
1423
2040
  if (!live)
1424
2041
  return false;
1425
- // Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
1426
- // reference implementation no-ops (204) when no turn is active.
1427
- if (!live.forwarder.isTurnOpen())
1428
- return false;
1429
2042
  const threadId = live.threadId ?? live.forwarder.threadId();
1430
- const turnId = live.forwarder.currentTurnId();
1431
- if (!threadId || !turnId)
1432
- return false;
2043
+ const pendingMcp = live.forwarder.cancelMcpStartup();
2044
+ const turnId = live.forwarder.isTurnOpen()
2045
+ ? live.forwarder.currentTurnId()
2046
+ : null;
2047
+ if (!threadId)
2048
+ return pendingMcp.length > 0;
2049
+ let handled = pendingMcp.length > 0;
2050
+ const interruptClient = this.injectionClientFactory(live.appServerUrl);
1433
2051
  try {
1434
- await live.injectClient.turnInterrupt({ threadId, turnId });
1435
- return true;
2052
+ await interruptClient.ensureInitialized();
2053
+ if (pendingMcp.length > 0) {
2054
+ // Codex's TUI cancels a provider-owned MCP startup round with an empty
2055
+ // turn id. Best-effort for Traex: it shares the app-server surface, while
2056
+ // the active-turn interrupt below remains authoritative if it rejects this.
2057
+ try {
2058
+ await interruptClient.turnInterrupt({ threadId, turnId: "" });
2059
+ }
2060
+ catch (error) {
2061
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
2062
+ }
2063
+ }
2064
+ if (turnId) {
2065
+ try {
2066
+ await interruptClient.turnInterrupt({ threadId, turnId });
2067
+ live.publishInterrupted(`resp_codex_${turnId}`);
2068
+ handled = true;
2069
+ }
2070
+ catch {
2071
+ // The startup cancellation above is still a handled Stop operation.
2072
+ }
2073
+ }
2074
+ return handled;
1436
2075
  }
1437
- catch {
1438
- return false;
2076
+ finally {
2077
+ await interruptClient.stop().catch(() => undefined);
1439
2078
  }
1440
2079
  }
1441
2080
  /** Stop + drop a session's live forwarder and its dedicated connection (session
@@ -1446,6 +2085,7 @@ export class LocalAgentHost {
1446
2085
  this.liveClaudeSessions.delete(localThreadId);
1447
2086
  claude.stopped = true;
1448
2087
  claude.forwarder.stop();
2088
+ this.trackCleanup(claude.forwarder.waitForStop());
1449
2089
  if (opts.deferClaudeInteractionCleanup) {
1450
2090
  this.pendingClaudeFinalizers.add(claude.forwarder);
1451
2091
  }
@@ -1453,8 +2093,6 @@ export class LocalAgentHost {
1453
2093
  claude.forwarder.finalizeStop();
1454
2094
  }
1455
2095
  removeManagedClaudeSettings(claude.bridgeDir);
1456
- // Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
1457
- void claude.skillCleanup?.();
1458
2096
  return;
1459
2097
  }
1460
2098
  const live = this.liveSessions.get(localThreadId);
@@ -1462,29 +2100,56 @@ export class LocalAgentHost {
1462
2100
  return;
1463
2101
  this.liveSessions.delete(localThreadId);
1464
2102
  live.stopped = true;
2103
+ if (!live.threadId) {
2104
+ live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
2105
+ live.markStartupFailed();
2106
+ }
1465
2107
  live.releaseActive?.();
1466
- live.injectClient.cancelInteractions("session_stopped");
2108
+ live.cancelSyntheticInteractions("session_stopped");
2109
+ live.appServerOwner.cancelInteractions("session_stopped");
1467
2110
  live.forwarderClient.cancelInteractions("session_stopped");
1468
- live.injectClient.setInteractionListener(null);
2111
+ live.appServerOwner.setInteractionListener(null);
1469
2112
  live.forwarderClient.setInteractionListener(null);
1470
- live.injectClient.setConnectionListener(null);
2113
+ live.appServerOwner.setConnectionListener(null);
1471
2114
  live.forwarderClient.setConnectionListener(null);
1472
- live.interactionOwners.clear();
1473
- live.interactionStandbys.clear();
1474
- live.interactionSubmissions.clear();
1475
2115
  live.forwarder.stop();
1476
- live.interactionRecoveries.clear();
1477
- live.interactionFailedClients.clear();
1478
- live.disconnectedClients.clear();
1479
2116
  live.canonicalInteractions.clear();
1480
2117
  live.settledInteractions.clear();
1481
- void live.forwarderClient.stop().catch(() => undefined);
2118
+ live.syntheticInteractions.clear();
2119
+ this.trackCleanup(live.forwarderClient.stop().catch(() => undefined));
1482
2120
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
1483
- void live.skillsCleanup?.();
2121
+ if (live.skillsCleanup)
2122
+ this.trackCleanup(live.skillsCleanup());
2123
+ }
2124
+ /** Tear down one codex-lineage native runtime without deleting its durable
2125
+ * session-store binding. reference implementation couples its auxiliary Terminal, observer,
2126
+ * forwarder and per-session app-server as one disposable runtime envelope;
2127
+ * the next message recreates that envelope and cold-resumes the native id. */
2128
+ teardownLiveCodexSession(localThreadId, error) {
2129
+ const live = this.liveSessions.get(localThreadId);
2130
+ if (!live)
2131
+ return false;
2132
+ const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
2133
+ const backendKey = live.runtime;
2134
+ const backend = this.backends.get(backendKey);
2135
+ const appServerOwner = live.appServerOwner;
2136
+ this.stopLiveCodexSession(localThreadId);
2137
+ if (backend?.appServerClient === appServerOwner) {
2138
+ this.backends.delete(backendKey);
2139
+ }
2140
+ this.trackCleanup(appServerOwner.stop().catch((stopError) => {
2141
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
2142
+ }));
2143
+ return turnFailed;
2144
+ }
2145
+ async teardownAuxiliaryTerminalRuntime(localThreadId) {
2146
+ const owner = this.liveSessions.get(localThreadId)?.appServerOwner;
2147
+ this.teardownLiveCodexSession(localThreadId);
2148
+ await owner?.stop().catch(() => undefined);
1484
2149
  }
1485
2150
  /** Complete the second shutdown phase after the runner has killed all native
1486
2151
  * terminals and hook subprocesses. Must run before the runner process exits. */
1487
- finalizeStoppedLiveSessions() {
2152
+ async finalizeStoppedLiveSessions() {
1488
2153
  for (const forwarder of this.pendingClaudeFinalizers)
1489
2154
  forwarder.finalizeStop();
1490
2155
  this.pendingClaudeFinalizers.clear();
@@ -1493,10 +2158,20 @@ export class LocalAgentHost {
1493
2158
  // that can only reconnect to a dead session. `stop()` sends SIGTERM before
1494
2159
  // its returned promise yields, so this remains safe in the synchronous
1495
2160
  // runner shutdown path.
1496
- for (const backend of this.backends.values()) {
1497
- void backend.appServerClient?.stop().catch(() => undefined);
1498
- }
2161
+ const backendStops = [...this.backends.values()].map((backend) => backend.appServerClient?.stop().catch(() => undefined));
1499
2162
  this.backends.clear();
2163
+ await Promise.all([
2164
+ ...backendStops,
2165
+ ...this.pendingCleanupTasks,
2166
+ ]);
2167
+ this.pendingCleanupTasks.clear();
2168
+ }
2169
+ trackCleanup(task) {
2170
+ const guarded = task.catch((error) => {
2171
+ console.warn(`[native-runtime] cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
2172
+ });
2173
+ this.pendingCleanupTasks.add(guarded);
2174
+ void guarded.then(() => this.pendingCleanupTasks.delete(guarded));
1500
2175
  }
1501
2176
  // --- claude-native live session (parallel to the codex methods above) -----
1502
2177
  /** The interactive `claude` TUI spec for a claude-native live session: the real
@@ -1520,14 +2195,14 @@ export class LocalAgentHost {
1520
2195
  const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
1521
2196
  const hookPermissionMode = live.permissionMode;
1522
2197
  const hookSettings = buildClaudeHookSettings({
1523
- bridgeDir: claudeBridgeDir(localThreadId),
2198
+ bridgeDir: live.bridgeDir,
1524
2199
  ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
1525
2200
  messageDisplay: true,
1526
2201
  statusLine: true,
1527
2202
  });
1528
2203
  const settings = { ...effectiveSettings, ...hookSettings };
1529
- const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
1530
- // The live session carries the immutable execution snapshot used by Chat.
2204
+ const settingsPath = writeManagedClaudeSettings(live.bridgeDir, settings);
2205
+ // The live session carries the daemon-owned execution state used by Chat.
1531
2206
  const args = buildClaudeTuiArgs({
1532
2207
  settingsJson: settingsPath,
1533
2208
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1535,26 +2210,32 @@ export class LocalAgentHost {
1535
2210
  // resolve the same native request while rynx still exposes it as pending.
1536
2211
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1537
2212
  settingSources: "",
1538
- model: live.execution.model,
1539
- ...(live.execution.instructions
1540
- ? { appendSystemPrompt: live.execution.instructions }
2213
+ ...(live.execution.model ? { model: live.execution.model } : {}),
2214
+ ...(live.execution.reasoningEffort
2215
+ ? { reasoningEffort: live.execution.reasoningEffort }
2216
+ : {}),
2217
+ ...(nativeAuthoredInstructions(live.execution)
2218
+ ? { appendSystemPrompt: nativeAuthoredInstructions(live.execution) }
1541
2219
  : {}),
1542
2220
  additionalDirs: providerAdditionalDirs(live.workspace),
1543
2221
  ...(live.forkIntent
1544
- ? {
1545
- resume: live.forkIntent.sourceClaudeSessionId,
1546
- forkSession: true,
1547
- sessionId: live.forkIntent.targetClaudeSessionId,
1548
- }
2222
+ ? live.forkTranscriptPrepared
2223
+ ? { resume: live.forkIntent.targetClaudeSessionId }
2224
+ : { sessionId: live.forkIntent.targetClaudeSessionId }
1549
2225
  : record?.codexSessionId
1550
2226
  ? { resume: record.codexSessionId }
1551
2227
  : {}),
1552
2228
  ...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
1553
2229
  });
1554
2230
  return {
2231
+ lifecycle: "required",
1555
2232
  command: resolveRuntimeBinary("claude"),
1556
2233
  args,
1557
2234
  cwd,
2235
+ scrollback: 50_000,
2236
+ tmuxAllowPassthrough: false,
2237
+ tmuxStartOnAttach: false,
2238
+ keepAliveAfterExit: true,
1558
2239
  // Same env the pane would inherit by default, plus the session scope for
1559
2240
  // agent-run CLIs (rynx-emulator resolves device-less commands via it).
1560
2241
  env: { ...process.env, RYNX_SESSION_ID: localThreadId },
@@ -1656,30 +2337,117 @@ export class LocalAgentHost {
1656
2337
  throw error;
1657
2338
  }
1658
2339
  }
2340
+ /** Repair the only cross-process crash window in native rotation. A target
2341
+ * started through the Session registry proves that logical publication is
2342
+ * durable; a published target in turn proves its source must no longer own
2343
+ * the shared live bridge. */
2344
+ async reconcileClaudeNativeRotation(localThreadId, record) {
2345
+ const records = await this.sessionStore.listAll();
2346
+ let current = record;
2347
+ const ownTarget = current?.nativeRotationSourceSessionId
2348
+ ? current
2349
+ : undefined;
2350
+ let promotedOwnTarget = false;
2351
+ const ownTargetSharedBridgeOwner = ownTarget?.bridgeOwnerSessionId;
2352
+ if (ownTarget && !ownTarget.nativeRotationPublished) {
2353
+ promotedOwnTarget = true;
2354
+ current = {
2355
+ ...ownTarget,
2356
+ // Recovery cannot resume the interrupted physical handoff. Give the
2357
+ // visible target its own bridge before publishing it, so a source that
2358
+ // was reopened first can never share hooks/cursors with this process.
2359
+ // The persisted initial transcript offset remains the replay boundary.
2360
+ bridgeOwnerSessionId: ownTarget.localThreadId,
2361
+ nativeRotationPublished: true,
2362
+ updatedAt: new Date().toISOString(),
2363
+ };
2364
+ await this.sessionStore.set(current);
2365
+ }
2366
+ const linkedTarget = current?.nativeRotationTargetSessionId
2367
+ ? records.find((candidate) => candidate.localThreadId === current?.nativeRotationTargetSessionId &&
2368
+ candidate.nativeRotationPublished &&
2369
+ candidate.nativeRotationSourceSessionId === localThreadId)
2370
+ : undefined;
2371
+ const legacyLatestTarget = records
2372
+ .filter((candidate) => candidate.nativeRotationPublished &&
2373
+ candidate.nativeRotationSourceSessionId === localThreadId)
2374
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) ||
2375
+ b.localThreadId.localeCompare(a.localThreadId))[0];
2376
+ // A Session can be both an inbound target (A→B) and later an outbound
2377
+ // source (B→C). Its latest outbound edge is authoritative for reopening B;
2378
+ // choosing B's older inbound marker would reopen the bridge now owned by C.
2379
+ const publishedTarget = linkedTarget ?? legacyLatestTarget ??
2380
+ (current?.nativeRotationSourceSessionId && current.nativeRotationPublished
2381
+ ? current
2382
+ : undefined);
2383
+ if (publishedTarget) {
2384
+ const source = await this.sessionStore.get(publishedTarget.nativeRotationSourceSessionId);
2385
+ const kind = publishedTarget.nativeRotationKind ?? "clear";
2386
+ const retiredBridgeOwner = `${source?.localThreadId ?? localThreadId}-${kind}-retired-${publishedTarget.localThreadId}`;
2387
+ // A source can rotate more than once after its old conversation is
2388
+ // reopened. Never let opening an historical target roll the source's
2389
+ // authoritative outbound link back from S->T2 to the older S->T1 edge.
2390
+ // Records written before nativeRotationTargetSessionId existed are only
2391
+ // repairable when source and target still prove they share one bridge.
2392
+ const sharedBridgeProof = promotedOwnTarget && publishedTarget.localThreadId === current?.localThreadId
2393
+ ? ownTargetSharedBridgeOwner
2394
+ : publishedTarget.bridgeOwnerSessionId;
2395
+ const isAuthoritativeTarget = source && (source.nativeRotationTargetSessionId === publishedTarget.localThreadId ||
2396
+ (!source.nativeRotationTargetSessionId &&
2397
+ source.bridgeOwnerSessionId === sharedBridgeProof));
2398
+ if (isAuthoritativeTarget && source.bridgeOwnerSessionId !== retiredBridgeOwner) {
2399
+ const retired = {
2400
+ ...source,
2401
+ bridgeOwnerSessionId: retiredBridgeOwner,
2402
+ nativeRotationTargetSessionId: publishedTarget.localThreadId,
2403
+ updatedAt: new Date().toISOString(),
2404
+ };
2405
+ await this.sessionStore.set(retired);
2406
+ if (source.localThreadId === localThreadId)
2407
+ current = retired;
2408
+ }
2409
+ }
2410
+ return current;
2411
+ }
1659
2412
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
1660
2413
  const retargetMirror = opts.retargetMirror;
2414
+ record = await this.reconcileClaudeNativeRotation(localThreadId, record);
1661
2415
  const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
1662
2416
  if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
1663
2417
  throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
1664
2418
  }
1665
- const bridgeDir = prepareClaudeBridgeDir(localThreadId);
2419
+ const runtimeHomeOwnerSessionId = record?.runtimeHomeOwnerSessionId ?? localThreadId;
2420
+ const bridgeOwnerSessionId = record?.bridgeOwnerSessionId ?? runtimeHomeOwnerSessionId;
2421
+ const bridgeDir = prepareClaudeBridgeDir(bridgeOwnerSessionId);
1666
2422
  const workspace = structuredClone(opts.workspace);
1667
2423
  const execution = structuredClone(opts.execution);
1668
2424
  if (execution.provider !== "claude") {
1669
2425
  throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1670
2426
  }
1671
2427
  const cwd = workspace.cwd;
1672
- let snapshotSkills;
1673
- try {
1674
- snapshotSkills = await this.prepareExecutionSkills(execution);
1675
- }
1676
- catch (err) {
1677
- console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1678
- return false;
2428
+ const forkResumePrefixBytes = forkIntent?.forkTranscriptPrefixBytes;
2429
+ const forkTranscriptPrepared = Boolean(forkIntent?.forkTranscriptPath);
2430
+ const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
2431
+ let skillPlugin = hasSkillSnapshot
2432
+ ? await reuseClaudePlugin(localThreadId)
2433
+ : null;
2434
+ if (!skillPlugin && hasSkillSnapshot) {
2435
+ let snapshotSkills;
2436
+ try {
2437
+ snapshotSkills = await this.prepareExecutionSkills(execution);
2438
+ }
2439
+ catch (err) {
2440
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
2441
+ return false;
2442
+ }
2443
+ try {
2444
+ skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
2445
+ }
2446
+ finally {
2447
+ await snapshotSkills.skillsCleanup();
2448
+ }
1679
2449
  }
1680
- const model = execution.model;
1681
- const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1682
- void snapshotSkills.skillsCleanup();
2450
+ const model = execution.model ?? "";
1683
2451
  const permissionMode = execution.permissionMode;
1684
2452
  const launchExtraArgs = [
1685
2453
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
@@ -1690,28 +2458,118 @@ export class LocalAgentHost {
1690
2458
  let currentSessionId = localThreadId;
1691
2459
  let normalizer = null;
1692
2460
  let currentResponseId;
2461
+ const subagentParentResponseIds = new Map();
2462
+ const lateSubagentNormalizers = new Map();
1693
2463
  let pendingRotationEvents = null;
1694
- const emitCurrent = (event) => {
2464
+ let rotationPublication = null;
2465
+ let transcriptDelivery = null;
2466
+ const emitRotationAware = (event, delivery) => {
1695
2467
  if (pendingRotationEvents) {
1696
- pendingRotationEvents.push(event);
2468
+ pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
1697
2469
  return;
1698
2470
  }
1699
- emit(event);
2471
+ return emit(event, delivery);
2472
+ };
2473
+ const publishForwarderFailure = (reason, responseId) => {
2474
+ void Promise.resolve().then(() => emitRotationAware({
2475
+ type: "session.status",
2476
+ sessionId: currentSessionId,
2477
+ ...(responseId ? { responseId } : {}),
2478
+ status: "failed",
2479
+ backgroundTaskCount: 0,
2480
+ note: reason,
2481
+ }, { policy: "best-effort", lane: "claude-forwarder-failure" })).catch((error) => {
2482
+ console.error("[claude-forwarder] failed to publish poison-item status:", error);
2483
+ });
2484
+ };
2485
+ const emitCurrent = (event, delivery) => {
2486
+ if (pendingRotationEvents) {
2487
+ pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
2488
+ return;
2489
+ }
2490
+ const source = transcriptDelivery;
2491
+ if (source && isDurableSessionEvent(event)) {
2492
+ const sourceId = `${source.sourceBase}:${source.nextItemIndex}:${event.item.type}`;
2493
+ source.nextItemIndex += 1;
2494
+ source.sourceIds.push(sourceId);
2495
+ if (source.isHandled(sourceId))
2496
+ return;
2497
+ const policy = delivery?.policy ?? "ordinary";
2498
+ const pending = Promise.resolve(emit(event, {
2499
+ policy,
2500
+ sourceId,
2501
+ lane: delivery?.lane ?? source.lane,
2502
+ ...(policy === "ordinary"
2503
+ ? { deadLetterPath: delivery?.deadLetterPath ?? source.deadLetterPath }
2504
+ : {}),
2505
+ onOutcome: (outcome) => {
2506
+ delivery?.onOutcome?.(outcome);
2507
+ if (policy === "ordinary" &&
2508
+ outcome === "dropped" &&
2509
+ !source.lane.startsWith("claude-subagent:")) {
2510
+ publishForwarderFailure(`transcript item ${sourceId} rejected`, event.item.responseId);
2511
+ }
2512
+ },
2513
+ })).then(() => {
2514
+ source.onHandled?.(sourceId);
2515
+ });
2516
+ source.pending.push(pending);
2517
+ return pending;
2518
+ }
2519
+ return emit(event, delivery ?? { policy: "best-effort" });
1700
2520
  };
1701
2521
  const startNormalizer = (turnId) => {
1702
2522
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1703
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1704
- (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
2523
+ const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1705
2524
  if (normalizer && currentResponseId === responseId)
1706
2525
  return normalizer;
1707
2526
  currentResponseId = responseId;
1708
2527
  normalizer = new SessionNormalizer({
1709
2528
  sessionId: currentSessionId,
1710
2529
  responseId,
1711
- model,
2530
+ model: model || "claude",
1712
2531
  });
1713
2532
  return normalizer;
1714
2533
  };
2534
+ const rememberSubagentParentResponse = (parentToolCallId, responseId) => {
2535
+ if (subagentParentResponseIds.has(parentToolCallId))
2536
+ return;
2537
+ subagentParentResponseIds.set(parentToolCallId, responseId);
2538
+ while (subagentParentResponseIds.size > 2_000) {
2539
+ const oldest = subagentParentResponseIds.keys().next().value;
2540
+ if (!oldest)
2541
+ break;
2542
+ subagentParentResponseIds.delete(oldest);
2543
+ lateSubagentNormalizers.delete(oldest);
2544
+ }
2545
+ };
2546
+ const subagentNormalizer = (parentToolCallId) => {
2547
+ let value = lateSubagentNormalizers.get(parentToolCallId);
2548
+ if (!value) {
2549
+ const responseId = subagentParentResponseIds.get(parentToolCallId) ??
2550
+ `resp_claude_parent_${createHash("sha256")
2551
+ .update(parentToolCallId)
2552
+ .digest("hex")
2553
+ .slice(0, 20)}`;
2554
+ value = new SessionNormalizer({
2555
+ sessionId: currentSessionId,
2556
+ responseId,
2557
+ model: model || "claude",
2558
+ });
2559
+ lateSubagentNormalizers.set(parentToolCallId, value);
2560
+ }
2561
+ return value;
2562
+ };
2563
+ const emitSubagentEvents = (events) => {
2564
+ for (const event of events) {
2565
+ // Rynx keeps native child items nested in the parent Session, but its
2566
+ // child normalizer remains
2567
+ // lifecycle-isolated and must never open/reopen a parent Response.
2568
+ if ((event.type === "response.created" || event.type === "session.status"))
2569
+ continue;
2570
+ emitCurrent(event);
2571
+ }
2572
+ };
1715
2573
  const forwardInteraction = (event) => {
1716
2574
  const n = normalizer ?? startNormalizer(event.turnId);
1717
2575
  const agentEvent = event.type === "requested"
@@ -1727,8 +2585,20 @@ export class LocalAgentHost {
1727
2585
  interactionId: event.interactionId,
1728
2586
  ...(event.reason ? { reason: event.reason } : {}),
1729
2587
  };
1730
- for (const se of n.next(agentEvent))
1731
- emitCurrent(se);
2588
+ // Permission/question elicitation stays on a separate live hook
2589
+ // side-channel rather than the durable transcript forwarder. Rynx stores
2590
+ // a delivered card for UI replay, but intentionally does not make this
2591
+ // bridge spool advance or block the transcript cursor.
2592
+ for (const se of n.next(agentEvent)) {
2593
+ try {
2594
+ void Promise.resolve(emitRotationAware(se, { policy: "best-effort", lane: "claude-interaction" })).catch((error) => {
2595
+ console.error("[claude-forwarder] interaction mirror failed:", error);
2596
+ });
2597
+ }
2598
+ catch (error) {
2599
+ console.error("[claude-forwarder] interaction mirror failed:", error);
2600
+ }
2601
+ }
1732
2602
  };
1733
2603
  let markReady;
1734
2604
  const ready = new Promise((resolve) => {
@@ -1743,9 +2613,14 @@ export class LocalAgentHost {
1743
2613
  workspace,
1744
2614
  execution,
1745
2615
  bridgeDir,
2616
+ runtimeHomeOwnerSessionId,
2617
+ bridgeOwnerSessionId,
1746
2618
  injectLock: Promise.resolve(),
2619
+ rotationPending: false,
1747
2620
  pendingImageInputs: new Map(),
1748
2621
  pendingInjectedInputs: [],
2622
+ currentResponseId: () => currentResponseId,
2623
+ publishInterrupted: () => undefined,
1749
2624
  ready,
1750
2625
  markReady,
1751
2626
  failed,
@@ -1755,9 +2630,93 @@ export class LocalAgentHost {
1755
2630
  launchExtraArgs,
1756
2631
  permissionMode,
1757
2632
  ...(forkIntent ? { forkIntent } : {}),
1758
- ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
2633
+ ...(forkResumePrefixBytes !== undefined ? { forkResumePrefixBytes } : {}),
2634
+ ...(forkTranscriptPrepared ? { forkTranscriptPrepared: true } : {}),
2635
+ };
2636
+ live.publishInterrupted = (responseId) => {
2637
+ if (live.interruptedResponseId === responseId)
2638
+ return;
2639
+ live.interruptedResponseId = responseId;
2640
+ emitCurrent({
2641
+ type: "session.interrupted",
2642
+ sessionId: currentSessionId,
2643
+ responseId,
2644
+ });
2645
+ };
2646
+ const settleClaudeTurn = (interrupted, usage, backgroundTaskCount, reason) => {
2647
+ if (!normalizer)
2648
+ return;
2649
+ const settlementDelivery = reason === "rotation"
2650
+ ? { policy: "best-effort", lane: "control" }
2651
+ : undefined;
2652
+ const publishSettlement = (event) => {
2653
+ void emitCurrent(event, settlementDelivery);
2654
+ };
2655
+ const rid = currentResponseId;
2656
+ if (interrupted) {
2657
+ for (const se of normalizer.interrupt()) {
2658
+ if (se.type === "session.interrupted" &&
2659
+ live.interruptedResponseId === rid)
2660
+ continue;
2661
+ publishSettlement(se);
2662
+ }
2663
+ live.interruptedResponseId = undefined;
2664
+ }
2665
+ else {
2666
+ // statusLine usage (context/cost) rides the turn's response.completed.
2667
+ if (usage) {
2668
+ for (const se of normalizer.next({ type: "turn_completed", usage })) {
2669
+ publishSettlement(se);
2670
+ }
2671
+ }
2672
+ for (const se of normalizer.next({ type: "done" })) {
2673
+ publishSettlement(se.type === "session.status" && backgroundTaskCount !== undefined
2674
+ ? { ...se, backgroundTaskCount }
2675
+ : se);
2676
+ }
2677
+ }
2678
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
2679
+ normalizer = null;
2680
+ currentResponseId = undefined;
2681
+ // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
2682
+ // cleared by the next turn's first item). statusLine pre-computes the %.
2683
+ const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
2684
+ if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
2685
+ live.contextWarned = true;
2686
+ publishSettlement({
2687
+ type: "session.status",
2688
+ sessionId: currentSessionId,
2689
+ responseId: rid,
2690
+ status: "idle",
2691
+ note: `context ${Math.round(pct)}% full — consider /compact`,
2692
+ });
2693
+ }
1759
2694
  };
1760
2695
  const sink = {
2696
+ onTranscriptRecordStart: (sourceBase, isHandled, delivery) => {
2697
+ if (transcriptDelivery) {
2698
+ throw new Error("Claude transcript delivery record overlapped");
2699
+ }
2700
+ transcriptDelivery = {
2701
+ sourceBase,
2702
+ nextItemIndex: 0,
2703
+ isHandled,
2704
+ sourceIds: [],
2705
+ pending: [],
2706
+ lane: delivery?.lane ?? "claude-main",
2707
+ deadLetterPath: delivery?.deadLetterPath ??
2708
+ path.join(bridgeDir, "dead_letter.jsonl"),
2709
+ ...(delivery?.onHandled ? { onHandled: delivery.onHandled } : {}),
2710
+ };
2711
+ },
2712
+ onTranscriptRecordEnd: async () => {
2713
+ const source = transcriptDelivery;
2714
+ if (!source)
2715
+ return [];
2716
+ transcriptDelivery = null;
2717
+ await Promise.all(source.pending);
2718
+ return source.sourceIds;
2719
+ },
1761
2720
  onTurnStart: (turnId) => startNormalizer(turnId),
1762
2721
  onUserMessage: (text) => {
1763
2722
  const n = normalizer ?? startNormalizer();
@@ -1766,141 +2725,361 @@ export class LocalAgentHost {
1766
2725
  if (token && content)
1767
2726
  live.pendingImageInputs.delete(token);
1768
2727
  const normalizedContent = content ?? [{ type: "input_text", text }];
1769
- const signature = JSON.stringify(normalizedContent);
1770
- const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1771
- if (pending?.state === "prepublished" || pending?.state === "optimistic") {
1772
- live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1773
- return;
1774
- }
1775
- if (pending)
2728
+ const pending = live.pendingInjectedInputs[0];
2729
+ if (pending) {
2730
+ pending.responseId = currentResponseId;
1776
2731
  pending.observed = true;
2732
+ if (pending.state === "accepted") {
2733
+ live.pendingInjectedInputs.shift();
2734
+ }
2735
+ }
1777
2736
  for (const se of n.userInput(normalizedContent))
1778
2737
  emitCurrent(se);
1779
2738
  },
2739
+ onMetaUserMessage: (text, sourceKey, parentToolCallId) => {
2740
+ const responseId = parentToolCallId
2741
+ ? subagentParentResponseIds.get(parentToolCallId) ??
2742
+ `resp_claude_parent_${createHash("sha256")
2743
+ .update(parentToolCallId)
2744
+ .digest("hex")
2745
+ .slice(0, 20)}`
2746
+ : `resp_claude_meta_${createHash("sha256")
2747
+ .update(sourceKey ?? text)
2748
+ .digest("hex")
2749
+ .slice(0, 20)}`;
2750
+ const metaNormalizer = new SessionNormalizer({
2751
+ sessionId: currentSessionId,
2752
+ responseId,
2753
+ model: model || "claude",
2754
+ });
2755
+ for (const event of metaNormalizer.userInput(text, parentToolCallId)) {
2756
+ if (event.type !== "session.input.consumed" || event.item.type !== "message")
2757
+ continue;
2758
+ emitCurrent({
2759
+ ...event,
2760
+ item: {
2761
+ ...event.item,
2762
+ data: { ...event.item.data, isMeta: true },
2763
+ },
2764
+ });
2765
+ }
2766
+ },
2767
+ onSubagentUserMessage: (text, parentToolCallId) => {
2768
+ const nested = subagentNormalizer(parentToolCallId);
2769
+ emitSubagentEvents(nested.userInput(text, parentToolCallId));
2770
+ },
2771
+ onSubagentTerminalCommand: (command, parentToolCallId) => {
2772
+ const nested = subagentNormalizer(parentToolCallId);
2773
+ emitSubagentEvents(nested.terminalCommand(command, parentToolCallId));
2774
+ },
2775
+ resolveSubagentParentResponseId: (parentToolCallId) => subagentParentResponseIds.get(parentToolCallId),
2776
+ onSubagentParentResponse: (parentToolCallId, responseId) => {
2777
+ rememberSubagentParentResponse(parentToolCallId, responseId);
2778
+ },
1780
2779
  onTerminalCommand: (cmd) => {
1781
2780
  const n = normalizer ?? startNormalizer();
1782
2781
  for (const se of n.terminalCommand(cmd))
1783
2782
  emitCurrent(se);
1784
2783
  },
1785
2784
  onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
2785
+ onCompactionStatus: (status) => {
2786
+ void emitCurrent({
2787
+ type: `response.compaction.${status}`,
2788
+ sessionId: currentSessionId,
2789
+ ...(currentResponseId ? { responseId: currentResponseId } : {}),
2790
+ }, { policy: "best-effort", lane: "claude-compaction" });
2791
+ },
2792
+ onCompactionBoundary: (summary, sequence, source) => {
2793
+ let outcome;
2794
+ const responseId = currentResponseId ?? "resp_claude_native";
2795
+ const delivery = emitCurrent({
2796
+ type: "response.output_item.done",
2797
+ responseId,
2798
+ item: {
2799
+ id: newSessionItemId("compaction"),
2800
+ sessionId: currentSessionId,
2801
+ position: 0,
2802
+ responseId,
2803
+ status: "completed",
2804
+ createdAt: Date.now(),
2805
+ type: "compaction",
2806
+ data: { summary, sequence },
2807
+ },
2808
+ }, {
2809
+ policy: source === "transcript" ? "compaction" : "compaction-hook",
2810
+ // A transcript compaction is still one ordered transcript record.
2811
+ // Only the independent hook boundary gets its own delivery lane.
2812
+ lane: source === "transcript" ? "claude-main" : "claude-compaction",
2813
+ onOutcome: (value) => { outcome = value; },
2814
+ });
2815
+ return Promise.resolve(delivery).then(() => outcome === undefined || outcome === "confirmed" || outcome === "ambiguous");
2816
+ },
1786
2817
  onEvent: (event) => {
2818
+ const parentToolCallId = "parentToolUseId" in event &&
2819
+ typeof event.parentToolUseId === "string"
2820
+ ? event.parentToolUseId
2821
+ : undefined;
2822
+ if (parentToolCallId) {
2823
+ const nested = subagentNormalizer(parentToolCallId);
2824
+ emitSubagentEvents(nested.next(event));
2825
+ return;
2826
+ }
1787
2827
  const n = normalizer ?? startNormalizer();
2828
+ if (currentResponseId &&
2829
+ event.type === "tool" &&
2830
+ event.event === "on_tool_start") {
2831
+ const input = typeof event.input === "object" && event.input !== null
2832
+ ? event.input
2833
+ : {};
2834
+ const data = typeof event.data === "object" && event.data !== null
2835
+ ? event.data
2836
+ : {};
2837
+ const parentId = typeof input.id === "string"
2838
+ ? input.id
2839
+ : typeof data.id === "string"
2840
+ ? data.id
2841
+ : undefined;
2842
+ if (parentId)
2843
+ rememberSubagentParentResponse(parentId, currentResponseId);
2844
+ }
1788
2845
  for (const se of n.next(event))
1789
2846
  emitCurrent(se);
1790
2847
  },
1791
2848
  onInteraction: forwardInteraction,
1792
- onTurnEnd: (usage) => {
1793
- if (!normalizer)
1794
- return;
1795
- const rid = currentResponseId;
1796
- // statusLine usage (context/cost) rides the turn's response.completed.
1797
- if (usage) {
1798
- for (const se of normalizer.next({ type: "turn_completed", usage }))
1799
- emitCurrent(se);
1800
- }
1801
- for (const se of normalizer.next({ type: "done" }))
1802
- emitCurrent(se);
1803
- live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1804
- normalizer = null;
1805
- currentResponseId = undefined;
1806
- // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1807
- // cleared by the next turn's first item). statusLine pre-computes the %.
1808
- const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1809
- if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1810
- live.contextWarned = true;
1811
- emitCurrent({
1812
- type: "session.status",
1813
- sessionId: currentSessionId,
1814
- responseId: rid,
1815
- status: "idle",
1816
- note: `context ${Math.round(pct)}% full — consider /compact`,
1817
- });
1818
- }
2849
+ onStatus: (status, blockedOn) => {
2850
+ const responseId = currentResponseId ??
2851
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
2852
+ emitCurrent({
2853
+ type: "session.status",
2854
+ sessionId: currentSessionId,
2855
+ ...(responseId ? { responseId } : {}),
2856
+ status,
2857
+ ...(blockedOn ? { note: blockedOn } : {}),
2858
+ });
1819
2859
  },
1820
- onIdle: () => {
1821
- // Surface idle on the current turn WITHOUT finalizing it (see the sink's
1822
- // onIdle doc): a late assistant record still joins this response.
1823
- if (currentResponseId) {
1824
- emitCurrent({
2860
+ onTurnEnd: (usage, backgroundTaskCount, reason) => settleClaudeTurn(false, usage, backgroundTaskCount, reason),
2861
+ onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
2862
+ onTurnInterruptRequested: () => {
2863
+ const responseId = currentResponseId;
2864
+ if (responseId)
2865
+ live.publishInterrupted(responseId);
2866
+ },
2867
+ onIdle: (backgroundTaskCount, sourceId) => {
2868
+ // Surface Stop even after its Turn has already closed: a later
2869
+ // authoritative zero is what clears a sticky background-shell tally.
2870
+ // When the Turn is still open, retain its identity without finalizing it
2871
+ // so a late assistant record continues to join the same Response.
2872
+ return emitCurrent({
2873
+ type: "session.status",
2874
+ sessionId: currentSessionId,
2875
+ ...(currentResponseId ? { responseId: currentResponseId } : {}),
2876
+ status: "idle",
2877
+ ...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
2878
+ }, {
2879
+ policy: "terminal-status",
2880
+ lane: "claude-status",
2881
+ ...(sourceId ? { sourceId } : {}),
2882
+ onOutcome: (outcome) => {
2883
+ if (outcome === "dropped") {
2884
+ publishForwarderFailure(`hook status idle rejected`, currentResponseId);
2885
+ }
2886
+ },
2887
+ });
2888
+ },
2889
+ onTurnError: (error, sourceId) => {
2890
+ const message = error.message || "Agent turn failed";
2891
+ if (!normalizer) {
2892
+ // Native pane/forwarder death is Session-level even when Claude is
2893
+ // between Turns. Emit a bare failed edge; the
2894
+ // explicit zero also retires any sticky background-shell tally.
2895
+ return emitCurrent({
1825
2896
  type: "session.status",
1826
2897
  sessionId: currentSessionId,
1827
- responseId: currentResponseId,
1828
- status: "idle",
2898
+ status: "failed",
2899
+ backgroundTaskCount: 0,
2900
+ note: message,
2901
+ }, {
2902
+ policy: "terminal-status",
2903
+ lane: "claude-status",
2904
+ ...(sourceId ? { sourceId } : {}),
1829
2905
  });
1830
2906
  }
1831
- },
1832
- onTurnError: () => {
1833
- if (!normalizer)
1834
- return;
1835
2907
  const rid = currentResponseId;
2908
+ const deliveries = [];
2909
+ let itemIndex = 0;
1836
2910
  for (const se of normalizer.fail({
1837
2911
  code: "agent_error",
1838
- message: "Agent turn failed",
2912
+ message,
1839
2913
  source: "execution",
1840
- }))
1841
- emitCurrent(se);
2914
+ })) {
2915
+ const event = se.type === "session.status"
2916
+ ? { ...se, status: "failed", note: message }
2917
+ : se;
2918
+ const durable = isDurableSessionEvent(event);
2919
+ const eventSourceId = sourceId
2920
+ ? `${sourceId}:${itemIndex++}:${durable && "item" in event ? event.item.type : event.type}`
2921
+ : undefined;
2922
+ const delivered = emitCurrent(event, {
2923
+ policy: durable ? "ordinary" : "terminal-status",
2924
+ lane: "claude-status",
2925
+ ...(eventSourceId ? { sourceId: eventSourceId } : {}),
2926
+ ...(durable
2927
+ ? { deadLetterPath: path.join(bridgeDir, "dead_letter.jsonl") }
2928
+ : {}),
2929
+ });
2930
+ if (delivered)
2931
+ deliveries.push(Promise.resolve(delivered));
2932
+ }
1842
2933
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1843
2934
  normalizer = null;
1844
2935
  currentResponseId = undefined;
2936
+ return Promise.all(deliveries).then(() => undefined);
1845
2937
  },
1846
2938
  onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
1847
2939
  onSessionResumeError: (error) => {
1848
2940
  live.error = error.message;
1849
2941
  live.markFailed();
1850
2942
  },
1851
- onSessionRotated: (kind, claudeSessionId) => {
1852
- if (pendingRotationEvents) {
1853
- live.error = "Claude reported another Session rotation before publication completed";
1854
- live.markFailed();
1855
- return;
2943
+ onSessionRotated: (kind, claudeSessionId, _transcriptPath, initialTranscriptOffset) => {
2944
+ let publication = rotationPublication?.claudeSessionId === claudeSessionId &&
2945
+ rotationPublication.kind === kind
2946
+ ? rotationPublication
2947
+ : null;
2948
+ if (publication?.operation)
2949
+ return publication.operation;
2950
+ if (publication?.completed)
2951
+ return Promise.resolve();
2952
+ if (!publication && pendingRotationEvents) {
2953
+ return Promise.reject(new Error("Claude reported another Session rotation before publication completed"));
1856
2954
  }
1857
- // The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
1858
- // new transcript. Persist the Provider binding first, then announce on
1859
- // the OLD session and re-target the mirror. Events produced during the
1860
- // file-store commit are buffered so none can land under the old identity.
1861
- const previousSessionId = currentSessionId;
1862
- const newSessionId = makeSessionId();
1863
- currentSessionId = newSessionId;
1864
- normalizer = null;
1865
- currentResponseId = undefined;
1866
- live.contextWarned = false;
1867
- pendingRotationEvents = [];
1868
- this.liveClaudeSessions.set(newSessionId, live);
1869
- void this.sessionStore
1870
- .set({
1871
- localThreadId: newSessionId,
1872
- codexSessionId: claudeSessionId,
1873
- ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1874
- updatedAt: new Date().toISOString(),
1875
- })
1876
- .then(() => {
1877
- emit({
1878
- type: "session.rotated",
1879
- sessionId: previousSessionId,
1880
- newSessionId,
2955
+ // Persist the Provider binding, then wait for the runner manager to
2956
+ // publish and ACK the new alias. The parent announces the rotation on
2957
+ // the OLD Session only after that target publication succeeds.
2958
+ // The forwarder does not consume the SessionStart hook or switch source
2959
+ // state until this operation resolves.
2960
+ if (!publication) {
2961
+ // Claude has already crossed its physical clear/fork boundary by the
2962
+ // time this hook arrives. Fence injection synchronously, before the
2963
+ // first asynchronous target-store operation or IPC publication.
2964
+ live.rotationPending = true;
2965
+ publication = {
2966
+ claudeSessionId,
1881
2967
  kind,
2968
+ initialTranscriptOffset,
2969
+ previousSessionId: currentSessionId,
2970
+ newSessionId: makeSessionId(),
2971
+ };
2972
+ rotationPublication = publication;
2973
+ }
2974
+ const { previousSessionId } = publication;
2975
+ // An exact publication retry keeps events buffered during the failed
2976
+ // attempt; they already belong to this same post-rotation target.
2977
+ pendingRotationEvents ??= [];
2978
+ const operation = this.sessionStore
2979
+ .findByCodexSessionId(claudeSessionId)
2980
+ .then(async (existingTarget) => {
2981
+ if (existingTarget &&
2982
+ existingTarget.localThreadId !== previousSessionId) {
2983
+ publication.newSessionId = existingTarget.localThreadId;
2984
+ }
2985
+ if (existingTarget?.nativeRotationInitialTranscriptOffset !== undefined) {
2986
+ publication.initialTranscriptOffset =
2987
+ existingTarget.nativeRotationInitialTranscriptOffset;
2988
+ }
2989
+ await this.sessionStore.set({
2990
+ localThreadId: publication.newSessionId,
2991
+ codexSessionId: claudeSessionId,
2992
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
2993
+ runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
2994
+ bridgeOwnerSessionId: live.bridgeOwnerSessionId,
2995
+ nativeRotationSourceSessionId: previousSessionId,
2996
+ nativeRotationKind: kind,
2997
+ nativeRotationInitialTranscriptOffset: publication.initialTranscriptOffset,
2998
+ ...(existingTarget?.nativeRotationPublished
2999
+ ? { nativeRotationPublished: true }
3000
+ : {}),
3001
+ updatedAt: new Date().toISOString(),
1882
3002
  });
1883
- retargetMirror?.(newSessionId, {
3003
+ })
3004
+ .then(async () => {
3005
+ const applyHostTarget = async () => {
3006
+ if (publication.hostApplied)
3007
+ return;
3008
+ const newSessionId = publication.newSessionId;
3009
+ const targetBinding = await this.sessionStore.get(newSessionId);
3010
+ if (targetBinding && !targetBinding.nativeRotationPublished) {
3011
+ await this.sessionStore.set({
3012
+ ...targetBinding,
3013
+ nativeRotationPublished: true,
3014
+ updatedAt: new Date().toISOString(),
3015
+ });
3016
+ }
3017
+ const sourceBinding = await this.sessionStore.get(previousSessionId);
3018
+ const retiredBridgeOwner = `${previousSessionId}-${kind}-retired-${newSessionId}`;
3019
+ if (sourceBinding &&
3020
+ sourceBinding.bridgeOwnerSessionId !== retiredBridgeOwner) {
3021
+ await this.sessionStore.set({
3022
+ ...sourceBinding,
3023
+ bridgeOwnerSessionId: retiredBridgeOwner,
3024
+ nativeRotationTargetSessionId: newSessionId,
3025
+ updatedAt: new Date().toISOString(),
3026
+ });
3027
+ }
3028
+ currentSessionId = newSessionId;
3029
+ normalizer = null;
3030
+ currentResponseId = undefined;
3031
+ live.contextWarned = false;
3032
+ this.liveClaudeSessions.set(newSessionId, live);
3033
+ this.liveClaudeSessions.delete(previousSessionId);
3034
+ const queued = pendingRotationEvents ?? [];
3035
+ pendingRotationEvents = null;
3036
+ live.rotationPending = false;
3037
+ publication.hostApplied = true;
3038
+ for (const entry of queued) {
3039
+ emit(retargetSessionEvent(entry.event, newSessionId), entry.delivery);
3040
+ }
3041
+ };
3042
+ await Promise.resolve(retargetMirror?.(publication.newSessionId, {
1884
3043
  kind,
1885
3044
  workspace: structuredClone(live.workspace),
1886
3045
  execution: structuredClone(live.execution),
1887
3046
  ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1888
- });
1889
- const queued = pendingRotationEvents ?? [];
1890
- pendingRotationEvents = null;
1891
- for (const event of queued)
1892
- emit(event);
3047
+ beforeApplied: applyHostTarget,
3048
+ }));
3049
+ if (!publication.hostApplied) {
3050
+ throw new Error("Claude retarget completed without applying the host target");
3051
+ }
3052
+ publication.completed = true;
1893
3053
  })
1894
3054
  .catch((error) => {
1895
- pendingRotationEvents = null;
3055
+ // Keep the fence and buffered events across an exact publication
3056
+ // retry. Re-opening either here would leak post-clear work back to
3057
+ // the retired logical Session.
1896
3058
  live.error = error instanceof Error ? error.message : String(error);
1897
3059
  live.markFailed();
3060
+ if (typeof error === "object" &&
3061
+ error !== null &&
3062
+ "classification" in error &&
3063
+ error.classification === "permanent") {
3064
+ // A deterministic target-publication rejection cannot recover by
3065
+ // polling the same hook forever. Keep injection fenced and stop
3066
+ // the forwarder so restart/operator action is explicit.
3067
+ queueMicrotask(() => live.forwarder.stop());
3068
+ }
3069
+ throw error;
3070
+ })
3071
+ .finally(() => {
3072
+ if (rotationPublication === publication) {
3073
+ delete publication.operation;
3074
+ }
1898
3075
  });
3076
+ publication.operation = operation;
3077
+ return operation;
1899
3078
  },
1900
3079
  };
1901
- // Claude itself resolves `--resume <id>`. The SessionStart hook is the
1902
- // authoritative source for the actual transcript path; do not infer or
1903
- // generate Claude's private on-disk format here.
3080
+ // Claude itself resolves `--resume <id>`. A managed fork pre-writes and
3081
+ // measures its target prefix, but SessionStart remains authoritative for
3082
+ // the transcript path the forwarder actually tails.
1904
3083
  live.forwarder = new ClaudeLiveSession({
1905
3084
  bridgeDir,
1906
3085
  sink,
@@ -1909,7 +3088,16 @@ export class LocalAgentHost {
1909
3088
  : record?.codexSessionId
1910
3089
  ? { claudeSessionId: record.codexSessionId }
1911
3090
  : {}),
1912
- ...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
3091
+ ...((record?.codexSessionId || forkTranscriptPrepared)
3092
+ ? { resumeAtEndOnDiscovery: true }
3093
+ : {}),
3094
+ ...(forkResumePrefixBytes !== undefined
3095
+ ? { initialTranscriptOffsetOnDiscovery: forkResumePrefixBytes }
3096
+ : record?.nativeRotationInitialTranscriptOffset !== undefined
3097
+ ? {
3098
+ initialTranscriptOffsetOnDiscovery: record.nativeRotationInitialTranscriptOffset,
3099
+ }
3100
+ : {}),
1913
3101
  });
1914
3102
  this.liveClaudeSessions.set(localThreadId, live);
1915
3103
  live.forwarder.start();
@@ -1929,12 +3117,16 @@ export class LocalAgentHost {
1929
3117
  }
1930
3118
  live.discoveredReady = true;
1931
3119
  void this.sessionStore
1932
- .set({
3120
+ .get(localThreadId)
3121
+ .then((existing) => this.sessionStore.set({
3122
+ ...existing,
1933
3123
  localThreadId,
1934
3124
  codexSessionId: claudeSessionId,
1935
3125
  ...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
3126
+ runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
3127
+ bridgeOwnerSessionId: live.bridgeOwnerSessionId,
1936
3128
  updatedAt: new Date().toISOString(),
1937
- })
3129
+ }))
1938
3130
  .then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
1939
3131
  .then(() => live.markReady())
1940
3132
  .catch((error) => {
@@ -1946,17 +3138,27 @@ export class LocalAgentHost {
1946
3138
  * serialized per session. Parks until the thread is ready AND the tmux injector
1947
3139
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
1948
3140
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
1949
- * fall-through-to-run signal. Returns {@link InjectOutcome}. */
1950
- injectClaude(live, localThreadId, text) {
3141
+ * fall-through-to-run signal. Returns {@link InjectResult}. */
3142
+ injectClaude(live, localThreadId, text, pendingInput) {
3143
+ if (live.rotationPending || live.stopped) {
3144
+ return Promise.resolve({ outcome: "notReady" });
3145
+ }
1951
3146
  const run = live.injectLock.then(async () => {
3147
+ if (live.rotationPending || live.stopped)
3148
+ return { outcome: "notReady" };
1952
3149
  const ready = await this.waitLiveReady(localThreadId, 60_000);
1953
3150
  if (!ready)
1954
- return "notReady";
3151
+ return { outcome: "notReady" };
3152
+ if (live.rotationPending || live.stopped)
3153
+ return { outcome: "notReady" };
1955
3154
  // Pane may have just relaunched — park until its injector re-attaches
1956
3155
  // (attachTerminalInjector resets it) instead of returning false → fallback.
1957
3156
  const injector = await this.waitInjector(live, 60_000);
1958
3157
  if (!injector)
1959
- return "notReady";
3158
+ return { outcome: "notReady" };
3159
+ if (live.rotationPending || live.stopped)
3160
+ return { outcome: "notReady" };
3161
+ const steered = live.forwarder.isTurnOpen();
1960
3162
  // Abortable: the web Stop button cancels an in-flight paste/submit (before
1961
3163
  // the message reaches claude) via interruptLive → injectAbort.abort().
1962
3164
  const abort = new AbortController();
@@ -1967,10 +3169,17 @@ export class LocalAgentHost {
1967
3169
  signal: abort.signal,
1968
3170
  submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
1969
3171
  });
1970
- return ok ? "injected" : "failed";
3172
+ if (!ok)
3173
+ return { outcome: "failed" };
3174
+ const responseId = pendingInput.responseId ?? live.currentResponseId();
3175
+ return {
3176
+ outcome: steered ? "steered" : "injected",
3177
+ ...(responseId ? { responseId } : {}),
3178
+ };
1971
3179
  }
1972
- catch {
1973
- return "failed";
3180
+ catch (error) {
3181
+ live.error = error instanceof Error ? error.message : String(error);
3182
+ return { outcome: "failed" };
1974
3183
  }
1975
3184
  finally {
1976
3185
  if (live.injectAbort === abort)
@@ -1992,12 +3201,31 @@ export class LocalAgentHost {
1992
3201
  return live.injector ?? null;
1993
3202
  }
1994
3203
  /** Attach a session's tmux pane injector (from the runner-child, which owns the
1995
- * terminal registry — the host does not hold tmux). Ignored for codex sessions
1996
- * (they inject via the app-server). Idempotent. */
3204
+ * terminal registry — the host does not hold tmux). Claude uses it for normal
3205
+ * message injection; Codex uses it only to dismiss the exact TUI-local Plan
3206
+ * picker after its synthetic Web form is submitted. Idempotent. */
1997
3207
  attachTerminalInjector(localThreadId, injector) {
1998
3208
  const live = this.liveClaudeSessions.get(localThreadId);
1999
- if (live)
3209
+ if (live) {
3210
+ if (live.injector === injector)
3211
+ return;
2000
3212
  live.injector = injector;
3213
+ live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
3214
+ return;
3215
+ }
3216
+ const codex = this.liveSessions.get(localThreadId);
3217
+ if (codex)
3218
+ codex.injector = injector;
3219
+ }
3220
+ /** Close an active native response when its terminal or runner disappears. */
3221
+ failLiveSession(localThreadId, error) {
3222
+ const claude = this.liveClaudeSessions.get(localThreadId);
3223
+ if (claude)
3224
+ return claude.forwarder.noteTerminalExit(error);
3225
+ const codex = this.liveSessions.get(localThreadId);
3226
+ if (!codex)
3227
+ return false;
3228
+ return codex.forwarder.failOpenTurn(error);
2001
3229
  }
2002
3230
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
2003
3231
  async listModels(runtime) {
@@ -2027,7 +3255,7 @@ export class LocalAgentHost {
2027
3255
  return { ok: false, reason: "unsupported" };
2028
3256
  }
2029
3257
  try {
2030
- return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
3258
+ return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
2031
3259
  }
2032
3260
  catch (error) {
2033
3261
  return {
@@ -2099,8 +3327,8 @@ export class LocalAgentHost {
2099
3327
  return { ok: true, data: undefined };
2100
3328
  }
2101
3329
  const runtime = execution.provider;
2102
- const client = live?.injectClient ??
2103
- this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
3330
+ const client = live?.appServerOwner ??
3331
+ this.getBackend(runtime).appServerClient;
2104
3332
  if (!client) {
2105
3333
  return { ok: false, reason: "unsupported" };
2106
3334
  }
@@ -2126,7 +3354,7 @@ export class LocalAgentHost {
2126
3354
  threadId: record.codexSessionId,
2127
3355
  excludeTurns: true,
2128
3356
  ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2129
- model: execution.model,
3357
+ ...(execution.model ? { model: execution.model } : {}),
2130
3358
  approvalPolicy: execution.approvalPolicy,
2131
3359
  });
2132
3360
  }
@@ -2155,6 +3383,9 @@ export class LocalAgentHost {
2155
3383
  codexSessionId: forkedThreadId,
2156
3384
  parentSessionId: currentLocalThreadId,
2157
3385
  runtimeHomeOwnerSessionId: sourceHomeOwner,
3386
+ ...(record.bridgeOwnerSessionId
3387
+ ? { bridgeOwnerSessionId: record.bridgeOwnerSessionId }
3388
+ : {}),
2158
3389
  updatedAt: new Date().toISOString(),
2159
3390
  });
2160
3391
  return { ok: true, data: undefined };
@@ -2168,11 +3399,11 @@ export class LocalAgentHost {
2168
3399
  }
2169
3400
  }
2170
3401
  }
2171
- function codexInjectionError(error) {
3402
+ function codexRpcError(error, method) {
2172
3403
  if (error instanceof CodexTransportError) {
2173
- return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
3404
+ return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
2174
3405
  }
2175
- return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
3406
+ return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
2176
3407
  }
2177
3408
  export function parseCodexLoginStatus(exitCode, output) {
2178
3409
  const normalized = output.trim();
@@ -2197,20 +3428,100 @@ export function parseCodexLoginStatus(exitCode, output) {
2197
3428
  issues: normalized ? [normalized] : [],
2198
3429
  };
2199
3430
  }
2200
- /** Race a live session's `ready` promise against failure / timeout. */
3431
+ /** Race a live session's `ready` promise against failure and, when supplied,
3432
+ * a timeout. */
2201
3433
  function raceReady(ready, timeoutMs, failed) {
2202
3434
  let timer;
2203
- const timeout = new Promise((resolve) => {
2204
- timer = setTimeout(() => resolve(false), timeoutMs);
2205
- });
2206
- const outcomes = [ready.then(() => true), timeout];
3435
+ const outcomes = [ready.then(() => true)];
2207
3436
  if (failed)
2208
3437
  outcomes.push(failed.then(() => false));
3438
+ if (timeoutMs !== null) {
3439
+ outcomes.push(new Promise((resolve) => {
3440
+ timer = setTimeout(() => resolve(false), timeoutMs);
3441
+ }));
3442
+ }
2209
3443
  return Promise.race(outcomes).finally(() => {
2210
3444
  if (timer)
2211
3445
  clearTimeout(timer);
2212
3446
  });
2213
3447
  }
3448
+ /** Record the authoritative settings returned while binding an existing
3449
+ * native thread. A Session-level explicit selection remains a pending desired
3450
+ * override; otherwise the resumed thread itself is the source of truth. */
3451
+ function adoptResumedThreadSettings(live, settings) {
3452
+ const model = settings.model?.trim();
3453
+ if (model) {
3454
+ live.mayUseProviderDefaultModel = false;
3455
+ live.appliedModel = model;
3456
+ if (!live.execution.model)
3457
+ live.model = model;
3458
+ }
3459
+ if (settings.reasoningEffort !== undefined) {
3460
+ live.appliedReasoningEffort = settings.reasoningEffort ?? undefined;
3461
+ if (!live.execution.reasoningEffort) {
3462
+ live.reasoningEffort = settings.reasoningEffort ?? undefined;
3463
+ }
3464
+ }
3465
+ }
3466
+ /** Resolve the model required by a full collaboration-mode snapshot. Only a
3467
+ * fresh thread with no explicit/config model may use the catalog default; a
3468
+ * resumed thread must report its own authoritative model. */
3469
+ async function resolveLiveCollaborationMode(client, live, mode, codexHome) {
3470
+ let model = live.model.trim();
3471
+ if (!model && live.mayUseProviderDefaultModel) {
3472
+ let catalog;
3473
+ try {
3474
+ catalog = await client.modelList();
3475
+ }
3476
+ catch (error) {
3477
+ throw new CodexRuntimeError(`collaboration mode requires the Provider default model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
3478
+ }
3479
+ const defaults = [...new Set(catalog.data
3480
+ .filter((candidate) => candidate.isDefault === true)
3481
+ .map((candidate) => (candidate.model || candidate.id).trim())
3482
+ .filter(Boolean))];
3483
+ if (defaults.length !== 1) {
3484
+ throw new CodexRuntimeError(`collaboration mode requires exactly one Provider default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
3485
+ }
3486
+ model = defaults[0];
3487
+ }
3488
+ let developerInstructions = null;
3489
+ if (live.runtime === "codex") {
3490
+ const state = codexHome
3491
+ ? readCodexDeveloperInstructionsState(codexHome)
3492
+ : { state: "unreadable" };
3493
+ if (state.state === "unreadable") {
3494
+ throw new CodexRuntimeError("collaboration mode requires the current developer instructions, but the private Codex config could not be read", 503, "collaboration_mode_instructions_unknown");
3495
+ }
3496
+ developerInstructions = state.state === "present" ? state.value : null;
3497
+ }
3498
+ return buildCollaborationMode(mode, model, live.reasoningEffort, developerInstructions);
3499
+ }
3500
+ /** Expand the public mode enum into the App Server's required settings
3501
+ * snapshot using the Session's tracked effective model. */
3502
+ function buildCollaborationMode(mode, desiredModel, desiredReasoningEffort, developerInstructions) {
3503
+ const model = desiredModel.trim();
3504
+ if (!model) {
3505
+ throw new CodexRuntimeError("collaboration mode requires the current model, but the Provider did not report one", 503, "collaboration_mode_model_unknown");
3506
+ }
3507
+ return {
3508
+ mode,
3509
+ settings: {
3510
+ model,
3511
+ reasoning_effort: desiredReasoningEffort ?? null,
3512
+ developer_instructions: developerInstructions,
3513
+ },
3514
+ };
3515
+ }
3516
+ /** New snapshots distinguish authored Agent text from framework-rendered
3517
+ * instructions. Older persisted snapshots fall back to their only available
3518
+ * field so existing sessions preserve their launch behavior. */
3519
+ function nativeAuthoredInstructions(execution) {
3520
+ const value = execution.authoredInstructions === undefined
3521
+ ? execution.instructions
3522
+ : execution.authoredInstructions;
3523
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
3524
+ }
2214
3525
  function sameSessionSnapshots(workspace, execution, opts) {
2215
3526
  return JSON.stringify(stableValue({
2216
3527
  workspace,
@@ -2220,6 +3531,40 @@ function sameSessionSnapshots(workspace, execution, opts) {
2220
3531
  execution: opts.execution,
2221
3532
  }));
2222
3533
  }
3534
+ function sameImmutableSessionSnapshots(workspace, execution, opts) {
3535
+ const current = {
3536
+ ...execution,
3537
+ model: null,
3538
+ reasoningEffort: null,
3539
+ collaborationMode: undefined,
3540
+ };
3541
+ const requested = {
3542
+ ...opts.execution,
3543
+ model: null,
3544
+ reasoningEffort: null,
3545
+ collaborationMode: undefined,
3546
+ };
3547
+ return JSON.stringify(stableValue({ workspace, execution: current })) ===
3548
+ JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
3549
+ }
3550
+ function updateLiveCodexTurnSettings(live, opts) {
3551
+ if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
3552
+ return false;
3553
+ }
3554
+ const previousModel = live.execution.model;
3555
+ const previousReasoningEffort = live.execution.reasoningEffort;
3556
+ live.execution = structuredClone(opts.execution);
3557
+ // Repeated calls normally carry the same immutable Session snapshot. Preserve
3558
+ // a newer native `/model` or effort change unless Core explicitly changed the
3559
+ // corresponding Session setting between calls.
3560
+ if (opts.execution.model !== previousModel) {
3561
+ live.model = opts.execution.model?.trim() ?? "";
3562
+ }
3563
+ if (opts.execution.reasoningEffort !== previousReasoningEffort) {
3564
+ live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
3565
+ }
3566
+ return true;
3567
+ }
2223
3568
  /** Freeze the Session's permission choice into the explicit managed settings.
2224
3569
  * Host/project settings may still contribute allow/deny rules, but changing
2225
3570
  * their defaultMode cannot silently mutate an existing Session on resume. */
@@ -2233,6 +3578,12 @@ function applyClaudePermissionModeSnapshot(settings, permissionMode) {
2233
3578
  permissions.defaultMode = permissionMode;
2234
3579
  else
2235
3580
  delete permissions.defaultMode;
3581
+ if (permissionMode === "bypassPermissions") {
3582
+ next.skipDangerousModePermissionPrompt = true;
3583
+ }
3584
+ else {
3585
+ delete next.skipDangerousModePermissionPrompt;
3586
+ }
2236
3587
  if (Object.keys(permissions).length > 0)
2237
3588
  next.permissions = permissions;
2238
3589
  else
@@ -2272,10 +3623,13 @@ export function isThreadNotReadyError(error) {
2272
3623
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2273
3624
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
2274
3625
  }
2275
- /** A persisted thread id that a freshly started app-server cannot load yet.
2276
- * During startup, both errors can be transient while the rollout index catches
2277
- * up. Retry the same id; never use either error as permission to replace it. */
2278
- export function isRetryableThreadResumeError(error) {
2279
- const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2280
- return message.includes("thread not found") || isThreadNotReadyError(error);
3626
+ /** Build the user-facing native lifecycle failure once, at the phase that owns
3627
+ * it. Downstream layers preserve this code/message verbatim instead of replacing
3628
+ * it with a generic "failed to start; see logs" wrapper. */
3629
+ function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
3630
+ const provider = runtime === "traex" ? "Traex" : "Codex";
3631
+ const detail = cause === undefined
3632
+ ? ""
3633
+ : `: ${cause instanceof Error ? cause.message : String(cause)}`;
3634
+ return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
2281
3635
  }