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

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 (40) hide show
  1. package/dist/claude/models.d.ts +0 -5
  2. package/dist/claude/models.js +1 -7
  3. package/dist/claude/native-bridge.js +3 -8
  4. package/dist/claude/native-integration.d.ts +12 -1
  5. package/dist/claude/native-integration.js +16 -2
  6. package/dist/claude/transcript.d.ts +0 -7
  7. package/dist/claude/transcript.js +6 -20
  8. package/dist/codex-app-server/client.d.ts +2 -1
  9. package/dist/codex-app-server/forwarder.d.ts +4 -1
  10. package/dist/codex-app-server/forwarder.js +19 -1
  11. package/dist/codex-app-server/protocol.d.ts +45 -1
  12. package/dist/codex-home.d.ts +9 -26
  13. package/dist/codex-home.js +37 -65
  14. package/dist/codex-session-store.d.ts +22 -10
  15. package/dist/codex-session-store.js +277 -12
  16. package/dist/host.d.ts +48 -47
  17. package/dist/host.js +810 -355
  18. package/dist/index.d.ts +2 -3
  19. package/dist/index.js +1 -2
  20. package/dist/models-catalog.d.ts +3 -1
  21. package/dist/models-catalog.js +125 -4
  22. package/dist/provider-workspace.d.ts +56 -0
  23. package/dist/provider-workspace.js +83 -0
  24. package/dist/runner/child.d.ts +59 -6
  25. package/dist/runner/child.js +138 -19
  26. package/dist/runner/manager.d.ts +104 -19
  27. package/dist/runner/manager.js +922 -89
  28. package/dist/runner/protocol.d.ts +7 -18
  29. package/dist/runner-main.js +12 -4
  30. package/dist/runtime-state-paths.d.ts +10 -0
  31. package/dist/runtime-state-paths.js +53 -0
  32. package/dist/terminal/claude-tui.d.ts +10 -1
  33. package/dist/terminal/claude-tui.js +9 -1
  34. package/dist/terminal/codex-tui.d.ts +5 -1
  35. package/dist/terminal/codex-tui.js +12 -3
  36. package/dist/terminal/tmux.d.ts +11 -1
  37. package/dist/terminal/tmux.js +61 -12
  38. package/package.json +3 -3
  39. package/dist/codex/rollout-synth.d.ts +0 -42
  40. 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,12 +269,17 @@ 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
- this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
282
+ this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
259
283
  this.injectedCommandRunner = commandRunner;
260
284
  this.injectedAppServerClient = appServerClient;
261
285
  this.clock = now;
@@ -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,44 @@ 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 traexYolo = runtime === "traex" &&
476
+ sandbox === "danger-full-access" &&
477
+ approvalPolicy === "never";
478
+ const configOverrides = sandbox === "workspace-write"
448
479
  ? ["sandbox_workspace_write.network_access=true"]
449
480
  : [];
481
+ configOverrides.push(`sandbox_mode=${JSON.stringify(sandbox)}`, `approval_policy=${JSON.stringify(approvalPolicy)}`);
482
+ // A managed TUI has no user at its stdin during startup. Codex's periodic
483
+ // "Update now / Skip" prompt would otherwise park the detached pane before
484
+ // it connects to the app-server, leaving the session only partially live.
485
+ if (runtime === "codex")
486
+ configOverrides.push("check_for_update_on_startup=false");
450
487
  // Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
451
488
  // shared per-runtime, so a per-session model rides a launch `-c model=` instead).
452
- const launchModel = live?.model;
489
+ const launchModel = live.model;
453
490
  if (launchModel)
454
491
  configOverrides.push(`model=${JSON.stringify(launchModel)}`);
455
- const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
492
+ const launchEffort = live.reasoningEffort;
456
493
  if (launchEffort)
457
494
  configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
458
495
  if (live?.instructions) {
459
496
  configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
460
497
  }
461
498
  const profile = getRuntimeProfile(runtime);
499
+ const activeThreadId = live?.threadId ?? record?.codexSessionId;
462
500
  return {
463
501
  command: resolveRuntimeBinary(runtime),
464
502
  args: buildCodexRemoteArgs({
465
503
  remoteUrl,
466
- ...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
504
+ ...(activeThreadId ? { threadId: activeThreadId } : {}),
467
505
  configOverrides,
506
+ codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
507
+ additionalDirs: providerAdditionalDirs(live.workspace),
468
508
  }),
469
- cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
509
+ cwd: live.workspace.cwd,
510
+ ...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
470
511
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
471
512
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
472
513
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
@@ -486,25 +527,33 @@ export class LocalAgentHost {
486
527
  * DETERMINISTIC (`resp_codex_<turnId>`) so items converge across snapshot/live.
487
528
  *
488
529
  * 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.
530
+ * streaming run path). For a fresh session the native remote TUI creates the
531
+ * Codex thread and its `thread/started` notification supplies the id. For an
532
+ * existing session, the app-server resumes the persisted id. Rynx never writes
533
+ * or repairs Codex's private rollout files.
495
534
  */
496
535
  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;
536
+ const snapshotOpts = {
537
+ workspace: structuredClone(opts.workspace),
538
+ execution: structuredClone(opts.execution),
539
+ ...(opts.retargetMirror ? { retargetMirror: opts.retargetMirror } : {}),
540
+ };
541
+ const existingClaude = this.liveClaudeSessions.get(localThreadId);
542
+ if (existingClaude) {
543
+ return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
544
+ }
502
545
  // Dedupe concurrent triggers so only one forwarder is ever created per
503
- // session (two would double-mirror every turn).
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.
504
550
  const inflight = this.liveEnsuring.get(localThreadId);
505
551
  if (inflight)
506
552
  return inflight;
507
- const started = this.startLiveCodexSession(localThreadId, emit, opts);
553
+ const live = this.liveSessions.get(localThreadId);
554
+ if (live)
555
+ return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
556
+ const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
508
557
  this.liveEnsuring.set(localThreadId, started);
509
558
  try {
510
559
  return await started;
@@ -522,48 +571,51 @@ export class LocalAgentHost {
522
571
  this.stopLiveCodexSession(localThreadId);
523
572
  }
524
573
  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;
574
+ const retargetMirror = opts.retargetMirror;
575
+ const workspace = structuredClone(opts.workspace);
576
+ const execution = structuredClone(opts.execution);
577
+ const runtime = execution.provider;
529
578
  if (runtime === "claude")
530
579
  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;
580
+ let snapshotSkills = {
581
+ selectedSkills: [],
582
+ skillsCleanup: async () => undefined,
583
+ };
584
+ if (this.runtimeHomeSessionId === this.sessionId) {
585
+ try {
586
+ snapshotSkills = await this.prepareExecutionSkills(execution);
587
+ }
588
+ catch (err) {
589
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
590
+ return false;
591
+ }
592
+ }
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.
595
+ const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
537
596
  try {
538
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, runtime, cwd);
597
+ populateCodexSkills(this.runtimeHome(runtime), liveSkills);
539
598
  }
540
599
  catch (err) {
541
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
600
+ void snapshotSkills.skillsCleanup();
601
+ console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
542
602
  return false;
543
603
  }
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;
604
+ const sandbox = execution.sandbox;
605
+ const approvalPolicy = execution.approvalPolicy;
606
+ if (!sandbox || !approvalPolicy) {
607
+ void snapshotSkills.skillsCleanup();
608
+ throw new CodexRuntimeError(`${runtime} execution snapshot is missing sandbox or approval policy`, 422, "invalid_execution_snapshot");
609
+ }
610
+ this.sessionSandbox = sandbox;
611
+ this.sessionApprovalPolicy = approvalPolicy;
560
612
  // Backend client owns the app-server + drives injection. Every failed-start
561
613
  // path below removes the session skills dir it would otherwise leak.
562
614
  const abandon = () => {
563
- void liveCfg.skillsCleanup();
615
+ void snapshotSkills.skillsCleanup();
564
616
  return false;
565
617
  };
566
- const injectClient = this.getBackend(runtime).appServerClient;
618
+ const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
567
619
  if (!injectClient) {
568
620
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
569
621
  return abandon();
@@ -591,36 +643,40 @@ export class LocalAgentHost {
591
643
  console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
592
644
  return abandon();
593
645
  }
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;
646
+ const model = execution.model ?? "";
647
+ const reasoningEffort = execution.reasoningEffort ?? undefined;
603
648
  let markReady;
604
649
  const ready = new Promise((resolve) => {
605
650
  markReady = resolve;
606
651
  });
652
+ let markTerminalReady;
653
+ const terminalReady = new Promise((resolve) => {
654
+ markTerminalReady = resolve;
655
+ });
607
656
  const live = {
608
657
  injectClient,
609
658
  forwarderClient,
610
659
  forwarder: null,
611
660
  runtime,
612
- cwd,
661
+ workspace,
662
+ execution,
663
+ sandbox,
664
+ approvalPolicy,
613
665
  model,
614
666
  reasoningEffort,
615
- ...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
667
+ ...(execution.instructions ? { instructions: execution.instructions } : {}),
616
668
  threadId: record?.codexSessionId ?? null,
617
669
  ready,
618
670
  markReady,
671
+ terminalReady,
672
+ markTerminalReady,
619
673
  injectLock: Promise.resolve(),
674
+ pendingInjectedInputs: [],
675
+ publishInjectedInput: () => undefined,
620
676
  subscribing: false,
677
+ rotationPending: false,
621
678
  stopped: false,
622
- skillsCleanup: liveCfg.skillsCleanup,
623
- configSignature,
679
+ skillsCleanup: snapshotSkills.skillsCleanup,
624
680
  interactionOwners: new Map(),
625
681
  interactionStandbys: new Map(),
626
682
  interactionSubmissions: new Map(),
@@ -630,26 +686,48 @@ export class LocalAgentHost {
630
686
  interactionFailedClients: new Map(),
631
687
  disconnectedClients: new Set(),
632
688
  };
689
+ let currentSessionId = localThreadId;
690
+ let pendingRotationEvents = null;
691
+ const emitCurrent = (event) => {
692
+ if (pendingRotationEvents) {
693
+ pendingRotationEvents.push(event);
694
+ return;
695
+ }
696
+ emit(event);
697
+ };
633
698
  let normalizer = null;
634
699
  let currentResponseId = null;
635
700
  const startNormalizer = (turnId) => {
636
- const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
701
+ const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
702
+ (turnId ? `resp_codex_${turnId}` : "resp_codex_native");
637
703
  if (normalizer && currentResponseId === responseId)
638
704
  return normalizer;
639
705
  currentResponseId = responseId;
640
706
  normalizer = new SessionNormalizer({
641
- sessionId: localThreadId,
707
+ sessionId: currentSessionId,
642
708
  // turnId unknown → fixed literal (NEVER random): items arriving before
643
709
  // `turn/started` land on ONE response instead of splitting into random
644
710
  // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
645
711
  responseId,
646
- // Traex intentionally leaves the launch model empty to use its own
647
- // config. Canonical/Direct events still require a printable producer
648
- // label, so report the runtime when the exact model is unknown.
712
+ // A null Session model intentionally leaves selection to the Provider
713
+ // CLI. Canonical events still require a printable label.
649
714
  model: model || runtime,
650
715
  });
651
716
  return normalizer;
652
717
  };
718
+ live.publishInjectedInput = (turnId, content) => {
719
+ const n = startNormalizer(turnId);
720
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === JSON.stringify(content) && entry.responseId === undefined);
721
+ if (pending && currentResponseId)
722
+ pending.responseId = currentResponseId;
723
+ for (const se of n.userInput(content))
724
+ emitCurrent(se);
725
+ };
726
+ const clearPendingInputsForResponse = (responseId) => {
727
+ if (!responseId)
728
+ return;
729
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
730
+ };
653
731
  const rememberSettledInteraction = (interactionId) => {
654
732
  live.settledInteractions.add(interactionId);
655
733
  if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
@@ -692,7 +770,7 @@ export class LocalAgentHost {
692
770
  ...(event.reason ? { reason: event.reason } : {}),
693
771
  };
694
772
  for (const se of n.next(agentEvent))
695
- emit(se);
773
+ emitCurrent(se);
696
774
  };
697
775
  const clearRecovery = (interactionId) => {
698
776
  live.interactionRecoveries.delete(interactionId);
@@ -848,24 +926,38 @@ export class LocalAgentHost {
848
926
  const sink = {
849
927
  onTurnStart: (turnId) => startNormalizer(turnId),
850
928
  onUserMessage: (content) => {
929
+ const normalizedContent = typeof content === "string"
930
+ ? [{ type: "input_text", text: content }]
931
+ : content;
932
+ const signature = JSON.stringify(normalizedContent);
933
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
934
+ if (pending?.state === "optimistic" || pending?.state === "prepublished") {
935
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
936
+ return;
937
+ }
938
+ if (pending)
939
+ pending.observed = true;
851
940
  const n = normalizer ?? startNormalizer();
852
- for (const se of n.userInput(content))
853
- emit(se);
941
+ for (const se of n.userInput(normalizedContent))
942
+ emitCurrent(se);
854
943
  },
855
944
  onEvent: (event) => {
856
945
  const n = normalizer ?? startNormalizer();
857
946
  for (const se of n.next(event))
858
- emit(se);
947
+ emitCurrent(se);
859
948
  },
860
949
  onTurnEnd: (usage) => {
861
950
  closeCanonicalInteractions();
862
951
  if (!normalizer)
863
952
  return;
864
- if (usage)
953
+ const completedResponseId = currentResponseId;
954
+ if (usage) {
865
955
  for (const se of normalizer.next({ type: "turn_completed", usage }))
866
- emit(se);
956
+ emitCurrent(se);
957
+ }
867
958
  for (const se of normalizer.next({ type: "done" }))
868
- emit(se);
959
+ emitCurrent(se);
960
+ clearPendingInputsForResponse(completedResponseId);
869
961
  normalizer = null;
870
962
  currentResponseId = null;
871
963
  },
@@ -873,6 +965,7 @@ export class LocalAgentHost {
873
965
  closeCanonicalInteractions();
874
966
  if (!normalizer)
875
967
  return;
968
+ const failedResponseId = currentResponseId;
876
969
  const responseStopped = error.message === "Codex turn was interrupted";
877
970
  const providerName = runtime === "traex" ? "Traex" : "Codex";
878
971
  const message = responseStopped
@@ -887,14 +980,14 @@ export class LocalAgentHost {
887
980
  message,
888
981
  source: "execution",
889
982
  }))
890
- emit(se);
983
+ emitCurrent(se);
984
+ clearPendingInputsForResponse(failedResponseId);
891
985
  normalizer = null;
892
986
  currentResponseId = null;
893
987
  },
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),
988
+ // A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
989
+ shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
990
+ onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
898
991
  onThreadActive: () => live.releaseActive?.(),
899
992
  };
900
993
  const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
@@ -908,99 +1001,169 @@ export class LocalAgentHost {
908
1001
  surfaceQueueStatus: runtime === "traex",
909
1002
  });
910
1003
  live.forwarder = forwarder;
1004
+ live.onNativeThreadRotated = (threadId, kind) => {
1005
+ if (live.rotationPending) {
1006
+ live.stopped = true;
1007
+ return;
1008
+ }
1009
+ const previousSessionId = currentSessionId;
1010
+ const newSessionId = makeSessionId();
1011
+ live.rotationPending = true;
1012
+ currentSessionId = newSessionId;
1013
+ normalizer = null;
1014
+ currentResponseId = null;
1015
+ live.pendingInjectedInputs = [];
1016
+ pendingRotationEvents = [];
1017
+ void this.sessionStore
1018
+ .get(previousSessionId)
1019
+ .then((previous) => this.sessionStore.set({
1020
+ localThreadId: newSessionId,
1021
+ codexSessionId: threadId,
1022
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1023
+ ...(previous?.runtimeHomeOwnerSessionId
1024
+ ? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
1025
+ : {}),
1026
+ updatedAt: new Date().toISOString(),
1027
+ }))
1028
+ .then(() => {
1029
+ emit({
1030
+ type: "session.rotated",
1031
+ sessionId: previousSessionId,
1032
+ newSessionId,
1033
+ kind,
1034
+ });
1035
+ this.liveSessions.set(newSessionId, live);
1036
+ retargetMirror?.(newSessionId, {
1037
+ kind,
1038
+ workspace: structuredClone(live.workspace),
1039
+ execution: structuredClone(live.execution),
1040
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1041
+ });
1042
+ const queued = pendingRotationEvents ?? [];
1043
+ pendingRotationEvents = null;
1044
+ live.rotationPending = false;
1045
+ for (const event of queued)
1046
+ emit(event);
1047
+ void this.subscribeUntilReady(live, threadId);
1048
+ })
1049
+ .catch(() => {
1050
+ pendingRotationEvents = null;
1051
+ live.rotationPending = false;
1052
+ live.stopped = true;
1053
+ });
1054
+ };
911
1055
  bindInteractionClient(injectClient);
912
1056
  bindInteractionClient(forwarderClient);
913
1057
  this.liveSessions.set(localThreadId, live);
914
1058
  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;
1059
+ // Existing bindings resume through the public app-server API. A fresh thread
1060
+ // is created by the native remote TUI instead: its `thread/started`
1061
+ // notification binds this live handle in `onLiveThreadStarted`. Do not
1062
+ // synthesize Codex's private rollout JSONL merely to make a just-created,
1063
+ // turn-less app-server thread resumable by the TUI.
930
1064
  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;
938
- 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);
1065
+ if (record?.codexSessionId) {
1066
+ let resumeError;
1067
+ let resumedThreadId;
1068
+ const attempts = 20;
1069
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1070
+ try {
1071
+ const resumed = await injectClient.threadResume({
1072
+ threadId: record.codexSessionId,
1073
+ ...threadWorkspaceParams(runtime, workspace, sandbox),
1074
+ approvalPolicy,
1075
+ excludeTurns: true,
1076
+ });
1077
+ resumedThreadId = resumed.threadId;
1078
+ break;
1079
+ }
1080
+ catch (error) {
1081
+ resumeError = error;
1082
+ if (!isRetryableThreadResumeError(error))
1083
+ throw error;
1084
+ if (attempt + 1 < attempts) {
1085
+ await new Promise((resolve) => setTimeout(resolve, 150));
1086
+ }
1087
+ }
1088
+ }
1089
+ if (resumedThreadId) {
1090
+ this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1091
+ }
1092
+ else {
1093
+ // Never replace an existing binding. A delayed native index is
1094
+ // retryable; a genuinely missing thread remains an explicit error.
1095
+ throw resumeError;
1096
+ }
1097
+ }
1098
+ else {
1099
+ // No thread exists yet, so the TUI must launch without `resume`. Once it
1100
+ // broadcasts `thread/started`, the normal ready + subscription path
1101
+ // takes over and web injection can begin.
1102
+ live.markTerminalReady(true);
972
1103
  }
973
- return true;
974
1104
  }
975
1105
  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;
1106
+ this.liveSessions.delete(localThreadId);
1107
+ live.stopped = true;
1108
+ forwarder.stop();
1109
+ void forwarderClient.stop().catch(() => undefined);
1110
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1111
+ return abandon();
979
1112
  }
1113
+ return true;
980
1114
  }
981
1115
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
982
1116
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
983
- onLiveThreadStarted(live, localThreadId, agent, threadId) {
1117
+ onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
1118
+ const previousThreadId = live.threadId;
1119
+ if (previousThreadId && previousThreadId !== threadId) {
1120
+ live.threadId = threadId;
1121
+ live.subscribing = true;
1122
+ live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
1123
+ return;
1124
+ }
984
1125
  if (live.subscribing)
985
1126
  return;
986
1127
  live.subscribing = true;
987
1128
  live.threadId = threadId;
988
1129
  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(),
1130
+ .get(localThreadId)
1131
+ .then((existing) => {
1132
+ if (existing?.codexSessionId === threadId)
1133
+ return;
1134
+ return this.sessionStore.set({
1135
+ localThreadId,
1136
+ codexSessionId: threadId,
1137
+ ...(existing?.parentSessionId
1138
+ ? { parentSessionId: existing.parentSessionId }
1139
+ : {}),
1140
+ ...(existing?.runtimeHomeOwnerSessionId
1141
+ ? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
1142
+ : {}),
1143
+ updatedAt: new Date().toISOString(),
1144
+ });
1000
1145
  })
1001
1146
  .catch(() => undefined);
1002
1147
  live.markReady(); // thread id known → injection can turn/start
1003
- void this.subscribeUntilReady(live, threadId);
1148
+ void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
1149
+ }
1150
+ shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1151
+ const pending = live.managedFork;
1152
+ if (pending && forkedFromId === pending.sourceThreadId) {
1153
+ pending.observedThreadIds.add(threadId);
1154
+ return true;
1155
+ }
1156
+ const expectedSource = this.managedForkThreadStarts.get(threadId);
1157
+ return expectedSource !== undefined && expectedSource === forkedFromId;
1158
+ }
1159
+ rememberManagedForkThreadStart(threadId, sourceThreadId) {
1160
+ this.managedForkThreadStarts.set(threadId, sourceThreadId);
1161
+ const timer = setTimeout(() => {
1162
+ if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
1163
+ this.managedForkThreadStarts.delete(threadId);
1164
+ }
1165
+ }, 5_000);
1166
+ timer.unref?.();
1004
1167
  }
1005
1168
  /**
1006
1169
  * Subscribe the forwarder connection to a thread (reference implementation's
@@ -1016,17 +1179,18 @@ export class LocalAgentHost {
1016
1179
  try {
1017
1180
  const resp = await live.forwarderClient.threadResume({
1018
1181
  threadId,
1019
- cwd: live.cwd,
1182
+ ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1183
+ approvalPolicy: live.approvalPolicy,
1020
1184
  excludeTurns: !sawNotReady,
1021
1185
  });
1022
1186
  if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1023
1187
  live.forwarder.replayBackfill(resp.thread.turns);
1024
1188
  }
1025
- return; // subscribed — live item/turn notifications now flow to the forwarder
1189
+ return true; // subscribed — live item/turn notifications now flow to the forwarder
1026
1190
  }
1027
1191
  catch (error) {
1028
1192
  if (!isThreadNotReadyError(error)) {
1029
- return; // other failure — injection still works via the backend client
1193
+ return false; // other failure — injection still works via the backend client
1030
1194
  }
1031
1195
  sawNotReady = true;
1032
1196
  // Park until the thread goes active (its first turn materializes the
@@ -1038,13 +1202,14 @@ export class LocalAgentHost {
1038
1202
  live.releaseActive = undefined;
1039
1203
  }
1040
1204
  }
1205
+ return false;
1041
1206
  }
1042
1207
  /** Await a live session's thread binding (bounded). Returns false on timeout /
1043
1208
  * no live session. Injection and the runner's `live.ready` gate on this. */
1044
1209
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1045
1210
  const claude = this.liveClaudeSessions.get(localThreadId);
1046
1211
  if (claude)
1047
- return raceReady(claude.ready, timeoutMs);
1212
+ return raceReady(claude.ready, timeoutMs, claude.failed);
1048
1213
  const live = this.liveSessions.get(localThreadId);
1049
1214
  if (!live)
1050
1215
  return false;
@@ -1057,11 +1222,36 @@ export class LocalAgentHost {
1057
1222
  clearTimeout(timer);
1058
1223
  return ok;
1059
1224
  }
1225
+ /** Await the stronger Terminal gate: another app-server connection has
1226
+ * successfully resumed the thread, so the detached TUI cannot race rollout
1227
+ * discovery or indexing. */
1228
+ async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
1229
+ const claude = this.liveClaudeSessions.get(localThreadId);
1230
+ // Claude has no separate app-server indexing gate. Its TUI must launch first;
1231
+ // SessionStart then reveals the native id + actual transcript path.
1232
+ if (claude)
1233
+ return true;
1234
+ const live = this.liveSessions.get(localThreadId);
1235
+ if (!live)
1236
+ return false;
1237
+ let timer;
1238
+ const timeout = new Promise((resolve) => {
1239
+ timer = setTimeout(() => resolve(false), timeoutMs);
1240
+ });
1241
+ const ready = await Promise.race([live.terminalReady, timeout]);
1242
+ if (timer)
1243
+ clearTimeout(timer);
1244
+ return ready;
1245
+ }
1246
+ /** Diagnostic from the provider adapter when native discovery/resume failed. */
1247
+ liveSessionError(localThreadId) {
1248
+ return this.liveClaudeSessions.get(localThreadId)?.error;
1249
+ }
1060
1250
  /**
1061
1251
  * Inject a user turn into a session's live codex thread — reference implementation's
1062
1252
  * 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
1253
+ * supplement), else `turn/start`. The thread was created by the native TUI or
1254
+ * resumed from the persisted native id. Serialized per session so two
1065
1255
  * injects can't double-open a turn.
1066
1256
  *
1067
1257
  * Returns an {@link InjectOutcome}: `notLive` when this session has no live
@@ -1077,15 +1267,34 @@ export class LocalAgentHost {
1077
1267
  const claude = this.liveClaudeSessions.get(localThreadId);
1078
1268
  if (claude) {
1079
1269
  const text = claudeInputText(runtimeInput);
1270
+ const content = runtimeUserContent(runtimeInput);
1271
+ const pendingInput = {
1272
+ content,
1273
+ signature: JSON.stringify(content),
1274
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1275
+ observed: false,
1276
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1277
+ };
1278
+ claude.pendingInjectedInputs.push(pendingInput);
1279
+ const forgetPendingInput = () => {
1280
+ const index = claude.pendingInjectedInputs.indexOf(pendingInput);
1281
+ if (index >= 0)
1282
+ claude.pendingInjectedInputs.splice(index, 1);
1283
+ };
1080
1284
  const token = claudeAttachmentToken(text);
1081
1285
  if (token) {
1082
- claude.pendingImageInputs.set(token, runtimeUserContent(runtimeInput));
1286
+ claude.pendingImageInputs.set(token, content);
1083
1287
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1084
1288
  expiry.unref?.();
1085
1289
  }
1086
1290
  const outcome = await this.injectClaude(claude, localThreadId, text);
1087
- if (token && (outcome === "notLive" || outcome === "notReady")) {
1088
- claude.pendingImageInputs.delete(token);
1291
+ if (outcome !== "injected") {
1292
+ forgetPendingInput();
1293
+ if (token)
1294
+ claude.pendingImageInputs.delete(token);
1295
+ }
1296
+ else if (pendingInput.observed) {
1297
+ forgetPendingInput();
1089
1298
  }
1090
1299
  return outcome;
1091
1300
  }
@@ -1093,6 +1302,8 @@ export class LocalAgentHost {
1093
1302
  if (!live)
1094
1303
  return "notLive";
1095
1304
  const run = live.injectLock.then(async () => {
1305
+ if (live.rotationPending || live.stopped)
1306
+ return "failed";
1096
1307
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1097
1308
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1098
1309
  const bound = await this.waitLiveReady(localThreadId, 60_000);
@@ -1100,34 +1311,74 @@ export class LocalAgentHost {
1100
1311
  if (!bound || !threadId)
1101
1312
  return "notReady";
1102
1313
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1314
+ const content = runtimeUserContent(runtimeInput);
1315
+ const pendingInput = {
1316
+ content,
1317
+ signature: JSON.stringify(content),
1318
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1319
+ observed: false,
1320
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1321
+ };
1322
+ live.pendingInjectedInputs.push(pendingInput);
1323
+ const forgetPendingInput = () => {
1324
+ const index = live.pendingInjectedInputs.indexOf(pendingInput);
1325
+ if (index >= 0)
1326
+ live.pendingInjectedInputs.splice(index, 1);
1327
+ };
1103
1328
  try {
1104
1329
  // Inject via the BACKEND client (the forwarder connection only observes).
1105
1330
  if (live.forwarder.isTurnOpen()) {
1106
1331
  const turnId = live.forwarder.currentTurnId();
1107
1332
  if (turnId) {
1108
- await live.injectClient.turnSteer({
1333
+ const steered = await live.injectClient.turnSteer({
1109
1334
  threadId,
1110
1335
  expectedTurnId: turnId,
1111
1336
  input: nativeInput,
1112
1337
  });
1338
+ if (pendingInput.state === "prepublished") {
1339
+ // The caller already persisted and published this user input
1340
+ // before waiting for the native Terminal to become ready.
1341
+ }
1342
+ else if (pendingInput.observed) {
1343
+ forgetPendingInput();
1344
+ }
1345
+ else {
1346
+ live.publishInjectedInput(steered.turnId, pendingInput.content);
1347
+ pendingInput.state = "optimistic";
1348
+ }
1113
1349
  return "injected";
1114
1350
  }
1115
1351
  }
1116
1352
  // Carry the agent-spec model on the turn so a web-injected turn runs the
1117
1353
  // agent's model even if the TUI's config default differs.
1118
- await live.injectClient.turnStart({
1354
+ const started = await live.injectClient.turnStart({
1119
1355
  threadId,
1120
1356
  input: nativeInput,
1121
- cwd: live.cwd,
1357
+ ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1358
+ approvalPolicy: live.approvalPolicy,
1122
1359
  ...(live.model ? { model: live.model } : {}),
1123
1360
  ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1124
1361
  });
1362
+ if (pendingInput.state === "prepublished") {
1363
+ // The caller already persisted and published this user input before
1364
+ // waiting for the native Terminal to become ready.
1365
+ }
1366
+ else if (pendingInput.observed) {
1367
+ forgetPendingInput();
1368
+ }
1369
+ else {
1370
+ live.publishInjectedInput(started.turnId, pendingInput.content);
1371
+ pendingInput.state = "optimistic";
1372
+ }
1125
1373
  return "injected";
1126
1374
  }
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";
1375
+ catch (error) {
1376
+ forgetPendingInput();
1377
+ // Preserve the app-server's error instead of collapsing every failure
1378
+ // into the unactionable `live injection failed` string.
1379
+ const detail = codexInjectionError(error);
1380
+ console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1381
+ throw new Error(detail, { cause: error });
1131
1382
  }
1132
1383
  });
1133
1384
  live.injectLock = run.then(() => undefined, () => undefined);
@@ -1236,6 +1487,15 @@ export class LocalAgentHost {
1236
1487
  for (const forwarder of this.pendingClaudeFinalizers)
1237
1488
  forwarder.finalizeStop();
1238
1489
  this.pendingClaudeFinalizers.clear();
1490
+ // A runner owns its app-server processes. Stopping only the forwarder leaves
1491
+ // those children re-parented after the runner exits, along with remote TUIs
1492
+ // that can only reconnect to a dead session. `stop()` sends SIGTERM before
1493
+ // its returned promise yields, so this remains safe in the synchronous
1494
+ // runner shutdown path.
1495
+ for (const backend of this.backends.values()) {
1496
+ void backend.appServerClient?.stop().catch(() => undefined);
1497
+ }
1498
+ this.backends.clear();
1239
1499
  }
1240
1500
  // --- claude-native live session (parallel to the codex methods above) -----
1241
1501
  /** The interactive `claude` TUI spec for a claude-native live session: the real
@@ -1245,7 +1505,10 @@ export class LocalAgentHost {
1245
1505
  * generic interaction bridge and remain inside the active Turn. */
1246
1506
  claudeTerminalSpec(localThreadId, record) {
1247
1507
  const live = this.liveClaudeSessions.get(localThreadId);
1248
- const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
1508
+ if (!live) {
1509
+ throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
1510
+ }
1511
+ const cwd = live.workspace.cwd;
1249
1512
  try {
1250
1513
  ensureProjectTrusted(cwd);
1251
1514
  }
@@ -1253,24 +1516,17 @@ export class LocalAgentHost {
1253
1516
  // A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
1254
1517
  }
1255
1518
  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;
1519
+ const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
1520
+ const hookPermissionMode = live.permissionMode;
1264
1521
  const hookSettings = buildClaudeHookSettings({
1265
1522
  bridgeDir: claudeBridgeDir(localThreadId),
1266
1523
  ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
1267
1524
  messageDisplay: true,
1268
1525
  statusLine: true,
1269
1526
  });
1270
- const settings = { ...inheritedSettings, ...hookSettings };
1527
+ const settings = { ...effectiveSettings, ...hookSettings };
1271
1528
  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.
1529
+ // The live session carries the daemon-owned execution state used by Chat.
1274
1530
  const args = buildClaudeTuiArgs({
1275
1531
  settingsJson: settingsPath,
1276
1532
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1278,10 +1534,24 @@ export class LocalAgentHost {
1278
1534
  // resolve the same native request while rynx still exposes it as pending.
1279
1535
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1280
1536
  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 } : {}),
1537
+ ...(live.execution.model ? { model: live.execution.model } : {}),
1538
+ ...(live.execution.reasoningEffort
1539
+ ? { reasoningEffort: live.execution.reasoningEffort }
1540
+ : {}),
1541
+ ...(live.execution.instructions
1542
+ ? { appendSystemPrompt: live.execution.instructions }
1543
+ : {}),
1544
+ additionalDirs: providerAdditionalDirs(live.workspace),
1545
+ ...(live.forkIntent
1546
+ ? {
1547
+ resume: live.forkIntent.sourceClaudeSessionId,
1548
+ forkSession: true,
1549
+ sessionId: live.forkIntent.targetClaudeSessionId,
1550
+ }
1551
+ : record?.codexSessionId
1552
+ ? { resume: record.codexSessionId }
1553
+ : {}),
1554
+ ...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
1285
1555
  });
1286
1556
  return {
1287
1557
  command: resolveRuntimeBinary("claude"),
@@ -1292,44 +1562,15 @@ export class LocalAgentHost {
1292
1562
  env: { ...process.env, RYNX_SESSION_ID: localThreadId },
1293
1563
  };
1294
1564
  }
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
- });
1565
+ /** Materialize only the immutable skill declarations stored on the Session.
1566
+ * Provider startup never re-opens an Agent template or process-level plugin
1567
+ * snapshot. */
1568
+ async prepareExecutionSkills(execution) {
1328
1569
  const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
1329
1570
  let skillEnv;
1330
1571
  try {
1331
- skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1332
- const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
1572
+ skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
1573
+ const declaredNames = execution.skills.map((skill) => skill.name);
1333
1574
  const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
1334
1575
  const { report } = skillEnv;
1335
1576
  if (report.errors.length > 0 ||
@@ -1351,52 +1592,97 @@ export class LocalAgentHost {
1351
1592
  await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1352
1593
  throw error;
1353
1594
  }
1354
- const agent = opts?.agentName ?? resolved.name ?? "-";
1355
1595
  const { report } = skillEnv;
1356
1596
  if (report.installed.length > 0) {
1357
- console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
1597
+ console.error(`[skills] installed=[${report.installed.join(", ")}]`);
1598
+ }
1599
+ try {
1600
+ const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
1601
+ const packageRoot = await realpath(source.packageRoot);
1602
+ const skillRoot = await realpath(source.root);
1603
+ const relativeSkillRoot = path.relative(packageRoot, skillRoot);
1604
+ if (relativeSkillRoot === ".." ||
1605
+ relativeSkillRoot.startsWith(`..${path.sep}`) ||
1606
+ path.isAbsolute(relativeSkillRoot)) {
1607
+ throw new Error(`plugin skill root escaped package: ${source.installationId}`);
1608
+ }
1609
+ const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
1610
+ await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
1611
+ await cp(packageRoot, snapshotPackageRoot, {
1612
+ recursive: true,
1613
+ errorOnExist: true,
1614
+ force: false,
1615
+ dereference: false,
1616
+ });
1617
+ const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
1618
+ const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
1619
+ if (integrity !== source.integrity) {
1620
+ throw new Error(`plugin package integrity changed: ${source.installationId}`);
1621
+ }
1622
+ const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
1623
+ const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
1624
+ if (relativeSnapshotSkillRoot === ".." ||
1625
+ relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
1626
+ path.isAbsolute(relativeSnapshotSkillRoot)) {
1627
+ throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
1628
+ }
1629
+ const discovered = await scanSkillsDir(snapshotSkillRoot);
1630
+ if (discovered.length === 0) {
1631
+ throw new Error(`plugin skill root is empty: ${source.installationId}`);
1632
+ }
1633
+ return discovered.map((skill) => ({
1634
+ ...skill,
1635
+ name: providerPluginSkillName(source.namespace, skill.name),
1636
+ }));
1637
+ }));
1638
+ const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
1639
+ if (failedPluginSkill?.status === "rejected") {
1640
+ throw failedPluginSkill.reason;
1641
+ }
1642
+ const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1643
+ const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
1644
+ const selectedNames = new Set();
1645
+ for (const skill of selectedSkills) {
1646
+ if (selectedNames.has(skill.name)) {
1647
+ throw new Error(`duplicate final Session skill name: ${skill.name}`);
1648
+ }
1649
+ selectedNames.add(skill.name);
1650
+ }
1651
+ return {
1652
+ selectedSkills,
1653
+ skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1654
+ };
1655
+ }
1656
+ catch (error) {
1657
+ await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1658
+ throw error;
1358
1659
  }
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
1660
  }
1372
1661
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
1373
- const retargetMirror = opts?.retargetMirror;
1662
+ const retargetMirror = opts.retargetMirror;
1663
+ const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
1664
+ if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
1665
+ throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
1666
+ }
1374
1667
  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;
1668
+ const workspace = structuredClone(opts.workspace);
1669
+ const execution = structuredClone(opts.execution);
1670
+ if (execution.provider !== "claude") {
1671
+ throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1672
+ }
1673
+ const cwd = workspace.cwd;
1674
+ let snapshotSkills;
1381
1675
  try {
1382
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, "claude", cwd);
1676
+ snapshotSkills = await this.prepareExecutionSkills(execution);
1383
1677
  }
1384
1678
  catch (err) {
1385
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
1679
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1386
1680
  return false;
1387
1681
  }
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;
1682
+ const model = execution.model ?? "";
1683
+ const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1684
+ void snapshotSkills.skillsCleanup();
1685
+ const permissionMode = execution.permissionMode;
1400
1686
  const launchExtraArgs = [
1401
1687
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
1402
1688
  ...(permissionMode ? ["--permission-mode", permissionMode] : []),
@@ -1406,16 +1692,25 @@ export class LocalAgentHost {
1406
1692
  let currentSessionId = localThreadId;
1407
1693
  let normalizer = null;
1408
1694
  let currentResponseId;
1695
+ let pendingRotationEvents = null;
1696
+ const emitCurrent = (event) => {
1697
+ if (pendingRotationEvents) {
1698
+ pendingRotationEvents.push(event);
1699
+ return;
1700
+ }
1701
+ emit(event);
1702
+ };
1409
1703
  const startNormalizer = (turnId) => {
1410
1704
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1411
- const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1705
+ const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1706
+ (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
1412
1707
  if (normalizer && currentResponseId === responseId)
1413
1708
  return normalizer;
1414
1709
  currentResponseId = responseId;
1415
1710
  normalizer = new SessionNormalizer({
1416
1711
  sessionId: currentSessionId,
1417
1712
  responseId,
1418
- model,
1713
+ model: model || "claude",
1419
1714
  });
1420
1715
  return normalizer;
1421
1716
  };
@@ -1435,26 +1730,33 @@ export class LocalAgentHost {
1435
1730
  ...(event.reason ? { reason: event.reason } : {}),
1436
1731
  };
1437
1732
  for (const se of n.next(agentEvent))
1438
- emit(se);
1733
+ emitCurrent(se);
1439
1734
  };
1440
1735
  let markReady;
1441
1736
  const ready = new Promise((resolve) => {
1442
1737
  markReady = resolve;
1443
1738
  });
1739
+ let markFailed;
1740
+ const failed = new Promise((resolve) => {
1741
+ markFailed = resolve;
1742
+ });
1444
1743
  const live = {
1445
1744
  forwarder: null,
1446
- cwd,
1745
+ workspace,
1746
+ execution,
1447
1747
  bridgeDir,
1448
1748
  injectLock: Promise.resolve(),
1449
1749
  pendingImageInputs: new Map(),
1750
+ pendingInjectedInputs: [],
1450
1751
  ready,
1451
1752
  markReady,
1753
+ failed,
1754
+ markFailed,
1452
1755
  discoveredReady: false,
1453
1756
  stopped: false,
1454
- launchModel: model,
1455
- ...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
1456
1757
  launchExtraArgs,
1457
- ...(permissionMode ? { permissionMode } : {}),
1758
+ permissionMode,
1759
+ ...(forkIntent ? { forkIntent } : {}),
1458
1760
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1459
1761
  };
1460
1762
  const sink = {
@@ -1465,19 +1767,28 @@ export class LocalAgentHost {
1465
1767
  const content = token ? live.pendingImageInputs.get(token) : undefined;
1466
1768
  if (token && content)
1467
1769
  live.pendingImageInputs.delete(token);
1468
- for (const se of n.userInput(content ?? text))
1469
- emit(se);
1770
+ const normalizedContent = content ?? [{ type: "input_text", text }];
1771
+ const signature = JSON.stringify(normalizedContent);
1772
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1773
+ if (pending?.state === "prepublished" || pending?.state === "optimistic") {
1774
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1775
+ return;
1776
+ }
1777
+ if (pending)
1778
+ pending.observed = true;
1779
+ for (const se of n.userInput(normalizedContent))
1780
+ emitCurrent(se);
1470
1781
  },
1471
1782
  onTerminalCommand: (cmd) => {
1472
1783
  const n = normalizer ?? startNormalizer();
1473
1784
  for (const se of n.terminalCommand(cmd))
1474
- emit(se);
1785
+ emitCurrent(se);
1475
1786
  },
1476
- onTodos: (todos) => emit({ type: "session.todos", sessionId: currentSessionId, todos }),
1787
+ onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
1477
1788
  onEvent: (event) => {
1478
1789
  const n = normalizer ?? startNormalizer();
1479
1790
  for (const se of n.next(event))
1480
- emit(se);
1791
+ emitCurrent(se);
1481
1792
  },
1482
1793
  onInteraction: forwardInteraction,
1483
1794
  onTurnEnd: (usage) => {
@@ -1485,11 +1796,13 @@ export class LocalAgentHost {
1485
1796
  return;
1486
1797
  const rid = currentResponseId;
1487
1798
  // statusLine usage (context/cost) rides the turn's response.completed.
1488
- if (usage)
1799
+ if (usage) {
1489
1800
  for (const se of normalizer.next({ type: "turn_completed", usage }))
1490
- emit(se);
1801
+ emitCurrent(se);
1802
+ }
1491
1803
  for (const se of normalizer.next({ type: "done" }))
1492
- emit(se);
1804
+ emitCurrent(se);
1805
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1493
1806
  normalizer = null;
1494
1807
  currentResponseId = undefined;
1495
1808
  // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
@@ -1497,7 +1810,7 @@ export class LocalAgentHost {
1497
1810
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1498
1811
  if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1499
1812
  live.contextWarned = true;
1500
- emit({
1813
+ emitCurrent({
1501
1814
  type: "session.status",
1502
1815
  sessionId: currentSessionId,
1503
1816
  responseId: rid,
@@ -1510,7 +1823,7 @@ export class LocalAgentHost {
1510
1823
  // Surface idle on the current turn WITHOUT finalizing it (see the sink's
1511
1824
  // onIdle doc): a late assistant record still joins this response.
1512
1825
  if (currentResponseId) {
1513
- emit({
1826
+ emitCurrent({
1514
1827
  type: "session.status",
1515
1828
  sessionId: currentSessionId,
1516
1829
  responseId: currentResponseId,
@@ -1521,87 +1834,115 @@ export class LocalAgentHost {
1521
1834
  onTurnError: () => {
1522
1835
  if (!normalizer)
1523
1836
  return;
1837
+ const rid = currentResponseId;
1524
1838
  for (const se of normalizer.fail({
1525
1839
  code: "agent_error",
1526
1840
  message: "Agent turn failed",
1527
1841
  source: "execution",
1528
1842
  }))
1529
- emit(se);
1843
+ emitCurrent(se);
1844
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1530
1845
  normalizer = null;
1531
1846
  currentResponseId = undefined;
1532
1847
  },
1533
- onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
1848
+ onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
1849
+ onSessionResumeError: (error) => {
1850
+ live.error = error.message;
1851
+ live.markFailed();
1852
+ },
1534
1853
  onSessionRotated: (kind, claudeSessionId) => {
1854
+ if (pendingRotationEvents) {
1855
+ live.error = "Claude reported another Session rotation before publication completed";
1856
+ live.markFailed();
1857
+ return;
1858
+ }
1535
1859
  // 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.
1860
+ // new transcript. Persist the Provider binding first, then announce on
1861
+ // the OLD session and re-target the mirror. Events produced during the
1862
+ // file-store commit are buffered so none can land under the old identity.
1539
1863
  const previousSessionId = currentSessionId;
1540
1864
  const newSessionId = makeSessionId();
1541
- emit({ type: "session.rotated", sessionId: previousSessionId, newSessionId, kind });
1865
+ currentSessionId = newSessionId;
1866
+ normalizer = null;
1867
+ currentResponseId = undefined;
1868
+ live.contextWarned = false;
1869
+ pendingRotationEvents = [];
1870
+ this.liveClaudeSessions.set(newSessionId, live);
1542
1871
  void this.sessionStore
1543
1872
  .set({
1544
1873
  localThreadId: newSessionId,
1545
1874
  codexSessionId: claudeSessionId,
1546
- cwd: live.cwd,
1547
- model,
1548
- runtime: "claude",
1549
- ...(record?.agent ? { agent: record.agent } : {}),
1550
1875
  ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1551
1876
  updatedAt: new Date().toISOString(),
1552
1877
  })
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,
1878
+ .then(() => {
1879
+ emit({
1880
+ type: "session.rotated",
1881
+ sessionId: previousSessionId,
1882
+ newSessionId,
1883
+ kind,
1884
+ });
1885
+ retargetMirror?.(newSessionId, {
1886
+ kind,
1887
+ workspace: structuredClone(live.workspace),
1888
+ execution: structuredClone(live.execution),
1889
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1890
+ });
1891
+ const queued = pendingRotationEvents ?? [];
1892
+ pendingRotationEvents = null;
1893
+ for (const event of queued)
1894
+ emit(event);
1895
+ })
1896
+ .catch((error) => {
1897
+ pendingRotationEvents = null;
1898
+ live.error = error instanceof Error ? error.message : String(error);
1899
+ live.markFailed();
1565
1900
  });
1566
1901
  },
1567
1902
  };
1568
- // Resume: a known claude session id tail its transcript immediately.
1569
- const transcriptPath = record?.codexSessionId
1570
- ? claudeTranscriptPath(cwd, record.codexSessionId)
1571
- : undefined;
1903
+ // Claude itself resolves `--resume <id>`. The SessionStart hook is the
1904
+ // authoritative source for the actual transcript path; do not infer or
1905
+ // generate Claude's private on-disk format here.
1572
1906
  live.forwarder = new ClaudeLiveSession({
1573
1907
  bridgeDir,
1574
1908
  sink,
1575
- ...(transcriptPath ? { transcriptPath } : {}),
1576
- ...(record?.codexSessionId ? { claudeSessionId: record.codexSessionId } : {}),
1909
+ ...(forkIntent
1910
+ ? { claudeSessionId: forkIntent.targetClaudeSessionId }
1911
+ : record?.codexSessionId
1912
+ ? { claudeSessionId: record.codexSessionId }
1913
+ : {}),
1914
+ ...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
1577
1915
  });
1578
1916
  this.liveClaudeSessions.set(localThreadId, live);
1579
1917
  live.forwarder.start();
1580
- if (record?.codexSessionId) {
1581
- live.discoveredReady = true;
1582
- markReady(); // resumed → injection can proceed without awaiting discovery
1583
- }
1584
1918
  return true;
1585
1919
  }
1586
1920
  /** Persist claude's discovered session id (reusing the `codexSessionId` store
1587
1921
  * field, as the claude executor already does) and release the readiness gate. */
1588
- onClaudeDiscovered(live, localThreadId, agent, claudeSessionId) {
1922
+ onClaudeDiscovered(live, localThreadId, claudeSessionId) {
1589
1923
  if (live.discoveredReady)
1590
1924
  return;
1925
+ const forkIntent = live.forkIntent;
1926
+ if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
1927
+ live.error =
1928
+ `Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
1929
+ live.markFailed();
1930
+ return;
1931
+ }
1591
1932
  live.discoveredReady = true;
1592
1933
  void this.sessionStore
1593
1934
  .set({
1594
1935
  localThreadId,
1595
1936
  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,
1937
+ ...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
1601
1938
  updatedAt: new Date().toISOString(),
1602
1939
  })
1603
- .catch(() => undefined);
1604
- live.markReady();
1940
+ .then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
1941
+ .then(() => live.markReady())
1942
+ .catch((error) => {
1943
+ live.error = error instanceof Error ? error.message : String(error);
1944
+ live.markFailed();
1945
+ });
1605
1946
  }
1606
1947
  /** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
1607
1948
  * serialized per session. Parks until the thread is ready AND the tmux injector
@@ -1683,12 +2024,12 @@ export class LocalAgentHost {
1683
2024
  if (!record?.codexSessionId) {
1684
2025
  return { ok: false, reason: "no_active_thread" };
1685
2026
  }
1686
- const backend = this.getBackend(record.runtime ?? "codex");
1687
- if (!backend.appServerClient) {
2027
+ const live = this.liveSessions.get(localThreadId);
2028
+ if (!live) {
1688
2029
  return { ok: false, reason: "unsupported" };
1689
2030
  }
1690
2031
  try {
1691
- return { ok: true, data: await fn(backend.appServerClient, record.codexSessionId) };
2032
+ return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
1692
2033
  }
1693
2034
  catch (error) {
1694
2035
  return {
@@ -1714,30 +2055,108 @@ export class LocalAgentHost {
1714
2055
  await client.threadGoalClear({ threadId });
1715
2056
  });
1716
2057
  }
1717
- async forkSession(currentLocalThreadId, newLocalThreadId) {
2058
+ async forkSession(currentLocalThreadId, newLocalThreadId, options) {
2059
+ const inflight = this.forkingTargets.get(newLocalThreadId);
2060
+ if (inflight)
2061
+ return inflight;
2062
+ const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
2063
+ this.forkingTargets.set(newLocalThreadId, operation);
2064
+ try {
2065
+ return await operation;
2066
+ }
2067
+ finally {
2068
+ if (this.forkingTargets.get(newLocalThreadId) === operation) {
2069
+ this.forkingTargets.delete(newLocalThreadId);
2070
+ }
2071
+ }
2072
+ }
2073
+ async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
1718
2074
  const record = await this.sessionStore.get(currentLocalThreadId);
1719
2075
  if (!record) {
1720
2076
  return { ok: false, reason: "no_active_thread" };
1721
2077
  }
1722
- const runtime = record.runtime ?? "codex";
1723
- const backend = this.getBackend(runtime);
1724
- if (!backend.appServerClient) {
1725
- return { ok: false, reason: "unsupported" };
2078
+ const live = this.liveSessions.get(currentLocalThreadId);
2079
+ const workspace = live?.workspace ?? options?.workspace;
2080
+ const execution = live?.execution ?? options?.execution;
2081
+ if (!workspace || !execution) {
2082
+ return { ok: false, reason: "no_active_thread" };
2083
+ }
2084
+ if (execution.provider === "claude") {
2085
+ return {
2086
+ ok: false,
2087
+ reason: "unsupported",
2088
+ message: "Claude fork must be materialized by the target runner",
2089
+ };
1726
2090
  }
1727
2091
  try {
1728
- const forked = await backend.appServerClient.threadFork({
1729
- threadId: record.codexSessionId,
1730
- model: record.model,
1731
- cwd: record.cwd,
1732
- });
2092
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
2093
+ if (existingTarget) {
2094
+ if (existingTarget.parentSessionId !== currentLocalThreadId) {
2095
+ return {
2096
+ ok: false,
2097
+ reason: "error",
2098
+ message: "target Session already belongs to a different fork",
2099
+ };
2100
+ }
2101
+ return { ok: true, data: undefined };
2102
+ }
2103
+ const runtime = execution.provider;
2104
+ const client = live?.injectClient ??
2105
+ this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
2106
+ if (!client) {
2107
+ return { ok: false, reason: "unsupported" };
2108
+ }
2109
+ await client.ensureInitialized();
2110
+ if (live?.managedFork) {
2111
+ return {
2112
+ ok: false,
2113
+ reason: "error",
2114
+ message: "source Session already has a managed fork in progress",
2115
+ };
2116
+ }
2117
+ const managedFork = live
2118
+ ? {
2119
+ sourceThreadId: record.codexSessionId,
2120
+ observedThreadIds: new Set(),
2121
+ }
2122
+ : undefined;
2123
+ const forked = await (async () => {
2124
+ if (live && managedFork)
2125
+ live.managedFork = managedFork;
2126
+ try {
2127
+ return await client.threadFork({
2128
+ threadId: record.codexSessionId,
2129
+ excludeTurns: true,
2130
+ ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2131
+ ...(execution.model ? { model: execution.model } : {}),
2132
+ approvalPolicy: execution.approvalPolicy,
2133
+ });
2134
+ }
2135
+ finally {
2136
+ if (live && live.managedFork === managedFork)
2137
+ delete live.managedFork;
2138
+ for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
2139
+ this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
2140
+ }
2141
+ }
2142
+ })();
1733
2143
  const forkedThreadId = forked.thread.id;
2144
+ if (live && forkedThreadId) {
2145
+ this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
2146
+ }
2147
+ if (!forkedThreadId ||
2148
+ forkedThreadId === record.codexSessionId ||
2149
+ (forked.thread.forkedFromId !== undefined &&
2150
+ forked.thread.forkedFromId !== null &&
2151
+ forked.thread.forkedFromId !== record.codexSessionId)) {
2152
+ throw new Error("Provider returned an invalid forked thread");
2153
+ }
2154
+ const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
1734
2155
  await this.sessionStore.set({
1735
2156
  localThreadId: newLocalThreadId,
1736
2157
  codexSessionId: forkedThreadId,
1737
- cwd: record.cwd,
1738
- model: record.model,
1739
- reasoningEffort: record.reasoningEffort,
1740
- runtime,
2158
+ parentSessionId: currentLocalThreadId,
2159
+ runtimeHomeOwnerSessionId: sourceHomeOwner,
1741
2160
  updatedAt: new Date().toISOString(),
1742
2161
  });
1743
2162
  return { ok: true, data: undefined };
@@ -1751,6 +2170,12 @@ export class LocalAgentHost {
1751
2170
  }
1752
2171
  }
1753
2172
  }
2173
+ function codexInjectionError(error) {
2174
+ if (error instanceof CodexTransportError) {
2175
+ return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
2176
+ }
2177
+ return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
2178
+ }
1754
2179
  export function parseCodexLoginStatus(exitCode, output) {
1755
2180
  const normalized = output.trim();
1756
2181
  const match = normalized.match(/Logged in using (.+)$/im);
@@ -1774,25 +2199,48 @@ export function parseCodexLoginStatus(exitCode, output) {
1774
2199
  issues: normalized ? [normalized] : [],
1775
2200
  };
1776
2201
  }
1777
- /** Race a live session's `ready` promise against a timeout → resolves boolean. */
1778
- function raceReady(ready, timeoutMs) {
2202
+ /** Race a live session's `ready` promise against failure / timeout. */
2203
+ function raceReady(ready, timeoutMs, failed) {
1779
2204
  let timer;
1780
2205
  const timeout = new Promise((resolve) => {
1781
2206
  timer = setTimeout(() => resolve(false), timeoutMs);
1782
2207
  });
1783
- return Promise.race([ready.then(() => true), timeout]).finally(() => {
2208
+ const outcomes = [ready.then(() => true), timeout];
2209
+ if (failed)
2210
+ outcomes.push(failed.then(() => false));
2211
+ return Promise.race(outcomes).finally(() => {
1784
2212
  if (timer)
1785
2213
  clearTimeout(timer);
1786
2214
  });
1787
2215
  }
1788
- function liveConfigSignature(opts, runtime, cwd) {
2216
+ function sameSessionSnapshots(workspace, execution, opts) {
1789
2217
  return JSON.stringify(stableValue({
1790
- runtime,
1791
- cwd,
1792
- agentName: opts?.agentName,
1793
- agentSpec: opts?.agentSpec,
2218
+ workspace,
2219
+ execution,
2220
+ })) === JSON.stringify(stableValue({
2221
+ workspace: opts.workspace,
2222
+ execution: opts.execution,
1794
2223
  }));
1795
2224
  }
2225
+ /** Freeze the Session's permission choice into the explicit managed settings.
2226
+ * Host/project settings may still contribute allow/deny rules, but changing
2227
+ * their defaultMode cannot silently mutate an existing Session on resume. */
2228
+ function applyClaudePermissionModeSnapshot(settings, permissionMode) {
2229
+ const next = { ...settings };
2230
+ const inherited = settings.permissions;
2231
+ const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
2232
+ ? { ...inherited }
2233
+ : {};
2234
+ if (permissionMode)
2235
+ permissions.defaultMode = permissionMode;
2236
+ else
2237
+ delete permissions.defaultMode;
2238
+ if (Object.keys(permissions).length > 0)
2239
+ next.permissions = permissions;
2240
+ else
2241
+ delete next.permissions;
2242
+ return next;
2243
+ }
1796
2244
  function stableValue(value) {
1797
2245
  if (Array.isArray(value))
1798
2246
  return value.map(stableValue);
@@ -1826,3 +2274,10 @@ export function isThreadNotReadyError(error) {
1826
2274
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1827
2275
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
1828
2276
  }
2277
+ /** A persisted thread id that a freshly started app-server cannot load yet.
2278
+ * During startup, both errors can be transient while the rollout index catches
2279
+ * up. Retry the same id; never use either error as permission to replace it. */
2280
+ export function isRetryableThreadResumeError(error) {
2281
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2282
+ return message.includes("thread not found") || isThreadNotReadyError(error);
2283
+ }