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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/claude/models.d.ts +0 -5
  2. package/dist/claude/models.js +1 -7
  3. package/dist/claude/native-bridge.js +3 -8
  4. package/dist/claude/native-integration.d.ts +12 -1
  5. package/dist/claude/native-integration.js +16 -2
  6. package/dist/claude/transcript.d.ts +0 -7
  7. package/dist/claude/transcript.js +6 -20
  8. package/dist/codex-app-server/client.d.ts +2 -1
  9. package/dist/codex-app-server/forwarder.d.ts +4 -1
  10. package/dist/codex-app-server/forwarder.js +19 -1
  11. package/dist/codex-app-server/protocol.d.ts +45 -1
  12. package/dist/codex-home.d.ts +9 -26
  13. package/dist/codex-home.js +37 -65
  14. package/dist/codex-session-store.d.ts +22 -10
  15. package/dist/codex-session-store.js +277 -12
  16. package/dist/host.d.ts +48 -47
  17. package/dist/host.js +810 -355
  18. package/dist/index.d.ts +2 -3
  19. package/dist/index.js +1 -2
  20. package/dist/models-catalog.d.ts +3 -1
  21. package/dist/models-catalog.js +125 -4
  22. package/dist/provider-workspace.d.ts +56 -0
  23. package/dist/provider-workspace.js +83 -0
  24. package/dist/runner/child.d.ts +59 -6
  25. package/dist/runner/child.js +138 -19
  26. package/dist/runner/manager.d.ts +104 -19
  27. package/dist/runner/manager.js +922 -89
  28. package/dist/runner/protocol.d.ts +7 -18
  29. package/dist/runner-main.js +12 -4
  30. package/dist/runtime-state-paths.d.ts +10 -0
  31. package/dist/runtime-state-paths.js +53 -0
  32. package/dist/terminal/claude-tui.d.ts +10 -1
  33. package/dist/terminal/claude-tui.js +9 -1
  34. package/dist/terminal/codex-tui.d.ts +5 -1
  35. package/dist/terminal/codex-tui.js +12 -3
  36. package/dist/terminal/tmux.d.ts +11 -1
  37. package/dist/terminal/tmux.js +61 -12
  38. package/package.json +3 -3
  39. package/dist/codex/rollout-synth.d.ts +0 -42
  40. package/dist/codex/rollout-synth.js +0 -245
@@ -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,19 @@ 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;
47
+ /** A Provider that never closes its active response must not keep a detached
48
+ * runner + tmux server alive forever. This is intentionally much longer than
49
+ * the normal idle TTL so legitimate long-running turns are not treated as
50
+ * stale. User input/control activity refreshes the deadline; Provider output
51
+ * does not, because a runaway redraw loop is the failure mode this bounds. */
52
+ const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
39
53
  /** Keep per-Session spawn context small enough to remain an environment handoff,
40
54
  * not an unbounded transport. */
41
55
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -136,9 +150,12 @@ export class RunnerManager {
136
150
  sessionStore;
137
151
  runnerEntry;
138
152
  idleTtlMs;
153
+ staleActiveTtlMs;
139
154
  spawn;
140
155
  childEnv;
141
156
  sessionContextProvider;
157
+ admissionOpen;
158
+ admissionReserve;
142
159
  now;
143
160
  defaultRuntime;
144
161
  handles = new Map();
@@ -148,8 +165,13 @@ export class RunnerManager {
148
165
  reapTimer;
149
166
  shutdownGraceMs;
150
167
  shutdownKillGraceMs;
168
+ signalChild;
169
+ terminateTerminalServer;
151
170
  liveStartTimeoutMs;
152
171
  liveReadyTimeoutMs;
172
+ liveInterruptTimeoutMs;
173
+ terminalInputHandoffTimeoutMs;
174
+ terminalInputCleanupRetryMs;
153
175
  stopping = false;
154
176
  stopPromise;
155
177
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -160,20 +182,46 @@ export class RunnerManager {
160
182
  liveSessionKeys = new Set();
161
183
  /** Last live-start error per local session, surfaced by the control API. */
162
184
  liveErrors = new Map();
185
+ /** Last immutable launch snapshots seen for a Session. Used only to restore a
186
+ * target request that crossed a fork reservation boundary. */
187
+ liveOptions = new Map();
188
+ /** A fork reserves its target before the first asynchronous store read. This
189
+ * prevents another entry point from starting the target against an
190
+ * uncommitted Provider binding. */
191
+ forkReservations = new Map();
192
+ /** A native fork temporarily makes the source read-only so its canonical
193
+ * snapshot and Provider context are captured at the same boundary. */
194
+ sourceForkReservations = new Map();
195
+ /** Manager-wide fork de-duplication. LocalAgentHost only sees one source
196
+ * runner, so the fence must live here to cover concurrent source runners. */
197
+ forkOperations = new Map();
198
+ forkBufferedMessages = new Map();
199
+ forkBufferedTerminalInputs = new Map();
200
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
201
+ * mirrored response or a durably published native rotation. */
202
+ terminalInputHandoffs = new Map();
163
203
  constructor(opts) {
164
204
  this.config = opts.config;
165
205
  this.sessionStore = opts.sessionStore;
166
206
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
167
207
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
208
+ this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
168
209
  this.spawn = opts.spawn ?? nodeSpawn;
169
210
  this.childEnv = opts.childEnv ?? {};
170
211
  this.sessionContextProvider = opts.sessionContextProvider;
212
+ this.admissionOpen = opts.admissionOpen ?? (() => true);
213
+ this.admissionReserve = opts.admissionReserve;
171
214
  this.now = opts.now ?? (() => Date.now());
172
- this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
215
+ this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
173
216
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
174
217
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
218
+ this.signalChild = opts.signalChild ?? signalRunnerChild;
219
+ this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
175
220
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
176
221
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
222
+ this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
223
+ this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
224
+ this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
177
225
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
178
226
  if (reapIntervalMs > 0) {
179
227
  this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
@@ -201,6 +249,10 @@ export class RunnerManager {
201
249
  * and attach.
202
250
  */
203
251
  openLiveTerminal(localThreadId, opts) {
252
+ this.assertAdmissionOpen();
253
+ if (this.forkReservations.has(localThreadId)) {
254
+ throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
255
+ }
204
256
  const handle = this.handles.get(localThreadId);
205
257
  if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
206
258
  throw new TerminalOpenError("terminal not live", "terminal_not_live");
@@ -210,7 +262,52 @@ export class RunnerManager {
210
262
  openTerminalOnHandle(handle, localThreadId, opts) {
211
263
  handle.lastUsedAt = this.now();
212
264
  const attachId = randomUUID();
213
- const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
265
+ const inputTracker = {
266
+ buffer: "",
267
+ previousWasCarriageReturn: false,
268
+ bracketedPaste: false,
269
+ pendingEscape: "",
270
+ };
271
+ const terminal = new ManagedTerminal(attachId, (msg) => {
272
+ if (msg.t === "term.input")
273
+ handle.lastUsedAt = this.now();
274
+ const handoffs = msg.t === "term.input" && opts.role === "owner"
275
+ ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
276
+ : [];
277
+ const send = () => {
278
+ try {
279
+ handle.transport.send(msg);
280
+ }
281
+ catch (error) {
282
+ for (const handoff of handoffs) {
283
+ this.finishTerminalInputHandoff(handle, handoff);
284
+ }
285
+ throw error;
286
+ }
287
+ };
288
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
289
+ if (msg.t === "term.input" && sourceReservation) {
290
+ void sourceReservation.then(() => {
291
+ if (!handle.dead)
292
+ send();
293
+ }).catch((error) => {
294
+ void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
295
+ logTerminationFailure(handle, terminationError);
296
+ });
297
+ });
298
+ return;
299
+ }
300
+ // A terminal request may race a fork reservation. Never let later
301
+ // keystrokes reach an unpublished Provider target.
302
+ if (msg.t === "term.input") {
303
+ const target = this.reservedForkTarget(handle, localThreadId);
304
+ if (target) {
305
+ this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
306
+ return;
307
+ }
308
+ }
309
+ send();
310
+ }, () => handle.terminals.delete(attachId));
214
311
  handle.terminals.set(attachId, terminal);
215
312
  handle.transport.send({
216
313
  t: "term.open",
@@ -229,6 +326,8 @@ export class RunnerManager {
229
326
  }
230
327
  /** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
231
328
  hasLiveSession(localThreadId) {
329
+ if (this.forkReservations.has(localThreadId))
330
+ return false;
232
331
  const handle = this.handles.get(localThreadId);
233
332
  return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
234
333
  }
@@ -245,6 +344,152 @@ export class RunnerManager {
245
344
  onRotate(listener) {
246
345
  this.rotateListener = listener;
247
346
  }
347
+ /** Accepted TUI submissions that have not crossed into an observable runtime
348
+ * state. The daemon folds this into runningTurns after closing admission. */
349
+ pendingTerminalInputCount() {
350
+ let count = 0;
351
+ for (const handoffs of this.terminalInputHandoffs.values()) {
352
+ count += handoffs.length;
353
+ }
354
+ return count;
355
+ }
356
+ beginTerminalInputHandoffs(handle, sessionId, kinds) {
357
+ if (kinds.length === 0)
358
+ return [];
359
+ const created = [];
360
+ try {
361
+ for (const kind of kinds) {
362
+ const handoff = {
363
+ sessionId,
364
+ kind,
365
+ reservation: this.reserveAdmission(),
366
+ timer: undefined,
367
+ };
368
+ handoff.timer = setTimeout(() => {
369
+ this.expireTerminalInputHandoffs(handle);
370
+ }, this.terminalInputHandoffTimeoutMs);
371
+ handoff.timer.unref?.();
372
+ const handoffs = this.terminalInputHandoffs.get(handle);
373
+ if (handoffs)
374
+ handoffs.push(handoff);
375
+ else
376
+ this.terminalInputHandoffs.set(handle, [handoff]);
377
+ created.push(handoff);
378
+ }
379
+ return created;
380
+ }
381
+ catch (error) {
382
+ for (const handoff of created) {
383
+ this.finishTerminalInputHandoff(handle, handoff);
384
+ }
385
+ throw error;
386
+ }
387
+ }
388
+ settleTerminalInputHandoff(handle, sessionId, kind) {
389
+ const handoff = this.terminalInputHandoffs
390
+ .get(handle)
391
+ ?.find((candidate) => candidate.sessionId === sessionId);
392
+ // Runtime observations settle accepted input in submission order. In
393
+ // particular, a response from the source Session while /clear or /fork is
394
+ // still publishing must not skip that rotation and release a later turn
395
+ // which has not yet been rebound or delivered.
396
+ if (handoff?.kind === kind)
397
+ this.finishTerminalInputHandoff(handle, handoff);
398
+ }
399
+ settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
400
+ const handoffs = this.terminalInputHandoffs.get(handle);
401
+ const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
402
+ if (!handoffs || rotationIndex < 0)
403
+ return;
404
+ // Turns submitted before /clear or /fork are superseded once the rotation
405
+ // is published. Inputs accepted afterwards belong to the transferred pane
406
+ // and must follow it to the target Session.
407
+ const rotation = handoffs[rotationIndex];
408
+ for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
409
+ if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
410
+ this.finishTerminalInputHandoff(handle, handoff);
411
+ }
412
+ }
413
+ if (rotation)
414
+ this.finishTerminalInputHandoff(handle, rotation);
415
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
416
+ if (handoff.sessionId === sourceSessionId)
417
+ handoff.sessionId = targetSessionId;
418
+ }
419
+ }
420
+ currentTerminalSessionId(handle, fallback) {
421
+ for (const [sessionId, candidate] of this.handles) {
422
+ if (candidate === handle && this.liveSessionKeys.has(sessionId))
423
+ return sessionId;
424
+ }
425
+ return fallback;
426
+ }
427
+ finishTerminalInputHandoff(handle, handoff) {
428
+ if (handoff.timer) {
429
+ clearTimeout(handoff.timer);
430
+ handoff.timer = undefined;
431
+ }
432
+ handoff.reservation?.release();
433
+ handoff.reservation = undefined;
434
+ const handoffs = this.terminalInputHandoffs.get(handle);
435
+ if (!handoffs)
436
+ return;
437
+ const index = handoffs.indexOf(handoff);
438
+ if (index >= 0)
439
+ handoffs.splice(index, 1);
440
+ if (handoffs.length === 0)
441
+ this.terminalInputHandoffs.delete(handle);
442
+ }
443
+ finishAllTerminalInputHandoffs(handle) {
444
+ if (handle.terminalCleanupRetryTimer) {
445
+ clearTimeout(handle.terminalCleanupRetryTimer);
446
+ delete handle.terminalCleanupRetryTimer;
447
+ }
448
+ handle.terminalCleanupRetryFailures = 0;
449
+ for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
450
+ this.finishTerminalInputHandoff(handle, handoff);
451
+ }
452
+ }
453
+ preserveTerminalInputHandoffsAsActivity(handle) {
454
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
455
+ if (handoff.timer) {
456
+ clearTimeout(handoff.timer);
457
+ handoff.timer = undefined;
458
+ }
459
+ handoff.reservation?.release();
460
+ handoff.reservation = undefined;
461
+ }
462
+ }
463
+ scheduleTerminalInputCleanupRetry(handle) {
464
+ if (handle.terminalCleanupRetryTimer ||
465
+ !this.terminalInputHandoffs.has(handle))
466
+ return;
467
+ const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
468
+ const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
469
+ handle.terminalCleanupRetryTimer = setTimeout(() => {
470
+ delete handle.terminalCleanupRetryTimer;
471
+ if (!this.terminalInputHandoffs.has(handle))
472
+ return;
473
+ void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
474
+ logTerminationFailure(handle, error);
475
+ });
476
+ }, delay);
477
+ handle.terminalCleanupRetryTimer.unref?.();
478
+ }
479
+ expireTerminalInputHandoffs(handle) {
480
+ const handoffs = this.terminalInputHandoffs.get(handle);
481
+ if (!handoffs?.length)
482
+ return;
483
+ for (const handoff of handoffs) {
484
+ if (handoff.timer) {
485
+ clearTimeout(handoff.timer);
486
+ handoff.timer = undefined;
487
+ }
488
+ }
489
+ void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
490
+ logTerminationFailure(handle, error);
491
+ });
492
+ }
248
493
  /**
249
494
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
250
495
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -252,16 +497,38 @@ export class RunnerManager {
252
497
  * non-codex / non-live session (the caller then uses the normal run path).
253
498
  */
254
499
  ensureLiveSession(localThreadId, opts) {
255
- return this.requestLiveSession(localThreadId, opts, true);
500
+ const admission = this.reserveAdmission();
501
+ return this.requestLiveSession(localThreadId, opts, true)
502
+ .finally(() => admission.release());
256
503
  }
257
504
  /** Start the Provider pane without waiting for login/onboarding to create a
258
505
  * native thread. This is the setup-terminal gate; callers may attach as soon
259
506
  * as it resolves, while normal message delivery still uses ensureLiveSession. */
260
507
  startLiveSession(localThreadId, opts) {
261
- return this.requestLiveSession(localThreadId, opts, false);
508
+ const admission = this.reserveAdmission();
509
+ return this.requestLiveSession(localThreadId, opts, false)
510
+ .finally(() => admission.release());
262
511
  }
263
- requestLiveSession(localThreadId, opts, waitForReady) {
264
- const handle = this.getOrSpawn(localThreadId);
512
+ requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
513
+ const sourceReservation = allowReservedForkTarget
514
+ ? undefined
515
+ : this.sourceForkReservations.get(localThreadId);
516
+ if (sourceReservation) {
517
+ return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
518
+ }
519
+ this.liveOptions.set(localThreadId, {
520
+ workspace: structuredClone(opts.workspace),
521
+ execution: structuredClone(opts.execution),
522
+ ...(opts.cols ? { cols: opts.cols } : {}),
523
+ ...(opts.rows ? { rows: opts.rows } : {}),
524
+ });
525
+ const reservation = allowReservedForkTarget
526
+ ? undefined
527
+ : this.forkReservations.get(localThreadId);
528
+ if (reservation) {
529
+ return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
530
+ }
531
+ const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
265
532
  handle.lastUsedAt = this.now();
266
533
  this.liveSessionKeys.add(localThreadId);
267
534
  const reqId = randomUUID();
@@ -270,39 +537,60 @@ export class RunnerManager {
270
537
  const timeout = setTimeout(() => {
271
538
  if (!handle.live.delete(reqId))
272
539
  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
- });
540
+ const finishTimeout = () => {
541
+ const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
542
+ this.liveErrors.set(localThreadId, reason);
543
+ this.liveSessionKeys.delete(localThreadId);
544
+ // A child that cannot answer a bounded control round-trip is unsafe
545
+ // to reuse. Reap it so the next click gets a fresh runner.
546
+ void this.terminateHandle(handle, reason).catch((error) => {
547
+ logTerminationFailure(handle, error);
548
+ });
549
+ return false;
550
+ };
551
+ const reservation = allowReservedForkTarget
552
+ ? undefined
553
+ : this.forkReservations.get(localThreadId);
554
+ if (reservation) {
555
+ void reservation
556
+ .then(() => finishTimeout())
557
+ .then(resolve, () => resolve(false));
558
+ return;
559
+ }
560
+ resolve(finishTimeout());
282
561
  }, timeoutMs);
283
562
  timeout.unref?.();
284
563
  handle.live.set(reqId, (res) => {
285
564
  clearTimeout(timeout);
286
565
  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");
566
+ const finish = () => {
567
+ if (ok) {
568
+ this.liveErrors.delete(localThreadId);
569
+ }
570
+ else {
571
+ this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
572
+ }
573
+ return ok;
574
+ };
575
+ const reservation = allowReservedForkTarget
576
+ ? undefined
577
+ : this.forkReservations.get(localThreadId);
578
+ if (reservation) {
579
+ void reservation
580
+ .then(() => finish())
581
+ .then(resolve, () => resolve(false));
582
+ return;
292
583
  }
293
- resolve(ok);
584
+ resolve(finish());
294
585
  });
295
586
  handle.transport.send({
296
587
  t: "live.ensure",
297
588
  reqId,
298
589
  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 } : {}),
590
+ workspace: opts.workspace,
591
+ execution: opts.execution,
592
+ ...(opts.cols ? { cols: opts.cols } : {}),
593
+ ...(opts.rows ? { rows: opts.rows } : {}),
306
594
  waitForReady,
307
595
  });
308
596
  });
@@ -317,11 +605,43 @@ export class RunnerManager {
317
605
  * the session has no live forwarder (caller falls back to the run path).
318
606
  */
319
607
  injectMessage(localThreadId, input) {
608
+ const admission = this.reserveAdmission();
609
+ return this.injectMessageAdmitted(localThreadId, input)
610
+ .finally(() => admission.release());
611
+ }
612
+ injectMessageAdmitted(localThreadId, input) {
613
+ const sourceReservation = this.sourceForkReservations.get(localThreadId);
614
+ if (sourceReservation) {
615
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
616
+ }
617
+ const reservation = this.forkReservations.get(localThreadId);
618
+ if (reservation) {
619
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
620
+ }
320
621
  const handle = this.getOrSpawn(localThreadId);
321
622
  handle.lastUsedAt = this.now();
322
623
  const reqId = randomUUID();
323
624
  return new Promise((resolve) => {
324
- handle.live.set(reqId, (res) => resolve(res.outcome ?? "failed"));
625
+ handle.live.set(reqId, (res) => {
626
+ const outcome = res.outcome ?? "failed";
627
+ const finish = () => {
628
+ if (outcome === "injected") {
629
+ this.liveErrors.delete(localThreadId);
630
+ }
631
+ else {
632
+ this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
633
+ }
634
+ return outcome;
635
+ };
636
+ const reservation = this.forkReservations.get(localThreadId);
637
+ if (reservation) {
638
+ void reservation
639
+ .then(() => finish())
640
+ .then(resolve, () => resolve("failed"));
641
+ return;
642
+ }
643
+ resolve(finish());
644
+ });
325
645
  handle.transport.send(typeof input === "string"
326
646
  ? { t: "inject", reqId, localThreadId, text: input }
327
647
  : { t: "inject", reqId, localThreadId, input });
@@ -339,7 +659,16 @@ export class RunnerManager {
339
659
  handle.lastUsedAt = this.now();
340
660
  const reqId = randomUUID();
341
661
  return new Promise((resolve) => {
342
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
662
+ const timeout = setTimeout(() => {
663
+ if (!handle.live.delete(reqId))
664
+ return;
665
+ resolve(false);
666
+ }, this.liveInterruptTimeoutMs);
667
+ timeout.unref?.();
668
+ handle.live.set(reqId, (res) => {
669
+ clearTimeout(timeout);
670
+ resolve(res.ok ?? false);
671
+ });
343
672
  handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
344
673
  });
345
674
  }
@@ -382,9 +711,83 @@ export class RunnerManager {
382
711
  clearGoal(localThreadId) {
383
712
  return this.forwardCap("clearGoal", [localThreadId], localThreadId);
384
713
  }
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);
714
+ forkSession(currentLocalThreadId, newLocalThreadId, options) {
715
+ this.assertAdmissionOpen();
716
+ if (currentLocalThreadId === newLocalThreadId) {
717
+ return Promise.resolve({
718
+ ok: false,
719
+ message: "source and target Session must differ",
720
+ });
721
+ }
722
+ const inflight = this.forkOperations.get(newLocalThreadId);
723
+ if (inflight) {
724
+ if (inflight.sourceSessionId === currentLocalThreadId) {
725
+ return inflight.operation.then(capabilityForkResult);
726
+ }
727
+ return Promise.resolve({
728
+ ok: false,
729
+ message: "target Session already has a fork in progress",
730
+ });
731
+ }
732
+ if (this.sourceForkReservations.has(currentLocalThreadId)) {
733
+ return Promise.resolve({
734
+ ok: false,
735
+ message: "source Session already has a fork in progress",
736
+ });
737
+ }
738
+ let releaseReservation;
739
+ const reservation = new Promise((resolve) => {
740
+ releaseReservation = resolve;
741
+ });
742
+ let releaseSource;
743
+ const sourceReservation = new Promise((resolve) => {
744
+ releaseSource = resolve;
745
+ });
746
+ this.forkReservations.set(newLocalThreadId, reservation);
747
+ this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
748
+ this.forkBufferedMessages.set(newLocalThreadId, []);
749
+ this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
750
+ const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
751
+ const operation = (async () => {
752
+ await options.beforeProviderFork();
753
+ return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
754
+ })()
755
+ .finally(() => {
756
+ const current = this.forkOperations.get(newLocalThreadId);
757
+ if (current?.operation === operation) {
758
+ this.forkOperations.delete(newLocalThreadId);
759
+ }
760
+ if (this.forkReservations.get(newLocalThreadId) === reservation) {
761
+ this.forkReservations.delete(newLocalThreadId);
762
+ }
763
+ if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
764
+ this.sourceForkReservations.delete(currentLocalThreadId);
765
+ }
766
+ const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
767
+ this.forkBufferedMessages.delete(newLocalThreadId);
768
+ const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
769
+ this.forkBufferedTerminalInputs.delete(newLocalThreadId);
770
+ for (const entry of buffered) {
771
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
772
+ }
773
+ for (const entry of bufferedTerminalInputs) {
774
+ if (!entry.handle.dead)
775
+ entry.handle.transport.send(entry.message);
776
+ }
777
+ releaseReservation();
778
+ releaseSource();
779
+ queueMicrotask(() => {
780
+ if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
781
+ && !this.handles.has(newLocalThreadId)) {
782
+ this.liveOptions.delete(newLocalThreadId);
783
+ }
784
+ });
785
+ });
786
+ this.forkOperations.set(newLocalThreadId, {
787
+ sourceSessionId: currentLocalThreadId,
788
+ operation,
789
+ });
790
+ return operation.then(capabilityForkResult);
388
791
  }
389
792
  /**
390
793
  * Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
@@ -395,7 +798,7 @@ export class RunnerManager {
395
798
  }
396
799
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
397
800
  stopRunner(localThreadId) {
398
- const handle = this.handles.get(localThreadId);
801
+ const handle = this.handleForCleanup(localThreadId);
399
802
  if (!handle) {
400
803
  return;
401
804
  }
@@ -403,6 +806,15 @@ export class RunnerManager {
403
806
  logTerminationFailure(handle, error);
404
807
  });
405
808
  }
809
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
810
+ * completion proves that the child process has exited. */
811
+ async terminateLiveSession(localThreadId) {
812
+ const handle = this.handleForCleanup(localThreadId);
813
+ if (!handle)
814
+ return false;
815
+ await this.terminateHandle(handle, "runner force-stopped");
816
+ return true;
817
+ }
406
818
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
407
819
  stop() {
408
820
  if (this.stopPromise)
@@ -411,12 +823,16 @@ export class RunnerManager {
411
823
  if (this.reapTimer) {
412
824
  clearInterval(this.reapTimer);
413
825
  }
414
- const children = [...this.childHandles];
415
- this.stopPromise = (async () => {
416
- const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
826
+ const children = new Set([
827
+ ...this.childHandles,
828
+ ...this.terminalInputHandoffs.keys(),
829
+ ]);
830
+ const attempt = (async () => {
831
+ const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
417
832
  this.handles.clear();
418
833
  this.liveSessionKeys.clear();
419
834
  this.liveErrors.clear();
835
+ this.liveOptions.clear();
420
836
  this.mirrorListener = null;
421
837
  this.rotateListener = null;
422
838
  const errors = results
@@ -427,9 +843,19 @@ export class RunnerManager {
427
843
  }
428
844
  this.childHandles.clear();
429
845
  })();
430
- return this.stopPromise;
846
+ this.stopPromise = attempt;
847
+ void attempt.catch(() => {
848
+ if (this.stopPromise === attempt)
849
+ this.stopPromise = undefined;
850
+ });
851
+ return attempt;
431
852
  }
432
853
  // ── internals ──────────────────────────────────────────────────────────────
854
+ handleForCleanup(localThreadId) {
855
+ return this.handles.get(localThreadId) ??
856
+ [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
857
+ handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
858
+ }
433
859
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
434
860
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
435
861
  * run on that session's child (which owns its private CODEX_HOME). */
@@ -442,19 +868,257 @@ export class RunnerManager {
442
868
  handle.transport.send({ t: "cap", capId, name, args });
443
869
  });
444
870
  }
445
- getOrSpawn(key) {
446
- if (this.stopping) {
447
- throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
871
+ getOrSpawn(key, allowReservedForkTarget = false) {
872
+ const admission = this.reserveAdmission();
873
+ try {
874
+ if (this.stopping) {
875
+ throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
876
+ }
877
+ if (key !== CAP_KEY &&
878
+ this.forkReservations.has(key) &&
879
+ !allowReservedForkTarget) {
880
+ throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
881
+ }
882
+ this.reapIdle();
883
+ const existing = this.handles.get(key);
884
+ if (existing && !existing.dead) {
885
+ existing.lastUsedAt = this.now();
886
+ return existing;
887
+ }
888
+ const handle = this.spawnHandle(key);
889
+ this.handles.set(key, handle);
890
+ return handle;
448
891
  }
449
- this.reapIdle();
450
- const existing = this.handles.get(key);
451
- if (existing && !existing.dead) {
452
- existing.lastUsedAt = this.now();
453
- return existing;
892
+ finally {
893
+ admission.release();
454
894
  }
455
- const handle = this.spawnHandle(key);
456
- this.handles.set(key, handle);
457
- return handle;
895
+ }
896
+ assertAdmissionOpen() {
897
+ const reservation = this.admissionReserve?.();
898
+ if (reservation) {
899
+ reservation.release();
900
+ return;
901
+ }
902
+ if (!this.admissionReserve && this.admissionOpen())
903
+ return;
904
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
905
+ }
906
+ reserveAdmission() {
907
+ const reservation = this.admissionReserve?.();
908
+ if (reservation)
909
+ return reservation;
910
+ if (!this.admissionReserve && this.admissionOpen()) {
911
+ return { release() { } };
912
+ }
913
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
914
+ }
915
+ async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
916
+ const existingTarget = await this.sessionStore.get(newLocalThreadId);
917
+ if (existingTarget?.parentSessionId === currentLocalThreadId) {
918
+ return { ok: true, data: undefined };
919
+ }
920
+ if (existingTarget?.parentSessionId) {
921
+ return {
922
+ ok: false,
923
+ reason: "error",
924
+ message: "target Session already belongs to a different fork",
925
+ };
926
+ }
927
+ const targetHandle = this.handles.get(newLocalThreadId);
928
+ if (existingTarget) {
929
+ return {
930
+ ok: false,
931
+ reason: "error",
932
+ message: "target Session already has a Provider binding",
933
+ };
934
+ }
935
+ if (targetHandle && !targetHandle.dead) {
936
+ return {
937
+ ok: false,
938
+ reason: "error",
939
+ message: "target Session already has a runner",
940
+ };
941
+ }
942
+ if (options.execution.provider === "claude") {
943
+ return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
944
+ }
945
+ // Runs on the SOURCE session's child (its home has the source rollout to
946
+ // fork). The Provider persists the native fork and target binding before
947
+ // returning. Only then is the target reservation released.
948
+ return this.forwardCap("forkSession", [
949
+ currentLocalThreadId,
950
+ newLocalThreadId,
951
+ {
952
+ workspace: structuredClone(options.workspace),
953
+ execution: structuredClone(options.execution),
954
+ },
955
+ ], currentLocalThreadId);
956
+ }
957
+ async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
958
+ const source = await this.sessionStore.get(currentLocalThreadId);
959
+ const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
960
+ const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
961
+ if (!source?.codexSessionId || !setIntent || !deleteIntent) {
962
+ return {
963
+ ok: false,
964
+ reason: "unsupported",
965
+ message: "Claude fork persistence is unavailable",
966
+ };
967
+ }
968
+ const targetClaudeSessionId = randomUUID();
969
+ try {
970
+ await setIntent({
971
+ targetSessionId: newLocalThreadId,
972
+ sourceSessionId: currentLocalThreadId,
973
+ sourceClaudeSessionId: source.codexSessionId,
974
+ targetClaudeSessionId,
975
+ updatedAt: new Date().toISOString(),
976
+ });
977
+ const ready = await this.requestLiveSession(newLocalThreadId, {
978
+ workspace: structuredClone(options.workspace),
979
+ execution: structuredClone(options.execution),
980
+ }, true, true);
981
+ const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
982
+ if (!target ||
983
+ target.parentSessionId !== currentLocalThreadId ||
984
+ target.codexSessionId !== targetClaudeSessionId) {
985
+ throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
986
+ "Claude did not materialize the requested fork");
987
+ }
988
+ return { ok: true, data: undefined };
989
+ }
990
+ catch (error) {
991
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
992
+ const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
993
+ if (target?.parentSessionId === currentLocalThreadId) {
994
+ await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
995
+ }
996
+ const handle = this.handles.get(newLocalThreadId);
997
+ if (handle && !handle.dead) {
998
+ await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
999
+ }
1000
+ return {
1001
+ ok: false,
1002
+ reason: "error",
1003
+ message: error instanceof Error ? error.message : String(error),
1004
+ };
1005
+ }
1006
+ }
1007
+ bufferForkTargetMessage(handle, message) {
1008
+ const directTarget = message.t === "mirror" ? message.sessionId : message.from;
1009
+ const target = this.reservedForkTarget(handle, directTarget);
1010
+ if (!target)
1011
+ return false;
1012
+ this.forkBufferedMessages.get(target)?.push({ handle, message });
1013
+ return true;
1014
+ }
1015
+ reservedForkTarget(handle, directTarget) {
1016
+ return this.forkReservations.has(directTarget)
1017
+ ? directTarget
1018
+ : [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
1019
+ }
1020
+ deliverForkBufferedMessage(handle, message) {
1021
+ if (handle.dead)
1022
+ return;
1023
+ if (message.t === "mirror") {
1024
+ this.observeHandleRuntimeEvent(handle, message.event);
1025
+ this.mirrorListener?.(message.sessionId, message.event);
1026
+ if (message.event.type === "response.created") {
1027
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1028
+ }
1029
+ return;
1030
+ }
1031
+ this.deliverRotateMessage(handle, message);
1032
+ }
1033
+ deliverRotateMessage(handle, message) {
1034
+ if (this.forkReservations.has(message.to) ||
1035
+ this.sourceForkReservations.has(message.from)) {
1036
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1037
+ return;
1038
+ }
1039
+ try {
1040
+ // Rotate the runtime-local context before publishing the new Session
1041
+ // alias to routing or observers. This prevents the new identity from
1042
+ // briefly inheriting stale context after `/clear` or `/fork`.
1043
+ handle.sessionContext?.rotate(message.to);
1044
+ }
1045
+ catch (error) {
1046
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1047
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
1048
+ logTerminationFailure(handle, terminationError);
1049
+ });
1050
+ return;
1051
+ }
1052
+ let releaseTarget;
1053
+ const targetReservation = new Promise((resolve) => {
1054
+ releaseTarget = resolve;
1055
+ });
1056
+ let releaseSource;
1057
+ const sourceReservation = new Promise((resolve) => {
1058
+ releaseSource = resolve;
1059
+ });
1060
+ this.forkReservations.set(message.to, targetReservation);
1061
+ this.sourceForkReservations.set(message.from, sourceReservation);
1062
+ this.forkBufferedMessages.set(message.to, []);
1063
+ this.forkBufferedTerminalInputs.set(message.to, []);
1064
+ // The physical pane has already rotated. Remove every old routing alias now:
1065
+ // a later request for the source must spawn a fresh runner that resumes the
1066
+ // source Provider binding, while existing terminal attachments continue to
1067
+ // follow the transferred pane.
1068
+ for (const [key, candidate] of this.handles) {
1069
+ if (candidate !== handle)
1070
+ continue;
1071
+ this.handles.delete(key);
1072
+ this.liveSessionKeys.delete(key);
1073
+ this.liveOptions.delete(key);
1074
+ }
1075
+ const rotation = {
1076
+ from: message.from,
1077
+ to: message.to,
1078
+ kind: message.kind,
1079
+ workspace: message.workspace,
1080
+ execution: message.execution,
1081
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1082
+ };
1083
+ void Promise.resolve(this.rotateListener?.(rotation))
1084
+ .then(() => {
1085
+ // The server's rotation listener resolves only after the target Session
1086
+ // is published. Release the terminal admission handoff afterwards so a
1087
+ // maintenance snapshot cannot observe a gap with neither source work
1088
+ // nor the target Session.
1089
+ this.settleTerminalRotationHandoff(handle, message.from, message.to);
1090
+ if (handle.dead)
1091
+ return;
1092
+ handle.activeResponseIds.clear();
1093
+ this.handles.set(message.to, handle);
1094
+ this.liveSessionKeys.add(message.to);
1095
+ this.liveOptions.set(message.to, {
1096
+ workspace: structuredClone(message.workspace),
1097
+ execution: structuredClone(message.execution),
1098
+ });
1099
+ for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
1100
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
1101
+ }
1102
+ for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
1103
+ if (!entry.handle.dead)
1104
+ entry.handle.transport.send(entry.message);
1105
+ }
1106
+ })
1107
+ .catch((error) => {
1108
+ void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
1109
+ })
1110
+ .finally(() => {
1111
+ if (this.forkReservations.get(message.to) === targetReservation) {
1112
+ this.forkReservations.delete(message.to);
1113
+ }
1114
+ if (this.sourceForkReservations.get(message.from) === sourceReservation) {
1115
+ this.sourceForkReservations.delete(message.from);
1116
+ }
1117
+ this.forkBufferedMessages.delete(message.to);
1118
+ this.forkBufferedTerminalInputs.delete(message.to);
1119
+ releaseTarget();
1120
+ releaseSource();
1121
+ });
458
1122
  }
459
1123
  spawnHandle(key) {
460
1124
  const args = this.runnerEntry.endsWith(".ts")
@@ -463,10 +1127,12 @@ export class RunnerManager {
463
1127
  const sessionContext = key === CAP_KEY
464
1128
  ? undefined
465
1129
  : this.openSessionContext(key);
1130
+ const processGroup = process.platform !== "win32";
466
1131
  let child;
467
1132
  try {
468
1133
  child = this.spawn(process.execPath, args, {
469
1134
  stdio: ["pipe", "pipe", "pipe"],
1135
+ detached: processGroup,
470
1136
  // `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
471
1137
  // for the shared capability child) so its LocalAgentHost scopes the private
472
1138
  // CODEX_HOME to this session. It is deliberately applied last.
@@ -490,8 +1156,11 @@ export class RunnerManager {
490
1156
  transport,
491
1157
  stderr: [],
492
1158
  lastUsedAt: this.now(),
1159
+ activeResponseIds: new Set(),
493
1160
  dead: false,
494
1161
  completion,
1162
+ terminalCleanupRetryFailures: 0,
1163
+ processGroup,
495
1164
  ...(sessionContext ? { sessionContext } : {}),
496
1165
  caps: new Map(),
497
1166
  terminals: new Map(),
@@ -550,37 +1219,25 @@ export class RunnerManager {
550
1219
  return;
551
1220
  }
552
1221
  case "mirror":
1222
+ if (handle.dead)
1223
+ return;
1224
+ if (this.bufferForkTargetMessage(handle, msg))
1225
+ return;
1226
+ this.observeHandleRuntimeEvent(handle, msg.event);
553
1227
  this.mirrorListener?.(msg.sessionId, msg.event);
1228
+ if (msg.event.type === "response.created") {
1229
+ // The listener projects response.created into SessionRuntimeIndex
1230
+ // synchronously. Only then may the accepted terminal reservation
1231
+ // drain into a maintenance activity snapshot.
1232
+ this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
1233
+ }
554
1234
  return;
555
1235
  case "rotate": {
556
1236
  if (handle.dead)
557
1237
  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
- });
1238
+ if (this.bufferForkTargetMessage(handle, msg))
569
1239
  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
- });
1240
+ this.deliverRotateMessage(handle, msg);
584
1241
  return;
585
1242
  }
586
1243
  case "live.ready":
@@ -614,6 +1271,9 @@ export class RunnerManager {
614
1271
  if (h === handle) {
615
1272
  this.handles.delete(key);
616
1273
  this.liveSessionKeys.delete(key);
1274
+ if (!this.forkReservations.has(key)) {
1275
+ this.liveOptions.delete(key);
1276
+ }
617
1277
  }
618
1278
  }
619
1279
  const tail = handle.stderr.join("\n");
@@ -636,7 +1296,11 @@ export class RunnerManager {
636
1296
  terminateHandle(handle, reason) {
637
1297
  if (handle.termination)
638
1298
  return handle.termination;
639
- handle.termination = (async () => {
1299
+ if (handle.terminalCleanupRetryTimer) {
1300
+ clearTimeout(handle.terminalCleanupRetryTimer);
1301
+ delete handle.terminalCleanupRetryTimer;
1302
+ }
1303
+ const attempt = (async () => {
640
1304
  if (!handle.dead) {
641
1305
  try {
642
1306
  handle.transport.send({ t: "shutdown" });
@@ -646,36 +1310,121 @@ export class RunnerManager {
646
1310
  }
647
1311
  this.failHandle(handle, reason);
648
1312
  }
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
- }
1313
+ this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1314
+ let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
1315
+ if (!childExited) {
1316
+ this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1317
+ childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1318
+ }
1319
+ const terminalStopped = handle.key === CAP_KEY
1320
+ ? true
1321
+ : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1322
+ if (!childExited) {
1323
+ throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1324
+ }
1325
+ if (!terminalStopped) {
1326
+ throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
655
1327
  }
656
1328
  this.childHandles.delete(handle);
657
1329
  })();
658
- return handle.termination;
1330
+ handle.termination = attempt;
1331
+ void attempt.then(() => {
1332
+ this.finishAllTerminalInputHandoffs(handle);
1333
+ }, () => {
1334
+ if (handle.termination === attempt)
1335
+ delete handle.termination;
1336
+ handle.terminalCleanupRetryFailures += 1;
1337
+ // A failed cleanup must not pin closeAndDrain forever. Keep the
1338
+ // submission in the daemon activity snapshot, but release its gate
1339
+ // reservation so maintenance returns a structured busy result.
1340
+ this.preserveTerminalInputHandoffsAsActivity(handle);
1341
+ this.scheduleTerminalInputCleanupRetry(handle);
1342
+ });
1343
+ return attempt;
659
1344
  }
660
1345
  reapIdle() {
661
1346
  const now = this.now();
662
1347
  for (const [key, handle] of this.handles) {
663
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
1348
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
664
1349
  continue;
665
1350
  }
666
- // A live codex forwarder must outlive terminal detach / idle turns — it is
667
- // the session's single event writer, so never reap a session that has one.
668
- if (this.liveSessionKeys.has(key)) {
1351
+ const idleForMs = now - handle.lastUsedAt;
1352
+ if (idleForMs < this.idleTtlMs) {
669
1353
  continue;
670
1354
  }
671
- if (now - handle.lastUsedAt < this.idleTtlMs) {
1355
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
1356
+ if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
672
1357
  continue;
673
1358
  }
674
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
1359
+ const reason = hasActiveResponse
1360
+ ? "stale active runner reaped"
1361
+ : this.liveSessionKeys.has(key)
1362
+ ? "idle live runner reaped"
1363
+ : "idle runner reaped";
1364
+ if (hasActiveResponse) {
1365
+ this.failStaleActiveResponses(handle, idleForMs);
1366
+ }
1367
+ void this.terminateHandle(handle, reason).catch((error) => {
675
1368
  logTerminationFailure(handle, error);
676
1369
  });
677
1370
  }
678
1371
  }
1372
+ /** Close server-side runtime state before fencing a Provider that stayed
1373
+ * active past its hard inactivity deadline. The child transport is closed by
1374
+ * terminateHandle immediately afterwards, so these are the final events for
1375
+ * the abandoned responses. */
1376
+ failStaleActiveResponses(handle, idleForMs) {
1377
+ const responseIds = [...handle.activeResponseIds];
1378
+ handle.activeResponseIds.clear();
1379
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1380
+ for (const responseId of responseIds) {
1381
+ this.mirrorListener?.(sessionId, {
1382
+ type: "response.failed",
1383
+ responseId,
1384
+ error: {
1385
+ source: "execution",
1386
+ code: "stale_runner_reaped",
1387
+ message: `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`,
1388
+ },
1389
+ });
1390
+ }
1391
+ }
1392
+ /** Track only response lifecycle, not output volume. Output deltas from a
1393
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
1394
+ observeHandleRuntimeEvent(handle, event) {
1395
+ switch (event.type) {
1396
+ case "response.created":
1397
+ case "response.output_text.delta":
1398
+ case "response.reasoning_summary_text.delta":
1399
+ case "response.function_call_output.delta":
1400
+ case "response.output_item.done":
1401
+ handle.activeResponseIds.add(event.responseId);
1402
+ return;
1403
+ case "session.interaction.requested":
1404
+ handle.activeResponseIds.add(event.responseId);
1405
+ return;
1406
+ case "response.completed":
1407
+ case "response.failed":
1408
+ handle.activeResponseIds.delete(event.responseId);
1409
+ return;
1410
+ case "session.status":
1411
+ if (event.status === "running" && event.responseId) {
1412
+ handle.activeResponseIds.add(event.responseId);
1413
+ }
1414
+ else if (event.status !== "running") {
1415
+ if (event.responseId)
1416
+ handle.activeResponseIds.delete(event.responseId);
1417
+ else
1418
+ handle.activeResponseIds.clear();
1419
+ }
1420
+ return;
1421
+ case "session.rotated":
1422
+ handle.activeResponseIds.clear();
1423
+ return;
1424
+ default:
1425
+ return;
1426
+ }
1427
+ }
679
1428
  openSessionContext(sessionId) {
680
1429
  if (!this.sessionContextProvider)
681
1430
  return undefined;
@@ -694,6 +1443,70 @@ export class RunnerManager {
694
1443
  }
695
1444
  }
696
1445
  }
1446
+ function trackedTerminalSubmissions(tracker, data) {
1447
+ const pasteStart = "\u001b[200~";
1448
+ const pasteEnd = "\u001b[201~";
1449
+ const input = tracker.pendingEscape + data;
1450
+ tracker.pendingEscape = "";
1451
+ const submissions = [];
1452
+ for (let index = 0; index < input.length; index += 1) {
1453
+ const character = input[index] ?? "";
1454
+ if (character === "\u001b") {
1455
+ const remaining = input.slice(index);
1456
+ if (remaining.startsWith(pasteStart)) {
1457
+ tracker.bracketedPaste = true;
1458
+ tracker.previousWasCarriageReturn = false;
1459
+ index += pasteStart.length - 1;
1460
+ continue;
1461
+ }
1462
+ if (remaining.startsWith(pasteEnd)) {
1463
+ tracker.bracketedPaste = false;
1464
+ tracker.previousWasCarriageReturn = false;
1465
+ index += pasteEnd.length - 1;
1466
+ continue;
1467
+ }
1468
+ if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
1469
+ tracker.pendingEscape = remaining;
1470
+ break;
1471
+ }
1472
+ }
1473
+ if (tracker.bracketedPaste) {
1474
+ tracker.buffer += character;
1475
+ tracker.previousWasCarriageReturn = false;
1476
+ continue;
1477
+ }
1478
+ if (character === "\n" && tracker.previousWasCarriageReturn) {
1479
+ tracker.previousWasCarriageReturn = false;
1480
+ continue;
1481
+ }
1482
+ tracker.previousWasCarriageReturn = character === "\r";
1483
+ if (character === "\r" || character === "\n") {
1484
+ const command = tracker.buffer.trim();
1485
+ tracker.buffer = "";
1486
+ if (command.length > 0 &&
1487
+ (command.includes("\u001b") ||
1488
+ !command.startsWith("/") ||
1489
+ /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1490
+ submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1491
+ }
1492
+ continue;
1493
+ }
1494
+ if (character === "\b" || character === "\u007f") {
1495
+ tracker.buffer = tracker.buffer.slice(0, -1);
1496
+ continue;
1497
+ }
1498
+ if (character === "\u0015" || character === "\u0003") {
1499
+ tracker.buffer = "";
1500
+ continue;
1501
+ }
1502
+ // Printable text and tabs are enough to distinguish work-producing prompts
1503
+ // from local TUI slash commands. Other control sequences are ignored.
1504
+ if (character === "\u001b" || character === "\t" || character >= " ") {
1505
+ tracker.buffer += character;
1506
+ }
1507
+ }
1508
+ return submissions;
1509
+ }
697
1510
  function validateSessionContext(context) {
698
1511
  if (!context || typeof context !== "object") {
699
1512
  throw new Error("Session context environment provider must return an object");
@@ -798,9 +1611,18 @@ function observeChildCompletion(child) {
798
1611
  child.once("exit", onExit);
799
1612
  });
800
1613
  }
801
- function signalChild(child, signal) {
1614
+ function signalRunnerChild(child, signal, processGroup) {
802
1615
  if (childHasExited(child))
803
1616
  return;
1617
+ if (processGroup && child.pid) {
1618
+ try {
1619
+ process.kill(-child.pid, signal);
1620
+ return;
1621
+ }
1622
+ catch {
1623
+ // The group may have exited between the completion check and the signal.
1624
+ }
1625
+ }
804
1626
  try {
805
1627
  child.kill(signal);
806
1628
  }
@@ -836,3 +1658,14 @@ function logTerminationFailure(handle, error) {
836
1658
  error: error instanceof Error ? error.message : String(error),
837
1659
  }));
838
1660
  }
1661
+ function capabilityForkResult(result) {
1662
+ return result.ok
1663
+ ? { ok: true }
1664
+ : {
1665
+ ok: false,
1666
+ message: result.message ??
1667
+ (result.reason === "unsupported"
1668
+ ? "Provider does not support Session fork"
1669
+ : "Provider Session is unavailable"),
1670
+ };
1671
+ }