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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/claude/native-bridge.js +3 -8
  2. package/dist/claude/native-integration.d.ts +12 -1
  3. package/dist/claude/native-integration.js +16 -2
  4. package/dist/claude/transcript.d.ts +0 -7
  5. package/dist/claude/transcript.js +6 -20
  6. package/dist/codex-app-server/client.d.ts +2 -1
  7. package/dist/codex-app-server/forwarder.d.ts +4 -1
  8. package/dist/codex-app-server/forwarder.js +19 -1
  9. package/dist/codex-app-server/protocol.d.ts +45 -1
  10. package/dist/codex-home.d.ts +9 -26
  11. package/dist/codex-home.js +37 -65
  12. package/dist/codex-session-store.d.ts +22 -10
  13. package/dist/codex-session-store.js +277 -12
  14. package/dist/host.d.ts +47 -47
  15. package/dist/host.js +790 -350
  16. package/dist/index.d.ts +1 -2
  17. package/dist/index.js +0 -1
  18. package/dist/models-catalog.d.ts +1 -0
  19. package/dist/models-catalog.js +43 -1
  20. package/dist/provider-workspace.d.ts +56 -0
  21. package/dist/provider-workspace.js +83 -0
  22. package/dist/runner/child.d.ts +54 -6
  23. package/dist/runner/child.js +42 -17
  24. package/dist/runner/manager.d.ts +41 -18
  25. package/dist/runner/manager.js +432 -55
  26. package/dist/runner/protocol.d.ts +7 -18
  27. package/dist/runner-main.js +12 -4
  28. package/dist/runtime-state-paths.d.ts +10 -0
  29. package/dist/runtime-state-paths.js +53 -0
  30. package/dist/terminal/claude-tui.d.ts +8 -1
  31. package/dist/terminal/claude-tui.js +7 -1
  32. package/dist/terminal/codex-tui.d.ts +5 -1
  33. package/dist/terminal/codex-tui.js +12 -3
  34. package/package.json +2 -2
  35. package/dist/codex/rollout-synth.d.ts +0 -42
  36. package/dist/codex/rollout-synth.js +0 -245
@@ -160,6 +160,21 @@ export class RunnerManager {
160
160
  liveSessionKeys = new Set();
161
161
  /** Last live-start error per local session, surfaced by the control API. */
162
162
  liveErrors = new Map();
163
+ /** Last immutable launch snapshots seen for a Session. Used only to restore a
164
+ * target request that crossed a fork reservation boundary. */
165
+ liveOptions = new Map();
166
+ /** A fork reserves its target before the first asynchronous store read. This
167
+ * prevents another entry point from starting the target against an
168
+ * uncommitted Provider binding. */
169
+ forkReservations = new Map();
170
+ /** A native fork temporarily makes the source read-only so its canonical
171
+ * snapshot and Provider context are captured at the same boundary. */
172
+ sourceForkReservations = new Map();
173
+ /** Manager-wide fork de-duplication. LocalAgentHost only sees one source
174
+ * runner, so the fence must live here to cover concurrent source runners. */
175
+ forkOperations = new Map();
176
+ forkBufferedMessages = new Map();
177
+ forkBufferedTerminalInputs = new Map();
163
178
  constructor(opts) {
164
179
  this.config = opts.config;
165
180
  this.sessionStore = opts.sessionStore;
@@ -201,6 +216,9 @@ export class RunnerManager {
201
216
  * and attach.
202
217
  */
203
218
  openLiveTerminal(localThreadId, opts) {
219
+ if (this.forkReservations.has(localThreadId)) {
220
+ throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
221
+ }
204
222
  const handle = this.handles.get(localThreadId);
205
223
  if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
206
224
  throw new TerminalOpenError("terminal not live", "terminal_not_live");
@@ -210,7 +228,26 @@ export class RunnerManager {
210
228
  openTerminalOnHandle(handle, localThreadId, opts) {
211
229
  handle.lastUsedAt = this.now();
212
230
  const attachId = randomUUID();
213
- const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
231
+ const terminal = new ManagedTerminal(attachId, (msg) => {
232
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
233
+ if (msg.t === "term.input" && sourceReservation) {
234
+ void sourceReservation.then(() => {
235
+ if (!handle.dead)
236
+ handle.transport.send(msg);
237
+ });
238
+ return;
239
+ }
240
+ // A terminal request may race a fork reservation. Never let later
241
+ // keystrokes reach an unpublished Provider target.
242
+ if (msg.t === "term.input") {
243
+ const target = this.reservedForkTarget(handle, localThreadId);
244
+ if (target) {
245
+ this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
246
+ return;
247
+ }
248
+ }
249
+ handle.transport.send(msg);
250
+ }, () => handle.terminals.delete(attachId));
214
251
  handle.terminals.set(attachId, terminal);
215
252
  handle.transport.send({
216
253
  t: "term.open",
@@ -229,6 +266,8 @@ export class RunnerManager {
229
266
  }
230
267
  /** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
231
268
  hasLiveSession(localThreadId) {
269
+ if (this.forkReservations.has(localThreadId))
270
+ return false;
232
271
  const handle = this.handles.get(localThreadId);
233
272
  return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
234
273
  }
@@ -260,8 +299,26 @@ export class RunnerManager {
260
299
  startLiveSession(localThreadId, opts) {
261
300
  return this.requestLiveSession(localThreadId, opts, false);
262
301
  }
263
- requestLiveSession(localThreadId, opts, waitForReady) {
264
- const handle = this.getOrSpawn(localThreadId);
302
+ requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
303
+ const sourceReservation = allowReservedForkTarget
304
+ ? undefined
305
+ : this.sourceForkReservations.get(localThreadId);
306
+ if (sourceReservation) {
307
+ return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
308
+ }
309
+ this.liveOptions.set(localThreadId, {
310
+ workspace: structuredClone(opts.workspace),
311
+ execution: structuredClone(opts.execution),
312
+ ...(opts.cols ? { cols: opts.cols } : {}),
313
+ ...(opts.rows ? { rows: opts.rows } : {}),
314
+ });
315
+ const reservation = allowReservedForkTarget
316
+ ? undefined
317
+ : this.forkReservations.get(localThreadId);
318
+ if (reservation) {
319
+ return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
320
+ }
321
+ const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
265
322
  handle.lastUsedAt = this.now();
266
323
  this.liveSessionKeys.add(localThreadId);
267
324
  const reqId = randomUUID();
@@ -270,39 +327,60 @@ export class RunnerManager {
270
327
  const timeout = setTimeout(() => {
271
328
  if (!handle.live.delete(reqId))
272
329
  return;
273
- const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
274
- this.liveErrors.set(localThreadId, reason);
275
- this.liveSessionKeys.delete(localThreadId);
276
- resolve(false);
277
- // A child that cannot answer a bounded control round-trip is unsafe to
278
- // reuse. Reap it so the next click gets a fresh runner.
279
- void this.terminateHandle(handle, reason).catch((error) => {
280
- logTerminationFailure(handle, error);
281
- });
330
+ const finishTimeout = () => {
331
+ const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
332
+ this.liveErrors.set(localThreadId, reason);
333
+ this.liveSessionKeys.delete(localThreadId);
334
+ // A child that cannot answer a bounded control round-trip is unsafe
335
+ // to reuse. Reap it so the next click gets a fresh runner.
336
+ void this.terminateHandle(handle, reason).catch((error) => {
337
+ logTerminationFailure(handle, error);
338
+ });
339
+ return false;
340
+ };
341
+ const reservation = allowReservedForkTarget
342
+ ? undefined
343
+ : this.forkReservations.get(localThreadId);
344
+ if (reservation) {
345
+ void reservation
346
+ .then(() => finishTimeout())
347
+ .then(resolve, () => resolve(false));
348
+ return;
349
+ }
350
+ resolve(finishTimeout());
282
351
  }, timeoutMs);
283
352
  timeout.unref?.();
284
353
  handle.live.set(reqId, (res) => {
285
354
  clearTimeout(timeout);
286
355
  const ok = res.ok ?? false;
287
- if (ok) {
288
- this.liveErrors.delete(localThreadId);
289
- }
290
- else {
291
- this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
356
+ const finish = () => {
357
+ if (ok) {
358
+ this.liveErrors.delete(localThreadId);
359
+ }
360
+ else {
361
+ this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
362
+ }
363
+ return ok;
364
+ };
365
+ const reservation = allowReservedForkTarget
366
+ ? undefined
367
+ : this.forkReservations.get(localThreadId);
368
+ if (reservation) {
369
+ void reservation
370
+ .then(() => finish())
371
+ .then(resolve, () => resolve(false));
372
+ return;
292
373
  }
293
- resolve(ok);
374
+ resolve(finish());
294
375
  });
295
376
  handle.transport.send({
296
377
  t: "live.ensure",
297
378
  reqId,
298
379
  localThreadId,
299
- ...(opts?.cwd ? { cwd: opts.cwd } : {}),
300
- ...(opts?.cols ? { cols: opts.cols } : {}),
301
- ...(opts?.rows ? { rows: opts.rows } : {}),
302
- ...(opts?.runtime ? { runtime: opts.runtime } : {}),
303
- ...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
304
- ...(opts?.agentName ? { agentName: opts.agentName } : {}),
305
- ...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
380
+ workspace: opts.workspace,
381
+ execution: opts.execution,
382
+ ...(opts.cols ? { cols: opts.cols } : {}),
383
+ ...(opts.rows ? { rows: opts.rows } : {}),
306
384
  waitForReady,
307
385
  });
308
386
  });
@@ -317,11 +395,38 @@ export class RunnerManager {
317
395
  * the session has no live forwarder (caller falls back to the run path).
318
396
  */
319
397
  injectMessage(localThreadId, input) {
398
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
399
+ if (sourceReservation) {
400
+ return sourceReservation.then(() => this.injectMessage(localThreadId, input));
401
+ }
402
+ const reservation = this.forkReservations.get(localThreadId);
403
+ if (reservation) {
404
+ return reservation.then(() => this.injectMessage(localThreadId, input));
405
+ }
320
406
  const handle = this.getOrSpawn(localThreadId);
321
407
  handle.lastUsedAt = this.now();
322
408
  const reqId = randomUUID();
323
409
  return new Promise((resolve) => {
324
- handle.live.set(reqId, (res) => resolve(res.outcome ?? "failed"));
410
+ handle.live.set(reqId, (res) => {
411
+ const outcome = res.outcome ?? "failed";
412
+ const finish = () => {
413
+ if (outcome === "injected") {
414
+ this.liveErrors.delete(localThreadId);
415
+ }
416
+ else {
417
+ this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
418
+ }
419
+ return outcome;
420
+ };
421
+ const reservation = this.forkReservations.get(localThreadId);
422
+ if (reservation) {
423
+ void reservation
424
+ .then(() => finish())
425
+ .then(resolve, () => resolve("failed"));
426
+ return;
427
+ }
428
+ resolve(finish());
429
+ });
325
430
  handle.transport.send(typeof input === "string"
326
431
  ? { t: "inject", reqId, localThreadId, text: input }
327
432
  : { t: "inject", reqId, localThreadId, input });
@@ -382,9 +487,82 @@ export class RunnerManager {
382
487
  clearGoal(localThreadId) {
383
488
  return this.forwardCap("clearGoal", [localThreadId], localThreadId);
384
489
  }
385
- forkSession(currentLocalThreadId, newLocalThreadId) {
386
- // Runs on the SOURCE session's child (its home has the source rollout to fork).
387
- return this.forwardCap("forkSession", [currentLocalThreadId, newLocalThreadId], currentLocalThreadId);
490
+ forkSession(currentLocalThreadId, newLocalThreadId, options) {
491
+ if (currentLocalThreadId === newLocalThreadId) {
492
+ return Promise.resolve({
493
+ ok: false,
494
+ message: "source and target Session must differ",
495
+ });
496
+ }
497
+ const inflight = this.forkOperations.get(newLocalThreadId);
498
+ if (inflight) {
499
+ if (inflight.sourceSessionId === currentLocalThreadId) {
500
+ return inflight.operation.then(capabilityForkResult);
501
+ }
502
+ return Promise.resolve({
503
+ ok: false,
504
+ message: "target Session already has a fork in progress",
505
+ });
506
+ }
507
+ if (this.sourceForkReservations.has(currentLocalThreadId)) {
508
+ return Promise.resolve({
509
+ ok: false,
510
+ message: "source Session already has a fork in progress",
511
+ });
512
+ }
513
+ let releaseReservation;
514
+ const reservation = new Promise((resolve) => {
515
+ releaseReservation = resolve;
516
+ });
517
+ let releaseSource;
518
+ const sourceReservation = new Promise((resolve) => {
519
+ releaseSource = resolve;
520
+ });
521
+ this.forkReservations.set(newLocalThreadId, reservation);
522
+ this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
523
+ this.forkBufferedMessages.set(newLocalThreadId, []);
524
+ this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
525
+ const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
526
+ const operation = (async () => {
527
+ await options.beforeProviderFork();
528
+ return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
529
+ })()
530
+ .finally(() => {
531
+ const current = this.forkOperations.get(newLocalThreadId);
532
+ if (current?.operation === operation) {
533
+ this.forkOperations.delete(newLocalThreadId);
534
+ }
535
+ if (this.forkReservations.get(newLocalThreadId) === reservation) {
536
+ this.forkReservations.delete(newLocalThreadId);
537
+ }
538
+ if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
539
+ this.sourceForkReservations.delete(currentLocalThreadId);
540
+ }
541
+ const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
542
+ this.forkBufferedMessages.delete(newLocalThreadId);
543
+ const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
544
+ this.forkBufferedTerminalInputs.delete(newLocalThreadId);
545
+ for (const entry of buffered) {
546
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
547
+ }
548
+ for (const entry of bufferedTerminalInputs) {
549
+ if (!entry.handle.dead)
550
+ entry.handle.transport.send(entry.message);
551
+ }
552
+ releaseReservation();
553
+ releaseSource();
554
+ queueMicrotask(() => {
555
+ if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
556
+ && !this.handles.has(newLocalThreadId)) {
557
+ this.liveOptions.delete(newLocalThreadId);
558
+ }
559
+ });
560
+ });
561
+ this.forkOperations.set(newLocalThreadId, {
562
+ sourceSessionId: currentLocalThreadId,
563
+ operation,
564
+ });
565
+ return operation.then(capabilityForkResult);
388
566
  }
389
567
  /**
390
568
  * Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
@@ -417,6 +595,7 @@ export class RunnerManager {
417
595
  this.handles.clear();
418
596
  this.liveSessionKeys.clear();
419
597
  this.liveErrors.clear();
598
+ this.liveOptions.clear();
420
599
  this.mirrorListener = null;
421
600
  this.rotateListener = null;
422
601
  const errors = results
@@ -442,10 +621,15 @@ export class RunnerManager {
442
621
  handle.transport.send({ t: "cap", capId, name, args });
443
622
  });
444
623
  }
445
- getOrSpawn(key) {
624
+ getOrSpawn(key, allowReservedForkTarget = false) {
446
625
  if (this.stopping) {
447
626
  throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
448
627
  }
628
+ if (key !== CAP_KEY &&
629
+ this.forkReservations.has(key) &&
630
+ !allowReservedForkTarget) {
631
+ throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
632
+ }
449
633
  this.reapIdle();
450
634
  const existing = this.handles.get(key);
451
635
  if (existing && !existing.dead) {
@@ -456,6 +640,204 @@ export class RunnerManager {
456
640
  this.handles.set(key, handle);
457
641
  return handle;
458
642
  }
643
+ async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
644
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
645
+ if (existingTarget?.parentSessionId === currentLocalThreadId) {
646
+ return { ok: true, data: undefined };
647
+ }
648
+ if (existingTarget?.parentSessionId) {
649
+ return {
650
+ ok: false,
651
+ reason: "error",
652
+ message: "target Session already belongs to a different fork",
653
+ };
654
+ }
655
+ const targetHandle = this.handles.get(newLocalThreadId);
656
+ if (existingTarget) {
657
+ return {
658
+ ok: false,
659
+ reason: "error",
660
+ message: "target Session already has a Provider binding",
661
+ };
662
+ }
663
+ if (targetHandle && !targetHandle.dead) {
664
+ return {
665
+ ok: false,
666
+ reason: "error",
667
+ message: "target Session already has a runner",
668
+ };
669
+ }
670
+ if (options.execution.provider === "claude") {
671
+ return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
672
+ }
673
+ // Runs on the SOURCE session's child (its home has the source rollout to
674
+ // fork). The Provider persists the native fork and target binding before
675
+ // returning. Only then is the target reservation released.
676
+ return this.forwardCap("forkSession", [
677
+ currentLocalThreadId,
678
+ newLocalThreadId,
679
+ {
680
+ workspace: structuredClone(options.workspace),
681
+ execution: structuredClone(options.execution),
682
+ },
683
+ ], currentLocalThreadId);
684
+ }
685
+ async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
686
+ const source = await this.sessionStore.get(currentLocalThreadId);
687
+ const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
688
+ const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
689
+ if (!source?.codexSessionId || !setIntent || !deleteIntent) {
690
+ return {
691
+ ok: false,
692
+ reason: "unsupported",
693
+ message: "Claude fork persistence is unavailable",
694
+ };
695
+ }
696
+ const targetClaudeSessionId = randomUUID();
697
+ try {
698
+ await setIntent({
699
+ targetSessionId: newLocalThreadId,
700
+ sourceSessionId: currentLocalThreadId,
701
+ sourceClaudeSessionId: source.codexSessionId,
702
+ targetClaudeSessionId,
703
+ updatedAt: new Date().toISOString(),
704
+ });
705
+ const ready = await this.requestLiveSession(newLocalThreadId, {
706
+ workspace: structuredClone(options.workspace),
707
+ execution: structuredClone(options.execution),
708
+ }, true, true);
709
+ const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
710
+ if (!target ||
711
+ target.parentSessionId !== currentLocalThreadId ||
712
+ target.codexSessionId !== targetClaudeSessionId) {
713
+ throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
714
+ "Claude did not materialize the requested fork");
715
+ }
716
+ return { ok: true, data: undefined };
717
+ }
718
+ catch (error) {
719
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
720
+ const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
721
+ if (target?.parentSessionId === currentLocalThreadId) {
722
+ await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
723
+ }
724
+ const handle = this.handles.get(newLocalThreadId);
725
+ if (handle && !handle.dead) {
726
+ await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
727
+ }
728
+ return {
729
+ ok: false,
730
+ reason: "error",
731
+ message: error instanceof Error ? error.message : String(error),
732
+ };
733
+ }
734
+ }
735
+ bufferForkTargetMessage(handle, message) {
736
+ const directTarget = message.t === "mirror" ? message.sessionId : message.from;
737
+ const target = this.reservedForkTarget(handle, directTarget);
738
+ if (!target)
739
+ return false;
740
+ this.forkBufferedMessages.get(target)?.push({ handle, message });
741
+ return true;
742
+ }
743
+ reservedForkTarget(handle, directTarget) {
744
+ return this.forkReservations.has(directTarget)
745
+ ? directTarget
746
+ : [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
747
+ }
748
+ deliverForkBufferedMessage(handle, message) {
749
+ if (handle.dead)
750
+ return;
751
+ if (message.t === "mirror") {
752
+ this.mirrorListener?.(message.sessionId, message.event);
753
+ return;
754
+ }
755
+ this.deliverRotateMessage(handle, message);
756
+ }
757
+ deliverRotateMessage(handle, message) {
758
+ if (this.forkReservations.has(message.to) ||
759
+ this.sourceForkReservations.has(message.from)) {
760
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
761
+ return;
762
+ }
763
+ try {
764
+ // Rotate the runtime-local context before publishing the new Session
765
+ // alias to routing or observers. This prevents the new identity from
766
+ // briefly inheriting stale context after `/clear` or `/fork`.
767
+ handle.sessionContext?.rotate(message.to);
768
+ }
769
+ catch (error) {
770
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
771
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
772
+ logTerminationFailure(handle, terminationError);
773
+ });
774
+ return;
775
+ }
776
+ let releaseTarget;
777
+ const targetReservation = new Promise((resolve) => {
778
+ releaseTarget = resolve;
779
+ });
780
+ let releaseSource;
781
+ const sourceReservation = new Promise((resolve) => {
782
+ releaseSource = resolve;
783
+ });
784
+ this.forkReservations.set(message.to, targetReservation);
785
+ this.sourceForkReservations.set(message.from, sourceReservation);
786
+ this.forkBufferedMessages.set(message.to, []);
787
+ this.forkBufferedTerminalInputs.set(message.to, []);
788
+ // The physical pane has already rotated. Remove every old routing alias now:
789
+ // a later request for the source must spawn a fresh runner that resumes the
790
+ // source Provider binding, while existing terminal attachments continue to
791
+ // follow the transferred pane.
792
+ for (const [key, candidate] of this.handles) {
793
+ if (candidate !== handle)
794
+ continue;
795
+ this.handles.delete(key);
796
+ this.liveSessionKeys.delete(key);
797
+ this.liveOptions.delete(key);
798
+ }
799
+ const rotation = {
800
+ from: message.from,
801
+ to: message.to,
802
+ kind: message.kind,
803
+ workspace: message.workspace,
804
+ execution: message.execution,
805
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
806
+ };
807
+ void Promise.resolve(this.rotateListener?.(rotation))
808
+ .then(() => {
809
+ if (handle.dead)
810
+ return;
811
+ this.handles.set(message.to, handle);
812
+ this.liveSessionKeys.add(message.to);
813
+ this.liveOptions.set(message.to, {
814
+ workspace: structuredClone(message.workspace),
815
+ execution: structuredClone(message.execution),
816
+ });
817
+ for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
818
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
819
+ }
820
+ for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
821
+ if (!entry.handle.dead)
822
+ entry.handle.transport.send(entry.message);
823
+ }
824
+ })
825
+ .catch((error) => {
826
+ void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
827
+ })
828
+ .finally(() => {
829
+ if (this.forkReservations.get(message.to) === targetReservation) {
830
+ this.forkReservations.delete(message.to);
831
+ }
832
+ if (this.sourceForkReservations.get(message.from) === sourceReservation) {
833
+ this.sourceForkReservations.delete(message.from);
834
+ }
835
+ this.forkBufferedMessages.delete(message.to);
836
+ this.forkBufferedTerminalInputs.delete(message.to);
837
+ releaseTarget();
838
+ releaseSource();
839
+ });
840
+ }
459
841
  spawnHandle(key) {
460
842
  const args = this.runnerEntry.endsWith(".ts")
461
843
  ? ["--import", "tsx", this.runnerEntry]
@@ -550,37 +932,18 @@ export class RunnerManager {
550
932
  return;
551
933
  }
552
934
  case "mirror":
935
+ if (handle.dead)
936
+ return;
937
+ if (this.bufferForkTargetMessage(handle, msg))
938
+ return;
553
939
  this.mirrorListener?.(msg.sessionId, msg.event);
554
940
  return;
555
941
  case "rotate": {
556
942
  if (handle.dead)
557
943
  return;
558
- try {
559
- // Rotate the runtime-local context before publishing the new Session
560
- // alias to routing or observers. This prevents the new identity from
561
- // briefly inheriting stale context after `/clear` or `/fork`.
562
- handle.sessionContext?.rotate(msg.to);
563
- }
564
- catch (error) {
565
- const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
566
- void this.terminateHandle(handle, reason).catch((terminationError) => {
567
- logTerminationFailure(handle, terminationError);
568
- });
944
+ if (this.bufferForkTargetMessage(handle, msg))
569
945
  return;
570
- }
571
- // Terminal transfer: alias the new session to THIS runner so its injection
572
- // (and live/approval) route to the same child that still owns the pane.
573
- this.handles.set(msg.to, handle);
574
- this.liveSessionKeys.add(msg.to);
575
- this.rotateListener?.({
576
- from: msg.from,
577
- to: msg.to,
578
- kind: msg.kind,
579
- ...(msg.agent ? { agent: msg.agent } : {}),
580
- ...(msg.model ? { model: msg.model } : {}),
581
- ...(msg.cwd ? { cwd: msg.cwd } : {}),
582
- ...(msg.parentSessionId ? { parentSessionId: msg.parentSessionId } : {}),
583
- });
946
+ this.deliverRotateMessage(handle, msg);
584
947
  return;
585
948
  }
586
949
  case "live.ready":
@@ -614,6 +977,9 @@ export class RunnerManager {
614
977
  if (h === handle) {
615
978
  this.handles.delete(key);
616
979
  this.liveSessionKeys.delete(key);
980
+ if (!this.forkReservations.has(key)) {
981
+ this.liveOptions.delete(key);
982
+ }
617
983
  }
618
984
  }
619
985
  const tail = handle.stderr.join("\n");
@@ -836,3 +1202,14 @@ function logTerminationFailure(handle, error) {
836
1202
  error: error instanceof Error ? error.message : String(error),
837
1203
  }));
838
1204
  }
1205
+ function capabilityForkResult(result) {
1206
+ return result.ok
1207
+ ? { ok: true }
1208
+ : {
1209
+ ok: false,
1210
+ message: result.message ??
1211
+ (result.reason === "unsupported"
1212
+ ? "Provider does not support Session fork"
1213
+ : "Provider Session is unavailable"),
1214
+ };
1215
+ }
@@ -6,7 +6,7 @@
6
6
  * (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
7
7
  * terminal attachment; the reply channels mirror each request's `reqId`.
8
8
  */
9
- import { AgentRuntimeError, type AgentRuntimeId, type AgentSpec, type ReasoningEffort, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent } from "@rynx-ai/core";
9
+ import { AgentRuntimeError, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
10
10
  import type { ResolveInteractionResult } from "../interactions.js";
11
11
  /** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
12
12
  export interface WireError {
@@ -76,22 +76,12 @@ export type ToChild = {
76
76
  t: "live.ensure";
77
77
  reqId: string;
78
78
  localThreadId: string;
79
- cwd?: string;
79
+ /** The two immutable Session snapshots. Provider launch/resume only
80
+ * projects these values and never resolves a Project or Agent. */
81
+ workspace: SessionWorkspaceSnapshot;
82
+ execution: ResolvedExecutionSnapshot;
80
83
  cols?: number;
81
84
  rows?: number;
82
- /** Runtime this session co-drives (resolved from the agent spec by the
83
- * control plane), so live picks the right backend per agent — not the
84
- * global default. Absent ⇒ host falls back to the store record / default. */
85
- runtime?: AgentRuntimeId;
86
- /** Model-advertised reasoning effort for the live TUI and turn/start. */
87
- reasoningEffort?: ReasoningEffort;
88
- /** The session's preset agent id, so the host can load its spec and apply
89
- * the agent's model / skills / instructions to the live launch (not just
90
- * the runtime). Absent for inline-config or agent-less sessions. */
91
- agentName?: string;
92
- /** An inline agent spec (console session created from an inline config
93
- * rather than a preset id) — same purpose as `agentName`. */
94
- agentSpec?: AgentSpec;
95
85
  /** Setup terminals only need the Provider pane to exist. Message delivery
96
86
  * keeps the default and waits until the native thread is actually bound. */
97
87
  waitForReady?: boolean;
@@ -166,9 +156,8 @@ export type FromChild = {
166
156
  from: string;
167
157
  to: string;
168
158
  kind: "clear" | "fork";
169
- agent?: string;
170
- model?: string;
171
- cwd?: string;
159
+ workspace: SessionWorkspaceSnapshot;
160
+ execution: ResolvedExecutionSnapshot;
172
161
  parentSessionId?: string;
173
162
  }
174
163
  /** Result of a `live.ensure`: `ok` once the requested gate is reached (pane