@rynx-ai/runtime 0.1.10 → 0.1.11-beta.2

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 (36) hide show
  1. package/dist/claude/native-bridge.js +3 -8
  2. package/dist/claude/native-integration.d.ts +12 -1
  3. package/dist/claude/native-integration.js +16 -2
  4. package/dist/claude/transcript.d.ts +0 -7
  5. package/dist/claude/transcript.js +6 -20
  6. package/dist/codex-app-server/client.d.ts +2 -1
  7. package/dist/codex-app-server/forwarder.d.ts +4 -1
  8. package/dist/codex-app-server/forwarder.js +19 -1
  9. package/dist/codex-app-server/protocol.d.ts +45 -1
  10. package/dist/codex-home.d.ts +9 -26
  11. package/dist/codex-home.js +37 -65
  12. package/dist/codex-session-store.d.ts +22 -10
  13. package/dist/codex-session-store.js +277 -12
  14. package/dist/host.d.ts +47 -47
  15. package/dist/host.js +790 -350
  16. package/dist/index.d.ts +1 -2
  17. package/dist/index.js +0 -1
  18. package/dist/models-catalog.d.ts +1 -0
  19. package/dist/models-catalog.js +43 -1
  20. package/dist/provider-workspace.d.ts +56 -0
  21. package/dist/provider-workspace.js +83 -0
  22. package/dist/runner/child.d.ts +54 -6
  23. package/dist/runner/child.js +42 -17
  24. package/dist/runner/manager.d.ts +41 -18
  25. package/dist/runner/manager.js +432 -55
  26. package/dist/runner/protocol.d.ts +7 -18
  27. package/dist/runner-main.js +12 -4
  28. package/dist/runtime-state-paths.d.ts +10 -0
  29. package/dist/runtime-state-paths.js +53 -0
  30. package/dist/terminal/claude-tui.d.ts +8 -1
  31. package/dist/terminal/claude-tui.js +7 -1
  32. package/dist/terminal/codex-tui.d.ts +5 -1
  33. package/dist/terminal/codex-tui.js +12 -3
  34. package/package.json +2 -2
  35. package/dist/codex/rollout-synth.d.ts +0 -42
  36. package/dist/codex/rollout-synth.js +0 -245
package/dist/host.js CHANGED
@@ -1,23 +1,24 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { cp, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import path from "node:path";
5
6
  import readline from "node:readline";
6
- import { resolveAgent, resolveAgentExecution, buildAgentSkillEnvironment, SessionNormalizer, } from "@rynx-ai/core";
7
+ import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, } from "@rynx-ai/core";
7
8
  import { getRuntimeProfile, } from "@rynx-ai/core";
8
- import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
9
+ import { resolveRuntimeBinary, } from "@rynx-ai/core";
9
10
  import { createCodexChildEnv } from "./codex-child-env.js";
10
- import { prepareRuntimeHome, populateCodexSkills, } from "./codex-home.js";
11
+ import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
11
12
  import { materializeSkillPlugin } from "./claude/executor.js";
12
13
  import { listClaudeModels } from "./claude/models.js";
13
14
  import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
14
- import { buildAppServerBaseArgs } from "./codex-app-server/transport.js";
15
+ import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
15
16
  import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
16
17
  import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
17
18
  import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
18
19
  import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
20
+ import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
19
21
  import { ensureProjectTrusted } from "./claude/trust.js";
20
- import { claudeTranscriptPath } from "./claude/transcript.js";
21
22
  import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
22
23
  import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
23
24
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
@@ -156,10 +157,22 @@ export class SpawnCodexCommandRunner {
156
157
  }
157
158
  async capture({ args, cwd = process.cwd(), }) {
158
159
  const running = this.start({ args, cwd });
159
- for await (const _ of running.stream) {
160
- // Drain output so completion captures stdout/stderr for parsing.
160
+ const completion = running.completion.then((result) => ({ ok: true, result }), (error) => ({ ok: false, error }));
161
+ let streamError;
162
+ try {
163
+ for await (const _ of running.stream) {
164
+ // Drain output so completion captures stdout/stderr for parsing.
165
+ }
166
+ }
167
+ catch (error) {
168
+ streamError = error;
161
169
  }
162
- return running.completion;
170
+ const outcome = await completion;
171
+ if (streamError)
172
+ throw streamError;
173
+ if (!outcome.ok)
174
+ throw outcome.error;
175
+ return outcome.result;
163
176
  }
164
177
  }
165
178
  /** Fraction of an agent's `contextWindow` budget at which the orchestrator
@@ -168,6 +181,13 @@ export class SpawnCodexCommandRunner {
168
181
  * budget (set proactively); claude has no programmatic compaction, so this
169
182
  * signal is the lever consumers surface to the user. */
170
183
  const CONTEXT_WARN_RATIO = 0.8;
184
+ function providerPluginSkillName(namespace, name) {
185
+ const candidate = `${namespace}-${name}`;
186
+ if (candidate.length <= 128)
187
+ return candidate;
188
+ const digest = createHash("sha256").update(candidate).digest("hex").slice(0, 12);
189
+ return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
190
+ }
171
191
  const MAX_CODEX_SETTLED_INTERACTIONS = 512;
172
192
  /**
173
193
  * The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
@@ -227,16 +247,15 @@ export class LocalAgentHost {
227
247
  // Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
228
248
  // a `${runtime}::${retryHash}` composite for a per-agent retry budget.
229
249
  backends = new Map();
230
- // The rynx session this host serves (set from `RYNX_RUNNER_SESSION`); the private
231
- // CODEX_HOME is scoped to it so concurrent agents never share a `skills/` dir.
250
+ // The rynx Session this host serves (set from `RYNX_RUNNER_SESSION`).
232
251
  sessionId;
252
+ runtimeHomeSessionId;
233
253
  // Per-agent codex knobs resolved at live-session start, applied to this
234
254
  // session's app-server (host is per-session, so the app-server is private to
235
255
  // this agent). Absent ⇒ fall back to the daemon `config.*` default.
236
256
  sessionSandbox;
237
257
  sessionApprovalPolicy;
238
- // Private CODEX_HOME / TRAE_HOME directories (login symlinked, settings
239
- // copied, update/NUX state NOT inherited) for this session's app-server + TUI.
258
+ // Private CODEX_HOME / TRAE_HOME directories for this Session lineage.
240
259
  runtimeHomes = new Map();
241
260
  // Per-session codex-native live forwarders, keyed by localThreadId. The
242
261
  // single-writer of the session's canonical events + the target of turn injection.
@@ -250,9 +269,14 @@ export class LocalAgentHost {
250
269
  // In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
251
270
  // `live.ensure` racing a `term.open`) share ONE forwarder — never two.
252
271
  liveEnsuring = new Map();
253
- constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, }) {
272
+ forkingTargets = new Map();
273
+ /** Short-lived dedupe for managed fork notifications delivered after the
274
+ * `thread/fork` response. Values are expected source Provider thread ids. */
275
+ 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, }) {
254
277
  this.config = config;
255
278
  this.sessionId = sessionId ?? "__default__";
279
+ this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
256
280
  this.sessionStore = sessionStore;
257
281
  this.allowedRoots = allowedRoots;
258
282
  this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
@@ -335,7 +359,9 @@ export class LocalAgentHost {
335
359
  const existing = this.runtimeHomes.get(runtime);
336
360
  if (existing)
337
361
  return existing;
338
- const prepared = prepareRuntimeHome(this.sessionId, runtime);
362
+ const prepared = this.runtimeHomeSessionId === this.sessionId
363
+ ? prepareRuntimeHome(this.runtimeHomeSessionId, runtime)
364
+ : runtimeHomePath(this.runtimeHomeSessionId, runtime);
339
365
  this.runtimeHomes.set(runtime, prepared);
340
366
  return prepared;
341
367
  }
@@ -425,11 +451,11 @@ export class LocalAgentHost {
425
451
  return this.claudeTerminalSpec(localThreadId, record);
426
452
  }
427
453
  const live = this.liveSessions.get(localThreadId);
428
- const runtime = record?.runtime ?? live?.runtime ?? this.defaultRuntime;
429
- if (runtime === "claude") {
430
- return this.claudeTerminalSpec(localThreadId, record);
454
+ if (!live) {
455
+ throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
431
456
  }
432
- const backend = this.getBackend(runtime);
457
+ const runtime = live.runtime;
458
+ const backend = this.getBackend(runtime, live.execution.budget ?? undefined);
433
459
  const client = backend.appServerClient;
434
460
  if (!client) {
435
461
  throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
@@ -444,29 +470,39 @@ export class LocalAgentHost {
444
470
  // The TUI shares this session's app-server, so it inherits the per-agent
445
471
  // sandbox already applied at boot; keep the network-access override aligned
446
472
  // to the same effective sandbox.
447
- const configOverrides = (this.sessionSandbox ?? this.config.AGENT_SANDBOX) === "workspace-write"
473
+ const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
474
+ const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
475
+ const configOverrides = sandbox === "workspace-write"
448
476
  ? ["sandbox_workspace_write.network_access=true"]
449
477
  : [];
478
+ configOverrides.push(`sandbox_mode=${JSON.stringify(sandbox)}`, `approval_policy=${JSON.stringify(approvalPolicy)}`);
479
+ // A managed TUI has no user at its stdin during startup. Codex's periodic
480
+ // "Update now / Skip" prompt would otherwise park the detached pane before
481
+ // it connects to the app-server, leaving the session only partially live.
482
+ if (runtime === "codex")
483
+ configOverrides.push("check_for_update_on_startup=false");
450
484
  // Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
451
485
  // shared per-runtime, so a per-session model rides a launch `-c model=` instead).
452
- const launchModel = live?.model;
486
+ const launchModel = live.model;
453
487
  if (launchModel)
454
488
  configOverrides.push(`model=${JSON.stringify(launchModel)}`);
455
- const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
489
+ const launchEffort = live.reasoningEffort;
456
490
  if (launchEffort)
457
491
  configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
458
492
  if (live?.instructions) {
459
493
  configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
460
494
  }
461
495
  const profile = getRuntimeProfile(runtime);
496
+ const activeThreadId = live?.threadId ?? record?.codexSessionId;
462
497
  return {
463
498
  command: resolveRuntimeBinary(runtime),
464
499
  args: buildCodexRemoteArgs({
465
500
  remoteUrl,
466
- ...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
501
+ ...(activeThreadId ? { threadId: activeThreadId } : {}),
467
502
  configOverrides,
503
+ additionalDirs: providerAdditionalDirs(live.workspace),
468
504
  }),
469
- cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
505
+ cwd: live.workspace.cwd,
470
506
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
471
507
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
472
508
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
@@ -486,25 +522,33 @@ export class LocalAgentHost {
486
522
  * DETERMINISTIC (`resp_codex_<turnId>`) so items converge across snapshot/live.
487
523
  *
488
524
  * Returns false for claude / no app-server (the caller falls back to the
489
- * streaming run path). This client CREATES the codex thread (`threadStart`) so
490
- * it owns the thread's item/turn notification stream the app-server delivers
491
- * items to the thread-creating connection, for turns started by ANY client (web
492
- * inject AND the co-driving `codex --remote` TUI). The TUI pane (launched by the
493
- * runner child, which owns the terminal registry) merely `resume`s this thread
494
- * to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
525
+ * streaming run path). For a fresh session the native remote TUI creates the
526
+ * Codex thread and its `thread/started` notification supplies the id. For an
527
+ * existing session, the app-server resumes the persisted id. Rynx never writes
528
+ * or repairs Codex's private rollout files.
495
529
  */
496
530
  async ensureLiveCodexSession(localThreadId, emit, opts) {
497
- const live = this.liveSessions.get(localThreadId);
498
- if (live)
499
- return this.refreshLiveCodexSession(localThreadId, live, opts);
500
- if (this.liveClaudeSessions.has(localThreadId))
501
- return true;
531
+ const snapshotOpts = {
532
+ workspace: structuredClone(opts.workspace),
533
+ execution: structuredClone(opts.execution),
534
+ ...(opts.retargetMirror ? { retargetMirror: opts.retargetMirror } : {}),
535
+ };
536
+ const existingClaude = this.liveClaudeSessions.get(localThreadId);
537
+ if (existingClaude) {
538
+ return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
539
+ }
502
540
  // Dedupe concurrent triggers so only one forwarder is ever created per
503
- // session (two would double-mirror every turn).
541
+ // session (two would double-mirror every turn). Check this before the live
542
+ // map: startLiveCodexSession publishes its partially initialized session
543
+ // before thread/start completes, so a concurrent Terminal start must await
544
+ // the thread id instead of launching a bare `codex --remote` pane.
504
545
  const inflight = this.liveEnsuring.get(localThreadId);
505
546
  if (inflight)
506
547
  return inflight;
507
- const started = this.startLiveCodexSession(localThreadId, emit, opts);
548
+ const live = this.liveSessions.get(localThreadId);
549
+ if (live)
550
+ return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
551
+ const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
508
552
  this.liveEnsuring.set(localThreadId, started);
509
553
  try {
510
554
  return await started;
@@ -522,48 +566,51 @@ export class LocalAgentHost {
522
566
  this.stopLiveCodexSession(localThreadId);
523
567
  }
524
568
  const record = await this.sessionStore.get(localThreadId);
525
- // Runtime precedence: the thread's bound runtime, else the agent-resolved
526
- // hint from the control plane, else the env default. This is what makes live
527
- // co-drive follow the agent spec instead of the global default.
528
- const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
569
+ const retargetMirror = opts.retargetMirror;
570
+ const workspace = structuredClone(opts.workspace);
571
+ const execution = structuredClone(opts.execution);
572
+ const runtime = execution.provider;
529
573
  if (runtime === "claude")
530
574
  return this.startLiveClaudeSession(localThreadId, emit, record, opts);
531
- const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
532
- const configSignature = liveConfigSignature(opts, runtime, cwd);
533
- // Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
534
- // logged with the agent context and fail the live start loudly — never a
535
- // bare mystery `false`.
536
- let liveCfg;
575
+ let snapshotSkills = {
576
+ selectedSkills: [],
577
+ skillsCleanup: async () => undefined,
578
+ };
579
+ if (this.runtimeHomeSessionId === this.sessionId) {
580
+ try {
581
+ snapshotSkills = await this.prepareExecutionSkills(execution);
582
+ }
583
+ catch (err) {
584
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ return false;
586
+ }
587
+ }
588
+ // Persist the owner's skills before the app-server boots. Fork targets reuse
589
+ // the owner's immutable copies and never mutate the shared Provider home.
590
+ const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
537
591
  try {
538
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, runtime, cwd);
592
+ populateCodexSkills(this.runtimeHome(runtime), liveSkills);
539
593
  }
540
594
  catch (err) {
541
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
595
+ void snapshotSkills.skillsCleanup();
596
+ console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
542
597
  return false;
543
598
  }
544
- // Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
545
- // app-server boots (and before the `--remote` TUI attaches) so the native Codex
546
- // discovers them reference implementation's filesystem mechanism. The live thread is created by
547
- // the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
548
- // the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
549
- // The agent spec is the single skill source: its refs were replayed into a
550
- // session-scoped dir (see resolveLiveAgentConfig); no spec / no `skills` means
551
- // zero skills — the owner's catalog is never consulted.
552
- const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
553
- populateCodexSkills(this.runtimeHome(runtime), liveSkills);
554
- // Apply this agent's sandbox / approval policy to the session's app-server
555
- // BEFORE it boots (getBackend below lazily creates it). Host is per-session,
556
- // so these knobs are the agent's own — no cross-session bleed. Set even when
557
- // absent (undefined) so a re-used host doesn't inherit a prior agent's values.
558
- this.sessionSandbox = liveCfg.sandbox;
559
- this.sessionApprovalPolicy = liveCfg.approvalPolicy;
599
+ const sandbox = execution.sandbox;
600
+ const approvalPolicy = execution.approvalPolicy;
601
+ if (!sandbox || !approvalPolicy) {
602
+ void snapshotSkills.skillsCleanup();
603
+ throw new CodexRuntimeError(`${runtime} execution snapshot is missing sandbox or approval policy`, 422, "invalid_execution_snapshot");
604
+ }
605
+ this.sessionSandbox = sandbox;
606
+ this.sessionApprovalPolicy = approvalPolicy;
560
607
  // Backend client owns the app-server + drives injection. Every failed-start
561
608
  // path below removes the session skills dir it would otherwise leak.
562
609
  const abandon = () => {
563
- void liveCfg.skillsCleanup();
610
+ void snapshotSkills.skillsCleanup();
564
611
  return false;
565
612
  };
566
- const injectClient = this.getBackend(runtime).appServerClient;
613
+ const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
567
614
  if (!injectClient) {
568
615
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
569
616
  return abandon();
@@ -591,36 +638,40 @@ export class LocalAgentHost {
591
638
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
592
639
  return abandon();
593
640
  }
594
- // Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
595
- // A resumed thread keeps its stored model; otherwise use the agent-spec model (or
596
- // the runtime default). The model rides a launch `-c model=` on the TUI (see
597
- // `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
598
- const model = resolveRuntimeModel(this.config, runtime, record?.model ?? liveCfg.model);
599
- const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
600
- const reasoningEffort = hasCurrentEffortSource
601
- ? liveCfg.reasoningEffort
602
- : record?.reasoningEffort ?? liveCfg.reasoningEffort;
641
+ const model = execution.model;
642
+ const reasoningEffort = execution.reasoningEffort ?? undefined;
603
643
  let markReady;
604
644
  const ready = new Promise((resolve) => {
605
645
  markReady = resolve;
606
646
  });
647
+ let markTerminalReady;
648
+ const terminalReady = new Promise((resolve) => {
649
+ markTerminalReady = resolve;
650
+ });
607
651
  const live = {
608
652
  injectClient,
609
653
  forwarderClient,
610
654
  forwarder: null,
611
655
  runtime,
612
- cwd,
656
+ workspace,
657
+ execution,
658
+ sandbox,
659
+ approvalPolicy,
613
660
  model,
614
661
  reasoningEffort,
615
- ...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
662
+ ...(execution.instructions ? { instructions: execution.instructions } : {}),
616
663
  threadId: record?.codexSessionId ?? null,
617
664
  ready,
618
665
  markReady,
666
+ terminalReady,
667
+ markTerminalReady,
619
668
  injectLock: Promise.resolve(),
669
+ pendingInjectedInputs: [],
670
+ publishInjectedInput: () => undefined,
620
671
  subscribing: false,
672
+ rotationPending: false,
621
673
  stopped: false,
622
- skillsCleanup: liveCfg.skillsCleanup,
623
- configSignature,
674
+ skillsCleanup: snapshotSkills.skillsCleanup,
624
675
  interactionOwners: new Map(),
625
676
  interactionStandbys: new Map(),
626
677
  interactionSubmissions: new Map(),
@@ -630,15 +681,25 @@ export class LocalAgentHost {
630
681
  interactionFailedClients: new Map(),
631
682
  disconnectedClients: new Set(),
632
683
  };
684
+ let currentSessionId = localThreadId;
685
+ let pendingRotationEvents = null;
686
+ const emitCurrent = (event) => {
687
+ if (pendingRotationEvents) {
688
+ pendingRotationEvents.push(event);
689
+ return;
690
+ }
691
+ emit(event);
692
+ };
633
693
  let normalizer = null;
634
694
  let currentResponseId = null;
635
695
  const startNormalizer = (turnId) => {
636
- const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
696
+ const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
697
+ (turnId ? `resp_codex_${turnId}` : "resp_codex_native");
637
698
  if (normalizer && currentResponseId === responseId)
638
699
  return normalizer;
639
700
  currentResponseId = responseId;
640
701
  normalizer = new SessionNormalizer({
641
- sessionId: localThreadId,
702
+ sessionId: currentSessionId,
642
703
  // turnId unknown → fixed literal (NEVER random): items arriving before
643
704
  // `turn/started` land on ONE response instead of splitting into random
644
705
  // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
@@ -650,6 +711,19 @@ export class LocalAgentHost {
650
711
  });
651
712
  return normalizer;
652
713
  };
714
+ live.publishInjectedInput = (turnId, content) => {
715
+ const n = startNormalizer(turnId);
716
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === JSON.stringify(content) && entry.responseId === undefined);
717
+ if (pending && currentResponseId)
718
+ pending.responseId = currentResponseId;
719
+ for (const se of n.userInput(content))
720
+ emitCurrent(se);
721
+ };
722
+ const clearPendingInputsForResponse = (responseId) => {
723
+ if (!responseId)
724
+ return;
725
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
726
+ };
653
727
  const rememberSettledInteraction = (interactionId) => {
654
728
  live.settledInteractions.add(interactionId);
655
729
  if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
@@ -692,7 +766,7 @@ export class LocalAgentHost {
692
766
  ...(event.reason ? { reason: event.reason } : {}),
693
767
  };
694
768
  for (const se of n.next(agentEvent))
695
- emit(se);
769
+ emitCurrent(se);
696
770
  };
697
771
  const clearRecovery = (interactionId) => {
698
772
  live.interactionRecoveries.delete(interactionId);
@@ -848,24 +922,38 @@ export class LocalAgentHost {
848
922
  const sink = {
849
923
  onTurnStart: (turnId) => startNormalizer(turnId),
850
924
  onUserMessage: (content) => {
925
+ const normalizedContent = typeof content === "string"
926
+ ? [{ type: "input_text", text: content }]
927
+ : content;
928
+ const signature = JSON.stringify(normalizedContent);
929
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
930
+ if (pending?.state === "optimistic" || pending?.state === "prepublished") {
931
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
932
+ return;
933
+ }
934
+ if (pending)
935
+ pending.observed = true;
851
936
  const n = normalizer ?? startNormalizer();
852
- for (const se of n.userInput(content))
853
- emit(se);
937
+ for (const se of n.userInput(normalizedContent))
938
+ emitCurrent(se);
854
939
  },
855
940
  onEvent: (event) => {
856
941
  const n = normalizer ?? startNormalizer();
857
942
  for (const se of n.next(event))
858
- emit(se);
943
+ emitCurrent(se);
859
944
  },
860
945
  onTurnEnd: (usage) => {
861
946
  closeCanonicalInteractions();
862
947
  if (!normalizer)
863
948
  return;
864
- if (usage)
949
+ const completedResponseId = currentResponseId;
950
+ if (usage) {
865
951
  for (const se of normalizer.next({ type: "turn_completed", usage }))
866
- emit(se);
952
+ emitCurrent(se);
953
+ }
867
954
  for (const se of normalizer.next({ type: "done" }))
868
- emit(se);
955
+ emitCurrent(se);
956
+ clearPendingInputsForResponse(completedResponseId);
869
957
  normalizer = null;
870
958
  currentResponseId = null;
871
959
  },
@@ -873,6 +961,7 @@ export class LocalAgentHost {
873
961
  closeCanonicalInteractions();
874
962
  if (!normalizer)
875
963
  return;
964
+ const failedResponseId = currentResponseId;
876
965
  const responseStopped = error.message === "Codex turn was interrupted";
877
966
  const providerName = runtime === "traex" ? "Traex" : "Codex";
878
967
  const message = responseStopped
@@ -887,14 +976,14 @@ export class LocalAgentHost {
887
976
  message,
888
977
  source: "execution",
889
978
  }))
890
- emit(se);
979
+ emitCurrent(se);
980
+ clearPendingInputsForResponse(failedResponseId);
891
981
  normalizer = null;
892
982
  currentResponseId = null;
893
983
  },
894
- // The TUI creates the thread; the forwarder connection sees its broadcast
895
- // `thread/started`. Persist the id, unblock injection, and start the
896
- // resume-subscribe loop (which parks until the thread's first turn).
897
- onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
984
+ // A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
985
+ shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
986
+ onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
898
987
  onThreadActive: () => live.releaseActive?.(),
899
988
  };
900
989
  const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
@@ -908,99 +997,169 @@ export class LocalAgentHost {
908
997
  surfaceQueueStatus: runtime === "traex",
909
998
  });
910
999
  live.forwarder = forwarder;
1000
+ live.onNativeThreadRotated = (threadId, kind) => {
1001
+ if (live.rotationPending) {
1002
+ live.stopped = true;
1003
+ return;
1004
+ }
1005
+ const previousSessionId = currentSessionId;
1006
+ const newSessionId = makeSessionId();
1007
+ live.rotationPending = true;
1008
+ currentSessionId = newSessionId;
1009
+ normalizer = null;
1010
+ currentResponseId = null;
1011
+ live.pendingInjectedInputs = [];
1012
+ pendingRotationEvents = [];
1013
+ void this.sessionStore
1014
+ .get(previousSessionId)
1015
+ .then((previous) => this.sessionStore.set({
1016
+ localThreadId: newSessionId,
1017
+ codexSessionId: threadId,
1018
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1019
+ ...(previous?.runtimeHomeOwnerSessionId
1020
+ ? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
1021
+ : {}),
1022
+ updatedAt: new Date().toISOString(),
1023
+ }))
1024
+ .then(() => {
1025
+ emit({
1026
+ type: "session.rotated",
1027
+ sessionId: previousSessionId,
1028
+ newSessionId,
1029
+ kind,
1030
+ });
1031
+ this.liveSessions.set(newSessionId, live);
1032
+ retargetMirror?.(newSessionId, {
1033
+ kind,
1034
+ workspace: structuredClone(live.workspace),
1035
+ execution: structuredClone(live.execution),
1036
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1037
+ });
1038
+ const queued = pendingRotationEvents ?? [];
1039
+ pendingRotationEvents = null;
1040
+ live.rotationPending = false;
1041
+ for (const event of queued)
1042
+ emit(event);
1043
+ void this.subscribeUntilReady(live, threadId);
1044
+ })
1045
+ .catch(() => {
1046
+ pendingRotationEvents = null;
1047
+ live.rotationPending = false;
1048
+ live.stopped = true;
1049
+ });
1050
+ };
911
1051
  bindInteractionClient(injectClient);
912
1052
  bindInteractionClient(forwarderClient);
913
1053
  this.liveSessions.set(localThreadId, live);
914
1054
  forwarder.start();
915
- // A resumed session already has a thread id bind immediately; a fresh
916
- // session waits for the TUI's broadcast `thread/started` (via onThreadStarted).
917
- if (record?.codexSessionId) {
918
- this.onLiveThreadStarted(live, localThreadId, record.agent, record.codexSessionId);
919
- }
920
- return true;
921
- }
922
- async refreshLiveCodexSession(localThreadId, live, opts) {
923
- const desiredRuntime = opts?.runtime ?? live.runtime;
924
- if (desiredRuntime !== live.runtime)
925
- return false;
926
- const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
927
- if (live.configSignature === configSignature)
928
- return true;
929
- let liveCfg;
930
- try {
931
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
932
- }
933
- catch (err) {
934
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
935
- return false;
936
- }
937
- const previousCleanup = live.skillsCleanup;
1055
+ // Existing bindings resume through the public app-server API. A fresh thread
1056
+ // is created by the native remote TUI instead: its `thread/started`
1057
+ // notification binds this live handle in `onLiveThreadStarted`. Do not
1058
+ // synthesize Codex's private rollout JSONL merely to make a just-created,
1059
+ // turn-less app-server thread resumable by the TUI.
938
1060
  try {
939
- const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
940
- populateCodexSkills(this.runtimeHome(live.runtime), liveSkills);
941
- void previousCleanup?.().catch(() => undefined);
942
- this.sessionSandbox = liveCfg.sandbox;
943
- this.sessionApprovalPolicy = liveCfg.approvalPolicy;
944
- live.model = resolveRuntimeModel(this.config, live.runtime, liveCfg.model);
945
- live.reasoningEffort = liveCfg.reasoningEffort;
946
- live.instructions = liveCfg.instructions;
947
- live.skillsCleanup = liveCfg.skillsCleanup;
948
- live.configSignature = configSignature;
949
- if (live.threadId) {
950
- // Keep already-running TUI turns aligned too; turn/start below carries
951
- // the same overrides for web injection. Older app-servers may not have
952
- // thread/settings/update, so refresh remains best-effort.
953
- await live.injectClient
954
- .threadSettingsUpdate({
955
- threadId: live.threadId,
956
- ...(live.model ? { model: live.model } : {}),
957
- effort: live.reasoningEffort ?? null,
958
- })
959
- .catch(() => undefined);
960
- void this.sessionStore
961
- .set({
962
- localThreadId,
963
- codexSessionId: live.threadId,
964
- cwd: live.cwd,
965
- model: live.model,
966
- reasoningEffort: live.reasoningEffort,
967
- runtime: live.runtime,
968
- agent: opts?.agentName,
969
- updatedAt: new Date().toISOString(),
970
- })
971
- .catch(() => undefined);
1061
+ if (record?.codexSessionId) {
1062
+ let resumeError;
1063
+ let resumedThreadId;
1064
+ const attempts = 20;
1065
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1066
+ try {
1067
+ const resumed = await injectClient.threadResume({
1068
+ threadId: record.codexSessionId,
1069
+ ...threadWorkspaceParams(runtime, workspace, sandbox),
1070
+ approvalPolicy,
1071
+ excludeTurns: true,
1072
+ });
1073
+ resumedThreadId = resumed.threadId;
1074
+ break;
1075
+ }
1076
+ catch (error) {
1077
+ resumeError = error;
1078
+ if (!isRetryableThreadResumeError(error))
1079
+ throw error;
1080
+ if (attempt + 1 < attempts) {
1081
+ await new Promise((resolve) => setTimeout(resolve, 150));
1082
+ }
1083
+ }
1084
+ }
1085
+ if (resumedThreadId) {
1086
+ this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1087
+ }
1088
+ else {
1089
+ // Never replace an existing binding. A delayed native index is
1090
+ // retryable; a genuinely missing thread remains an explicit error.
1091
+ throw resumeError;
1092
+ }
1093
+ }
1094
+ else {
1095
+ // No thread exists yet, so the TUI must launch without `resume`. Once it
1096
+ // broadcasts `thread/started`, the normal ready + subscription path
1097
+ // takes over and web injection can begin.
1098
+ live.markTerminalReady(true);
972
1099
  }
973
- return true;
974
1100
  }
975
1101
  catch (err) {
976
- void liveCfg.skillsCleanup().catch(() => undefined);
977
- console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} skill refresh failed: ${err instanceof Error ? err.message : String(err)}`);
978
- return false;
1102
+ this.liveSessions.delete(localThreadId);
1103
+ live.stopped = true;
1104
+ forwarder.stop();
1105
+ void forwarderClient.stop().catch(() => undefined);
1106
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1107
+ return abandon();
979
1108
  }
1109
+ return true;
980
1110
  }
981
1111
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
982
1112
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
983
- onLiveThreadStarted(live, localThreadId, agent, threadId) {
1113
+ onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
1114
+ const previousThreadId = live.threadId;
1115
+ if (previousThreadId && previousThreadId !== threadId) {
1116
+ live.threadId = threadId;
1117
+ live.subscribing = true;
1118
+ live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
1119
+ return;
1120
+ }
984
1121
  if (live.subscribing)
985
1122
  return;
986
1123
  live.subscribing = true;
987
1124
  live.threadId = threadId;
988
1125
  void this.sessionStore
989
- .set({
990
- localThreadId,
991
- codexSessionId: threadId,
992
- cwd: live.cwd,
993
- // Persist the agent-spec-resolved model (was the config default), so a
994
- // resume/reconnect keeps running the agent's model, not the global one.
995
- model: live.model,
996
- reasoningEffort: live.reasoningEffort,
997
- runtime: live.runtime,
998
- agent,
999
- updatedAt: new Date().toISOString(),
1126
+ .get(localThreadId)
1127
+ .then((existing) => {
1128
+ if (existing?.codexSessionId === threadId)
1129
+ return;
1130
+ return this.sessionStore.set({
1131
+ localThreadId,
1132
+ codexSessionId: threadId,
1133
+ ...(existing?.parentSessionId
1134
+ ? { parentSessionId: existing.parentSessionId }
1135
+ : {}),
1136
+ ...(existing?.runtimeHomeOwnerSessionId
1137
+ ? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
1138
+ : {}),
1139
+ updatedAt: new Date().toISOString(),
1140
+ });
1000
1141
  })
1001
1142
  .catch(() => undefined);
1002
1143
  live.markReady(); // thread id known → injection can turn/start
1003
- void this.subscribeUntilReady(live, threadId);
1144
+ void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
1145
+ }
1146
+ shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1147
+ const pending = live.managedFork;
1148
+ if (pending && forkedFromId === pending.sourceThreadId) {
1149
+ pending.observedThreadIds.add(threadId);
1150
+ return true;
1151
+ }
1152
+ const expectedSource = this.managedForkThreadStarts.get(threadId);
1153
+ return expectedSource !== undefined && expectedSource === forkedFromId;
1154
+ }
1155
+ rememberManagedForkThreadStart(threadId, sourceThreadId) {
1156
+ this.managedForkThreadStarts.set(threadId, sourceThreadId);
1157
+ const timer = setTimeout(() => {
1158
+ if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
1159
+ this.managedForkThreadStarts.delete(threadId);
1160
+ }
1161
+ }, 5_000);
1162
+ timer.unref?.();
1004
1163
  }
1005
1164
  /**
1006
1165
  * Subscribe the forwarder connection to a thread (reference implementation's
@@ -1016,17 +1175,18 @@ export class LocalAgentHost {
1016
1175
  try {
1017
1176
  const resp = await live.forwarderClient.threadResume({
1018
1177
  threadId,
1019
- cwd: live.cwd,
1178
+ ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1179
+ approvalPolicy: live.approvalPolicy,
1020
1180
  excludeTurns: !sawNotReady,
1021
1181
  });
1022
1182
  if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1023
1183
  live.forwarder.replayBackfill(resp.thread.turns);
1024
1184
  }
1025
- return; // subscribed — live item/turn notifications now flow to the forwarder
1185
+ return true; // subscribed — live item/turn notifications now flow to the forwarder
1026
1186
  }
1027
1187
  catch (error) {
1028
1188
  if (!isThreadNotReadyError(error)) {
1029
- return; // other failure — injection still works via the backend client
1189
+ return false; // other failure — injection still works via the backend client
1030
1190
  }
1031
1191
  sawNotReady = true;
1032
1192
  // Park until the thread goes active (its first turn materializes the
@@ -1038,13 +1198,14 @@ export class LocalAgentHost {
1038
1198
  live.releaseActive = undefined;
1039
1199
  }
1040
1200
  }
1201
+ return false;
1041
1202
  }
1042
1203
  /** Await a live session's thread binding (bounded). Returns false on timeout /
1043
1204
  * no live session. Injection and the runner's `live.ready` gate on this. */
1044
1205
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1045
1206
  const claude = this.liveClaudeSessions.get(localThreadId);
1046
1207
  if (claude)
1047
- return raceReady(claude.ready, timeoutMs);
1208
+ return raceReady(claude.ready, timeoutMs, claude.failed);
1048
1209
  const live = this.liveSessions.get(localThreadId);
1049
1210
  if (!live)
1050
1211
  return false;
@@ -1057,11 +1218,36 @@ export class LocalAgentHost {
1057
1218
  clearTimeout(timer);
1058
1219
  return ok;
1059
1220
  }
1221
+ /** Await the stronger Terminal gate: another app-server connection has
1222
+ * successfully resumed the thread, so the detached TUI cannot race rollout
1223
+ * discovery or indexing. */
1224
+ async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
1225
+ const claude = this.liveClaudeSessions.get(localThreadId);
1226
+ // Claude has no separate app-server indexing gate. Its TUI must launch first;
1227
+ // SessionStart then reveals the native id + actual transcript path.
1228
+ if (claude)
1229
+ return true;
1230
+ const live = this.liveSessions.get(localThreadId);
1231
+ if (!live)
1232
+ return false;
1233
+ let timer;
1234
+ const timeout = new Promise((resolve) => {
1235
+ timer = setTimeout(() => resolve(false), timeoutMs);
1236
+ });
1237
+ const ready = await Promise.race([live.terminalReady, timeout]);
1238
+ if (timer)
1239
+ clearTimeout(timer);
1240
+ return ready;
1241
+ }
1242
+ /** Diagnostic from the provider adapter when native discovery/resume failed. */
1243
+ liveSessionError(localThreadId) {
1244
+ return this.liveClaudeSessions.get(localThreadId)?.error;
1245
+ }
1060
1246
  /**
1061
1247
  * Inject a user turn into a session's live codex thread — reference implementation's
1062
1248
  * single-writer web send. `turn/steer` when a turn is open (mid-turn
1063
- * supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
1064
- * this targets the id the forwarder captured). Serialized per session so two
1249
+ * supplement), else `turn/start`. The thread was created by the native TUI or
1250
+ * resumed from the persisted native id. Serialized per session so two
1065
1251
  * injects can't double-open a turn.
1066
1252
  *
1067
1253
  * Returns an {@link InjectOutcome}: `notLive` when this session has no live
@@ -1077,15 +1263,34 @@ export class LocalAgentHost {
1077
1263
  const claude = this.liveClaudeSessions.get(localThreadId);
1078
1264
  if (claude) {
1079
1265
  const text = claudeInputText(runtimeInput);
1266
+ const content = runtimeUserContent(runtimeInput);
1267
+ const pendingInput = {
1268
+ content,
1269
+ signature: JSON.stringify(content),
1270
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1271
+ observed: false,
1272
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1273
+ };
1274
+ claude.pendingInjectedInputs.push(pendingInput);
1275
+ const forgetPendingInput = () => {
1276
+ const index = claude.pendingInjectedInputs.indexOf(pendingInput);
1277
+ if (index >= 0)
1278
+ claude.pendingInjectedInputs.splice(index, 1);
1279
+ };
1080
1280
  const token = claudeAttachmentToken(text);
1081
1281
  if (token) {
1082
- claude.pendingImageInputs.set(token, runtimeUserContent(runtimeInput));
1282
+ claude.pendingImageInputs.set(token, content);
1083
1283
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1084
1284
  expiry.unref?.();
1085
1285
  }
1086
1286
  const outcome = await this.injectClaude(claude, localThreadId, text);
1087
- if (token && (outcome === "notLive" || outcome === "notReady")) {
1088
- claude.pendingImageInputs.delete(token);
1287
+ if (outcome !== "injected") {
1288
+ forgetPendingInput();
1289
+ if (token)
1290
+ claude.pendingImageInputs.delete(token);
1291
+ }
1292
+ else if (pendingInput.observed) {
1293
+ forgetPendingInput();
1089
1294
  }
1090
1295
  return outcome;
1091
1296
  }
@@ -1093,6 +1298,8 @@ export class LocalAgentHost {
1093
1298
  if (!live)
1094
1299
  return "notLive";
1095
1300
  const run = live.injectLock.then(async () => {
1301
+ if (live.rotationPending || live.stopped)
1302
+ return "failed";
1096
1303
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1097
1304
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1098
1305
  const bound = await this.waitLiveReady(localThreadId, 60_000);
@@ -1100,34 +1307,74 @@ export class LocalAgentHost {
1100
1307
  if (!bound || !threadId)
1101
1308
  return "notReady";
1102
1309
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1310
+ const content = runtimeUserContent(runtimeInput);
1311
+ const pendingInput = {
1312
+ content,
1313
+ signature: JSON.stringify(content),
1314
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1315
+ observed: false,
1316
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1317
+ };
1318
+ live.pendingInjectedInputs.push(pendingInput);
1319
+ const forgetPendingInput = () => {
1320
+ const index = live.pendingInjectedInputs.indexOf(pendingInput);
1321
+ if (index >= 0)
1322
+ live.pendingInjectedInputs.splice(index, 1);
1323
+ };
1103
1324
  try {
1104
1325
  // Inject via the BACKEND client (the forwarder connection only observes).
1105
1326
  if (live.forwarder.isTurnOpen()) {
1106
1327
  const turnId = live.forwarder.currentTurnId();
1107
1328
  if (turnId) {
1108
- await live.injectClient.turnSteer({
1329
+ const steered = await live.injectClient.turnSteer({
1109
1330
  threadId,
1110
1331
  expectedTurnId: turnId,
1111
1332
  input: nativeInput,
1112
1333
  });
1334
+ if (pendingInput.state === "prepublished") {
1335
+ // The caller already persisted and published this user input
1336
+ // before waiting for the native Terminal to become ready.
1337
+ }
1338
+ else if (pendingInput.observed) {
1339
+ forgetPendingInput();
1340
+ }
1341
+ else {
1342
+ live.publishInjectedInput(steered.turnId, pendingInput.content);
1343
+ pendingInput.state = "optimistic";
1344
+ }
1113
1345
  return "injected";
1114
1346
  }
1115
1347
  }
1116
1348
  // Carry the agent-spec model on the turn so a web-injected turn runs the
1117
1349
  // agent's model even if the TUI's config default differs.
1118
- await live.injectClient.turnStart({
1350
+ const started = await live.injectClient.turnStart({
1119
1351
  threadId,
1120
1352
  input: nativeInput,
1121
- cwd: live.cwd,
1353
+ ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1354
+ approvalPolicy: live.approvalPolicy,
1122
1355
  ...(live.model ? { model: live.model } : {}),
1123
1356
  ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1124
1357
  });
1358
+ if (pendingInput.state === "prepublished") {
1359
+ // The caller already persisted and published this user input before
1360
+ // waiting for the native Terminal to become ready.
1361
+ }
1362
+ else if (pendingInput.observed) {
1363
+ forgetPendingInput();
1364
+ }
1365
+ else {
1366
+ live.publishInjectedInput(started.turnId, pendingInput.content);
1367
+ pendingInput.state = "optimistic";
1368
+ }
1125
1369
  return "injected";
1126
1370
  }
1127
- catch {
1128
- // RPC failed → hard error; caller reports it and does NOT re-run
1129
- // (reference implementation: inject failure response.failed, never local re-run).
1130
- return "failed";
1371
+ catch (error) {
1372
+ forgetPendingInput();
1373
+ // Preserve the app-server's error instead of collapsing every failure
1374
+ // into the unactionable `live injection failed` string.
1375
+ const detail = codexInjectionError(error);
1376
+ console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1377
+ throw new Error(detail, { cause: error });
1131
1378
  }
1132
1379
  });
1133
1380
  live.injectLock = run.then(() => undefined, () => undefined);
@@ -1236,6 +1483,15 @@ export class LocalAgentHost {
1236
1483
  for (const forwarder of this.pendingClaudeFinalizers)
1237
1484
  forwarder.finalizeStop();
1238
1485
  this.pendingClaudeFinalizers.clear();
1486
+ // A runner owns its app-server processes. Stopping only the forwarder leaves
1487
+ // those children re-parented after the runner exits, along with remote TUIs
1488
+ // that can only reconnect to a dead session. `stop()` sends SIGTERM before
1489
+ // its returned promise yields, so this remains safe in the synchronous
1490
+ // runner shutdown path.
1491
+ for (const backend of this.backends.values()) {
1492
+ void backend.appServerClient?.stop().catch(() => undefined);
1493
+ }
1494
+ this.backends.clear();
1239
1495
  }
1240
1496
  // --- claude-native live session (parallel to the codex methods above) -----
1241
1497
  /** The interactive `claude` TUI spec for a claude-native live session: the real
@@ -1245,7 +1501,10 @@ export class LocalAgentHost {
1245
1501
  * generic interaction bridge and remain inside the active Turn. */
1246
1502
  claudeTerminalSpec(localThreadId, record) {
1247
1503
  const live = this.liveClaudeSessions.get(localThreadId);
1248
- const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
1504
+ if (!live) {
1505
+ throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
1506
+ }
1507
+ const cwd = live.workspace.cwd;
1249
1508
  try {
1250
1509
  ensureProjectTrusted(cwd);
1251
1510
  }
@@ -1253,24 +1512,17 @@ export class LocalAgentHost {
1253
1512
  // A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
1254
1513
  }
1255
1514
  const inheritedSettings = buildManagedClaudeSettings(cwd, {});
1256
- const inheritedPermissions = inheritedSettings.permissions;
1257
- const inheritedPermissionMode = inheritedPermissions &&
1258
- typeof inheritedPermissions === "object" &&
1259
- !Array.isArray(inheritedPermissions) &&
1260
- typeof inheritedPermissions.defaultMode === "string"
1261
- ? inheritedPermissions.defaultMode
1262
- : undefined;
1263
- const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
1515
+ const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
1516
+ const hookPermissionMode = live.permissionMode;
1264
1517
  const hookSettings = buildClaudeHookSettings({
1265
1518
  bridgeDir: claudeBridgeDir(localThreadId),
1266
1519
  ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
1267
1520
  messageDisplay: true,
1268
1521
  statusLine: true,
1269
1522
  });
1270
- const settings = { ...inheritedSettings, ...hookSettings };
1523
+ const settings = { ...effectiveSettings, ...hookSettings };
1271
1524
  const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
1272
- // The live session (created by startLiveClaudeSession before the pane launches)
1273
- // carries the agent-spec launch config: model, instructions, and skills.
1525
+ // The live session carries the immutable execution snapshot used by Chat.
1274
1526
  const args = buildClaudeTuiArgs({
1275
1527
  settingsJson: settingsPath,
1276
1528
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1278,10 +1530,21 @@ export class LocalAgentHost {
1278
1530
  // resolve the same native request while rynx still exposes it as pending.
1279
1531
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1280
1532
  settingSources: "",
1281
- model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
1282
- ...(live?.launchInstructions ? { appendSystemPrompt: live.launchInstructions } : {}),
1283
- ...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
1284
- ...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
1533
+ model: live.execution.model,
1534
+ ...(live.execution.instructions
1535
+ ? { appendSystemPrompt: live.execution.instructions }
1536
+ : {}),
1537
+ additionalDirs: providerAdditionalDirs(live.workspace),
1538
+ ...(live.forkIntent
1539
+ ? {
1540
+ resume: live.forkIntent.sourceClaudeSessionId,
1541
+ forkSession: true,
1542
+ sessionId: live.forkIntent.targetClaudeSessionId,
1543
+ }
1544
+ : record?.codexSessionId
1545
+ ? { resume: record.codexSessionId }
1546
+ : {}),
1547
+ ...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
1285
1548
  });
1286
1549
  return {
1287
1550
  command: resolveRuntimeBinary("claude"),
@@ -1292,44 +1555,15 @@ export class LocalAgentHost {
1292
1555
  env: { ...process.env, RYNX_SESSION_ID: localThreadId },
1293
1556
  };
1294
1557
  }
1295
- /** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
1296
- * hooks write into it), then tail bridge + transcript and drive a per-turn
1297
- * {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
1298
- /** Resolve an agent spec's launch config — model, instructions, and the session skill env —
1299
- * for a LIVE native session, reusing the same core resolvers the non-live
1300
- * run path uses ({@link resolveAgentExecution} for the model,
1301
- * {@link resolveAgent} for instructions and skills).
1302
- *
1303
- * Skills are spec-rooted: each declared ref is replayed into a
1304
- * SESSION-scoped temp dir (cache-accelerated); the owner's catalog plays no
1305
- * role, and no spec / no `skills` means ZERO skills. Every declared skill
1306
- * must resolve at its declared content hash; missing, failed, or drifted
1307
- * materialization aborts launch before a native session starts.
1308
- * `skillsCleanup` removes the session dir (call on session stop). */
1309
- async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
1310
- // Inline spec needs an id only so a relative instructions-file path can
1311
- // resolve (mirrors runLocked); a preset agent loads by name.
1312
- const spec = opts?.agentSpec
1313
- ? { ...opts.agentSpec, id: opts.agentName ?? "inline" }
1314
- : undefined;
1315
- const exec = await resolveAgentExecution({
1316
- config: this.config,
1317
- ...(opts?.agentName ? { agentName: opts.agentName } : {}),
1318
- ...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
1319
- runtime,
1320
- ...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
1321
- });
1322
- const resolved = await resolveAgent({
1323
- config: this.config,
1324
- cwd,
1325
- ...(opts?.agentName ? { agentName: opts.agentName } : {}),
1326
- ...(spec ? { spec } : {}),
1327
- });
1558
+ /** Materialize only the immutable skill declarations stored on the Session.
1559
+ * Provider startup never re-opens an Agent template or process-level plugin
1560
+ * snapshot. */
1561
+ async prepareExecutionSkills(execution) {
1328
1562
  const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
1329
1563
  let skillEnv;
1330
1564
  try {
1331
- skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1332
- const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
1565
+ skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
1566
+ const declaredNames = execution.skills.map((skill) => skill.name);
1333
1567
  const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
1334
1568
  const { report } = skillEnv;
1335
1569
  if (report.errors.length > 0 ||
@@ -1351,52 +1585,89 @@ export class LocalAgentHost {
1351
1585
  await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1352
1586
  throw error;
1353
1587
  }
1354
- const agent = opts?.agentName ?? resolved.name ?? "-";
1355
1588
  const { report } = skillEnv;
1356
1589
  if (report.installed.length > 0) {
1357
- console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
1590
+ console.error(`[skills] installed=[${report.installed.join(", ")}]`);
1591
+ }
1592
+ try {
1593
+ const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
1594
+ const packageRoot = await realpath(source.packageRoot);
1595
+ const skillRoot = await realpath(source.root);
1596
+ const relativeSkillRoot = path.relative(packageRoot, skillRoot);
1597
+ if (relativeSkillRoot === ".." ||
1598
+ relativeSkillRoot.startsWith(`..${path.sep}`) ||
1599
+ path.isAbsolute(relativeSkillRoot)) {
1600
+ throw new Error(`plugin skill root escaped package: ${source.installationId}`);
1601
+ }
1602
+ const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
1603
+ await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
1604
+ await cp(packageRoot, snapshotPackageRoot, {
1605
+ recursive: true,
1606
+ errorOnExist: true,
1607
+ force: false,
1608
+ dereference: false,
1609
+ });
1610
+ const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
1611
+ const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
1612
+ if (integrity !== source.integrity) {
1613
+ throw new Error(`plugin package integrity changed: ${source.installationId}`);
1614
+ }
1615
+ const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
1616
+ const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
1617
+ if (relativeSnapshotSkillRoot === ".." ||
1618
+ relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
1619
+ path.isAbsolute(relativeSnapshotSkillRoot)) {
1620
+ throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
1621
+ }
1622
+ const discovered = await scanSkillsDir(snapshotSkillRoot);
1623
+ if (discovered.length === 0) {
1624
+ throw new Error(`plugin skill root is empty: ${source.installationId}`);
1625
+ }
1626
+ return discovered.map((skill) => ({
1627
+ ...skill,
1628
+ name: providerPluginSkillName(source.namespace, skill.name),
1629
+ }));
1630
+ }));
1631
+ const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
1632
+ if (failedPluginSkill?.status === "rejected") {
1633
+ throw failedPluginSkill.reason;
1634
+ }
1635
+ const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1636
+ return {
1637
+ selectedSkills: [...skillEnv.resolved, ...pluginSkills],
1638
+ skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1639
+ };
1640
+ }
1641
+ catch (error) {
1642
+ await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1643
+ throw error;
1358
1644
  }
1359
- return {
1360
- model: exec.model,
1361
- ...((opts?.agentName || opts?.agentSpec) && resolved.instructions
1362
- ? { instructions: resolved.instructions }
1363
- : {}),
1364
- selectedSkills: skillEnv.resolved,
1365
- skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1366
- ...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
1367
- ...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
1368
- ...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
1369
- ...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
1370
- };
1371
1645
  }
1372
1646
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
1373
- const retargetMirror = opts?.retargetMirror;
1647
+ const retargetMirror = opts.retargetMirror;
1648
+ const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
1649
+ if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
1650
+ throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
1651
+ }
1374
1652
  const bridgeDir = prepareClaudeBridgeDir(localThreadId);
1375
- const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
1376
- // Apply the agent spec (model / skills / instructions) to the live TUI launch —
1377
- // NOT just the config default. The stored record model wins if present (a
1378
- // resumed thread), else the agent-spec resolution. Spec-load errors are logged
1379
- // and fail the start loudly (mirrors the codex path).
1380
- let liveCfg;
1653
+ const workspace = structuredClone(opts.workspace);
1654
+ const execution = structuredClone(opts.execution);
1655
+ if (execution.provider !== "claude") {
1656
+ throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1657
+ }
1658
+ const cwd = workspace.cwd;
1659
+ let snapshotSkills;
1381
1660
  try {
1382
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, "claude", cwd);
1661
+ snapshotSkills = await this.prepareExecutionSkills(execution);
1383
1662
  }
1384
1663
  catch (err) {
1385
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
1664
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1386
1665
  return false;
1387
1666
  }
1388
- const model = resolveRuntimeModel(this.config, "claude", record?.model ?? liveCfg.model);
1389
- // Skills throwaway `--plugin-dir`; instructions → Claude Code's native
1390
- // `--append-system-prompt`. The plugin dir gets its own copy, so the session
1391
- // skills dir is disposable here.
1392
- const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
1393
- void liveCfg.skillsCleanup();
1394
- // Agent-spec permission mode → claude's `--permission-mode` (claude-private
1395
- // approval axis). Unset sends no flag (claude's own default); any set value
1396
- // is passed through (`bypassPermissions` is how an agent opts out of the
1397
- // approval prompt). The shared `sandbox` axis is intentionally NOT applied
1398
- // here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
1399
- const permissionMode = liveCfg.permissionMode;
1667
+ const model = execution.model;
1668
+ const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1669
+ void snapshotSkills.skillsCleanup();
1670
+ const permissionMode = execution.permissionMode;
1400
1671
  const launchExtraArgs = [
1401
1672
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
1402
1673
  ...(permissionMode ? ["--permission-mode", permissionMode] : []),
@@ -1406,9 +1677,18 @@ export class LocalAgentHost {
1406
1677
  let currentSessionId = localThreadId;
1407
1678
  let normalizer = null;
1408
1679
  let currentResponseId;
1680
+ let pendingRotationEvents = null;
1681
+ const emitCurrent = (event) => {
1682
+ if (pendingRotationEvents) {
1683
+ pendingRotationEvents.push(event);
1684
+ return;
1685
+ }
1686
+ emit(event);
1687
+ };
1409
1688
  const startNormalizer = (turnId) => {
1410
1689
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1411
- const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1690
+ const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1691
+ (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
1412
1692
  if (normalizer && currentResponseId === responseId)
1413
1693
  return normalizer;
1414
1694
  currentResponseId = responseId;
@@ -1435,26 +1715,33 @@ export class LocalAgentHost {
1435
1715
  ...(event.reason ? { reason: event.reason } : {}),
1436
1716
  };
1437
1717
  for (const se of n.next(agentEvent))
1438
- emit(se);
1718
+ emitCurrent(se);
1439
1719
  };
1440
1720
  let markReady;
1441
1721
  const ready = new Promise((resolve) => {
1442
1722
  markReady = resolve;
1443
1723
  });
1724
+ let markFailed;
1725
+ const failed = new Promise((resolve) => {
1726
+ markFailed = resolve;
1727
+ });
1444
1728
  const live = {
1445
1729
  forwarder: null,
1446
- cwd,
1730
+ workspace,
1731
+ execution,
1447
1732
  bridgeDir,
1448
1733
  injectLock: Promise.resolve(),
1449
1734
  pendingImageInputs: new Map(),
1735
+ pendingInjectedInputs: [],
1450
1736
  ready,
1451
1737
  markReady,
1738
+ failed,
1739
+ markFailed,
1452
1740
  discoveredReady: false,
1453
1741
  stopped: false,
1454
- launchModel: model,
1455
- ...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
1456
1742
  launchExtraArgs,
1457
- ...(permissionMode ? { permissionMode } : {}),
1743
+ permissionMode,
1744
+ ...(forkIntent ? { forkIntent } : {}),
1458
1745
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1459
1746
  };
1460
1747
  const sink = {
@@ -1465,19 +1752,28 @@ export class LocalAgentHost {
1465
1752
  const content = token ? live.pendingImageInputs.get(token) : undefined;
1466
1753
  if (token && content)
1467
1754
  live.pendingImageInputs.delete(token);
1468
- for (const se of n.userInput(content ?? text))
1469
- emit(se);
1755
+ const normalizedContent = content ?? [{ type: "input_text", text }];
1756
+ const signature = JSON.stringify(normalizedContent);
1757
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1758
+ if (pending?.state === "prepublished" || pending?.state === "optimistic") {
1759
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1760
+ return;
1761
+ }
1762
+ if (pending)
1763
+ pending.observed = true;
1764
+ for (const se of n.userInput(normalizedContent))
1765
+ emitCurrent(se);
1470
1766
  },
1471
1767
  onTerminalCommand: (cmd) => {
1472
1768
  const n = normalizer ?? startNormalizer();
1473
1769
  for (const se of n.terminalCommand(cmd))
1474
- emit(se);
1770
+ emitCurrent(se);
1475
1771
  },
1476
- onTodos: (todos) => emit({ type: "session.todos", sessionId: currentSessionId, todos }),
1772
+ onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
1477
1773
  onEvent: (event) => {
1478
1774
  const n = normalizer ?? startNormalizer();
1479
1775
  for (const se of n.next(event))
1480
- emit(se);
1776
+ emitCurrent(se);
1481
1777
  },
1482
1778
  onInteraction: forwardInteraction,
1483
1779
  onTurnEnd: (usage) => {
@@ -1485,11 +1781,13 @@ export class LocalAgentHost {
1485
1781
  return;
1486
1782
  const rid = currentResponseId;
1487
1783
  // statusLine usage (context/cost) rides the turn's response.completed.
1488
- if (usage)
1784
+ if (usage) {
1489
1785
  for (const se of normalizer.next({ type: "turn_completed", usage }))
1490
- emit(se);
1786
+ emitCurrent(se);
1787
+ }
1491
1788
  for (const se of normalizer.next({ type: "done" }))
1492
- emit(se);
1789
+ emitCurrent(se);
1790
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1493
1791
  normalizer = null;
1494
1792
  currentResponseId = undefined;
1495
1793
  // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
@@ -1497,7 +1795,7 @@ export class LocalAgentHost {
1497
1795
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1498
1796
  if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1499
1797
  live.contextWarned = true;
1500
- emit({
1798
+ emitCurrent({
1501
1799
  type: "session.status",
1502
1800
  sessionId: currentSessionId,
1503
1801
  responseId: rid,
@@ -1510,7 +1808,7 @@ export class LocalAgentHost {
1510
1808
  // Surface idle on the current turn WITHOUT finalizing it (see the sink's
1511
1809
  // onIdle doc): a late assistant record still joins this response.
1512
1810
  if (currentResponseId) {
1513
- emit({
1811
+ emitCurrent({
1514
1812
  type: "session.status",
1515
1813
  sessionId: currentSessionId,
1516
1814
  responseId: currentResponseId,
@@ -1521,87 +1819,115 @@ export class LocalAgentHost {
1521
1819
  onTurnError: () => {
1522
1820
  if (!normalizer)
1523
1821
  return;
1822
+ const rid = currentResponseId;
1524
1823
  for (const se of normalizer.fail({
1525
1824
  code: "agent_error",
1526
1825
  message: "Agent turn failed",
1527
1826
  source: "execution",
1528
1827
  }))
1529
- emit(se);
1828
+ emitCurrent(se);
1829
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1530
1830
  normalizer = null;
1531
1831
  currentResponseId = undefined;
1532
1832
  },
1533
- onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
1833
+ onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
1834
+ onSessionResumeError: (error) => {
1835
+ live.error = error.message;
1836
+ live.markFailed();
1837
+ },
1534
1838
  onSessionRotated: (kind, claudeSessionId) => {
1839
+ if (pendingRotationEvents) {
1840
+ live.error = "Claude reported another Session rotation before publication completed";
1841
+ live.markFailed();
1842
+ return;
1843
+ }
1535
1844
  // The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
1536
- // new transcript. Rotate the rynx session: announce on the OLD session (the
1537
- // mirror still targets it), mint a fresh one, persist it, and re-target the
1538
- // mirror + terminal transfer so the new session is live AND injectable.
1845
+ // new transcript. Persist the Provider binding first, then announce on
1846
+ // the OLD session and re-target the mirror. Events produced during the
1847
+ // file-store commit are buffered so none can land under the old identity.
1539
1848
  const previousSessionId = currentSessionId;
1540
1849
  const newSessionId = makeSessionId();
1541
- emit({ type: "session.rotated", sessionId: previousSessionId, newSessionId, kind });
1850
+ currentSessionId = newSessionId;
1851
+ normalizer = null;
1852
+ currentResponseId = undefined;
1853
+ live.contextWarned = false;
1854
+ pendingRotationEvents = [];
1855
+ this.liveClaudeSessions.set(newSessionId, live);
1542
1856
  void this.sessionStore
1543
1857
  .set({
1544
1858
  localThreadId: newSessionId,
1545
1859
  codexSessionId: claudeSessionId,
1546
- cwd: live.cwd,
1547
- model,
1548
- runtime: "claude",
1549
- ...(record?.agent ? { agent: record.agent } : {}),
1550
1860
  ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1551
1861
  updatedAt: new Date().toISOString(),
1552
1862
  })
1553
- .catch(() => undefined);
1554
- currentSessionId = newSessionId;
1555
- normalizer = null;
1556
- live.contextWarned = false;
1557
- // Route the new session to THIS live session (host map) + this runner (daemon).
1558
- this.liveClaudeSessions.set(newSessionId, live);
1559
- retargetMirror?.(newSessionId, {
1560
- kind,
1561
- ...(record?.agent ? { agent: record.agent } : {}),
1562
- model,
1563
- cwd: live.cwd,
1564
- parentSessionId: previousSessionId,
1863
+ .then(() => {
1864
+ emit({
1865
+ type: "session.rotated",
1866
+ sessionId: previousSessionId,
1867
+ newSessionId,
1868
+ kind,
1869
+ });
1870
+ retargetMirror?.(newSessionId, {
1871
+ kind,
1872
+ workspace: structuredClone(live.workspace),
1873
+ execution: structuredClone(live.execution),
1874
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1875
+ });
1876
+ const queued = pendingRotationEvents ?? [];
1877
+ pendingRotationEvents = null;
1878
+ for (const event of queued)
1879
+ emit(event);
1880
+ })
1881
+ .catch((error) => {
1882
+ pendingRotationEvents = null;
1883
+ live.error = error instanceof Error ? error.message : String(error);
1884
+ live.markFailed();
1565
1885
  });
1566
1886
  },
1567
1887
  };
1568
- // Resume: a known claude session id tail its transcript immediately.
1569
- const transcriptPath = record?.codexSessionId
1570
- ? claudeTranscriptPath(cwd, record.codexSessionId)
1571
- : undefined;
1888
+ // Claude itself resolves `--resume <id>`. The SessionStart hook is the
1889
+ // authoritative source for the actual transcript path; do not infer or
1890
+ // generate Claude's private on-disk format here.
1572
1891
  live.forwarder = new ClaudeLiveSession({
1573
1892
  bridgeDir,
1574
1893
  sink,
1575
- ...(transcriptPath ? { transcriptPath } : {}),
1576
- ...(record?.codexSessionId ? { claudeSessionId: record.codexSessionId } : {}),
1894
+ ...(forkIntent
1895
+ ? { claudeSessionId: forkIntent.targetClaudeSessionId }
1896
+ : record?.codexSessionId
1897
+ ? { claudeSessionId: record.codexSessionId }
1898
+ : {}),
1899
+ ...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
1577
1900
  });
1578
1901
  this.liveClaudeSessions.set(localThreadId, live);
1579
1902
  live.forwarder.start();
1580
- if (record?.codexSessionId) {
1581
- live.discoveredReady = true;
1582
- markReady(); // resumed → injection can proceed without awaiting discovery
1583
- }
1584
1903
  return true;
1585
1904
  }
1586
1905
  /** Persist claude's discovered session id (reusing the `codexSessionId` store
1587
1906
  * field, as the claude executor already does) and release the readiness gate. */
1588
- onClaudeDiscovered(live, localThreadId, agent, claudeSessionId) {
1907
+ onClaudeDiscovered(live, localThreadId, claudeSessionId) {
1589
1908
  if (live.discoveredReady)
1590
1909
  return;
1910
+ const forkIntent = live.forkIntent;
1911
+ if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
1912
+ live.error =
1913
+ `Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
1914
+ live.markFailed();
1915
+ return;
1916
+ }
1591
1917
  live.discoveredReady = true;
1592
1918
  void this.sessionStore
1593
1919
  .set({
1594
1920
  localThreadId,
1595
1921
  codexSessionId: claudeSessionId,
1596
- cwd: live.cwd,
1597
- // Persist the agent-spec-resolved launch model (was the config default).
1598
- model: live.launchModel,
1599
- runtime: "claude",
1600
- agent,
1922
+ ...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
1601
1923
  updatedAt: new Date().toISOString(),
1602
1924
  })
1603
- .catch(() => undefined);
1604
- live.markReady();
1925
+ .then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
1926
+ .then(() => live.markReady())
1927
+ .catch((error) => {
1928
+ live.error = error instanceof Error ? error.message : String(error);
1929
+ live.markFailed();
1930
+ });
1605
1931
  }
1606
1932
  /** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
1607
1933
  * serialized per session. Parks until the thread is ready AND the tmux injector
@@ -1683,12 +2009,12 @@ export class LocalAgentHost {
1683
2009
  if (!record?.codexSessionId) {
1684
2010
  return { ok: false, reason: "no_active_thread" };
1685
2011
  }
1686
- const backend = this.getBackend(record.runtime ?? "codex");
1687
- if (!backend.appServerClient) {
2012
+ const live = this.liveSessions.get(localThreadId);
2013
+ if (!live) {
1688
2014
  return { ok: false, reason: "unsupported" };
1689
2015
  }
1690
2016
  try {
1691
- return { ok: true, data: await fn(backend.appServerClient, record.codexSessionId) };
2017
+ return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
1692
2018
  }
1693
2019
  catch (error) {
1694
2020
  return {
@@ -1714,30 +2040,108 @@ export class LocalAgentHost {
1714
2040
  await client.threadGoalClear({ threadId });
1715
2041
  });
1716
2042
  }
1717
- async forkSession(currentLocalThreadId, newLocalThreadId) {
2043
+ async forkSession(currentLocalThreadId, newLocalThreadId, options) {
2044
+ const inflight = this.forkingTargets.get(newLocalThreadId);
2045
+ if (inflight)
2046
+ return inflight;
2047
+ const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
2048
+ this.forkingTargets.set(newLocalThreadId, operation);
2049
+ try {
2050
+ return await operation;
2051
+ }
2052
+ finally {
2053
+ if (this.forkingTargets.get(newLocalThreadId) === operation) {
2054
+ this.forkingTargets.delete(newLocalThreadId);
2055
+ }
2056
+ }
2057
+ }
2058
+ async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
1718
2059
  const record = await this.sessionStore.get(currentLocalThreadId);
1719
2060
  if (!record) {
1720
2061
  return { ok: false, reason: "no_active_thread" };
1721
2062
  }
1722
- const runtime = record.runtime ?? "codex";
1723
- const backend = this.getBackend(runtime);
1724
- if (!backend.appServerClient) {
1725
- return { ok: false, reason: "unsupported" };
2063
+ const live = this.liveSessions.get(currentLocalThreadId);
2064
+ const workspace = live?.workspace ?? options?.workspace;
2065
+ const execution = live?.execution ?? options?.execution;
2066
+ if (!workspace || !execution) {
2067
+ return { ok: false, reason: "no_active_thread" };
2068
+ }
2069
+ if (execution.provider === "claude") {
2070
+ return {
2071
+ ok: false,
2072
+ reason: "unsupported",
2073
+ message: "Claude fork must be materialized by the target runner",
2074
+ };
1726
2075
  }
1727
2076
  try {
1728
- const forked = await backend.appServerClient.threadFork({
1729
- threadId: record.codexSessionId,
1730
- model: record.model,
1731
- cwd: record.cwd,
1732
- });
2077
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
2078
+ if (existingTarget) {
2079
+ if (existingTarget.parentSessionId !== currentLocalThreadId) {
2080
+ return {
2081
+ ok: false,
2082
+ reason: "error",
2083
+ message: "target Session already belongs to a different fork",
2084
+ };
2085
+ }
2086
+ return { ok: true, data: undefined };
2087
+ }
2088
+ const runtime = execution.provider;
2089
+ const client = live?.injectClient ??
2090
+ this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
2091
+ if (!client) {
2092
+ return { ok: false, reason: "unsupported" };
2093
+ }
2094
+ await client.ensureInitialized();
2095
+ if (live?.managedFork) {
2096
+ return {
2097
+ ok: false,
2098
+ reason: "error",
2099
+ message: "source Session already has a managed fork in progress",
2100
+ };
2101
+ }
2102
+ const managedFork = live
2103
+ ? {
2104
+ sourceThreadId: record.codexSessionId,
2105
+ observedThreadIds: new Set(),
2106
+ }
2107
+ : undefined;
2108
+ const forked = await (async () => {
2109
+ if (live && managedFork)
2110
+ live.managedFork = managedFork;
2111
+ try {
2112
+ return await client.threadFork({
2113
+ threadId: record.codexSessionId,
2114
+ excludeTurns: true,
2115
+ ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2116
+ model: execution.model,
2117
+ approvalPolicy: execution.approvalPolicy,
2118
+ });
2119
+ }
2120
+ finally {
2121
+ if (live && live.managedFork === managedFork)
2122
+ delete live.managedFork;
2123
+ for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
2124
+ this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
2125
+ }
2126
+ }
2127
+ })();
1733
2128
  const forkedThreadId = forked.thread.id;
2129
+ if (live && forkedThreadId) {
2130
+ this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
2131
+ }
2132
+ if (!forkedThreadId ||
2133
+ forkedThreadId === record.codexSessionId ||
2134
+ (forked.thread.forkedFromId !== undefined &&
2135
+ forked.thread.forkedFromId !== null &&
2136
+ forked.thread.forkedFromId !== record.codexSessionId)) {
2137
+ throw new Error("Provider returned an invalid forked thread");
2138
+ }
2139
+ const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
1734
2140
  await this.sessionStore.set({
1735
2141
  localThreadId: newLocalThreadId,
1736
2142
  codexSessionId: forkedThreadId,
1737
- cwd: record.cwd,
1738
- model: record.model,
1739
- reasoningEffort: record.reasoningEffort,
1740
- runtime,
2143
+ parentSessionId: currentLocalThreadId,
2144
+ runtimeHomeOwnerSessionId: sourceHomeOwner,
1741
2145
  updatedAt: new Date().toISOString(),
1742
2146
  });
1743
2147
  return { ok: true, data: undefined };
@@ -1751,6 +2155,12 @@ export class LocalAgentHost {
1751
2155
  }
1752
2156
  }
1753
2157
  }
2158
+ function codexInjectionError(error) {
2159
+ if (error instanceof CodexTransportError) {
2160
+ return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
2161
+ }
2162
+ return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
2163
+ }
1754
2164
  export function parseCodexLoginStatus(exitCode, output) {
1755
2165
  const normalized = output.trim();
1756
2166
  const match = normalized.match(/Logged in using (.+)$/im);
@@ -1774,25 +2184,48 @@ export function parseCodexLoginStatus(exitCode, output) {
1774
2184
  issues: normalized ? [normalized] : [],
1775
2185
  };
1776
2186
  }
1777
- /** Race a live session's `ready` promise against a timeout → resolves boolean. */
1778
- function raceReady(ready, timeoutMs) {
2187
+ /** Race a live session's `ready` promise against failure / timeout. */
2188
+ function raceReady(ready, timeoutMs, failed) {
1779
2189
  let timer;
1780
2190
  const timeout = new Promise((resolve) => {
1781
2191
  timer = setTimeout(() => resolve(false), timeoutMs);
1782
2192
  });
1783
- return Promise.race([ready.then(() => true), timeout]).finally(() => {
2193
+ const outcomes = [ready.then(() => true), timeout];
2194
+ if (failed)
2195
+ outcomes.push(failed.then(() => false));
2196
+ return Promise.race(outcomes).finally(() => {
1784
2197
  if (timer)
1785
2198
  clearTimeout(timer);
1786
2199
  });
1787
2200
  }
1788
- function liveConfigSignature(opts, runtime, cwd) {
2201
+ function sameSessionSnapshots(workspace, execution, opts) {
1789
2202
  return JSON.stringify(stableValue({
1790
- runtime,
1791
- cwd,
1792
- agentName: opts?.agentName,
1793
- agentSpec: opts?.agentSpec,
2203
+ workspace,
2204
+ execution,
2205
+ })) === JSON.stringify(stableValue({
2206
+ workspace: opts.workspace,
2207
+ execution: opts.execution,
1794
2208
  }));
1795
2209
  }
2210
+ /** Freeze the Session's permission choice into the explicit managed settings.
2211
+ * Host/project settings may still contribute allow/deny rules, but changing
2212
+ * their defaultMode cannot silently mutate an existing Session on resume. */
2213
+ function applyClaudePermissionModeSnapshot(settings, permissionMode) {
2214
+ const next = { ...settings };
2215
+ const inherited = settings.permissions;
2216
+ const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
2217
+ ? { ...inherited }
2218
+ : {};
2219
+ if (permissionMode)
2220
+ permissions.defaultMode = permissionMode;
2221
+ else
2222
+ delete permissions.defaultMode;
2223
+ if (Object.keys(permissions).length > 0)
2224
+ next.permissions = permissions;
2225
+ else
2226
+ delete next.permissions;
2227
+ return next;
2228
+ }
1796
2229
  function stableValue(value) {
1797
2230
  if (Array.isArray(value))
1798
2231
  return value.map(stableValue);
@@ -1826,3 +2259,10 @@ export function isThreadNotReadyError(error) {
1826
2259
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1827
2260
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
1828
2261
  }
2262
+ /** A persisted thread id that a freshly started app-server cannot load yet.
2263
+ * During startup, both errors can be transient while the rollout index catches
2264
+ * up. Retry the same id; never use either error as permission to replace it. */
2265
+ export function isRetryableThreadResumeError(error) {
2266
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2267
+ return message.includes("thread not found") || isThreadNotReadyError(error);
2268
+ }