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

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 (44) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +2 -0
  6. package/dist/claude/native-bridge.js +23 -0
  7. package/dist/claude/native-hook-main.js +62 -0
  8. package/dist/claude/native-integration.d.ts +50 -10
  9. package/dist/claude/native-integration.js +262 -37
  10. package/dist/claude/session-status.d.ts +39 -0
  11. package/dist/claude/session-status.js +163 -0
  12. package/dist/claude/transcript.js +27 -17
  13. package/dist/codex-app-server/client.d.ts +10 -6
  14. package/dist/codex-app-server/client.js +67 -15
  15. package/dist/codex-app-server/forwarder.d.ts +92 -3
  16. package/dist/codex-app-server/forwarder.js +509 -56
  17. package/dist/codex-app-server/mapping.d.ts +3 -6
  18. package/dist/codex-app-server/mapping.js +174 -28
  19. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  20. package/dist/codex-app-server/mcp-startup.js +63 -0
  21. package/dist/codex-app-server/protocol.d.ts +64 -7
  22. package/dist/codex-app-server/ws-channel.js +19 -19
  23. package/dist/codex-home.js +2 -4
  24. package/dist/host.d.ts +64 -21
  25. package/dist/host.js +1330 -441
  26. package/dist/index.d.ts +1 -1
  27. package/dist/input-resources.d.ts +4 -0
  28. package/dist/input-resources.js +21 -5
  29. package/dist/models-catalog.d.ts +2 -1
  30. package/dist/models-catalog.js +94 -6
  31. package/dist/runner/child.d.ts +48 -21
  32. package/dist/runner/child.js +550 -48
  33. package/dist/runner/manager.d.ts +54 -13
  34. package/dist/runner/manager.js +479 -114
  35. package/dist/runner/protocol.d.ts +62 -19
  36. package/dist/runner/protocol.js +5 -0
  37. package/dist/runner/startup-policy.d.ts +7 -0
  38. package/dist/runner/startup-policy.js +10 -0
  39. package/dist/terminal/claude-tui.d.ts +3 -1
  40. package/dist/terminal/claude-tui.js +3 -1
  41. package/dist/terminal/registry.js +3 -2
  42. package/dist/terminal/tmux.d.ts +50 -7
  43. package/dist/terminal/tmux.js +168 -47
  44. package/package.json +4 -3
@@ -22,8 +22,9 @@ 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";
26
- import { fromWireError, } from "./protocol.js";
25
+ import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
26
+ import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, fromWireError, } from "./protocol.js";
27
+ import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
27
28
  import { StdioRunnerTransport } from "./transport.js";
28
29
  /** Routing key for the shared capability runner (slash-command RPCs). */
29
30
  const CAP_KEY = "__cap__";
@@ -33,17 +34,23 @@ const STDERR_TAIL_LINES = 40;
33
34
  const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
34
35
  /** Time allowed for exit after SIGKILL before shutdown reports failure. */
35
36
  const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
36
- /** Setup-pane launch should acknowledge quickly; never pin its HTTP request. */
37
- const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
38
- /** Thread readiness may legitimately wait through Provider startup. */
37
+ /** Legacy setup-pane acknowledgement deadline for providers that do not own
38
+ * phase-specific startup errors. Codex/Traex are deliberately excluded. */
39
+ const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
40
+ /** Legacy fallback for a provider without a native startup policy. */
39
41
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
42
+ /** Claude has no app-server bridge and keeps a parent-owned SessionStart
43
+ * deadline. Codex-lineage startup instead acknowledges pane/observer startup
44
+ * and lets thread discovery race injection. */
45
+ const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
40
46
  /** A submitted TUI command should become a mirrored turn or native rotation
41
47
  * quickly. If it does not, the runner is fenced by a verified process-tree
42
48
  * shutdown before maintenance may treat the submission as settled. */
43
49
  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;
50
+ /** Full idle window before an inactive native pane becomes reapable. */
51
+ const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
52
+ /** tmux output this recent independently proves that a native pane is busy. */
53
+ const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
47
54
  /** Keep per-Session spawn context small enough to remain an environment handoff,
48
55
  * not an unbounded transport. */
49
56
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -144,6 +151,8 @@ export class RunnerManager {
144
151
  sessionStore;
145
152
  runnerEntry;
146
153
  idleTtlMs;
154
+ nativePaneIdleTtlMs;
155
+ nativePaneOutputBusyWindowMs;
147
156
  spawn;
148
157
  childEnv;
149
158
  sessionContextProvider;
@@ -160,15 +169,22 @@ export class RunnerManager {
160
169
  shutdownKillGraceMs;
161
170
  signalChild;
162
171
  terminateTerminalServer;
172
+ terminalWindowActivityAt;
173
+ terminalHasAttachedClient;
174
+ wallNow;
163
175
  liveStartTimeoutMs;
164
176
  liveReadyTimeoutMs;
177
+ nativeLiveStartTimeoutMs;
165
178
  liveInterruptTimeoutMs;
166
179
  terminalInputHandoffTimeoutMs;
167
- terminalInputCleanupRetryMs;
180
+ reapPromise = null;
168
181
  stopping = false;
169
182
  stopPromise;
170
183
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
171
184
  mirrorListener = null;
185
+ /** Synchronous persistence hook for native collaboration-mode reflection.
186
+ * It runs before the event reaches the Session bus. */
187
+ collaborationModeListener = null;
172
188
  /** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
173
189
  rotateListener = null;
174
190
  /** Session keys with a live codex forwarder — never reaped while present. */
@@ -198,25 +214,30 @@ export class RunnerManager {
198
214
  this.sessionStore = opts.sessionStore;
199
215
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
200
216
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
217
+ this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
218
+ this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
201
219
  this.spawn = opts.spawn ?? nodeSpawn;
202
220
  this.childEnv = opts.childEnv ?? {};
203
221
  this.sessionContextProvider = opts.sessionContextProvider;
204
222
  this.admissionOpen = opts.admissionOpen ?? (() => true);
205
223
  this.admissionReserve = opts.admissionReserve;
206
224
  this.now = opts.now ?? (() => Date.now());
225
+ this.wallNow = opts.wallNow ?? (() => Date.now());
207
226
  this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
208
227
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
209
228
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
210
229
  this.signalChild = opts.signalChild ?? signalRunnerChild;
211
230
  this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
231
+ this.terminalWindowActivityAt = opts.terminalWindowActivityAt ?? tmuxWindowActivityAt;
232
+ this.terminalHasAttachedClient = opts.terminalHasAttachedClient ?? tmuxHasAttachedClient;
212
233
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
213
234
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
235
+ this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
214
236
  this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
215
237
  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);
217
238
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
218
239
  if (reapIntervalMs > 0) {
219
- this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
240
+ this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
220
241
  this.reapTimer.unref?.();
221
242
  }
222
243
  else {
@@ -261,6 +282,8 @@ export class RunnerManager {
261
282
  pendingEscape: "",
262
283
  };
263
284
  const terminal = new ManagedTerminal(attachId, (msg) => {
285
+ if (msg.t === "term.input")
286
+ handle.lastUsedAt = this.now();
264
287
  const handoffs = msg.t === "term.input" && opts.role === "owner"
265
288
  ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
289
  : [];
@@ -329,6 +352,9 @@ export class RunnerManager {
329
352
  onMirror(listener) {
330
353
  this.mirrorListener = listener;
331
354
  }
355
+ onCollaborationMode(listener) {
356
+ this.collaborationModeListener = listener;
357
+ }
332
358
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
333
359
  * records the new session's meta (carry-over agent/model/title). */
334
360
  onRotate(listener) {
@@ -431,11 +457,6 @@ export class RunnerManager {
431
457
  this.terminalInputHandoffs.delete(handle);
432
458
  }
433
459
  finishAllTerminalInputHandoffs(handle) {
434
- if (handle.terminalCleanupRetryTimer) {
435
- clearTimeout(handle.terminalCleanupRetryTimer);
436
- delete handle.terminalCleanupRetryTimer;
437
- }
438
- handle.terminalCleanupRetryFailures = 0;
439
460
  for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
440
461
  this.finishTerminalInputHandoff(handle, handoff);
441
462
  }
@@ -450,22 +471,6 @@ export class RunnerManager {
450
471
  handoff.reservation = undefined;
451
472
  }
452
473
  }
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
474
  expireTerminalInputHandoffs(handle) {
470
475
  const handoffs = this.terminalInputHandoffs.get(handle);
471
476
  if (!handoffs?.length)
@@ -483,8 +488,10 @@ export class RunnerManager {
483
488
  /**
484
489
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
485
490
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
486
- * needed. Idempotent. Resolves true once the codex thread is bound; false for a
487
- * non-codex / non-live session (the caller then uses the normal run path).
491
+ * needed. Idempotent. For Codex-lineage sessions, resolves after backend
492
+ * preload and pane launch; known-thread observer attach and fresh-thread
493
+ * discovery continue in the background. Other providers retain their
494
+ * readiness gate.
488
495
  */
489
496
  ensureLiveSession(localThreadId, opts) {
490
497
  const admission = this.reserveAdmission();
@@ -523,42 +530,66 @@ export class RunnerManager {
523
530
  this.liveSessionKeys.add(localThreadId);
524
531
  const reqId = randomUUID();
525
532
  return new Promise((resolve) => {
526
- const timeoutMs = waitForReady ? this.liveReadyTimeoutMs : this.liveStartTimeoutMs;
527
- const timeout = setTimeout(() => {
528
- if (!handle.live.delete(reqId))
529
- return;
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());
551
- }, timeoutMs);
552
- timeout.unref?.();
533
+ const provider = opts.execution.provider;
534
+ const timeoutMs = isCodexLineageProvider(provider)
535
+ ? null
536
+ : !waitForReady
537
+ ? this.liveStartTimeoutMs
538
+ : provider === "claude"
539
+ ? this.nativeLiveStartTimeoutMs
540
+ : this.liveReadyTimeoutMs;
541
+ const timeout = timeoutMs === null
542
+ ? undefined
543
+ : setTimeout(() => {
544
+ if (!handle.live.delete(reqId))
545
+ return;
546
+ const finishTimeout = () => {
547
+ const reason = !waitForReady
548
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
549
+ : provider === "claude"
550
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
551
+ : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
552
+ this.liveErrors.set(localThreadId, {
553
+ message: reason,
554
+ code: provider === "claude"
555
+ ? "claude_session_start_timeout"
556
+ : "native_runner_ack_timeout",
557
+ statusCode: 503,
558
+ });
559
+ this.liveSessionKeys.delete(localThreadId);
560
+ // Legacy providers without phase-owned startup errors retain the
561
+ // bounded control guard. Codex/Traex never enter this branch.
562
+ void this.terminateHandle(handle, reason).catch((error) => {
563
+ logTerminationFailure(handle, error);
564
+ });
565
+ return false;
566
+ };
567
+ const reservation = allowReservedForkTarget
568
+ ? undefined
569
+ : this.forkReservations.get(localThreadId);
570
+ if (reservation) {
571
+ void reservation
572
+ .then(() => finishTimeout())
573
+ .then(resolve, () => resolve(false));
574
+ return;
575
+ }
576
+ resolve(finishTimeout());
577
+ }, timeoutMs);
578
+ timeout?.unref?.();
553
579
  handle.live.set(reqId, (res) => {
554
- clearTimeout(timeout);
580
+ if (timeout)
581
+ clearTimeout(timeout);
555
582
  const ok = res.ok ?? false;
556
583
  const finish = () => {
557
584
  if (ok) {
558
585
  this.liveErrors.delete(localThreadId);
559
586
  }
560
587
  else {
561
- this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
588
+ this.liveErrors.set(localThreadId, res.error ?? {
589
+ message: "live session did not become ready",
590
+ code: "native_readiness_failed",
591
+ statusCode: 503,
592
+ });
562
593
  }
563
594
  return ok;
564
595
  };
@@ -586,7 +617,11 @@ export class RunnerManager {
586
617
  });
587
618
  }
588
619
  lastLiveSessionError(localThreadId) {
589
- return this.liveErrors.get(localThreadId);
620
+ return this.liveErrors.get(localThreadId)?.message;
621
+ }
622
+ lastLiveSessionFailure(localThreadId) {
623
+ const failure = this.liveErrors.get(localThreadId);
624
+ return failure ? { ...failure } : undefined;
590
625
  }
591
626
  /**
592
627
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -594,47 +629,55 @@ export class RunnerManager {
594
629
  * all output. Resolves true when the app-server accepted the turn, false when
595
630
  * the session has no live forwarder (caller falls back to the run path).
596
631
  */
597
- injectMessage(localThreadId, input) {
632
+ injectMessage(localThreadId, input, options) {
598
633
  const admission = this.reserveAdmission();
599
- return this.injectMessageAdmitted(localThreadId, input)
634
+ return this.injectMessageAdmitted(localThreadId, input, options)
600
635
  .finally(() => admission.release());
601
636
  }
602
- injectMessageAdmitted(localThreadId, input) {
637
+ injectMessageAdmitted(localThreadId, input, options) {
603
638
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
604
639
  if (sourceReservation) {
605
- return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
640
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
606
641
  }
607
642
  const reservation = this.forkReservations.get(localThreadId);
608
643
  if (reservation) {
609
- return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
644
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
610
645
  }
611
646
  const handle = this.getOrSpawn(localThreadId);
612
647
  handle.lastUsedAt = this.now();
613
648
  const reqId = randomUUID();
614
649
  return new Promise((resolve) => {
615
650
  handle.live.set(reqId, (res) => {
616
- const outcome = res.outcome ?? "failed";
651
+ const result = res.result ?? { outcome: "failed" };
617
652
  const finish = () => {
618
- if (outcome === "injected") {
653
+ if (result.outcome === "injected" || result.outcome === "steered") {
619
654
  this.liveErrors.delete(localThreadId);
620
655
  }
621
656
  else {
622
- this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
657
+ this.liveErrors.set(localThreadId, res.error ?? {
658
+ message: `live injection ${result.outcome}`,
659
+ code: result.outcome === "notReady"
660
+ ? "native_thread_not_ready"
661
+ : result.outcome === "notLive"
662
+ ? "native_session_not_live"
663
+ : "native_message_injection_failed",
664
+ statusCode: 503,
665
+ });
623
666
  }
624
- return outcome;
667
+ return result;
625
668
  };
626
669
  const reservation = this.forkReservations.get(localThreadId);
627
670
  if (reservation) {
628
671
  void reservation
629
672
  .then(() => finish())
630
- .then(resolve, () => resolve("failed"));
673
+ .then(resolve, () => resolve({ outcome: "failed" }));
631
674
  return;
632
675
  }
633
676
  resolve(finish());
634
677
  });
635
678
  handle.transport.send(typeof input === "string"
636
- ? { t: "inject", reqId, localThreadId, text: input }
637
- : { t: "inject", reqId, localThreadId, input });
679
+ ? { t: "inject", reqId, localThreadId, text: input, ...(options ? { options } : {}) }
680
+ : { t: "inject", reqId, localThreadId, input, ...(options ? { options } : {}) });
638
681
  });
639
682
  }
640
683
  /**
@@ -674,7 +717,7 @@ export class RunnerManager {
674
717
  handle.live.set(reqId, (reply) => {
675
718
  resolve(reply.interactionResult ?? {
676
719
  disposition: "invalid",
677
- message: reply.error ?? "runner did not return an interaction result",
720
+ message: reply.error?.message ?? "runner did not return an interaction result",
678
721
  });
679
722
  });
680
723
  handle.transport.send({
@@ -686,6 +729,34 @@ export class RunnerManager {
686
729
  });
687
730
  });
688
731
  }
732
+ /** Update a loaded Codex-lineage native thread without spawning a runner. */
733
+ updateCollaborationMode(localThreadId, mode) {
734
+ const handle = this.handles.get(localThreadId);
735
+ if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
736
+ return Promise.reject(new AgentRuntimeError("Native Session is not live", 409, "native_session_not_live"));
737
+ }
738
+ handle.lastUsedAt = this.now();
739
+ const reqId = randomUUID();
740
+ return new Promise((resolve, reject) => {
741
+ handle.live.set(reqId, (reply) => {
742
+ if (reply.collaborationUpdated) {
743
+ resolve();
744
+ return;
745
+ }
746
+ reject(fromWireError(reply.error ?? {
747
+ message: "Native collaboration mode update failed",
748
+ code: "collaboration_mode_update_failed",
749
+ statusCode: 503,
750
+ }));
751
+ });
752
+ handle.transport.send({
753
+ t: "collaboration.update",
754
+ reqId,
755
+ localThreadId,
756
+ mode,
757
+ });
758
+ });
759
+ }
689
760
  // ── AgentCapabilities ──────────────────────────────────────────────────────
690
761
  async listModels(runtime) {
691
762
  return listRuntimeModels(this.config, runtime ?? this.defaultRuntime);
@@ -869,7 +940,6 @@ export class RunnerManager {
869
940
  !allowReservedForkTarget) {
870
941
  throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
871
942
  }
872
- this.reapIdle();
873
943
  const existing = this.handles.get(key);
874
944
  if (existing && !existing.dead) {
875
945
  existing.lastUsedAt = this.now();
@@ -1011,14 +1081,56 @@ export class RunnerManager {
1011
1081
  if (handle.dead)
1012
1082
  return;
1013
1083
  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
- }
1084
+ this.deliverMirrorMessage(handle, message);
1018
1085
  return;
1019
1086
  }
1020
1087
  this.deliverRotateMessage(handle, message);
1021
1088
  }
1089
+ deliverMirrorMessage(handle, message) {
1090
+ if (handle.dead)
1091
+ return;
1092
+ if (message.event.type === "session.collaboration_mode") {
1093
+ this.collaborationModeListener?.(message.sessionId, message.event.mode);
1094
+ }
1095
+ this.observeHandleRuntimeEvent(handle, message.event);
1096
+ let delivered;
1097
+ try {
1098
+ delivered = Promise.resolve(this.mirrorListener?.(message.sessionId, message.event));
1099
+ }
1100
+ catch (error) {
1101
+ delivered = Promise.reject(error);
1102
+ }
1103
+ if (message.event.type === "response.created") {
1104
+ // The listener projects response.created into SessionRuntimeIndex
1105
+ // synchronously. Only then may the accepted terminal reservation drain
1106
+ // into a maintenance activity snapshot.
1107
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1108
+ }
1109
+ if (message.transferId && message.seq !== undefined) {
1110
+ void delivered.then(() => {
1111
+ if (!handle.dead) {
1112
+ handle.transport.send({
1113
+ t: "mirror.image.ack",
1114
+ transferId: message.transferId,
1115
+ seq: message.seq,
1116
+ });
1117
+ }
1118
+ }, (error) => {
1119
+ this.failMirrorImageTransfer(handle, `generated image publication failed: ${errorMessage(error)}`);
1120
+ });
1121
+ }
1122
+ else {
1123
+ void delivered.catch((error) => {
1124
+ console.error(`Session mirror publication failed: ${errorMessage(error)}`);
1125
+ });
1126
+ }
1127
+ }
1128
+ failMirrorImageTransfer(handle, reason) {
1129
+ handle.imageTransfers.clear();
1130
+ void this.terminateHandle(handle, reason).catch((error) => {
1131
+ logTerminationFailure(handle, error);
1132
+ });
1133
+ }
1022
1134
  deliverRotateMessage(handle, message) {
1023
1135
  if (this.forkReservations.has(message.to) ||
1024
1136
  this.sourceForkReservations.has(message.from)) {
@@ -1078,6 +1190,7 @@ export class RunnerManager {
1078
1190
  this.settleTerminalRotationHandoff(handle, message.from, message.to);
1079
1191
  if (handle.dead)
1080
1192
  return;
1193
+ handle.activeResponseIds.clear();
1081
1194
  this.handles.set(message.to, handle);
1082
1195
  this.liveSessionKeys.add(message.to);
1083
1196
  this.liveOptions.set(message.to, {
@@ -1144,14 +1257,15 @@ export class RunnerManager {
1144
1257
  transport,
1145
1258
  stderr: [],
1146
1259
  lastUsedAt: this.now(),
1260
+ activeResponseIds: new Set(),
1147
1261
  dead: false,
1148
1262
  completion,
1149
- terminalCleanupRetryFailures: 0,
1150
1263
  processGroup,
1151
1264
  ...(sessionContext ? { sessionContext } : {}),
1152
1265
  caps: new Map(),
1153
1266
  terminals: new Map(),
1154
1267
  live: new Map(),
1268
+ imageTransfers: new Map(),
1155
1269
  };
1156
1270
  this.childHandles.add(handle);
1157
1271
  void completion.then(() => this.childHandles.delete(handle));
@@ -1210,14 +1324,83 @@ export class RunnerManager {
1210
1324
  return;
1211
1325
  if (this.bufferForkTargetMessage(handle, msg))
1212
1326
  return;
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");
1327
+ this.deliverMirrorMessage(handle, msg);
1328
+ return;
1329
+ case "mirror.image.begin": {
1330
+ if (handle.dead ||
1331
+ handle.imageTransfers.has(msg.transferId) ||
1332
+ !Number.isSafeInteger(msg.totalChars) ||
1333
+ msg.totalChars <= 0 ||
1334
+ msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
1335
+ !isDetachedGeneratedImageEvent(msg.event)) {
1336
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer start");
1337
+ return;
1338
+ }
1339
+ handle.imageTransfers.set(msg.transferId, {
1340
+ sessionId: msg.sessionId,
1341
+ event: msg.event,
1342
+ totalChars: msg.totalChars,
1343
+ receivedChars: 0,
1344
+ nextSeq: 1,
1345
+ chunks: [],
1346
+ });
1347
+ handle.transport.send({ t: "mirror.image.ack", transferId: msg.transferId, seq: 0 });
1348
+ return;
1349
+ }
1350
+ case "mirror.image.chunk": {
1351
+ const transfer = handle.imageTransfers.get(msg.transferId);
1352
+ if (handle.dead ||
1353
+ !transfer ||
1354
+ msg.seq !== transfer.nextSeq ||
1355
+ !msg.data ||
1356
+ msg.data.length > RUNNER_IMAGE_CHUNK_CHARS ||
1357
+ transfer.receivedChars + msg.data.length > transfer.totalChars) {
1358
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer chunk");
1359
+ return;
1360
+ }
1361
+ transfer.chunks.push(msg.data);
1362
+ transfer.receivedChars += msg.data.length;
1363
+ transfer.nextSeq += 1;
1364
+ handle.transport.send({
1365
+ t: "mirror.image.ack",
1366
+ transferId: msg.transferId,
1367
+ seq: msg.seq,
1368
+ });
1369
+ return;
1370
+ }
1371
+ case "mirror.image.commit": {
1372
+ const transfer = handle.imageTransfers.get(msg.transferId);
1373
+ if (handle.dead ||
1374
+ !transfer ||
1375
+ msg.seq !== transfer.nextSeq ||
1376
+ transfer.receivedChars !== transfer.totalChars) {
1377
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
1378
+ return;
1219
1379
  }
1380
+ handle.imageTransfers.delete(msg.transferId);
1381
+ const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
1382
+ if (!event) {
1383
+ this.failMirrorImageTransfer(handle, "generated image transfer target changed");
1384
+ return;
1385
+ }
1386
+ const mirrored = {
1387
+ t: "mirror",
1388
+ sessionId: transfer.sessionId,
1389
+ event,
1390
+ transferId: msg.transferId,
1391
+ seq: msg.seq,
1392
+ };
1393
+ if (this.bufferForkTargetMessage(handle, mirrored)) {
1394
+ handle.transport.send({
1395
+ t: "mirror.image.hold",
1396
+ transferId: msg.transferId,
1397
+ seq: msg.seq,
1398
+ });
1399
+ return;
1400
+ }
1401
+ this.deliverMirrorMessage(handle, mirrored);
1220
1402
  return;
1403
+ }
1221
1404
  case "rotate": {
1222
1405
  if (handle.dead)
1223
1406
  return;
@@ -1232,10 +1415,19 @@ export class RunnerManager {
1232
1415
  const resolve = handle.live.get(msg.reqId);
1233
1416
  handle.live.delete(msg.reqId);
1234
1417
  resolve?.(msg.t === "injected"
1235
- ? { outcome: msg.outcome, error: msg.error }
1418
+ ? { result: msg.result, error: msg.error }
1236
1419
  : { ok: msg.ok, error: msg.error });
1237
1420
  return;
1238
1421
  }
1422
+ case "collaboration.updated": {
1423
+ const resolve = handle.live.get(msg.reqId);
1424
+ handle.live.delete(msg.reqId);
1425
+ resolve?.({
1426
+ collaborationUpdated: msg.ok,
1427
+ ...(msg.error ? { error: msg.error } : {}),
1428
+ });
1429
+ return;
1430
+ }
1239
1431
  case "interaction.resolved": {
1240
1432
  const resolve = handle.live.get(msg.reqId);
1241
1433
  handle.live.delete(msg.reqId);
@@ -1245,7 +1437,7 @@ export class RunnerManager {
1245
1437
  }
1246
1438
  }
1247
1439
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
1248
- failHandle(handle, reason) {
1440
+ failHandle(handle, reason, opts = {}) {
1249
1441
  if (handle.dead) {
1250
1442
  return;
1251
1443
  }
@@ -1264,6 +1456,9 @@ export class RunnerManager {
1264
1456
  }
1265
1457
  const tail = handle.stderr.join("\n");
1266
1458
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
1459
+ if (opts.failActiveResponses !== false) {
1460
+ this.failActiveResponses(handle, "runner_crashed", message);
1461
+ }
1267
1462
  const error = new AgentRuntimeError(message, 500, "runner_crashed");
1268
1463
  for (const pending of handle.caps.values()) {
1269
1464
  pending.reject(error);
@@ -1272,20 +1467,20 @@ export class RunnerManager {
1272
1467
  terminal._fail(message);
1273
1468
  }
1274
1469
  for (const resolve of handle.live.values()) {
1275
- resolve({ ok: false, error: message });
1470
+ resolve({
1471
+ ok: false,
1472
+ error: { message, code: "runner_crashed", statusCode: 500 },
1473
+ });
1276
1474
  }
1277
1475
  handle.caps.clear();
1278
1476
  handle.terminals.clear();
1279
1477
  handle.live.clear();
1478
+ handle.imageTransfers.clear();
1280
1479
  handle.transport.close();
1281
1480
  }
1282
1481
  terminateHandle(handle, reason) {
1283
1482
  if (handle.termination)
1284
1483
  return handle.termination;
1285
- if (handle.terminalCleanupRetryTimer) {
1286
- clearTimeout(handle.terminalCleanupRetryTimer);
1287
- delete handle.terminalCleanupRetryTimer;
1288
- }
1289
1484
  const attempt = (async () => {
1290
1485
  if (!handle.dead) {
1291
1486
  try {
@@ -1294,7 +1489,8 @@ export class RunnerManager {
1294
1489
  catch {
1295
1490
  // The process signal below remains the authoritative shutdown path.
1296
1491
  }
1297
- this.failHandle(handle, reason);
1492
+ this.interruptActiveResponses(handle);
1493
+ this.failHandle(handle, reason, { failActiveResponses: false });
1298
1494
  }
1299
1495
  this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1300
1496
  let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
@@ -1302,15 +1498,14 @@ export class RunnerManager {
1302
1498
  this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1303
1499
  childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1304
1500
  }
1305
- const terminalStopped = handle.key === CAP_KEY
1306
- ? true
1307
- : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1501
+ if (handle.key !== CAP_KEY) {
1502
+ await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch((error) => {
1503
+ console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
1504
+ });
1505
+ }
1308
1506
  if (!childExited) {
1309
1507
  throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1310
1508
  }
1311
- if (!terminalStopped) {
1312
- throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1313
- }
1314
1509
  this.childHandles.delete(handle);
1315
1510
  })();
1316
1511
  handle.termination = attempt;
@@ -1319,34 +1514,174 @@ export class RunnerManager {
1319
1514
  }, () => {
1320
1515
  if (handle.termination === attempt)
1321
1516
  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.
1517
+ // A child that survives SIGKILL is still real activity. Release the
1518
+ // gate reservation but retain the handoff until a later explicit stop.
1326
1519
  this.preserveTerminalInputHandoffsAsActivity(handle);
1327
- this.scheduleTerminalInputCleanupRetry(handle);
1328
1520
  });
1329
1521
  return attempt;
1330
1522
  }
1331
1523
  reapIdle() {
1524
+ if (this.reapPromise)
1525
+ return this.reapPromise;
1526
+ const attempt = this.reapIdleOnce();
1527
+ this.reapPromise = attempt;
1528
+ const clear = () => {
1529
+ if (this.reapPromise === attempt)
1530
+ this.reapPromise = null;
1531
+ };
1532
+ void attempt.then(clear, clear);
1533
+ return attempt;
1534
+ }
1535
+ async reapIdleOnce() {
1332
1536
  const now = this.now();
1333
1537
  for (const [key, handle] of this.handles) {
1334
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
1538
+ if (this.isManagedNativeHandle(handle)) {
1539
+ await this.reapNativePane(handle, now);
1335
1540
  continue;
1336
1541
  }
1337
- // A live codex forwarder must outlive terminal detach / idle turns — it is
1338
- // the session's single event writer, so never reap a session that has one.
1339
- if (this.liveSessionKeys.has(key)) {
1542
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1340
1543
  continue;
1341
1544
  }
1342
- if (now - handle.lastUsedAt < this.idleTtlMs) {
1545
+ const idleForMs = now - handle.lastUsedAt;
1546
+ if (idleForMs < this.idleTtlMs) {
1343
1547
  continue;
1344
1548
  }
1345
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
1549
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
1550
+ if (hasActiveResponse)
1551
+ continue;
1552
+ const reason = this.liveSessionKeys.has(key)
1553
+ ? "idle live runner reaped"
1554
+ : "idle runner reaped";
1555
+ void this.terminateHandle(handle, reason).catch((error) => {
1346
1556
  logTerminationFailure(handle, error);
1347
1557
  });
1348
1558
  }
1349
1559
  }
1560
+ /** Provider-authoritative turn state is the lifecycle truth; attached clients
1561
+ * and tmux's own activity clock are independent busy evidence. There is no
1562
+ * "user was inactive for an hour" override for an active turn. */
1563
+ async reapNativePane(handle, now) {
1564
+ if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
1565
+ return;
1566
+ if (await this.isNativePaneBusy(handle)) {
1567
+ handle.nativePaneLastBusyAt = now;
1568
+ return;
1569
+ }
1570
+ const lastBusyAt = handle.nativePaneLastBusyAt;
1571
+ if (lastBusyAt === undefined) {
1572
+ // First idle observation starts a full grace window.
1573
+ handle.nativePaneLastBusyAt = now;
1574
+ return;
1575
+ }
1576
+ if (now - lastBusyAt < this.nativePaneIdleTtlMs)
1577
+ return;
1578
+ // Close the select→reap race: activity may begin after classification but
1579
+ // before teardown.
1580
+ if (await this.isNativePaneBusy(handle)) {
1581
+ handle.nativePaneLastBusyAt = this.now();
1582
+ return;
1583
+ }
1584
+ // A failed teardown must re-arm on the next scan instead of retrying on
1585
+ // every sweep forever.
1586
+ delete handle.nativePaneLastBusyAt;
1587
+ await this.terminateHandle(handle, "idle native pane reaped").catch((error) => logTerminationFailure(handle, error));
1588
+ }
1589
+ async isNativePaneBusy(handle) {
1590
+ if (handle.activeResponseIds.size > 0 ||
1591
+ handle.caps.size > 0 ||
1592
+ handle.live.size > 0 ||
1593
+ handle.terminals.size > 0) {
1594
+ return true;
1595
+ }
1596
+ const terminalName = `${handle.key}-main`;
1597
+ try {
1598
+ if (await this.terminalHasAttachedClient(terminalName))
1599
+ return true;
1600
+ }
1601
+ catch {
1602
+ // A failed probe contributes no busy evidence; check activity next.
1603
+ }
1604
+ let activityAt = null;
1605
+ try {
1606
+ activityAt = await this.terminalWindowActivityAt(terminalName);
1607
+ }
1608
+ catch {
1609
+ // A failed probe contributes no busy evidence.
1610
+ }
1611
+ return activityAt !== null &&
1612
+ this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
1613
+ }
1614
+ isManagedNativeHandle(handle) {
1615
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1616
+ const provider = this.liveOptions.get(sessionId)?.execution.provider;
1617
+ return isManagedNativeProvider(provider);
1618
+ }
1619
+ failActiveResponses(handle, code, message) {
1620
+ const responseIds = [...handle.activeResponseIds];
1621
+ handle.activeResponseIds.clear();
1622
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1623
+ for (const responseId of responseIds) {
1624
+ this.mirrorListener?.(sessionId, {
1625
+ type: "response.failed",
1626
+ responseId,
1627
+ error: {
1628
+ source: "execution",
1629
+ code,
1630
+ message,
1631
+ },
1632
+ });
1633
+ }
1634
+ }
1635
+ interruptActiveResponses(handle) {
1636
+ const responseIds = [...handle.activeResponseIds];
1637
+ handle.activeResponseIds.clear();
1638
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1639
+ for (const responseId of responseIds) {
1640
+ this.mirrorListener?.(sessionId, {
1641
+ type: "session.interrupted",
1642
+ sessionId,
1643
+ responseId,
1644
+ });
1645
+ }
1646
+ }
1647
+ /** Track the provider-authoritative response lifecycle. Native-pane busy
1648
+ * classification consumes this level directly; output volume is separately
1649
+ * grounded in tmux's own activity clock. */
1650
+ observeHandleRuntimeEvent(handle, event) {
1651
+ switch (event.type) {
1652
+ case "response.created":
1653
+ case "response.output_text.delta":
1654
+ case "response.reasoning_summary_text.delta":
1655
+ case "response.function_call_output.delta":
1656
+ case "response.output_item.done":
1657
+ handle.activeResponseIds.add(event.responseId);
1658
+ return;
1659
+ case "session.interaction.requested":
1660
+ handle.activeResponseIds.add(event.responseId);
1661
+ return;
1662
+ case "response.completed":
1663
+ case "response.failed":
1664
+ case "session.interrupted":
1665
+ handle.activeResponseIds.delete(event.responseId);
1666
+ return;
1667
+ case "session.status":
1668
+ if (event.status === "running" && event.responseId) {
1669
+ handle.activeResponseIds.add(event.responseId);
1670
+ }
1671
+ else if (event.status !== "running") {
1672
+ if (event.responseId)
1673
+ handle.activeResponseIds.delete(event.responseId);
1674
+ else
1675
+ handle.activeResponseIds.clear();
1676
+ }
1677
+ return;
1678
+ case "session.rotated":
1679
+ handle.activeResponseIds.clear();
1680
+ return;
1681
+ default:
1682
+ return;
1683
+ }
1684
+ }
1350
1685
  openSessionContext(sessionId) {
1351
1686
  if (!this.sessionContextProvider)
1352
1687
  return undefined;
@@ -1503,6 +1838,36 @@ function closeSessionContext(context) {
1503
1838
  }));
1504
1839
  }
1505
1840
  }
1841
+ function isDetachedGeneratedImageEvent(event) {
1842
+ if (event.type !== "response.output_item.done" ||
1843
+ event.item.type !== "function_call_output") {
1844
+ return false;
1845
+ }
1846
+ const data = event.item.data;
1847
+ return Boolean(data.__rynxGeneratedImage && data.__rynxGeneratedImage.result === undefined);
1848
+ }
1849
+ function attachGeneratedImageResult(event, result) {
1850
+ if (!isDetachedGeneratedImageEvent(event))
1851
+ return undefined;
1852
+ const outputEvent = event;
1853
+ const item = outputEvent.item;
1854
+ if (item.type !== "function_call_output")
1855
+ return undefined;
1856
+ const data = item.data;
1857
+ return {
1858
+ ...outputEvent,
1859
+ item: {
1860
+ ...item,
1861
+ data: {
1862
+ ...data,
1863
+ __rynxGeneratedImage: {
1864
+ ...data.__rynxGeneratedImage,
1865
+ result,
1866
+ },
1867
+ },
1868
+ },
1869
+ };
1870
+ }
1506
1871
  function errorMessage(error) {
1507
1872
  return error instanceof Error ? error.message : String(error);
1508
1873
  }