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

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 (38) 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 +799 -351
  16. package/dist/index.d.ts +2 -3
  17. package/dist/index.js +1 -2
  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 +92 -19
  25. package/dist/runner/manager.js +838 -83
  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/dist/terminal/tmux.d.ts +8 -0
  35. package/dist/terminal/tmux.js +36 -3
  36. package/package.json +2 -2
  37. package/dist/codex/rollout-synth.d.ts +0 -42
  38. package/dist/codex/rollout-synth.js +0 -245
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import { AgentRuntimeError, } from "@rynx-ai/core";
23
23
  import { listRuntimeModels } from "../models-catalog.js";
24
24
  import { probeRuntimeStatus } from "../runtime-status.js";
25
+ import { terminateTmuxServer } from "../terminal/tmux.js";
25
26
  import { fromWireError, } from "./protocol.js";
26
27
  import { StdioRunnerTransport } from "./transport.js";
27
28
  /** Routing key for the shared capability runner (slash-command RPCs). */
@@ -36,6 +37,13 @@ const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
36
37
  const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
37
38
  /** Thread readiness may legitimately wait through Provider startup. */
38
39
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
40
+ /** A submitted TUI command should become a mirrored turn or native rotation
41
+ * quickly. If it does not, the runner is fenced by a verified process-tree
42
+ * shutdown before maintenance may treat the submission as settled. */
43
+ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
44
+ /** Retry transient terminal-server cleanup without spinning forever. */
45
+ const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
46
+ const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
39
47
  /** Keep per-Session spawn context small enough to remain an environment handoff,
40
48
  * not an unbounded transport. */
41
49
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -139,6 +147,8 @@ export class RunnerManager {
139
147
  spawn;
140
148
  childEnv;
141
149
  sessionContextProvider;
150
+ admissionOpen;
151
+ admissionReserve;
142
152
  now;
143
153
  defaultRuntime;
144
154
  handles = new Map();
@@ -148,8 +158,13 @@ export class RunnerManager {
148
158
  reapTimer;
149
159
  shutdownGraceMs;
150
160
  shutdownKillGraceMs;
161
+ signalChild;
162
+ terminateTerminalServer;
151
163
  liveStartTimeoutMs;
152
164
  liveReadyTimeoutMs;
165
+ liveInterruptTimeoutMs;
166
+ terminalInputHandoffTimeoutMs;
167
+ terminalInputCleanupRetryMs;
153
168
  stopping = false;
154
169
  stopPromise;
155
170
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -160,6 +175,24 @@ export class RunnerManager {
160
175
  liveSessionKeys = new Set();
161
176
  /** Last live-start error per local session, surfaced by the control API. */
162
177
  liveErrors = new Map();
178
+ /** Last immutable launch snapshots seen for a Session. Used only to restore a
179
+ * target request that crossed a fork reservation boundary. */
180
+ liveOptions = new Map();
181
+ /** A fork reserves its target before the first asynchronous store read. This
182
+ * prevents another entry point from starting the target against an
183
+ * uncommitted Provider binding. */
184
+ forkReservations = new Map();
185
+ /** A native fork temporarily makes the source read-only so its canonical
186
+ * snapshot and Provider context are captured at the same boundary. */
187
+ sourceForkReservations = new Map();
188
+ /** Manager-wide fork de-duplication. LocalAgentHost only sees one source
189
+ * runner, so the fence must live here to cover concurrent source runners. */
190
+ forkOperations = new Map();
191
+ forkBufferedMessages = new Map();
192
+ forkBufferedTerminalInputs = new Map();
193
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
194
+ * mirrored response or a durably published native rotation. */
195
+ terminalInputHandoffs = new Map();
163
196
  constructor(opts) {
164
197
  this.config = opts.config;
165
198
  this.sessionStore = opts.sessionStore;
@@ -168,12 +201,19 @@ export class RunnerManager {
168
201
  this.spawn = opts.spawn ?? nodeSpawn;
169
202
  this.childEnv = opts.childEnv ?? {};
170
203
  this.sessionContextProvider = opts.sessionContextProvider;
204
+ this.admissionOpen = opts.admissionOpen ?? (() => true);
205
+ this.admissionReserve = opts.admissionReserve;
171
206
  this.now = opts.now ?? (() => Date.now());
172
- this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
207
+ this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
173
208
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
174
209
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
210
+ this.signalChild = opts.signalChild ?? signalRunnerChild;
211
+ this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
175
212
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
176
213
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
214
+ this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
215
+ this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
216
+ this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
177
217
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
178
218
  if (reapIntervalMs > 0) {
179
219
  this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
@@ -201,6 +241,10 @@ export class RunnerManager {
201
241
  * and attach.
202
242
  */
203
243
  openLiveTerminal(localThreadId, opts) {
244
+ this.assertAdmissionOpen();
245
+ if (this.forkReservations.has(localThreadId)) {
246
+ throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
247
+ }
204
248
  const handle = this.handles.get(localThreadId);
205
249
  if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
206
250
  throw new TerminalOpenError("terminal not live", "terminal_not_live");
@@ -210,7 +254,50 @@ export class RunnerManager {
210
254
  openTerminalOnHandle(handle, localThreadId, opts) {
211
255
  handle.lastUsedAt = this.now();
212
256
  const attachId = randomUUID();
213
- const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
257
+ const inputTracker = {
258
+ buffer: "",
259
+ previousWasCarriageReturn: false,
260
+ bracketedPaste: false,
261
+ pendingEscape: "",
262
+ };
263
+ const terminal = new ManagedTerminal(attachId, (msg) => {
264
+ const handoffs = msg.t === "term.input" && opts.role === "owner"
265
+ ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
+ : [];
267
+ const send = () => {
268
+ try {
269
+ handle.transport.send(msg);
270
+ }
271
+ catch (error) {
272
+ for (const handoff of handoffs) {
273
+ this.finishTerminalInputHandoff(handle, handoff);
274
+ }
275
+ throw error;
276
+ }
277
+ };
278
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
279
+ if (msg.t === "term.input" && sourceReservation) {
280
+ void sourceReservation.then(() => {
281
+ if (!handle.dead)
282
+ send();
283
+ }).catch((error) => {
284
+ void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
285
+ logTerminationFailure(handle, terminationError);
286
+ });
287
+ });
288
+ return;
289
+ }
290
+ // A terminal request may race a fork reservation. Never let later
291
+ // keystrokes reach an unpublished Provider target.
292
+ if (msg.t === "term.input") {
293
+ const target = this.reservedForkTarget(handle, localThreadId);
294
+ if (target) {
295
+ this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
296
+ return;
297
+ }
298
+ }
299
+ send();
300
+ }, () => handle.terminals.delete(attachId));
214
301
  handle.terminals.set(attachId, terminal);
215
302
  handle.transport.send({
216
303
  t: "term.open",
@@ -229,6 +316,8 @@ export class RunnerManager {
229
316
  }
230
317
  /** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
231
318
  hasLiveSession(localThreadId) {
319
+ if (this.forkReservations.has(localThreadId))
320
+ return false;
232
321
  const handle = this.handles.get(localThreadId);
233
322
  return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
234
323
  }
@@ -245,6 +334,152 @@ export class RunnerManager {
245
334
  onRotate(listener) {
246
335
  this.rotateListener = listener;
247
336
  }
337
+ /** Accepted TUI submissions that have not crossed into an observable runtime
338
+ * state. The daemon folds this into runningTurns after closing admission. */
339
+ pendingTerminalInputCount() {
340
+ let count = 0;
341
+ for (const handoffs of this.terminalInputHandoffs.values()) {
342
+ count += handoffs.length;
343
+ }
344
+ return count;
345
+ }
346
+ beginTerminalInputHandoffs(handle, sessionId, kinds) {
347
+ if (kinds.length === 0)
348
+ return [];
349
+ const created = [];
350
+ try {
351
+ for (const kind of kinds) {
352
+ const handoff = {
353
+ sessionId,
354
+ kind,
355
+ reservation: this.reserveAdmission(),
356
+ timer: undefined,
357
+ };
358
+ handoff.timer = setTimeout(() => {
359
+ this.expireTerminalInputHandoffs(handle);
360
+ }, this.terminalInputHandoffTimeoutMs);
361
+ handoff.timer.unref?.();
362
+ const handoffs = this.terminalInputHandoffs.get(handle);
363
+ if (handoffs)
364
+ handoffs.push(handoff);
365
+ else
366
+ this.terminalInputHandoffs.set(handle, [handoff]);
367
+ created.push(handoff);
368
+ }
369
+ return created;
370
+ }
371
+ catch (error) {
372
+ for (const handoff of created) {
373
+ this.finishTerminalInputHandoff(handle, handoff);
374
+ }
375
+ throw error;
376
+ }
377
+ }
378
+ settleTerminalInputHandoff(handle, sessionId, kind) {
379
+ const handoff = this.terminalInputHandoffs
380
+ .get(handle)
381
+ ?.find((candidate) => candidate.sessionId === sessionId);
382
+ // Runtime observations settle accepted input in submission order. In
383
+ // particular, a response from the source Session while /clear or /fork is
384
+ // still publishing must not skip that rotation and release a later turn
385
+ // which has not yet been rebound or delivered.
386
+ if (handoff?.kind === kind)
387
+ this.finishTerminalInputHandoff(handle, handoff);
388
+ }
389
+ settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
390
+ const handoffs = this.terminalInputHandoffs.get(handle);
391
+ const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
392
+ if (!handoffs || rotationIndex < 0)
393
+ return;
394
+ // Turns submitted before /clear or /fork are superseded once the rotation
395
+ // is published. Inputs accepted afterwards belong to the transferred pane
396
+ // and must follow it to the target Session.
397
+ const rotation = handoffs[rotationIndex];
398
+ for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
399
+ if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
400
+ this.finishTerminalInputHandoff(handle, handoff);
401
+ }
402
+ }
403
+ if (rotation)
404
+ this.finishTerminalInputHandoff(handle, rotation);
405
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
406
+ if (handoff.sessionId === sourceSessionId)
407
+ handoff.sessionId = targetSessionId;
408
+ }
409
+ }
410
+ currentTerminalSessionId(handle, fallback) {
411
+ for (const [sessionId, candidate] of this.handles) {
412
+ if (candidate === handle && this.liveSessionKeys.has(sessionId))
413
+ return sessionId;
414
+ }
415
+ return fallback;
416
+ }
417
+ finishTerminalInputHandoff(handle, handoff) {
418
+ if (handoff.timer) {
419
+ clearTimeout(handoff.timer);
420
+ handoff.timer = undefined;
421
+ }
422
+ handoff.reservation?.release();
423
+ handoff.reservation = undefined;
424
+ const handoffs = this.terminalInputHandoffs.get(handle);
425
+ if (!handoffs)
426
+ return;
427
+ const index = handoffs.indexOf(handoff);
428
+ if (index >= 0)
429
+ handoffs.splice(index, 1);
430
+ if (handoffs.length === 0)
431
+ this.terminalInputHandoffs.delete(handle);
432
+ }
433
+ finishAllTerminalInputHandoffs(handle) {
434
+ if (handle.terminalCleanupRetryTimer) {
435
+ clearTimeout(handle.terminalCleanupRetryTimer);
436
+ delete handle.terminalCleanupRetryTimer;
437
+ }
438
+ handle.terminalCleanupRetryFailures = 0;
439
+ for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
440
+ this.finishTerminalInputHandoff(handle, handoff);
441
+ }
442
+ }
443
+ preserveTerminalInputHandoffsAsActivity(handle) {
444
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
445
+ if (handoff.timer) {
446
+ clearTimeout(handoff.timer);
447
+ handoff.timer = undefined;
448
+ }
449
+ handoff.reservation?.release();
450
+ handoff.reservation = undefined;
451
+ }
452
+ }
453
+ scheduleTerminalInputCleanupRetry(handle) {
454
+ if (handle.terminalCleanupRetryTimer ||
455
+ !this.terminalInputHandoffs.has(handle))
456
+ return;
457
+ const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
458
+ const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
459
+ handle.terminalCleanupRetryTimer = setTimeout(() => {
460
+ delete handle.terminalCleanupRetryTimer;
461
+ if (!this.terminalInputHandoffs.has(handle))
462
+ return;
463
+ void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
464
+ logTerminationFailure(handle, error);
465
+ });
466
+ }, delay);
467
+ handle.terminalCleanupRetryTimer.unref?.();
468
+ }
469
+ expireTerminalInputHandoffs(handle) {
470
+ const handoffs = this.terminalInputHandoffs.get(handle);
471
+ if (!handoffs?.length)
472
+ return;
473
+ for (const handoff of handoffs) {
474
+ if (handoff.timer) {
475
+ clearTimeout(handoff.timer);
476
+ handoff.timer = undefined;
477
+ }
478
+ }
479
+ void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
480
+ logTerminationFailure(handle, error);
481
+ });
482
+ }
248
483
  /**
249
484
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
250
485
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -252,16 +487,38 @@ export class RunnerManager {
252
487
  * non-codex / non-live session (the caller then uses the normal run path).
253
488
  */
254
489
  ensureLiveSession(localThreadId, opts) {
255
- return this.requestLiveSession(localThreadId, opts, true);
490
+ const admission = this.reserveAdmission();
491
+ return this.requestLiveSession(localThreadId, opts, true)
492
+ .finally(() => admission.release());
256
493
  }
257
494
  /** Start the Provider pane without waiting for login/onboarding to create a
258
495
  * native thread. This is the setup-terminal gate; callers may attach as soon
259
496
  * as it resolves, while normal message delivery still uses ensureLiveSession. */
260
497
  startLiveSession(localThreadId, opts) {
261
- return this.requestLiveSession(localThreadId, opts, false);
498
+ const admission = this.reserveAdmission();
499
+ return this.requestLiveSession(localThreadId, opts, false)
500
+ .finally(() => admission.release());
262
501
  }
263
- requestLiveSession(localThreadId, opts, waitForReady) {
264
- const handle = this.getOrSpawn(localThreadId);
502
+ requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
503
+ const sourceReservation = allowReservedForkTarget
504
+ ? undefined
505
+ : this.sourceForkReservations.get(localThreadId);
506
+ if (sourceReservation) {
507
+ return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
508
+ }
509
+ this.liveOptions.set(localThreadId, {
510
+ workspace: structuredClone(opts.workspace),
511
+ execution: structuredClone(opts.execution),
512
+ ...(opts.cols ? { cols: opts.cols } : {}),
513
+ ...(opts.rows ? { rows: opts.rows } : {}),
514
+ });
515
+ const reservation = allowReservedForkTarget
516
+ ? undefined
517
+ : this.forkReservations.get(localThreadId);
518
+ if (reservation) {
519
+ return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
520
+ }
521
+ const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
265
522
  handle.lastUsedAt = this.now();
266
523
  this.liveSessionKeys.add(localThreadId);
267
524
  const reqId = randomUUID();
@@ -270,39 +527,60 @@ export class RunnerManager {
270
527
  const timeout = setTimeout(() => {
271
528
  if (!handle.live.delete(reqId))
272
529
  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
- });
530
+ const finishTimeout = () => {
531
+ const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
532
+ this.liveErrors.set(localThreadId, reason);
533
+ this.liveSessionKeys.delete(localThreadId);
534
+ // A child that cannot answer a bounded control round-trip is unsafe
535
+ // to reuse. Reap it so the next click gets a fresh runner.
536
+ void this.terminateHandle(handle, reason).catch((error) => {
537
+ logTerminationFailure(handle, error);
538
+ });
539
+ return false;
540
+ };
541
+ const reservation = allowReservedForkTarget
542
+ ? undefined
543
+ : this.forkReservations.get(localThreadId);
544
+ if (reservation) {
545
+ void reservation
546
+ .then(() => finishTimeout())
547
+ .then(resolve, () => resolve(false));
548
+ return;
549
+ }
550
+ resolve(finishTimeout());
282
551
  }, timeoutMs);
283
552
  timeout.unref?.();
284
553
  handle.live.set(reqId, (res) => {
285
554
  clearTimeout(timeout);
286
555
  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");
556
+ const finish = () => {
557
+ if (ok) {
558
+ this.liveErrors.delete(localThreadId);
559
+ }
560
+ else {
561
+ this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
562
+ }
563
+ return ok;
564
+ };
565
+ const reservation = allowReservedForkTarget
566
+ ? undefined
567
+ : this.forkReservations.get(localThreadId);
568
+ if (reservation) {
569
+ void reservation
570
+ .then(() => finish())
571
+ .then(resolve, () => resolve(false));
572
+ return;
292
573
  }
293
- resolve(ok);
574
+ resolve(finish());
294
575
  });
295
576
  handle.transport.send({
296
577
  t: "live.ensure",
297
578
  reqId,
298
579
  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 } : {}),
580
+ workspace: opts.workspace,
581
+ execution: opts.execution,
582
+ ...(opts.cols ? { cols: opts.cols } : {}),
583
+ ...(opts.rows ? { rows: opts.rows } : {}),
306
584
  waitForReady,
307
585
  });
308
586
  });
@@ -317,11 +595,43 @@ export class RunnerManager {
317
595
  * the session has no live forwarder (caller falls back to the run path).
318
596
  */
319
597
  injectMessage(localThreadId, input) {
598
+ const admission = this.reserveAdmission();
599
+ return this.injectMessageAdmitted(localThreadId, input)
600
+ .finally(() => admission.release());
601
+ }
602
+ injectMessageAdmitted(localThreadId, input) {
603
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
604
+ if (sourceReservation) {
605
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
606
+ }
607
+ const reservation = this.forkReservations.get(localThreadId);
608
+ if (reservation) {
609
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
610
+ }
320
611
  const handle = this.getOrSpawn(localThreadId);
321
612
  handle.lastUsedAt = this.now();
322
613
  const reqId = randomUUID();
323
614
  return new Promise((resolve) => {
324
- handle.live.set(reqId, (res) => resolve(res.outcome ?? "failed"));
615
+ handle.live.set(reqId, (res) => {
616
+ const outcome = res.outcome ?? "failed";
617
+ const finish = () => {
618
+ if (outcome === "injected") {
619
+ this.liveErrors.delete(localThreadId);
620
+ }
621
+ else {
622
+ this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
623
+ }
624
+ return outcome;
625
+ };
626
+ const reservation = this.forkReservations.get(localThreadId);
627
+ if (reservation) {
628
+ void reservation
629
+ .then(() => finish())
630
+ .then(resolve, () => resolve("failed"));
631
+ return;
632
+ }
633
+ resolve(finish());
634
+ });
325
635
  handle.transport.send(typeof input === "string"
326
636
  ? { t: "inject", reqId, localThreadId, text: input }
327
637
  : { t: "inject", reqId, localThreadId, input });
@@ -339,7 +649,16 @@ export class RunnerManager {
339
649
  handle.lastUsedAt = this.now();
340
650
  const reqId = randomUUID();
341
651
  return new Promise((resolve) => {
342
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
652
+ const timeout = setTimeout(() => {
653
+ if (!handle.live.delete(reqId))
654
+ return;
655
+ resolve(false);
656
+ }, this.liveInterruptTimeoutMs);
657
+ timeout.unref?.();
658
+ handle.live.set(reqId, (res) => {
659
+ clearTimeout(timeout);
660
+ resolve(res.ok ?? false);
661
+ });
343
662
  handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
344
663
  });
345
664
  }
@@ -382,9 +701,83 @@ export class RunnerManager {
382
701
  clearGoal(localThreadId) {
383
702
  return this.forwardCap("clearGoal", [localThreadId], localThreadId);
384
703
  }
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);
704
+ forkSession(currentLocalThreadId, newLocalThreadId, options) {
705
+ this.assertAdmissionOpen();
706
+ if (currentLocalThreadId === newLocalThreadId) {
707
+ return Promise.resolve({
708
+ ok: false,
709
+ message: "source and target Session must differ",
710
+ });
711
+ }
712
+ const inflight = this.forkOperations.get(newLocalThreadId);
713
+ if (inflight) {
714
+ if (inflight.sourceSessionId === currentLocalThreadId) {
715
+ return inflight.operation.then(capabilityForkResult);
716
+ }
717
+ return Promise.resolve({
718
+ ok: false,
719
+ message: "target Session already has a fork in progress",
720
+ });
721
+ }
722
+ if (this.sourceForkReservations.has(currentLocalThreadId)) {
723
+ return Promise.resolve({
724
+ ok: false,
725
+ message: "source Session already has a fork in progress",
726
+ });
727
+ }
728
+ let releaseReservation;
729
+ const reservation = new Promise((resolve) => {
730
+ releaseReservation = resolve;
731
+ });
732
+ let releaseSource;
733
+ const sourceReservation = new Promise((resolve) => {
734
+ releaseSource = resolve;
735
+ });
736
+ this.forkReservations.set(newLocalThreadId, reservation);
737
+ this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
738
+ this.forkBufferedMessages.set(newLocalThreadId, []);
739
+ this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
740
+ const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
741
+ const operation = (async () => {
742
+ await options.beforeProviderFork();
743
+ return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
744
+ })()
745
+ .finally(() => {
746
+ const current = this.forkOperations.get(newLocalThreadId);
747
+ if (current?.operation === operation) {
748
+ this.forkOperations.delete(newLocalThreadId);
749
+ }
750
+ if (this.forkReservations.get(newLocalThreadId) === reservation) {
751
+ this.forkReservations.delete(newLocalThreadId);
752
+ }
753
+ if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
754
+ this.sourceForkReservations.delete(currentLocalThreadId);
755
+ }
756
+ const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
757
+ this.forkBufferedMessages.delete(newLocalThreadId);
758
+ const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
759
+ this.forkBufferedTerminalInputs.delete(newLocalThreadId);
760
+ for (const entry of buffered) {
761
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
762
+ }
763
+ for (const entry of bufferedTerminalInputs) {
764
+ if (!entry.handle.dead)
765
+ entry.handle.transport.send(entry.message);
766
+ }
767
+ releaseReservation();
768
+ releaseSource();
769
+ queueMicrotask(() => {
770
+ if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
771
+ && !this.handles.has(newLocalThreadId)) {
772
+ this.liveOptions.delete(newLocalThreadId);
773
+ }
774
+ });
775
+ });
776
+ this.forkOperations.set(newLocalThreadId, {
777
+ sourceSessionId: currentLocalThreadId,
778
+ operation,
779
+ });
780
+ return operation.then(capabilityForkResult);
388
781
  }
389
782
  /**
390
783
  * Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
@@ -395,7 +788,7 @@ export class RunnerManager {
395
788
  }
396
789
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
397
790
  stopRunner(localThreadId) {
398
- const handle = this.handles.get(localThreadId);
791
+ const handle = this.handleForCleanup(localThreadId);
399
792
  if (!handle) {
400
793
  return;
401
794
  }
@@ -403,6 +796,15 @@ export class RunnerManager {
403
796
  logTerminationFailure(handle, error);
404
797
  });
405
798
  }
799
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
800
+ * completion proves that the child process has exited. */
801
+ async terminateLiveSession(localThreadId) {
802
+ const handle = this.handleForCleanup(localThreadId);
803
+ if (!handle)
804
+ return false;
805
+ await this.terminateHandle(handle, "runner force-stopped");
806
+ return true;
807
+ }
406
808
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
407
809
  stop() {
408
810
  if (this.stopPromise)
@@ -411,12 +813,16 @@ export class RunnerManager {
411
813
  if (this.reapTimer) {
412
814
  clearInterval(this.reapTimer);
413
815
  }
414
- const children = [...this.childHandles];
415
- this.stopPromise = (async () => {
416
- const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
816
+ const children = new Set([
817
+ ...this.childHandles,
818
+ ...this.terminalInputHandoffs.keys(),
819
+ ]);
820
+ const attempt = (async () => {
821
+ const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
417
822
  this.handles.clear();
418
823
  this.liveSessionKeys.clear();
419
824
  this.liveErrors.clear();
825
+ this.liveOptions.clear();
420
826
  this.mirrorListener = null;
421
827
  this.rotateListener = null;
422
828
  const errors = results
@@ -427,9 +833,19 @@ export class RunnerManager {
427
833
  }
428
834
  this.childHandles.clear();
429
835
  })();
430
- return this.stopPromise;
836
+ this.stopPromise = attempt;
837
+ void attempt.catch(() => {
838
+ if (this.stopPromise === attempt)
839
+ this.stopPromise = undefined;
840
+ });
841
+ return attempt;
431
842
  }
432
843
  // ── internals ──────────────────────────────────────────────────────────────
844
+ handleForCleanup(localThreadId) {
845
+ return this.handles.get(localThreadId) ??
846
+ [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
847
+ handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
848
+ }
433
849
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
434
850
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
435
851
  * run on that session's child (which owns its private CODEX_HOME). */
@@ -442,19 +858,255 @@ export class RunnerManager {
442
858
  handle.transport.send({ t: "cap", capId, name, args });
443
859
  });
444
860
  }
445
- getOrSpawn(key) {
446
- if (this.stopping) {
447
- throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
861
+ getOrSpawn(key, allowReservedForkTarget = false) {
862
+ const admission = this.reserveAdmission();
863
+ try {
864
+ if (this.stopping) {
865
+ throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
866
+ }
867
+ if (key !== CAP_KEY &&
868
+ this.forkReservations.has(key) &&
869
+ !allowReservedForkTarget) {
870
+ throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
871
+ }
872
+ this.reapIdle();
873
+ const existing = this.handles.get(key);
874
+ if (existing && !existing.dead) {
875
+ existing.lastUsedAt = this.now();
876
+ return existing;
877
+ }
878
+ const handle = this.spawnHandle(key);
879
+ this.handles.set(key, handle);
880
+ return handle;
448
881
  }
449
- this.reapIdle();
450
- const existing = this.handles.get(key);
451
- if (existing && !existing.dead) {
452
- existing.lastUsedAt = this.now();
453
- return existing;
882
+ finally {
883
+ admission.release();
454
884
  }
455
- const handle = this.spawnHandle(key);
456
- this.handles.set(key, handle);
457
- return handle;
885
+ }
886
+ assertAdmissionOpen() {
887
+ const reservation = this.admissionReserve?.();
888
+ if (reservation) {
889
+ reservation.release();
890
+ return;
891
+ }
892
+ if (!this.admissionReserve && this.admissionOpen())
893
+ return;
894
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
895
+ }
896
+ reserveAdmission() {
897
+ const reservation = this.admissionReserve?.();
898
+ if (reservation)
899
+ return reservation;
900
+ if (!this.admissionReserve && this.admissionOpen()) {
901
+ return { release() { } };
902
+ }
903
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
904
+ }
905
+ async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
906
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
907
+ if (existingTarget?.parentSessionId === currentLocalThreadId) {
908
+ return { ok: true, data: undefined };
909
+ }
910
+ if (existingTarget?.parentSessionId) {
911
+ return {
912
+ ok: false,
913
+ reason: "error",
914
+ message: "target Session already belongs to a different fork",
915
+ };
916
+ }
917
+ const targetHandle = this.handles.get(newLocalThreadId);
918
+ if (existingTarget) {
919
+ return {
920
+ ok: false,
921
+ reason: "error",
922
+ message: "target Session already has a Provider binding",
923
+ };
924
+ }
925
+ if (targetHandle && !targetHandle.dead) {
926
+ return {
927
+ ok: false,
928
+ reason: "error",
929
+ message: "target Session already has a runner",
930
+ };
931
+ }
932
+ if (options.execution.provider === "claude") {
933
+ return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
934
+ }
935
+ // Runs on the SOURCE session's child (its home has the source rollout to
936
+ // fork). The Provider persists the native fork and target binding before
937
+ // returning. Only then is the target reservation released.
938
+ return this.forwardCap("forkSession", [
939
+ currentLocalThreadId,
940
+ newLocalThreadId,
941
+ {
942
+ workspace: structuredClone(options.workspace),
943
+ execution: structuredClone(options.execution),
944
+ },
945
+ ], currentLocalThreadId);
946
+ }
947
+ async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
948
+ const source = await this.sessionStore.get(currentLocalThreadId);
949
+ const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
950
+ const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
951
+ if (!source?.codexSessionId || !setIntent || !deleteIntent) {
952
+ return {
953
+ ok: false,
954
+ reason: "unsupported",
955
+ message: "Claude fork persistence is unavailable",
956
+ };
957
+ }
958
+ const targetClaudeSessionId = randomUUID();
959
+ try {
960
+ await setIntent({
961
+ targetSessionId: newLocalThreadId,
962
+ sourceSessionId: currentLocalThreadId,
963
+ sourceClaudeSessionId: source.codexSessionId,
964
+ targetClaudeSessionId,
965
+ updatedAt: new Date().toISOString(),
966
+ });
967
+ const ready = await this.requestLiveSession(newLocalThreadId, {
968
+ workspace: structuredClone(options.workspace),
969
+ execution: structuredClone(options.execution),
970
+ }, true, true);
971
+ const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
972
+ if (!target ||
973
+ target.parentSessionId !== currentLocalThreadId ||
974
+ target.codexSessionId !== targetClaudeSessionId) {
975
+ throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
976
+ "Claude did not materialize the requested fork");
977
+ }
978
+ return { ok: true, data: undefined };
979
+ }
980
+ catch (error) {
981
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
982
+ const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
983
+ if (target?.parentSessionId === currentLocalThreadId) {
984
+ await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
985
+ }
986
+ const handle = this.handles.get(newLocalThreadId);
987
+ if (handle && !handle.dead) {
988
+ await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
989
+ }
990
+ return {
991
+ ok: false,
992
+ reason: "error",
993
+ message: error instanceof Error ? error.message : String(error),
994
+ };
995
+ }
996
+ }
997
+ bufferForkTargetMessage(handle, message) {
998
+ const directTarget = message.t === "mirror" ? message.sessionId : message.from;
999
+ const target = this.reservedForkTarget(handle, directTarget);
1000
+ if (!target)
1001
+ return false;
1002
+ this.forkBufferedMessages.get(target)?.push({ handle, message });
1003
+ return true;
1004
+ }
1005
+ reservedForkTarget(handle, directTarget) {
1006
+ return this.forkReservations.has(directTarget)
1007
+ ? directTarget
1008
+ : [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
1009
+ }
1010
+ deliverForkBufferedMessage(handle, message) {
1011
+ if (handle.dead)
1012
+ return;
1013
+ if (message.t === "mirror") {
1014
+ this.mirrorListener?.(message.sessionId, message.event);
1015
+ if (message.event.type === "response.created") {
1016
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1017
+ }
1018
+ return;
1019
+ }
1020
+ this.deliverRotateMessage(handle, message);
1021
+ }
1022
+ deliverRotateMessage(handle, message) {
1023
+ if (this.forkReservations.has(message.to) ||
1024
+ this.sourceForkReservations.has(message.from)) {
1025
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1026
+ return;
1027
+ }
1028
+ try {
1029
+ // Rotate the runtime-local context before publishing the new Session
1030
+ // alias to routing or observers. This prevents the new identity from
1031
+ // briefly inheriting stale context after `/clear` or `/fork`.
1032
+ handle.sessionContext?.rotate(message.to);
1033
+ }
1034
+ catch (error) {
1035
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1036
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
1037
+ logTerminationFailure(handle, terminationError);
1038
+ });
1039
+ return;
1040
+ }
1041
+ let releaseTarget;
1042
+ const targetReservation = new Promise((resolve) => {
1043
+ releaseTarget = resolve;
1044
+ });
1045
+ let releaseSource;
1046
+ const sourceReservation = new Promise((resolve) => {
1047
+ releaseSource = resolve;
1048
+ });
1049
+ this.forkReservations.set(message.to, targetReservation);
1050
+ this.sourceForkReservations.set(message.from, sourceReservation);
1051
+ this.forkBufferedMessages.set(message.to, []);
1052
+ this.forkBufferedTerminalInputs.set(message.to, []);
1053
+ // The physical pane has already rotated. Remove every old routing alias now:
1054
+ // a later request for the source must spawn a fresh runner that resumes the
1055
+ // source Provider binding, while existing terminal attachments continue to
1056
+ // follow the transferred pane.
1057
+ for (const [key, candidate] of this.handles) {
1058
+ if (candidate !== handle)
1059
+ continue;
1060
+ this.handles.delete(key);
1061
+ this.liveSessionKeys.delete(key);
1062
+ this.liveOptions.delete(key);
1063
+ }
1064
+ const rotation = {
1065
+ from: message.from,
1066
+ to: message.to,
1067
+ kind: message.kind,
1068
+ workspace: message.workspace,
1069
+ execution: message.execution,
1070
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1071
+ };
1072
+ void Promise.resolve(this.rotateListener?.(rotation))
1073
+ .then(() => {
1074
+ // The server's rotation listener resolves only after the target Session
1075
+ // is published. Release the terminal admission handoff afterwards so a
1076
+ // maintenance snapshot cannot observe a gap with neither source work
1077
+ // nor the target Session.
1078
+ this.settleTerminalRotationHandoff(handle, message.from, message.to);
1079
+ if (handle.dead)
1080
+ return;
1081
+ this.handles.set(message.to, handle);
1082
+ this.liveSessionKeys.add(message.to);
1083
+ this.liveOptions.set(message.to, {
1084
+ workspace: structuredClone(message.workspace),
1085
+ execution: structuredClone(message.execution),
1086
+ });
1087
+ for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
1088
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
1089
+ }
1090
+ for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
1091
+ if (!entry.handle.dead)
1092
+ entry.handle.transport.send(entry.message);
1093
+ }
1094
+ })
1095
+ .catch((error) => {
1096
+ void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
1097
+ })
1098
+ .finally(() => {
1099
+ if (this.forkReservations.get(message.to) === targetReservation) {
1100
+ this.forkReservations.delete(message.to);
1101
+ }
1102
+ if (this.sourceForkReservations.get(message.from) === sourceReservation) {
1103
+ this.sourceForkReservations.delete(message.from);
1104
+ }
1105
+ this.forkBufferedMessages.delete(message.to);
1106
+ this.forkBufferedTerminalInputs.delete(message.to);
1107
+ releaseTarget();
1108
+ releaseSource();
1109
+ });
458
1110
  }
459
1111
  spawnHandle(key) {
460
1112
  const args = this.runnerEntry.endsWith(".ts")
@@ -463,10 +1115,12 @@ export class RunnerManager {
463
1115
  const sessionContext = key === CAP_KEY
464
1116
  ? undefined
465
1117
  : this.openSessionContext(key);
1118
+ const processGroup = process.platform !== "win32";
466
1119
  let child;
467
1120
  try {
468
1121
  child = this.spawn(process.execPath, args, {
469
1122
  stdio: ["pipe", "pipe", "pipe"],
1123
+ detached: processGroup,
470
1124
  // `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
471
1125
  // for the shared capability child) so its LocalAgentHost scopes the private
472
1126
  // CODEX_HOME to this session. It is deliberately applied last.
@@ -492,6 +1146,8 @@ export class RunnerManager {
492
1146
  lastUsedAt: this.now(),
493
1147
  dead: false,
494
1148
  completion,
1149
+ terminalCleanupRetryFailures: 0,
1150
+ processGroup,
495
1151
  ...(sessionContext ? { sessionContext } : {}),
496
1152
  caps: new Map(),
497
1153
  terminals: new Map(),
@@ -550,37 +1206,24 @@ export class RunnerManager {
550
1206
  return;
551
1207
  }
552
1208
  case "mirror":
1209
+ if (handle.dead)
1210
+ return;
1211
+ if (this.bufferForkTargetMessage(handle, msg))
1212
+ return;
553
1213
  this.mirrorListener?.(msg.sessionId, msg.event);
1214
+ if (msg.event.type === "response.created") {
1215
+ // The listener projects response.created into SessionRuntimeIndex
1216
+ // synchronously. Only then may the accepted terminal reservation
1217
+ // drain into a maintenance activity snapshot.
1218
+ this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
1219
+ }
554
1220
  return;
555
1221
  case "rotate": {
556
1222
  if (handle.dead)
557
1223
  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
- });
1224
+ if (this.bufferForkTargetMessage(handle, msg))
569
1225
  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
- });
1226
+ this.deliverRotateMessage(handle, msg);
584
1227
  return;
585
1228
  }
586
1229
  case "live.ready":
@@ -614,6 +1257,9 @@ export class RunnerManager {
614
1257
  if (h === handle) {
615
1258
  this.handles.delete(key);
616
1259
  this.liveSessionKeys.delete(key);
1260
+ if (!this.forkReservations.has(key)) {
1261
+ this.liveOptions.delete(key);
1262
+ }
617
1263
  }
618
1264
  }
619
1265
  const tail = handle.stderr.join("\n");
@@ -636,7 +1282,11 @@ export class RunnerManager {
636
1282
  terminateHandle(handle, reason) {
637
1283
  if (handle.termination)
638
1284
  return handle.termination;
639
- handle.termination = (async () => {
1285
+ if (handle.terminalCleanupRetryTimer) {
1286
+ clearTimeout(handle.terminalCleanupRetryTimer);
1287
+ delete handle.terminalCleanupRetryTimer;
1288
+ }
1289
+ const attempt = (async () => {
640
1290
  if (!handle.dead) {
641
1291
  try {
642
1292
  handle.transport.send({ t: "shutdown" });
@@ -646,16 +1296,37 @@ export class RunnerManager {
646
1296
  }
647
1297
  this.failHandle(handle, reason);
648
1298
  }
649
- signalChild(handle.child, "SIGTERM");
650
- if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
651
- signalChild(handle.child, "SIGKILL");
652
- if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
653
- throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
654
- }
1299
+ this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1300
+ let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
1301
+ if (!childExited) {
1302
+ this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1303
+ childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1304
+ }
1305
+ const terminalStopped = handle.key === CAP_KEY
1306
+ ? true
1307
+ : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1308
+ if (!childExited) {
1309
+ throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1310
+ }
1311
+ if (!terminalStopped) {
1312
+ throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
655
1313
  }
656
1314
  this.childHandles.delete(handle);
657
1315
  })();
658
- return handle.termination;
1316
+ handle.termination = attempt;
1317
+ void attempt.then(() => {
1318
+ this.finishAllTerminalInputHandoffs(handle);
1319
+ }, () => {
1320
+ if (handle.termination === attempt)
1321
+ delete handle.termination;
1322
+ handle.terminalCleanupRetryFailures += 1;
1323
+ // A failed cleanup must not pin closeAndDrain forever. Keep the
1324
+ // submission in the daemon activity snapshot, but release its gate
1325
+ // reservation so maintenance returns a structured busy result.
1326
+ this.preserveTerminalInputHandoffsAsActivity(handle);
1327
+ this.scheduleTerminalInputCleanupRetry(handle);
1328
+ });
1329
+ return attempt;
659
1330
  }
660
1331
  reapIdle() {
661
1332
  const now = this.now();
@@ -694,6 +1365,70 @@ export class RunnerManager {
694
1365
  }
695
1366
  }
696
1367
  }
1368
+ function trackedTerminalSubmissions(tracker, data) {
1369
+ const pasteStart = "\u001b[200~";
1370
+ const pasteEnd = "\u001b[201~";
1371
+ const input = tracker.pendingEscape + data;
1372
+ tracker.pendingEscape = "";
1373
+ const submissions = [];
1374
+ for (let index = 0; index < input.length; index += 1) {
1375
+ const character = input[index] ?? "";
1376
+ if (character === "\u001b") {
1377
+ const remaining = input.slice(index);
1378
+ if (remaining.startsWith(pasteStart)) {
1379
+ tracker.bracketedPaste = true;
1380
+ tracker.previousWasCarriageReturn = false;
1381
+ index += pasteStart.length - 1;
1382
+ continue;
1383
+ }
1384
+ if (remaining.startsWith(pasteEnd)) {
1385
+ tracker.bracketedPaste = false;
1386
+ tracker.previousWasCarriageReturn = false;
1387
+ index += pasteEnd.length - 1;
1388
+ continue;
1389
+ }
1390
+ if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
1391
+ tracker.pendingEscape = remaining;
1392
+ break;
1393
+ }
1394
+ }
1395
+ if (tracker.bracketedPaste) {
1396
+ tracker.buffer += character;
1397
+ tracker.previousWasCarriageReturn = false;
1398
+ continue;
1399
+ }
1400
+ if (character === "\n" && tracker.previousWasCarriageReturn) {
1401
+ tracker.previousWasCarriageReturn = false;
1402
+ continue;
1403
+ }
1404
+ tracker.previousWasCarriageReturn = character === "\r";
1405
+ if (character === "\r" || character === "\n") {
1406
+ const command = tracker.buffer.trim();
1407
+ tracker.buffer = "";
1408
+ if (command.length > 0 &&
1409
+ (command.includes("\u001b") ||
1410
+ !command.startsWith("/") ||
1411
+ /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1412
+ submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1413
+ }
1414
+ continue;
1415
+ }
1416
+ if (character === "\b" || character === "\u007f") {
1417
+ tracker.buffer = tracker.buffer.slice(0, -1);
1418
+ continue;
1419
+ }
1420
+ if (character === "\u0015" || character === "\u0003") {
1421
+ tracker.buffer = "";
1422
+ continue;
1423
+ }
1424
+ // Printable text and tabs are enough to distinguish work-producing prompts
1425
+ // from local TUI slash commands. Other control sequences are ignored.
1426
+ if (character === "\u001b" || character === "\t" || character >= " ") {
1427
+ tracker.buffer += character;
1428
+ }
1429
+ }
1430
+ return submissions;
1431
+ }
697
1432
  function validateSessionContext(context) {
698
1433
  if (!context || typeof context !== "object") {
699
1434
  throw new Error("Session context environment provider must return an object");
@@ -798,9 +1533,18 @@ function observeChildCompletion(child) {
798
1533
  child.once("exit", onExit);
799
1534
  });
800
1535
  }
801
- function signalChild(child, signal) {
1536
+ function signalRunnerChild(child, signal, processGroup) {
802
1537
  if (childHasExited(child))
803
1538
  return;
1539
+ if (processGroup && child.pid) {
1540
+ try {
1541
+ process.kill(-child.pid, signal);
1542
+ return;
1543
+ }
1544
+ catch {
1545
+ // The group may have exited between the completion check and the signal.
1546
+ }
1547
+ }
804
1548
  try {
805
1549
  child.kill(signal);
806
1550
  }
@@ -836,3 +1580,14 @@ function logTerminationFailure(handle, error) {
836
1580
  error: error instanceof Error ? error.message : String(error),
837
1581
  }));
838
1582
  }
1583
+ function capabilityForkResult(result) {
1584
+ return result.ok
1585
+ ? { ok: true }
1586
+ : {
1587
+ ok: false,
1588
+ message: result.message ??
1589
+ (result.reason === "unsupported"
1590
+ ? "Provider does not support Session fork"
1591
+ : "Provider Session is unavailable"),
1592
+ };
1593
+ }