@rynx-ai/runtime 0.1.11-beta.1 → 0.1.11-beta.11

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 +823 -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,182 @@ 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 if (resumeError &&
1093
+ isThreadNotReadyError(resumeError) &&
1094
+ !record.parentSessionId &&
1095
+ !record.runtimeHomeOwnerSessionId) {
1096
+ // A native thread that still has no rollout after the bounded retry
1097
+ // window has never materialized a Turn. It is safe to forget this
1098
+ // ordinary binding and let the TUI create a fresh native thread.
1099
+ // Fork bindings stay strict because their Provider-native ancestry
1100
+ // must not be silently replaced.
1101
+ await this.sessionStore.delete(localThreadId);
1102
+ live.threadId = null;
1103
+ live.markTerminalReady(true);
1104
+ }
1105
+ else {
1106
+ // A delayed/missing native index or a fork binding remains explicit:
1107
+ // neither is proof that replacing Provider history is safe.
1108
+ throw resumeError;
1109
+ }
1110
+ }
1111
+ else {
1112
+ // No thread exists yet, so the TUI must launch without `resume`. Once it
1113
+ // broadcasts `thread/started`, the normal ready + subscription path
1114
+ // takes over and web injection can begin.
1115
+ live.markTerminalReady(true);
972
1116
  }
973
- return true;
974
1117
  }
975
1118
  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;
1119
+ this.liveSessions.delete(localThreadId);
1120
+ live.stopped = true;
1121
+ forwarder.stop();
1122
+ void forwarderClient.stop().catch(() => undefined);
1123
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
1124
+ return abandon();
979
1125
  }
1126
+ return true;
980
1127
  }
981
1128
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
982
1129
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
983
- onLiveThreadStarted(live, localThreadId, agent, threadId) {
1130
+ onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
1131
+ const previousThreadId = live.threadId;
1132
+ if (previousThreadId && previousThreadId !== threadId) {
1133
+ live.threadId = threadId;
1134
+ live.subscribing = true;
1135
+ live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
1136
+ return;
1137
+ }
984
1138
  if (live.subscribing)
985
1139
  return;
986
1140
  live.subscribing = true;
987
1141
  live.threadId = threadId;
988
1142
  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(),
1143
+ .get(localThreadId)
1144
+ .then((existing) => {
1145
+ if (existing?.codexSessionId === threadId)
1146
+ return;
1147
+ return this.sessionStore.set({
1148
+ localThreadId,
1149
+ codexSessionId: threadId,
1150
+ ...(existing?.parentSessionId
1151
+ ? { parentSessionId: existing.parentSessionId }
1152
+ : {}),
1153
+ ...(existing?.runtimeHomeOwnerSessionId
1154
+ ? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
1155
+ : {}),
1156
+ updatedAt: new Date().toISOString(),
1157
+ });
1000
1158
  })
1001
1159
  .catch(() => undefined);
1002
1160
  live.markReady(); // thread id known → injection can turn/start
1003
- void this.subscribeUntilReady(live, threadId);
1161
+ void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
1162
+ }
1163
+ shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1164
+ const pending = live.managedFork;
1165
+ if (pending && forkedFromId === pending.sourceThreadId) {
1166
+ pending.observedThreadIds.add(threadId);
1167
+ return true;
1168
+ }
1169
+ const expectedSource = this.managedForkThreadStarts.get(threadId);
1170
+ return expectedSource !== undefined && expectedSource === forkedFromId;
1171
+ }
1172
+ rememberManagedForkThreadStart(threadId, sourceThreadId) {
1173
+ this.managedForkThreadStarts.set(threadId, sourceThreadId);
1174
+ const timer = setTimeout(() => {
1175
+ if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
1176
+ this.managedForkThreadStarts.delete(threadId);
1177
+ }
1178
+ }, 5_000);
1179
+ timer.unref?.();
1004
1180
  }
1005
1181
  /**
1006
1182
  * Subscribe the forwarder connection to a thread (reference implementation's
@@ -1016,17 +1192,18 @@ export class LocalAgentHost {
1016
1192
  try {
1017
1193
  const resp = await live.forwarderClient.threadResume({
1018
1194
  threadId,
1019
- cwd: live.cwd,
1195
+ ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1196
+ approvalPolicy: live.approvalPolicy,
1020
1197
  excludeTurns: !sawNotReady,
1021
1198
  });
1022
1199
  if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
1023
1200
  live.forwarder.replayBackfill(resp.thread.turns);
1024
1201
  }
1025
- return; // subscribed — live item/turn notifications now flow to the forwarder
1202
+ return true; // subscribed — live item/turn notifications now flow to the forwarder
1026
1203
  }
1027
1204
  catch (error) {
1028
1205
  if (!isThreadNotReadyError(error)) {
1029
- return; // other failure — injection still works via the backend client
1206
+ return false; // other failure — injection still works via the backend client
1030
1207
  }
1031
1208
  sawNotReady = true;
1032
1209
  // Park until the thread goes active (its first turn materializes the
@@ -1038,13 +1215,14 @@ export class LocalAgentHost {
1038
1215
  live.releaseActive = undefined;
1039
1216
  }
1040
1217
  }
1218
+ return false;
1041
1219
  }
1042
1220
  /** Await a live session's thread binding (bounded). Returns false on timeout /
1043
1221
  * no live session. Injection and the runner's `live.ready` gate on this. */
1044
1222
  async waitLiveReady(localThreadId, timeoutMs = 20_000) {
1045
1223
  const claude = this.liveClaudeSessions.get(localThreadId);
1046
1224
  if (claude)
1047
- return raceReady(claude.ready, timeoutMs);
1225
+ return raceReady(claude.ready, timeoutMs, claude.failed);
1048
1226
  const live = this.liveSessions.get(localThreadId);
1049
1227
  if (!live)
1050
1228
  return false;
@@ -1057,11 +1235,36 @@ export class LocalAgentHost {
1057
1235
  clearTimeout(timer);
1058
1236
  return ok;
1059
1237
  }
1238
+ /** Await the stronger Terminal gate: another app-server connection has
1239
+ * successfully resumed the thread, so the detached TUI cannot race rollout
1240
+ * discovery or indexing. */
1241
+ async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
1242
+ const claude = this.liveClaudeSessions.get(localThreadId);
1243
+ // Claude has no separate app-server indexing gate. Its TUI must launch first;
1244
+ // SessionStart then reveals the native id + actual transcript path.
1245
+ if (claude)
1246
+ return true;
1247
+ const live = this.liveSessions.get(localThreadId);
1248
+ if (!live)
1249
+ return false;
1250
+ let timer;
1251
+ const timeout = new Promise((resolve) => {
1252
+ timer = setTimeout(() => resolve(false), timeoutMs);
1253
+ });
1254
+ const ready = await Promise.race([live.terminalReady, timeout]);
1255
+ if (timer)
1256
+ clearTimeout(timer);
1257
+ return ready;
1258
+ }
1259
+ /** Diagnostic from the provider adapter when native discovery/resume failed. */
1260
+ liveSessionError(localThreadId) {
1261
+ return this.liveClaudeSessions.get(localThreadId)?.error;
1262
+ }
1060
1263
  /**
1061
1264
  * Inject a user turn into a session's live codex thread — reference implementation's
1062
1265
  * 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
1266
+ * supplement), else `turn/start`. The thread was created by the native TUI or
1267
+ * resumed from the persisted native id. Serialized per session so two
1065
1268
  * injects can't double-open a turn.
1066
1269
  *
1067
1270
  * Returns an {@link InjectOutcome}: `notLive` when this session has no live
@@ -1077,15 +1280,34 @@ export class LocalAgentHost {
1077
1280
  const claude = this.liveClaudeSessions.get(localThreadId);
1078
1281
  if (claude) {
1079
1282
  const text = claudeInputText(runtimeInput);
1283
+ const content = runtimeUserContent(runtimeInput);
1284
+ const pendingInput = {
1285
+ content,
1286
+ signature: JSON.stringify(content),
1287
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1288
+ observed: false,
1289
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1290
+ };
1291
+ claude.pendingInjectedInputs.push(pendingInput);
1292
+ const forgetPendingInput = () => {
1293
+ const index = claude.pendingInjectedInputs.indexOf(pendingInput);
1294
+ if (index >= 0)
1295
+ claude.pendingInjectedInputs.splice(index, 1);
1296
+ };
1080
1297
  const token = claudeAttachmentToken(text);
1081
1298
  if (token) {
1082
- claude.pendingImageInputs.set(token, runtimeUserContent(runtimeInput));
1299
+ claude.pendingImageInputs.set(token, content);
1083
1300
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1084
1301
  expiry.unref?.();
1085
1302
  }
1086
1303
  const outcome = await this.injectClaude(claude, localThreadId, text);
1087
- if (token && (outcome === "notLive" || outcome === "notReady")) {
1088
- claude.pendingImageInputs.delete(token);
1304
+ if (outcome !== "injected") {
1305
+ forgetPendingInput();
1306
+ if (token)
1307
+ claude.pendingImageInputs.delete(token);
1308
+ }
1309
+ else if (pendingInput.observed) {
1310
+ forgetPendingInput();
1089
1311
  }
1090
1312
  return outcome;
1091
1313
  }
@@ -1093,6 +1315,8 @@ export class LocalAgentHost {
1093
1315
  if (!live)
1094
1316
  return "notLive";
1095
1317
  const run = live.injectLock.then(async () => {
1318
+ if (live.rotationPending || live.stopped)
1319
+ return "failed";
1096
1320
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1097
1321
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1098
1322
  const bound = await this.waitLiveReady(localThreadId, 60_000);
@@ -1100,34 +1324,74 @@ export class LocalAgentHost {
1100
1324
  if (!bound || !threadId)
1101
1325
  return "notReady";
1102
1326
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1327
+ const content = runtimeUserContent(runtimeInput);
1328
+ const pendingInput = {
1329
+ content,
1330
+ signature: JSON.stringify(content),
1331
+ state: runtimeInput.responseId ? "prepublished" : "awaiting",
1332
+ observed: false,
1333
+ ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1334
+ };
1335
+ live.pendingInjectedInputs.push(pendingInput);
1336
+ const forgetPendingInput = () => {
1337
+ const index = live.pendingInjectedInputs.indexOf(pendingInput);
1338
+ if (index >= 0)
1339
+ live.pendingInjectedInputs.splice(index, 1);
1340
+ };
1103
1341
  try {
1104
1342
  // Inject via the BACKEND client (the forwarder connection only observes).
1105
1343
  if (live.forwarder.isTurnOpen()) {
1106
1344
  const turnId = live.forwarder.currentTurnId();
1107
1345
  if (turnId) {
1108
- await live.injectClient.turnSteer({
1346
+ const steered = await live.injectClient.turnSteer({
1109
1347
  threadId,
1110
1348
  expectedTurnId: turnId,
1111
1349
  input: nativeInput,
1112
1350
  });
1351
+ if (pendingInput.state === "prepublished") {
1352
+ // The caller already persisted and published this user input
1353
+ // before waiting for the native Terminal to become ready.
1354
+ }
1355
+ else if (pendingInput.observed) {
1356
+ forgetPendingInput();
1357
+ }
1358
+ else {
1359
+ live.publishInjectedInput(steered.turnId, pendingInput.content);
1360
+ pendingInput.state = "optimistic";
1361
+ }
1113
1362
  return "injected";
1114
1363
  }
1115
1364
  }
1116
1365
  // Carry the agent-spec model on the turn so a web-injected turn runs the
1117
1366
  // agent's model even if the TUI's config default differs.
1118
- await live.injectClient.turnStart({
1367
+ const started = await live.injectClient.turnStart({
1119
1368
  threadId,
1120
1369
  input: nativeInput,
1121
- cwd: live.cwd,
1370
+ ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1371
+ approvalPolicy: live.approvalPolicy,
1122
1372
  ...(live.model ? { model: live.model } : {}),
1123
1373
  ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1124
1374
  });
1375
+ if (pendingInput.state === "prepublished") {
1376
+ // The caller already persisted and published this user input before
1377
+ // waiting for the native Terminal to become ready.
1378
+ }
1379
+ else if (pendingInput.observed) {
1380
+ forgetPendingInput();
1381
+ }
1382
+ else {
1383
+ live.publishInjectedInput(started.turnId, pendingInput.content);
1384
+ pendingInput.state = "optimistic";
1385
+ }
1125
1386
  return "injected";
1126
1387
  }
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";
1388
+ catch (error) {
1389
+ forgetPendingInput();
1390
+ // Preserve the app-server's error instead of collapsing every failure
1391
+ // into the unactionable `live injection failed` string.
1392
+ const detail = codexInjectionError(error);
1393
+ console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1394
+ throw new Error(detail, { cause: error });
1131
1395
  }
1132
1396
  });
1133
1397
  live.injectLock = run.then(() => undefined, () => undefined);
@@ -1236,6 +1500,15 @@ export class LocalAgentHost {
1236
1500
  for (const forwarder of this.pendingClaudeFinalizers)
1237
1501
  forwarder.finalizeStop();
1238
1502
  this.pendingClaudeFinalizers.clear();
1503
+ // A runner owns its app-server processes. Stopping only the forwarder leaves
1504
+ // those children re-parented after the runner exits, along with remote TUIs
1505
+ // that can only reconnect to a dead session. `stop()` sends SIGTERM before
1506
+ // its returned promise yields, so this remains safe in the synchronous
1507
+ // runner shutdown path.
1508
+ for (const backend of this.backends.values()) {
1509
+ void backend.appServerClient?.stop().catch(() => undefined);
1510
+ }
1511
+ this.backends.clear();
1239
1512
  }
1240
1513
  // --- claude-native live session (parallel to the codex methods above) -----
1241
1514
  /** The interactive `claude` TUI spec for a claude-native live session: the real
@@ -1245,7 +1518,10 @@ export class LocalAgentHost {
1245
1518
  * generic interaction bridge and remain inside the active Turn. */
1246
1519
  claudeTerminalSpec(localThreadId, record) {
1247
1520
  const live = this.liveClaudeSessions.get(localThreadId);
1248
- const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
1521
+ if (!live) {
1522
+ throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
1523
+ }
1524
+ const cwd = live.workspace.cwd;
1249
1525
  try {
1250
1526
  ensureProjectTrusted(cwd);
1251
1527
  }
@@ -1253,24 +1529,17 @@ export class LocalAgentHost {
1253
1529
  // A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
1254
1530
  }
1255
1531
  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;
1532
+ const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
1533
+ const hookPermissionMode = live.permissionMode;
1264
1534
  const hookSettings = buildClaudeHookSettings({
1265
1535
  bridgeDir: claudeBridgeDir(localThreadId),
1266
1536
  ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
1267
1537
  messageDisplay: true,
1268
1538
  statusLine: true,
1269
1539
  });
1270
- const settings = { ...inheritedSettings, ...hookSettings };
1540
+ const settings = { ...effectiveSettings, ...hookSettings };
1271
1541
  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.
1542
+ // The live session carries the daemon-owned execution state used by Chat.
1274
1543
  const args = buildClaudeTuiArgs({
1275
1544
  settingsJson: settingsPath,
1276
1545
  // A rynx-managed Session must have one interaction owner. Loading host or
@@ -1278,10 +1547,24 @@ export class LocalAgentHost {
1278
1547
  // resolve the same native request while rynx still exposes it as pending.
1279
1548
  // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1280
1549
  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 } : {}),
1550
+ ...(live.execution.model ? { model: live.execution.model } : {}),
1551
+ ...(live.execution.reasoningEffort
1552
+ ? { reasoningEffort: live.execution.reasoningEffort }
1553
+ : {}),
1554
+ ...(live.execution.instructions
1555
+ ? { appendSystemPrompt: live.execution.instructions }
1556
+ : {}),
1557
+ additionalDirs: providerAdditionalDirs(live.workspace),
1558
+ ...(live.forkIntent
1559
+ ? {
1560
+ resume: live.forkIntent.sourceClaudeSessionId,
1561
+ forkSession: true,
1562
+ sessionId: live.forkIntent.targetClaudeSessionId,
1563
+ }
1564
+ : record?.codexSessionId
1565
+ ? { resume: record.codexSessionId }
1566
+ : {}),
1567
+ ...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
1285
1568
  });
1286
1569
  return {
1287
1570
  command: resolveRuntimeBinary("claude"),
@@ -1292,44 +1575,15 @@ export class LocalAgentHost {
1292
1575
  env: { ...process.env, RYNX_SESSION_ID: localThreadId },
1293
1576
  };
1294
1577
  }
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
- });
1578
+ /** Materialize only the immutable skill declarations stored on the Session.
1579
+ * Provider startup never re-opens an Agent template or process-level plugin
1580
+ * snapshot. */
1581
+ async prepareExecutionSkills(execution) {
1328
1582
  const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
1329
1583
  let skillEnv;
1330
1584
  try {
1331
- skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1332
- const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
1585
+ skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
1586
+ const declaredNames = execution.skills.map((skill) => skill.name);
1333
1587
  const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
1334
1588
  const { report } = skillEnv;
1335
1589
  if (report.errors.length > 0 ||
@@ -1351,52 +1605,97 @@ export class LocalAgentHost {
1351
1605
  await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1352
1606
  throw error;
1353
1607
  }
1354
- const agent = opts?.agentName ?? resolved.name ?? "-";
1355
1608
  const { report } = skillEnv;
1356
1609
  if (report.installed.length > 0) {
1357
- console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
1610
+ console.error(`[skills] installed=[${report.installed.join(", ")}]`);
1611
+ }
1612
+ try {
1613
+ const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
1614
+ const packageRoot = await realpath(source.packageRoot);
1615
+ const skillRoot = await realpath(source.root);
1616
+ const relativeSkillRoot = path.relative(packageRoot, skillRoot);
1617
+ if (relativeSkillRoot === ".." ||
1618
+ relativeSkillRoot.startsWith(`..${path.sep}`) ||
1619
+ path.isAbsolute(relativeSkillRoot)) {
1620
+ throw new Error(`plugin skill root escaped package: ${source.installationId}`);
1621
+ }
1622
+ const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
1623
+ await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
1624
+ await cp(packageRoot, snapshotPackageRoot, {
1625
+ recursive: true,
1626
+ errorOnExist: true,
1627
+ force: false,
1628
+ dereference: false,
1629
+ });
1630
+ const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
1631
+ const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
1632
+ if (integrity !== source.integrity) {
1633
+ throw new Error(`plugin package integrity changed: ${source.installationId}`);
1634
+ }
1635
+ const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
1636
+ const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
1637
+ if (relativeSnapshotSkillRoot === ".." ||
1638
+ relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
1639
+ path.isAbsolute(relativeSnapshotSkillRoot)) {
1640
+ throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
1641
+ }
1642
+ const discovered = await scanSkillsDir(snapshotSkillRoot);
1643
+ if (discovered.length === 0) {
1644
+ throw new Error(`plugin skill root is empty: ${source.installationId}`);
1645
+ }
1646
+ return discovered.map((skill) => ({
1647
+ ...skill,
1648
+ name: providerPluginSkillName(source.namespace, skill.name),
1649
+ }));
1650
+ }));
1651
+ const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
1652
+ if (failedPluginSkill?.status === "rejected") {
1653
+ throw failedPluginSkill.reason;
1654
+ }
1655
+ const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1656
+ const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
1657
+ const selectedNames = new Set();
1658
+ for (const skill of selectedSkills) {
1659
+ if (selectedNames.has(skill.name)) {
1660
+ throw new Error(`duplicate final Session skill name: ${skill.name}`);
1661
+ }
1662
+ selectedNames.add(skill.name);
1663
+ }
1664
+ return {
1665
+ selectedSkills,
1666
+ skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1667
+ };
1668
+ }
1669
+ catch (error) {
1670
+ await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1671
+ throw error;
1358
1672
  }
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
1673
  }
1372
1674
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
1373
- const retargetMirror = opts?.retargetMirror;
1675
+ const retargetMirror = opts.retargetMirror;
1676
+ const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
1677
+ if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
1678
+ throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
1679
+ }
1374
1680
  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;
1681
+ const workspace = structuredClone(opts.workspace);
1682
+ const execution = structuredClone(opts.execution);
1683
+ if (execution.provider !== "claude") {
1684
+ throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1685
+ }
1686
+ const cwd = workspace.cwd;
1687
+ let snapshotSkills;
1381
1688
  try {
1382
- liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, "claude", cwd);
1689
+ snapshotSkills = await this.prepareExecutionSkills(execution);
1383
1690
  }
1384
1691
  catch (err) {
1385
- console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
1692
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1386
1693
  return false;
1387
1694
  }
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;
1695
+ const model = execution.model ?? "";
1696
+ const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1697
+ void snapshotSkills.skillsCleanup();
1698
+ const permissionMode = execution.permissionMode;
1400
1699
  const launchExtraArgs = [
1401
1700
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
1402
1701
  ...(permissionMode ? ["--permission-mode", permissionMode] : []),
@@ -1406,16 +1705,25 @@ export class LocalAgentHost {
1406
1705
  let currentSessionId = localThreadId;
1407
1706
  let normalizer = null;
1408
1707
  let currentResponseId;
1708
+ let pendingRotationEvents = null;
1709
+ const emitCurrent = (event) => {
1710
+ if (pendingRotationEvents) {
1711
+ pendingRotationEvents.push(event);
1712
+ return;
1713
+ }
1714
+ emit(event);
1715
+ };
1409
1716
  const startNormalizer = (turnId) => {
1410
1717
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1411
- const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1718
+ const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1719
+ (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
1412
1720
  if (normalizer && currentResponseId === responseId)
1413
1721
  return normalizer;
1414
1722
  currentResponseId = responseId;
1415
1723
  normalizer = new SessionNormalizer({
1416
1724
  sessionId: currentSessionId,
1417
1725
  responseId,
1418
- model,
1726
+ model: model || "claude",
1419
1727
  });
1420
1728
  return normalizer;
1421
1729
  };
@@ -1435,26 +1743,33 @@ export class LocalAgentHost {
1435
1743
  ...(event.reason ? { reason: event.reason } : {}),
1436
1744
  };
1437
1745
  for (const se of n.next(agentEvent))
1438
- emit(se);
1746
+ emitCurrent(se);
1439
1747
  };
1440
1748
  let markReady;
1441
1749
  const ready = new Promise((resolve) => {
1442
1750
  markReady = resolve;
1443
1751
  });
1752
+ let markFailed;
1753
+ const failed = new Promise((resolve) => {
1754
+ markFailed = resolve;
1755
+ });
1444
1756
  const live = {
1445
1757
  forwarder: null,
1446
- cwd,
1758
+ workspace,
1759
+ execution,
1447
1760
  bridgeDir,
1448
1761
  injectLock: Promise.resolve(),
1449
1762
  pendingImageInputs: new Map(),
1763
+ pendingInjectedInputs: [],
1450
1764
  ready,
1451
1765
  markReady,
1766
+ failed,
1767
+ markFailed,
1452
1768
  discoveredReady: false,
1453
1769
  stopped: false,
1454
- launchModel: model,
1455
- ...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
1456
1770
  launchExtraArgs,
1457
- ...(permissionMode ? { permissionMode } : {}),
1771
+ permissionMode,
1772
+ ...(forkIntent ? { forkIntent } : {}),
1458
1773
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1459
1774
  };
1460
1775
  const sink = {
@@ -1465,19 +1780,28 @@ export class LocalAgentHost {
1465
1780
  const content = token ? live.pendingImageInputs.get(token) : undefined;
1466
1781
  if (token && content)
1467
1782
  live.pendingImageInputs.delete(token);
1468
- for (const se of n.userInput(content ?? text))
1469
- emit(se);
1783
+ const normalizedContent = content ?? [{ type: "input_text", text }];
1784
+ const signature = JSON.stringify(normalizedContent);
1785
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1786
+ if (pending?.state === "prepublished" || pending?.state === "optimistic") {
1787
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1788
+ return;
1789
+ }
1790
+ if (pending)
1791
+ pending.observed = true;
1792
+ for (const se of n.userInput(normalizedContent))
1793
+ emitCurrent(se);
1470
1794
  },
1471
1795
  onTerminalCommand: (cmd) => {
1472
1796
  const n = normalizer ?? startNormalizer();
1473
1797
  for (const se of n.terminalCommand(cmd))
1474
- emit(se);
1798
+ emitCurrent(se);
1475
1799
  },
1476
- onTodos: (todos) => emit({ type: "session.todos", sessionId: currentSessionId, todos }),
1800
+ onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
1477
1801
  onEvent: (event) => {
1478
1802
  const n = normalizer ?? startNormalizer();
1479
1803
  for (const se of n.next(event))
1480
- emit(se);
1804
+ emitCurrent(se);
1481
1805
  },
1482
1806
  onInteraction: forwardInteraction,
1483
1807
  onTurnEnd: (usage) => {
@@ -1485,11 +1809,13 @@ export class LocalAgentHost {
1485
1809
  return;
1486
1810
  const rid = currentResponseId;
1487
1811
  // statusLine usage (context/cost) rides the turn's response.completed.
1488
- if (usage)
1812
+ if (usage) {
1489
1813
  for (const se of normalizer.next({ type: "turn_completed", usage }))
1490
- emit(se);
1814
+ emitCurrent(se);
1815
+ }
1491
1816
  for (const se of normalizer.next({ type: "done" }))
1492
- emit(se);
1817
+ emitCurrent(se);
1818
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1493
1819
  normalizer = null;
1494
1820
  currentResponseId = undefined;
1495
1821
  // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
@@ -1497,7 +1823,7 @@ export class LocalAgentHost {
1497
1823
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1498
1824
  if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1499
1825
  live.contextWarned = true;
1500
- emit({
1826
+ emitCurrent({
1501
1827
  type: "session.status",
1502
1828
  sessionId: currentSessionId,
1503
1829
  responseId: rid,
@@ -1510,7 +1836,7 @@ export class LocalAgentHost {
1510
1836
  // Surface idle on the current turn WITHOUT finalizing it (see the sink's
1511
1837
  // onIdle doc): a late assistant record still joins this response.
1512
1838
  if (currentResponseId) {
1513
- emit({
1839
+ emitCurrent({
1514
1840
  type: "session.status",
1515
1841
  sessionId: currentSessionId,
1516
1842
  responseId: currentResponseId,
@@ -1521,87 +1847,115 @@ export class LocalAgentHost {
1521
1847
  onTurnError: () => {
1522
1848
  if (!normalizer)
1523
1849
  return;
1850
+ const rid = currentResponseId;
1524
1851
  for (const se of normalizer.fail({
1525
1852
  code: "agent_error",
1526
1853
  message: "Agent turn failed",
1527
1854
  source: "execution",
1528
1855
  }))
1529
- emit(se);
1856
+ emitCurrent(se);
1857
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1530
1858
  normalizer = null;
1531
1859
  currentResponseId = undefined;
1532
1860
  },
1533
- onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
1861
+ onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
1862
+ onSessionResumeError: (error) => {
1863
+ live.error = error.message;
1864
+ live.markFailed();
1865
+ },
1534
1866
  onSessionRotated: (kind, claudeSessionId) => {
1867
+ if (pendingRotationEvents) {
1868
+ live.error = "Claude reported another Session rotation before publication completed";
1869
+ live.markFailed();
1870
+ return;
1871
+ }
1535
1872
  // 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.
1873
+ // new transcript. Persist the Provider binding first, then announce on
1874
+ // the OLD session and re-target the mirror. Events produced during the
1875
+ // file-store commit are buffered so none can land under the old identity.
1539
1876
  const previousSessionId = currentSessionId;
1540
1877
  const newSessionId = makeSessionId();
1541
- emit({ type: "session.rotated", sessionId: previousSessionId, newSessionId, kind });
1878
+ currentSessionId = newSessionId;
1879
+ normalizer = null;
1880
+ currentResponseId = undefined;
1881
+ live.contextWarned = false;
1882
+ pendingRotationEvents = [];
1883
+ this.liveClaudeSessions.set(newSessionId, live);
1542
1884
  void this.sessionStore
1543
1885
  .set({
1544
1886
  localThreadId: newSessionId,
1545
1887
  codexSessionId: claudeSessionId,
1546
- cwd: live.cwd,
1547
- model,
1548
- runtime: "claude",
1549
- ...(record?.agent ? { agent: record.agent } : {}),
1550
1888
  ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1551
1889
  updatedAt: new Date().toISOString(),
1552
1890
  })
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,
1891
+ .then(() => {
1892
+ emit({
1893
+ type: "session.rotated",
1894
+ sessionId: previousSessionId,
1895
+ newSessionId,
1896
+ kind,
1897
+ });
1898
+ retargetMirror?.(newSessionId, {
1899
+ kind,
1900
+ workspace: structuredClone(live.workspace),
1901
+ execution: structuredClone(live.execution),
1902
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
1903
+ });
1904
+ const queued = pendingRotationEvents ?? [];
1905
+ pendingRotationEvents = null;
1906
+ for (const event of queued)
1907
+ emit(event);
1908
+ })
1909
+ .catch((error) => {
1910
+ pendingRotationEvents = null;
1911
+ live.error = error instanceof Error ? error.message : String(error);
1912
+ live.markFailed();
1565
1913
  });
1566
1914
  },
1567
1915
  };
1568
- // Resume: a known claude session id → tail its transcript immediately.
1569
- const transcriptPath = record?.codexSessionId
1570
- ? claudeTranscriptPath(cwd, record.codexSessionId)
1571
- : undefined;
1916
+ // Claude itself resolves `--resume <id>`. The SessionStart hook is the
1917
+ // authoritative source for the actual transcript path; do not infer or
1918
+ // generate Claude's private on-disk format here.
1572
1919
  live.forwarder = new ClaudeLiveSession({
1573
1920
  bridgeDir,
1574
1921
  sink,
1575
- ...(transcriptPath ? { transcriptPath } : {}),
1576
- ...(record?.codexSessionId ? { claudeSessionId: record.codexSessionId } : {}),
1922
+ ...(forkIntent
1923
+ ? { claudeSessionId: forkIntent.targetClaudeSessionId }
1924
+ : record?.codexSessionId
1925
+ ? { claudeSessionId: record.codexSessionId }
1926
+ : {}),
1927
+ ...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
1577
1928
  });
1578
1929
  this.liveClaudeSessions.set(localThreadId, live);
1579
1930
  live.forwarder.start();
1580
- if (record?.codexSessionId) {
1581
- live.discoveredReady = true;
1582
- markReady(); // resumed → injection can proceed without awaiting discovery
1583
- }
1584
1931
  return true;
1585
1932
  }
1586
1933
  /** Persist claude's discovered session id (reusing the `codexSessionId` store
1587
1934
  * field, as the claude executor already does) and release the readiness gate. */
1588
- onClaudeDiscovered(live, localThreadId, agent, claudeSessionId) {
1935
+ onClaudeDiscovered(live, localThreadId, claudeSessionId) {
1589
1936
  if (live.discoveredReady)
1590
1937
  return;
1938
+ const forkIntent = live.forkIntent;
1939
+ if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
1940
+ live.error =
1941
+ `Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
1942
+ live.markFailed();
1943
+ return;
1944
+ }
1591
1945
  live.discoveredReady = true;
1592
1946
  void this.sessionStore
1593
1947
  .set({
1594
1948
  localThreadId,
1595
1949
  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,
1950
+ ...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
1601
1951
  updatedAt: new Date().toISOString(),
1602
1952
  })
1603
- .catch(() => undefined);
1604
- live.markReady();
1953
+ .then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
1954
+ .then(() => live.markReady())
1955
+ .catch((error) => {
1956
+ live.error = error instanceof Error ? error.message : String(error);
1957
+ live.markFailed();
1958
+ });
1605
1959
  }
1606
1960
  /** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
1607
1961
  * serialized per session. Parks until the thread is ready AND the tmux injector
@@ -1683,12 +2037,12 @@ export class LocalAgentHost {
1683
2037
  if (!record?.codexSessionId) {
1684
2038
  return { ok: false, reason: "no_active_thread" };
1685
2039
  }
1686
- const backend = this.getBackend(record.runtime ?? "codex");
1687
- if (!backend.appServerClient) {
2040
+ const live = this.liveSessions.get(localThreadId);
2041
+ if (!live) {
1688
2042
  return { ok: false, reason: "unsupported" };
1689
2043
  }
1690
2044
  try {
1691
- return { ok: true, data: await fn(backend.appServerClient, record.codexSessionId) };
2045
+ return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
1692
2046
  }
1693
2047
  catch (error) {
1694
2048
  return {
@@ -1714,30 +2068,108 @@ export class LocalAgentHost {
1714
2068
  await client.threadGoalClear({ threadId });
1715
2069
  });
1716
2070
  }
1717
- async forkSession(currentLocalThreadId, newLocalThreadId) {
2071
+ async forkSession(currentLocalThreadId, newLocalThreadId, options) {
2072
+ const inflight = this.forkingTargets.get(newLocalThreadId);
2073
+ if (inflight)
2074
+ return inflight;
2075
+ const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
2076
+ this.forkingTargets.set(newLocalThreadId, operation);
2077
+ try {
2078
+ return await operation;
2079
+ }
2080
+ finally {
2081
+ if (this.forkingTargets.get(newLocalThreadId) === operation) {
2082
+ this.forkingTargets.delete(newLocalThreadId);
2083
+ }
2084
+ }
2085
+ }
2086
+ async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
1718
2087
  const record = await this.sessionStore.get(currentLocalThreadId);
1719
2088
  if (!record) {
1720
2089
  return { ok: false, reason: "no_active_thread" };
1721
2090
  }
1722
- const runtime = record.runtime ?? "codex";
1723
- const backend = this.getBackend(runtime);
1724
- if (!backend.appServerClient) {
1725
- return { ok: false, reason: "unsupported" };
2091
+ const live = this.liveSessions.get(currentLocalThreadId);
2092
+ const workspace = live?.workspace ?? options?.workspace;
2093
+ const execution = live?.execution ?? options?.execution;
2094
+ if (!workspace || !execution) {
2095
+ return { ok: false, reason: "no_active_thread" };
2096
+ }
2097
+ if (execution.provider === "claude") {
2098
+ return {
2099
+ ok: false,
2100
+ reason: "unsupported",
2101
+ message: "Claude fork must be materialized by the target runner",
2102
+ };
1726
2103
  }
1727
2104
  try {
1728
- const forked = await backend.appServerClient.threadFork({
1729
- threadId: record.codexSessionId,
1730
- model: record.model,
1731
- cwd: record.cwd,
1732
- });
2105
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
2106
+ if (existingTarget) {
2107
+ if (existingTarget.parentSessionId !== currentLocalThreadId) {
2108
+ return {
2109
+ ok: false,
2110
+ reason: "error",
2111
+ message: "target Session already belongs to a different fork",
2112
+ };
2113
+ }
2114
+ return { ok: true, data: undefined };
2115
+ }
2116
+ const runtime = execution.provider;
2117
+ const client = live?.injectClient ??
2118
+ this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
2119
+ if (!client) {
2120
+ return { ok: false, reason: "unsupported" };
2121
+ }
2122
+ await client.ensureInitialized();
2123
+ if (live?.managedFork) {
2124
+ return {
2125
+ ok: false,
2126
+ reason: "error",
2127
+ message: "source Session already has a managed fork in progress",
2128
+ };
2129
+ }
2130
+ const managedFork = live
2131
+ ? {
2132
+ sourceThreadId: record.codexSessionId,
2133
+ observedThreadIds: new Set(),
2134
+ }
2135
+ : undefined;
2136
+ const forked = await (async () => {
2137
+ if (live && managedFork)
2138
+ live.managedFork = managedFork;
2139
+ try {
2140
+ return await client.threadFork({
2141
+ threadId: record.codexSessionId,
2142
+ excludeTurns: true,
2143
+ ...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
2144
+ ...(execution.model ? { model: execution.model } : {}),
2145
+ approvalPolicy: execution.approvalPolicy,
2146
+ });
2147
+ }
2148
+ finally {
2149
+ if (live && live.managedFork === managedFork)
2150
+ delete live.managedFork;
2151
+ for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
2152
+ this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
2153
+ }
2154
+ }
2155
+ })();
1733
2156
  const forkedThreadId = forked.thread.id;
2157
+ if (live && forkedThreadId) {
2158
+ this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
2159
+ }
2160
+ if (!forkedThreadId ||
2161
+ forkedThreadId === record.codexSessionId ||
2162
+ (forked.thread.forkedFromId !== undefined &&
2163
+ forked.thread.forkedFromId !== null &&
2164
+ forked.thread.forkedFromId !== record.codexSessionId)) {
2165
+ throw new Error("Provider returned an invalid forked thread");
2166
+ }
2167
+ const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
1734
2168
  await this.sessionStore.set({
1735
2169
  localThreadId: newLocalThreadId,
1736
2170
  codexSessionId: forkedThreadId,
1737
- cwd: record.cwd,
1738
- model: record.model,
1739
- reasoningEffort: record.reasoningEffort,
1740
- runtime,
2171
+ parentSessionId: currentLocalThreadId,
2172
+ runtimeHomeOwnerSessionId: sourceHomeOwner,
1741
2173
  updatedAt: new Date().toISOString(),
1742
2174
  });
1743
2175
  return { ok: true, data: undefined };
@@ -1751,6 +2183,12 @@ export class LocalAgentHost {
1751
2183
  }
1752
2184
  }
1753
2185
  }
2186
+ function codexInjectionError(error) {
2187
+ if (error instanceof CodexTransportError) {
2188
+ return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
2189
+ }
2190
+ return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
2191
+ }
1754
2192
  export function parseCodexLoginStatus(exitCode, output) {
1755
2193
  const normalized = output.trim();
1756
2194
  const match = normalized.match(/Logged in using (.+)$/im);
@@ -1774,25 +2212,48 @@ export function parseCodexLoginStatus(exitCode, output) {
1774
2212
  issues: normalized ? [normalized] : [],
1775
2213
  };
1776
2214
  }
1777
- /** Race a live session's `ready` promise against a timeout → resolves boolean. */
1778
- function raceReady(ready, timeoutMs) {
2215
+ /** Race a live session's `ready` promise against failure / timeout. */
2216
+ function raceReady(ready, timeoutMs, failed) {
1779
2217
  let timer;
1780
2218
  const timeout = new Promise((resolve) => {
1781
2219
  timer = setTimeout(() => resolve(false), timeoutMs);
1782
2220
  });
1783
- return Promise.race([ready.then(() => true), timeout]).finally(() => {
2221
+ const outcomes = [ready.then(() => true), timeout];
2222
+ if (failed)
2223
+ outcomes.push(failed.then(() => false));
2224
+ return Promise.race(outcomes).finally(() => {
1784
2225
  if (timer)
1785
2226
  clearTimeout(timer);
1786
2227
  });
1787
2228
  }
1788
- function liveConfigSignature(opts, runtime, cwd) {
2229
+ function sameSessionSnapshots(workspace, execution, opts) {
1789
2230
  return JSON.stringify(stableValue({
1790
- runtime,
1791
- cwd,
1792
- agentName: opts?.agentName,
1793
- agentSpec: opts?.agentSpec,
2231
+ workspace,
2232
+ execution,
2233
+ })) === JSON.stringify(stableValue({
2234
+ workspace: opts.workspace,
2235
+ execution: opts.execution,
1794
2236
  }));
1795
2237
  }
2238
+ /** Freeze the Session's permission choice into the explicit managed settings.
2239
+ * Host/project settings may still contribute allow/deny rules, but changing
2240
+ * their defaultMode cannot silently mutate an existing Session on resume. */
2241
+ function applyClaudePermissionModeSnapshot(settings, permissionMode) {
2242
+ const next = { ...settings };
2243
+ const inherited = settings.permissions;
2244
+ const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
2245
+ ? { ...inherited }
2246
+ : {};
2247
+ if (permissionMode)
2248
+ permissions.defaultMode = permissionMode;
2249
+ else
2250
+ delete permissions.defaultMode;
2251
+ if (Object.keys(permissions).length > 0)
2252
+ next.permissions = permissions;
2253
+ else
2254
+ delete next.permissions;
2255
+ return next;
2256
+ }
1796
2257
  function stableValue(value) {
1797
2258
  if (Array.isArray(value))
1798
2259
  return value.map(stableValue);
@@ -1826,3 +2287,10 @@ export function isThreadNotReadyError(error) {
1826
2287
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1827
2288
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
1828
2289
  }
2290
+ /** A persisted thread id that a freshly started app-server cannot load yet.
2291
+ * During startup, both errors can be transient while the rollout index catches
2292
+ * up. Retry the same id; never use either error as permission to replace it. */
2293
+ export function isRetryableThreadResumeError(error) {
2294
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2295
+ return message.includes("thread not found") || isThreadNotReadyError(error);
2296
+ }