@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41

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 (65) 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 +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -22,9 +22,11 @@ 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";
29
+ import { cloneClaudeTranscript } from "../claude/transcript-clone.js";
28
30
  /** Routing key for the shared capability runner (slash-command RPCs). */
29
31
  const CAP_KEY = "__cap__";
30
32
  /** Max stderr lines retained per handle for the crash exit-report tail. */
@@ -33,23 +35,30 @@ const STDERR_TAIL_LINES = 40;
33
35
  const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
34
36
  /** Time allowed for exit after SIGKILL before shutdown reports failure. */
35
37
  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. */
38
+ /** Legacy setup-pane acknowledgement deadline for providers that do not own
39
+ * phase-specific startup errors. Codex/Traex are deliberately excluded. */
40
+ const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
41
+ /** Legacy fallback for a provider without a native startup policy. */
39
42
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
43
+ /** Claude has no app-server bridge and keeps a parent-owned SessionStart
44
+ * deadline. Codex-lineage startup instead acknowledges pane/observer startup
45
+ * and lets thread discovery race injection. */
46
+ const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
40
47
  /** A submitted TUI command should become a mirrored turn or native rotation
41
48
  * quickly. If it does not, the runner is fenced by a verified process-tree
42
49
  * shutdown before maintenance may treat the submission as settled. */
43
50
  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;
51
+ /** Full idle window before an inactive native pane becomes reapable. */
52
+ const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
53
+ /** tmux output this recent independently proves that a native pane is busy. */
54
+ const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
47
55
  /** Keep per-Session spawn context small enough to remain an environment handoff,
48
56
  * not an unbounded transport. */
49
57
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
50
58
  const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
51
59
  const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
52
60
  const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
61
+ const MAX_TERMINAL_RESPONSE_IDS = 2_000;
53
62
  /** A typed attach failure so transport adapters can distinguish an expected
54
63
  * absent pane from an infrastructure failure without matching error strings. */
55
64
  export class TerminalOpenError extends Error {
@@ -65,11 +74,16 @@ class ManagedTerminal {
65
74
  sendMsg;
66
75
  onClose;
67
76
  ready;
68
- dataListener;
69
- exitListener;
77
+ readerDone;
78
+ resizeListener;
79
+ dimensions;
80
+ role = "read-only";
81
+ seedBytes = 0;
70
82
  resolveReady;
71
83
  rejectReady;
84
+ resolveReaderDone;
72
85
  state = "opening";
86
+ pending = new Map();
73
87
  constructor(attachId, sendMsg, onClose) {
74
88
  this.attachId = attachId;
75
89
  this.sendMsg = sendMsg;
@@ -78,20 +92,75 @@ class ManagedTerminal {
78
92
  this.resolveReady = resolve;
79
93
  this.rejectReady = reject;
80
94
  });
95
+ this.readerDone = new Promise((resolve) => {
96
+ this.resolveReaderDone = resolve;
97
+ });
81
98
  // Never surface an unhandled rejection if no caller awaits `ready`.
82
99
  void this.ready.catch(() => undefined);
83
100
  }
84
- onData(listener) {
85
- this.dataListener = listener;
101
+ onResize(listener) {
102
+ this.resizeListener = listener;
103
+ if (this.dimensions)
104
+ queueMicrotask(() => listener(this.dimensions));
105
+ }
106
+ readSeed(offset, maxBytes) {
107
+ if (this.state !== "prepared")
108
+ return Promise.reject(new Error("terminal is not prepared"));
109
+ return this.requestRead("seed", {
110
+ t: "term.seed.read",
111
+ attachId: this.attachId,
112
+ reqId: randomUUID(),
113
+ offset,
114
+ maxBytes,
115
+ });
86
116
  }
87
- onExit(listener) {
88
- this.exitListener = listener;
117
+ start() {
118
+ if (this.state !== "prepared")
119
+ return Promise.reject(new Error("terminal is not prepared"));
120
+ this.state = "starting";
121
+ const reqId = randomUUID();
122
+ return this.request("start", { t: "term.start", attachId: this.attachId, reqId })
123
+ .then(() => {
124
+ if (this.state === "starting")
125
+ this.state = "started";
126
+ });
127
+ }
128
+ read(offset, maxBytes) {
129
+ if (this.state !== "started")
130
+ return Promise.reject(new Error("terminal has not started"));
131
+ return this.requestRead("read", {
132
+ t: "term.read",
133
+ attachId: this.attachId,
134
+ reqId: randomUUID(),
135
+ offset,
136
+ maxBytes,
137
+ });
89
138
  }
90
139
  write(data) {
91
- this.sendMsg({ t: "term.input", attachId: this.attachId, dataB64: Buffer.from(data, "utf8").toString("base64") });
140
+ if (this.state !== "started" || this.role !== "owner")
141
+ return Promise.resolve();
142
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
143
+ if (bytes.byteLength === 0)
144
+ return Promise.resolve();
145
+ const reqId = randomUUID();
146
+ return this.request("input", {
147
+ t: "term.input",
148
+ attachId: this.attachId,
149
+ reqId,
150
+ dataB64: bytes.toString("base64"),
151
+ }).then(() => undefined);
92
152
  }
93
153
  resize(cols, rows) {
94
- this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
154
+ if (this.state !== "started")
155
+ return Promise.resolve();
156
+ const reqId = randomUUID();
157
+ return this.request("resize", {
158
+ t: "term.resize",
159
+ attachId: this.attachId,
160
+ reqId,
161
+ cols,
162
+ rows,
163
+ }).then(() => undefined);
95
164
  }
96
165
  close() {
97
166
  if (this.state === "closed")
@@ -100,43 +169,96 @@ class ManagedTerminal {
100
169
  this.state = "closed";
101
170
  this.sendMsg({ t: "term.close", attachId: this.attachId });
102
171
  this.onClose();
172
+ this.rejectPending(new Error("terminal attachment was closed"));
103
173
  if (wasOpening) {
104
174
  this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
105
175
  }
176
+ this.resolveReaderDone({ finalOffset: 0, reason: "client_closed", exitCode: 0 });
106
177
  }
107
178
  // ── internal (driven by onChildMessage) ──
108
- _opened(role) {
179
+ _prepared(role, seedBytes, cols, rows) {
109
180
  if (this.state !== "opening")
110
181
  return;
111
- this.state = "opened";
112
- this.resolveReady({ role });
113
- }
114
- _data(dataB64) {
115
- this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
182
+ this.state = "prepared";
183
+ this.role = role;
184
+ this.seedBytes = seedBytes;
185
+ this.dimensions = cols !== undefined && rows !== undefined ? { cols, rows } : undefined;
186
+ this.resolveReady({
187
+ role,
188
+ seedBytes,
189
+ ...(this.dimensions ? { dimensions: this.dimensions } : {}),
190
+ });
116
191
  }
117
- _exit(exitCode) {
192
+ _dimensions(cols, rows) {
118
193
  if (this.state === "closed")
119
194
  return;
120
- const wasOpening = this.state === "opening";
121
- this.state = "closed";
122
- if (wasOpening) {
123
- this.rejectReady(new TerminalOpenError(`terminal exited before opening (code=${exitCode})`, "terminal_open_failed"));
124
- }
125
- else {
126
- this.exitListener?.({ exitCode });
127
- }
195
+ this.dimensions = { cols, rows };
196
+ this.resizeListener?.(this.dimensions);
197
+ }
198
+ _started(reqId) {
199
+ this.resolvePending(reqId, "start", undefined);
200
+ }
201
+ _chunk(reqId, operation, dataB64, nextOffset, done, finalOffset) {
202
+ this.resolvePending(reqId, operation, {
203
+ data: Uint8Array.from(Buffer.from(dataB64, "base64")),
204
+ nextOffset,
205
+ done,
206
+ ...(finalOffset === undefined ? {} : { finalOffset }),
207
+ });
208
+ }
209
+ _ack(reqId, operation) {
210
+ this.resolvePending(reqId, operation, undefined);
211
+ }
212
+ _readerDone(info) {
213
+ this.resolveReaderDone(info);
128
214
  }
129
- _fail(message, code = "terminal_open_failed") {
215
+ _fail(message, code = "terminal_open_failed", reqId) {
130
216
  if (this.state === "closed")
131
217
  return;
218
+ if (reqId) {
219
+ const pending = this.pending.get(reqId);
220
+ if (pending) {
221
+ this.pending.delete(reqId);
222
+ pending.reject(new TerminalOpenError(message, code));
223
+ return;
224
+ }
225
+ }
132
226
  const wasOpening = this.state === "opening";
133
227
  this.state = "closed";
228
+ this.rejectPending(new TerminalOpenError(message, code));
134
229
  if (wasOpening) {
135
230
  this.rejectReady(new TerminalOpenError(message, code));
136
231
  }
137
- else {
138
- this.exitListener?.({ exitCode: 1 });
232
+ this.resolveReaderDone({ finalOffset: 0, reason: "internal", exitCode: 1 });
233
+ }
234
+ requestRead(operation, message) {
235
+ return this.request(operation, message);
236
+ }
237
+ request(operation, message) {
238
+ return new Promise((resolve, reject) => {
239
+ this.pending.set(message.reqId, { operation, resolve, reject });
240
+ try {
241
+ this.sendMsg(message);
242
+ }
243
+ catch (error) {
244
+ this.pending.delete(message.reqId);
245
+ reject(error instanceof Error ? error : new Error(String(error)));
246
+ }
247
+ });
248
+ }
249
+ resolvePending(reqId, operation, value) {
250
+ const pending = this.pending.get(reqId);
251
+ if (!pending || pending.operation !== operation) {
252
+ this._fail(`terminal response ${reqId} is duplicate or out of order`);
253
+ return;
139
254
  }
255
+ this.pending.delete(reqId);
256
+ pending.resolve(value);
257
+ }
258
+ rejectPending(error) {
259
+ for (const pending of this.pending.values())
260
+ pending.reject(error);
261
+ this.pending.clear();
140
262
  }
141
263
  }
142
264
  export class RunnerManager {
@@ -144,6 +266,8 @@ export class RunnerManager {
144
266
  sessionStore;
145
267
  runnerEntry;
146
268
  idleTtlMs;
269
+ nativePaneIdleTtlMs;
270
+ nativePaneOutputBusyWindowMs;
147
271
  spawn;
148
272
  childEnv;
149
273
  sessionContextProvider;
@@ -160,15 +284,28 @@ export class RunnerManager {
160
284
  shutdownKillGraceMs;
161
285
  signalChild;
162
286
  terminateTerminalServer;
287
+ terminalWindowActivityAt;
288
+ terminalHasAttachedClient;
289
+ wallNow;
163
290
  liveStartTimeoutMs;
164
291
  liveReadyTimeoutMs;
292
+ nativeLiveStartTimeoutMs;
165
293
  liveInterruptTimeoutMs;
166
294
  terminalInputHandoffTimeoutMs;
167
- terminalInputCleanupRetryMs;
295
+ reapPromise = null;
168
296
  stopping = false;
169
297
  stopPromise;
170
298
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
171
299
  mirrorListener = null;
300
+ /** Settles process-local projections only when an ordinary durable item has
301
+ * exhausted its permanent retry budget. */
302
+ mirrorAbandonListener = null;
303
+ /** Clears process-live optimistic state when /clear permanently moves the
304
+ * native pane away from the old Session. */
305
+ mirrorSupersedeListener = null;
306
+ /** Synchronous persistence hook for native collaboration-mode reflection.
307
+ * It runs before the event reaches the Session bus. */
308
+ collaborationModeListener = null;
172
309
  /** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
173
310
  rotateListener = null;
174
311
  /** Session keys with a live codex forwarder — never reaped while present. */
@@ -198,25 +335,33 @@ export class RunnerManager {
198
335
  this.sessionStore = opts.sessionStore;
199
336
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
200
337
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
338
+ this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
339
+ this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
201
340
  this.spawn = opts.spawn ?? nodeSpawn;
202
341
  this.childEnv = opts.childEnv ?? {};
203
342
  this.sessionContextProvider = opts.sessionContextProvider;
204
343
  this.admissionOpen = opts.admissionOpen ?? (() => true);
205
344
  this.admissionReserve = opts.admissionReserve;
206
345
  this.now = opts.now ?? (() => Date.now());
346
+ this.wallNow = opts.wallNow ?? (() => Date.now());
207
347
  this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
208
348
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
209
349
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
210
350
  this.signalChild = opts.signalChild ?? signalRunnerChild;
211
- this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
351
+ this.terminateTerminalServer = opts.terminateTerminalServer ??
352
+ ((name, ownerPid) => terminateTmuxServer(name, undefined, ownerPid));
353
+ this.terminalWindowActivityAt = opts.terminalWindowActivityAt ??
354
+ ((name, ownerPid) => tmuxWindowActivityAt(name, undefined, ownerPid));
355
+ this.terminalHasAttachedClient = opts.terminalHasAttachedClient ??
356
+ ((name, ownerPid) => tmuxHasAttachedClient(name, undefined, ownerPid));
212
357
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
213
358
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
359
+ this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
214
360
  this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
215
361
  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
362
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
218
363
  if (reapIntervalMs > 0) {
219
- this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
364
+ this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
220
365
  this.reapTimer.unref?.();
221
366
  }
222
367
  else {
@@ -227,9 +372,8 @@ export class RunnerManager {
227
372
  /**
228
373
  * Open a live terminal on the session's runner child (spawning it if needed).
229
374
  * Phase C hosts one terminal ("main") per session; the returned handle is a
230
- * single attach client — `owner` (read-write) or a downgraded `read-only`
231
- * viewer per the child's ownership rule. Not part of `AgentExecutor`; the WS
232
- * bridge calls it directly.
375
+ * single attach client with the requested `owner` (read-write) or `read-only`
376
+ * role. Not part of `AgentExecutor`; the WS bridge calls it directly.
233
377
  */
234
378
  openTerminal(localThreadId, opts) {
235
379
  const handle = this.getOrSpawn(localThreadId);
@@ -261,6 +405,8 @@ export class RunnerManager {
261
405
  pendingEscape: "",
262
406
  };
263
407
  const terminal = new ManagedTerminal(attachId, (msg) => {
408
+ if (msg.t === "term.input")
409
+ handle.lastUsedAt = this.now();
264
410
  const handoffs = msg.t === "term.input" && opts.role === "owner"
265
411
  ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
412
  : [];
@@ -329,6 +475,15 @@ export class RunnerManager {
329
475
  onMirror(listener) {
330
476
  this.mirrorListener = listener;
331
477
  }
478
+ onMirrorAbandon(listener) {
479
+ this.mirrorAbandonListener = listener;
480
+ }
481
+ onMirrorSupersede(listener) {
482
+ this.mirrorSupersedeListener = listener;
483
+ }
484
+ onCollaborationMode(listener) {
485
+ this.collaborationModeListener = listener;
486
+ }
332
487
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
333
488
  * records the new session's meta (carry-over agent/model/title). */
334
489
  onRotate(listener) {
@@ -412,7 +567,7 @@ export class RunnerManager {
412
567
  if (candidate === handle && this.liveSessionKeys.has(sessionId))
413
568
  return sessionId;
414
569
  }
415
- return fallback;
570
+ return handle.activeSessionId || fallback;
416
571
  }
417
572
  finishTerminalInputHandoff(handle, handoff) {
418
573
  if (handoff.timer) {
@@ -431,11 +586,6 @@ export class RunnerManager {
431
586
  this.terminalInputHandoffs.delete(handle);
432
587
  }
433
588
  finishAllTerminalInputHandoffs(handle) {
434
- if (handle.terminalCleanupRetryTimer) {
435
- clearTimeout(handle.terminalCleanupRetryTimer);
436
- delete handle.terminalCleanupRetryTimer;
437
- }
438
- handle.terminalCleanupRetryFailures = 0;
439
589
  for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
440
590
  this.finishTerminalInputHandoff(handle, handoff);
441
591
  }
@@ -450,22 +600,6 @@ export class RunnerManager {
450
600
  handoff.reservation = undefined;
451
601
  }
452
602
  }
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
603
  expireTerminalInputHandoffs(handle) {
470
604
  const handoffs = this.terminalInputHandoffs.get(handle);
471
605
  if (!handoffs?.length)
@@ -483,8 +617,10 @@ export class RunnerManager {
483
617
  /**
484
618
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
485
619
  * 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).
620
+ * needed. Idempotent. For Codex-lineage sessions, resolves after backend
621
+ * preload and pane launch; known-thread observer attach and fresh-thread
622
+ * discovery continue in the background. Other providers retain their
623
+ * readiness gate.
488
624
  */
489
625
  ensureLiveSession(localThreadId, opts) {
490
626
  const admission = this.reserveAdmission();
@@ -523,42 +659,66 @@ export class RunnerManager {
523
659
  this.liveSessionKeys.add(localThreadId);
524
660
  const reqId = randomUUID();
525
661
  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?.();
662
+ const provider = opts.execution.provider;
663
+ const timeoutMs = isCodexLineageProvider(provider)
664
+ ? null
665
+ : !waitForReady
666
+ ? this.liveStartTimeoutMs
667
+ : provider === "claude"
668
+ ? this.nativeLiveStartTimeoutMs
669
+ : this.liveReadyTimeoutMs;
670
+ const timeout = timeoutMs === null
671
+ ? undefined
672
+ : setTimeout(() => {
673
+ if (!handle.live.delete(reqId))
674
+ return;
675
+ const finishTimeout = () => {
676
+ const reason = !waitForReady
677
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
678
+ : provider === "claude"
679
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
680
+ : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
681
+ this.liveErrors.set(localThreadId, {
682
+ message: reason,
683
+ code: provider === "claude"
684
+ ? "claude_session_start_timeout"
685
+ : "native_runner_ack_timeout",
686
+ statusCode: 503,
687
+ });
688
+ this.liveSessionKeys.delete(localThreadId);
689
+ // Legacy providers without phase-owned startup errors retain the
690
+ // bounded control guard. Codex/Traex never enter this branch.
691
+ void this.terminateHandle(handle, reason).catch((error) => {
692
+ logTerminationFailure(handle, error);
693
+ });
694
+ return false;
695
+ };
696
+ const reservation = allowReservedForkTarget
697
+ ? undefined
698
+ : this.forkReservations.get(localThreadId);
699
+ if (reservation) {
700
+ void reservation
701
+ .then(() => finishTimeout())
702
+ .then(resolve, () => resolve(false));
703
+ return;
704
+ }
705
+ resolve(finishTimeout());
706
+ }, timeoutMs);
707
+ timeout?.unref?.();
553
708
  handle.live.set(reqId, (res) => {
554
- clearTimeout(timeout);
709
+ if (timeout)
710
+ clearTimeout(timeout);
555
711
  const ok = res.ok ?? false;
556
712
  const finish = () => {
557
713
  if (ok) {
558
714
  this.liveErrors.delete(localThreadId);
559
715
  }
560
716
  else {
561
- this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
717
+ this.liveErrors.set(localThreadId, res.error ?? {
718
+ message: "live session did not become ready",
719
+ code: "native_readiness_failed",
720
+ statusCode: 503,
721
+ });
562
722
  }
563
723
  return ok;
564
724
  };
@@ -586,7 +746,11 @@ export class RunnerManager {
586
746
  });
587
747
  }
588
748
  lastLiveSessionError(localThreadId) {
589
- return this.liveErrors.get(localThreadId);
749
+ return this.liveErrors.get(localThreadId)?.message;
750
+ }
751
+ lastLiveSessionFailure(localThreadId) {
752
+ const failure = this.liveErrors.get(localThreadId);
753
+ return failure ? { ...failure } : undefined;
590
754
  }
591
755
  /**
592
756
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -594,47 +758,63 @@ export class RunnerManager {
594
758
  * all output. Resolves true when the app-server accepted the turn, false when
595
759
  * the session has no live forwarder (caller falls back to the run path).
596
760
  */
597
- injectMessage(localThreadId, input) {
761
+ injectMessage(localThreadId, input, options) {
598
762
  const admission = this.reserveAdmission();
599
- return this.injectMessageAdmitted(localThreadId, input)
763
+ return this.injectMessageAdmitted(localThreadId, input, options)
600
764
  .finally(() => admission.release());
601
765
  }
602
- injectMessageAdmitted(localThreadId, input) {
766
+ injectMessageAdmitted(localThreadId, input, options) {
767
+ const rotatingHandle = this.handles.get(localThreadId);
768
+ if (rotatingHandle?.pendingRotation?.from === localThreadId &&
769
+ !rotatingHandle.dead) {
770
+ // Reject input addressed to the old active Session during
771
+ // cutover. Do not wait and then silently reopen the retired Session: the
772
+ // user can retry after the client follows session.rotated.
773
+ return Promise.resolve({ outcome: "notReady" });
774
+ }
603
775
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
604
776
  if (sourceReservation) {
605
- return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
777
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
606
778
  }
607
779
  const reservation = this.forkReservations.get(localThreadId);
608
780
  if (reservation) {
609
- return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
781
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
610
782
  }
611
783
  const handle = this.getOrSpawn(localThreadId);
612
784
  handle.lastUsedAt = this.now();
613
785
  const reqId = randomUUID();
614
786
  return new Promise((resolve) => {
615
787
  handle.live.set(reqId, (res) => {
616
- const outcome = res.outcome ?? "failed";
788
+ const result = res.result ?? { outcome: "failed" };
617
789
  const finish = () => {
618
- if (outcome === "injected") {
790
+ if (result.outcome === "injected" || result.outcome === "steered") {
619
791
  this.liveErrors.delete(localThreadId);
620
792
  }
621
793
  else {
622
- this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
794
+ this.liveErrors.set(localThreadId, res.error ?? {
795
+ message: `live injection ${result.outcome}`,
796
+ code: result.outcome === "notReady"
797
+ ? "native_thread_not_ready"
798
+ : result.outcome === "notLive"
799
+ ? "native_session_not_live"
800
+ : "native_message_injection_failed",
801
+ statusCode: 503,
802
+ });
623
803
  }
624
- return outcome;
804
+ return result;
625
805
  };
626
806
  const reservation = this.forkReservations.get(localThreadId);
627
807
  if (reservation) {
628
808
  void reservation
629
809
  .then(() => finish())
630
- .then(resolve, () => resolve("failed"));
810
+ .then(resolve, () => resolve({ outcome: "failed" }));
631
811
  return;
632
812
  }
633
813
  resolve(finish());
634
814
  });
635
815
  handle.transport.send(typeof input === "string"
636
- ? { t: "inject", reqId, localThreadId, text: input }
637
- : { t: "inject", reqId, localThreadId, input });
816
+ ? { t: "inject", reqId, localThreadId, text: input, ...(options ? { options } : {}) }
817
+ : { t: "inject", reqId, localThreadId, input, ...(options ? { options } : {}) });
638
818
  });
639
819
  }
640
820
  /**
@@ -674,7 +854,7 @@ export class RunnerManager {
674
854
  handle.live.set(reqId, (reply) => {
675
855
  resolve(reply.interactionResult ?? {
676
856
  disposition: "invalid",
677
- message: reply.error ?? "runner did not return an interaction result",
857
+ message: reply.error?.message ?? "runner did not return an interaction result",
678
858
  });
679
859
  });
680
860
  handle.transport.send({
@@ -686,6 +866,34 @@ export class RunnerManager {
686
866
  });
687
867
  });
688
868
  }
869
+ /** Update a loaded Codex-lineage native thread without spawning a runner. */
870
+ updateCollaborationMode(localThreadId, mode) {
871
+ const handle = this.handles.get(localThreadId);
872
+ if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
873
+ return Promise.reject(new AgentRuntimeError("Native Session is not live", 409, "native_session_not_live"));
874
+ }
875
+ handle.lastUsedAt = this.now();
876
+ const reqId = randomUUID();
877
+ return new Promise((resolve, reject) => {
878
+ handle.live.set(reqId, (reply) => {
879
+ if (reply.collaborationUpdated) {
880
+ resolve();
881
+ return;
882
+ }
883
+ reject(fromWireError(reply.error ?? {
884
+ message: "Native collaboration mode update failed",
885
+ code: "collaboration_mode_update_failed",
886
+ statusCode: 503,
887
+ }));
888
+ });
889
+ handle.transport.send({
890
+ t: "collaboration.update",
891
+ reqId,
892
+ localThreadId,
893
+ mode,
894
+ });
895
+ });
896
+ }
689
897
  // ── AgentCapabilities ──────────────────────────────────────────────────────
690
898
  async listModels(runtime) {
691
899
  return listRuntimeModels(this.config, runtime ?? this.defaultRuntime);
@@ -824,6 +1032,7 @@ export class RunnerManager {
824
1032
  this.liveErrors.clear();
825
1033
  this.liveOptions.clear();
826
1034
  this.mirrorListener = null;
1035
+ this.mirrorAbandonListener = null;
827
1036
  this.rotateListener = null;
828
1037
  const errors = results
829
1038
  .filter((result) => result.status === "rejected")
@@ -869,7 +1078,6 @@ export class RunnerManager {
869
1078
  !allowReservedForkTarget) {
870
1079
  throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
871
1080
  }
872
- this.reapIdle();
873
1081
  const existing = this.handles.get(key);
874
1082
  if (existing && !existing.dead) {
875
1083
  existing.lastUsedAt = this.now();
@@ -946,29 +1154,67 @@ export class RunnerManager {
946
1154
  }
947
1155
  async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
948
1156
  const source = await this.sessionStore.get(currentLocalThreadId);
1157
+ const getIntent = this.sessionStore.getClaudeForkIntent?.bind(this.sessionStore);
949
1158
  const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
950
1159
  const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
951
- if (!source?.codexSessionId || !setIntent || !deleteIntent) {
1160
+ if (!source?.codexSessionId || !getIntent || !setIntent || !deleteIntent) {
952
1161
  return {
953
1162
  ok: false,
954
1163
  reason: "unsupported",
955
1164
  message: "Claude fork persistence is unavailable",
956
1165
  };
957
1166
  }
958
- const targetClaudeSessionId = randomUUID();
1167
+ const persistedIntent = await getIntent(newLocalThreadId);
1168
+ if (persistedIntent &&
1169
+ persistedIntent.sourceSessionId !== currentLocalThreadId) {
1170
+ return {
1171
+ ok: false,
1172
+ reason: "error",
1173
+ message: "target Session already has a different Claude fork intent",
1174
+ };
1175
+ }
1176
+ // A durable fork operation may be replayed after clone+intent persistence
1177
+ // but before SessionStart commits the Provider binding. Reuse that exact
1178
+ // target id/path/prefix: re-cloning would move the fork boundary if the
1179
+ // source advanced (or silently lose history if it disappeared).
1180
+ const targetClaudeSessionId = persistedIntent?.targetClaudeSessionId ?? randomUUID();
959
1181
  try {
960
- await setIntent({
961
- targetSessionId: newLocalThreadId,
962
- sourceSessionId: currentLocalThreadId,
963
- sourceClaudeSessionId: source.codexSessionId,
964
- targetClaudeSessionId,
965
- updatedAt: new Date().toISOString(),
966
- });
967
- const ready = await this.requestLiveSession(newLocalThreadId, {
1182
+ if (!persistedIntent) {
1183
+ let clonedTranscript = null;
1184
+ try {
1185
+ clonedTranscript = await cloneClaudeTranscript({
1186
+ sourceClaudeSessionId: source.codexSessionId,
1187
+ targetClaudeSessionId,
1188
+ targetCwd: options.workspace.cwd,
1189
+ });
1190
+ }
1191
+ catch (error) {
1192
+ console.error(`[claude-fork] could not clone source transcript for ${newLocalThreadId}; target will start without Provider history: ${error instanceof Error ? error.message : String(error)}`);
1193
+ }
1194
+ await setIntent({
1195
+ targetSessionId: newLocalThreadId,
1196
+ sourceSessionId: currentLocalThreadId,
1197
+ sourceClaudeSessionId: source.codexSessionId,
1198
+ targetClaudeSessionId,
1199
+ ...(clonedTranscript
1200
+ ? {
1201
+ forkTranscriptPath: clonedTranscript.transcriptPath,
1202
+ ...(clonedTranscript.prefixBytes === undefined
1203
+ ? {}
1204
+ : { forkTranscriptPrefixBytes: clonedTranscript.prefixBytes }),
1205
+ }
1206
+ : {}),
1207
+ updatedAt: new Date().toISOString(),
1208
+ });
1209
+ }
1210
+ await this.requestLiveSession(newLocalThreadId, {
968
1211
  workspace: structuredClone(options.workspace),
969
1212
  execution: structuredClone(options.execution),
970
1213
  }, true, true);
971
- const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
1214
+ // SessionStart commits the binding before the child reports live.ready.
1215
+ // If that final ACK is lost (or the child exits in between), the durable
1216
+ // binding is authoritative and must not be rolled back.
1217
+ const target = await this.sessionStore.get(newLocalThreadId);
972
1218
  if (!target ||
973
1219
  target.parentSessionId !== currentLocalThreadId ||
974
1220
  target.codexSessionId !== targetClaudeSessionId) {
@@ -978,10 +1224,8 @@ export class RunnerManager {
978
1224
  return { ok: true, data: undefined };
979
1225
  }
980
1226
  catch (error) {
981
- await deleteIntent(newLocalThreadId).catch(() => undefined);
982
- const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
983
- if (target?.parentSessionId === currentLocalThreadId) {
984
- await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
1227
+ if (!persistedIntent) {
1228
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
985
1229
  }
986
1230
  const handle = this.handles.get(newLocalThreadId);
987
1231
  if (handle && !handle.dead) {
@@ -1011,103 +1255,550 @@ export class RunnerManager {
1011
1255
  if (handle.dead)
1012
1256
  return;
1013
1257
  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
- }
1258
+ this.deliverMirrorMessage(handle, message);
1018
1259
  return;
1019
1260
  }
1020
1261
  this.deliverRotateMessage(handle, message);
1021
1262
  }
1022
- deliverRotateMessage(handle, message) {
1023
- if (this.forkReservations.has(message.to) ||
1024
- this.sourceForkReservations.has(message.from)) {
1025
- void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1263
+ deliverMirrorMessage(handle, message) {
1264
+ if (handle.dead)
1026
1265
  return;
1266
+ if (this.isStaleMirrorGeneration(handle, message.generation)) {
1267
+ if ("deliveryId" in message) {
1268
+ handle.transport.send({
1269
+ t: "mirror.ack",
1270
+ deliveryId: message.deliveryId,
1271
+ generation: message.generation,
1272
+ });
1273
+ }
1274
+ else {
1275
+ handle.imageTransfers.delete(message.transferId);
1276
+ handle.transport.send({
1277
+ t: "mirror.image.ack",
1278
+ transferId: message.transferId,
1279
+ seq: message.seq,
1280
+ generation: message.generation,
1281
+ });
1282
+ }
1283
+ return;
1284
+ }
1285
+ if ("deliveryId" in message) {
1286
+ const existing = handle.mirrorDeliveries.get(message.deliveryId);
1287
+ if (existing && existing.sessionId !== message.sessionId) {
1288
+ void this.terminateHandle(handle, "conflicting ordinary mirror delivery replay").catch((error) => logTerminationFailure(handle, error));
1289
+ return;
1290
+ }
1291
+ if (!existing) {
1292
+ handle.mirrorDeliveries.set(message.deliveryId, {
1293
+ sessionId: message.sessionId,
1294
+ abandonRequested: false,
1295
+ });
1296
+ }
1027
1297
  }
1298
+ let observationCommitted = false;
1299
+ const commitObservation = () => {
1300
+ if (observationCommitted)
1301
+ return;
1302
+ observationCommitted = true;
1303
+ if (this.isStaleMirrorGeneration(handle, message.generation))
1304
+ return;
1305
+ if (message.event.type === "session.collaboration_mode") {
1306
+ this.collaborationModeListener?.(message.sessionId, message.event.mode);
1307
+ }
1308
+ this.observeHandleRuntimeEvent(handle, message.event);
1309
+ if (message.event.type === "response.created") {
1310
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1311
+ }
1312
+ };
1313
+ const commitDeliveredObservation = () => {
1314
+ commitObservation();
1315
+ if (handle.dead) {
1316
+ // The listener may have persisted/observed this event before its
1317
+ // Promise settled, while failHandle still saw no committed active id.
1318
+ // Close that newly visible response instead of leaving server runtime
1319
+ // state permanently running.
1320
+ this.failActiveResponses(handle, "runner_crashed", handle.failureReason ?? "runner exited during mirror publication");
1321
+ }
1322
+ };
1323
+ let delivered;
1028
1324
  try {
1029
- // Rotate the runtime-local context before publishing the new Session
1030
- // alias to routing or observers. This prevents the new identity from
1031
- // briefly inheriting stale context after `/clear` or `/fork`.
1032
- handle.sessionContext?.rotate(message.to);
1325
+ const result = this.mirrorListener?.(message.sessionId, message.event);
1326
+ if (result && typeof result.then === "function") {
1327
+ delivered = Promise.resolve(result);
1328
+ }
1329
+ else {
1330
+ commitObservation();
1331
+ delivered = Promise.resolve();
1332
+ }
1033
1333
  }
1034
1334
  catch (error) {
1035
- const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1036
- void this.terminateHandle(handle, reason).catch((terminationError) => {
1037
- logTerminationFailure(handle, terminationError);
1335
+ delivered = Promise.reject(error);
1336
+ }
1337
+ if ("transferId" in message) {
1338
+ const transfer = handle.imageTransfers.get(message.transferId);
1339
+ if (transfer)
1340
+ transfer.publication = delivered;
1341
+ void delivered.then(() => {
1342
+ commitDeliveredObservation();
1343
+ if (!handle.dead) {
1344
+ if (handle.imageTransfers.get(message.transferId) === transfer) {
1345
+ handle.imageTransfers.delete(message.transferId);
1346
+ }
1347
+ handle.transport.send({
1348
+ t: "mirror.image.ack",
1349
+ transferId: message.transferId,
1350
+ seq: message.seq,
1351
+ generation: message.generation,
1352
+ });
1353
+ }
1354
+ }, (error) => {
1355
+ if (handle.dead)
1356
+ return;
1357
+ if (handle.imageTransfers.get(message.transferId) === transfer && transfer) {
1358
+ delete transfer.publication;
1359
+ delete transfer.commitSeq;
1360
+ }
1361
+ handle.transport.send({
1362
+ t: "mirror.image.nack",
1363
+ transferId: message.transferId,
1364
+ seq: message.seq,
1365
+ generation: message.generation,
1366
+ classification: mirrorNackClassification(error),
1367
+ message: errorMessage(error),
1368
+ });
1038
1369
  });
1039
- return;
1040
1370
  }
1041
- let releaseTarget;
1042
- const targetReservation = new Promise((resolve) => {
1043
- releaseTarget = resolve;
1044
- });
1045
- let releaseSource;
1046
- const sourceReservation = new Promise((resolve) => {
1047
- releaseSource = resolve;
1371
+ else {
1372
+ void delivered.then(() => {
1373
+ commitDeliveredObservation();
1374
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1375
+ if (!handle.dead) {
1376
+ handle.transport.send({
1377
+ t: "mirror.ack",
1378
+ deliveryId: message.deliveryId,
1379
+ generation: message.generation,
1380
+ });
1381
+ }
1382
+ }, (error) => {
1383
+ const abandonToken = mirrorAbandonToken(error);
1384
+ if (handle.dead) {
1385
+ // The child can disappear while SQLite publication is still in
1386
+ // flight. A later exact pending-input failure still owns cleanup;
1387
+ // only the wire NACK is impossible after process death.
1388
+ if (abandonToken) {
1389
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1390
+ }
1391
+ return;
1392
+ }
1393
+ {
1394
+ const classification = mirrorNackClassification(error);
1395
+ const delivery = handle.mirrorDeliveries.get(message.deliveryId);
1396
+ if (delivery && abandonToken)
1397
+ delivery.abandonToken = abandonToken;
1398
+ if (classification === "permanent" &&
1399
+ message.attempt >= 3 &&
1400
+ abandonToken) {
1401
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1402
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1403
+ }
1404
+ else if (delivery?.abandonRequested && abandonToken) {
1405
+ this.mirrorAbandonListener?.(delivery.sessionId, abandonToken);
1406
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1407
+ }
1408
+ else if (!abandonToken) {
1409
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1410
+ }
1411
+ else if (delivery && !delivery.cleanupTimer) {
1412
+ // A timely NACK may be retried with a fresh delivery id. Retain
1413
+ // the old association briefly for the timeout/NACK race, then
1414
+ // release it if the child never reports an ambiguous outcome.
1415
+ const timer = setTimeout(() => {
1416
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1417
+ }, 60_000);
1418
+ timer.unref?.();
1419
+ delivery.cleanupTimer = timer;
1420
+ }
1421
+ handle.transport.send({
1422
+ t: "mirror.nack",
1423
+ deliveryId: message.deliveryId,
1424
+ generation: message.generation,
1425
+ classification,
1426
+ message: errorMessage(error),
1427
+ });
1428
+ }
1429
+ });
1430
+ }
1431
+ }
1432
+ releaseMirrorDelivery(handle, deliveryId) {
1433
+ const delivery = handle.mirrorDeliveries.get(deliveryId);
1434
+ if (!delivery)
1435
+ return;
1436
+ if (delivery.cleanupTimer)
1437
+ clearTimeout(delivery.cleanupTimer);
1438
+ handle.mirrorDeliveries.delete(deliveryId);
1439
+ }
1440
+ failMirrorImageTransfer(handle, reason) {
1441
+ handle.imageTransfers.clear();
1442
+ void this.terminateHandle(handle, reason).catch((error) => {
1443
+ logTerminationFailure(handle, error);
1048
1444
  });
1049
- this.forkReservations.set(message.to, targetReservation);
1050
- this.sourceForkReservations.set(message.from, sourceReservation);
1051
- this.forkBufferedMessages.set(message.to, []);
1052
- this.forkBufferedTerminalInputs.set(message.to, []);
1053
- // The physical pane has already rotated. Remove every old routing alias now:
1054
- // a later request for the source must spawn a fresh runner that resumes the
1055
- // source Provider binding, while existing terminal attachments continue to
1056
- // follow the transferred pane.
1057
- for (const [key, candidate] of this.handles) {
1058
- if (candidate !== handle)
1059
- continue;
1060
- this.handles.delete(key);
1061
- this.liveSessionKeys.delete(key);
1062
- this.liveOptions.delete(key);
1063
- }
1064
- const rotation = {
1065
- from: message.from,
1066
- to: message.to,
1067
- kind: message.kind,
1068
- workspace: message.workspace,
1069
- execution: message.execution,
1070
- ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1071
- };
1072
- void Promise.resolve(this.rotateListener?.(rotation))
1073
- .then(() => {
1074
- // The server's rotation listener resolves only after the target Session
1075
- // is published. Release the terminal admission handoff afterwards so a
1076
- // maintenance snapshot cannot observe a gap with neither source work
1077
- // nor the target Session.
1078
- this.settleTerminalRotationHandoff(handle, message.from, message.to);
1079
- if (handle.dead)
1445
+ }
1446
+ deliverRotateMessage(handle, message) {
1447
+ const generation = message.generation;
1448
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1449
+ handle.completedRotation.generation === generation &&
1450
+ handle.completedRotation.from === message.from &&
1451
+ handle.completedRotation.to === message.to) {
1452
+ handle.transport.send({
1453
+ t: "rotate.ack",
1454
+ rotationId: message.rotationId,
1455
+ generation,
1456
+ });
1457
+ return;
1458
+ }
1459
+ let pending = handle.pendingRotation;
1460
+ if (pending) {
1461
+ if (pending.rotationId !== message.rotationId ||
1462
+ pending.generation !== generation ||
1463
+ pending.from !== message.from ||
1464
+ pending.to !== message.to ||
1465
+ pending.kind !== message.kind) {
1466
+ void this.terminateHandle(handle, "conflicting Session rotation replay").catch((error) => logTerminationFailure(handle, error));
1467
+ return;
1468
+ }
1469
+ if (pending.publicationComplete) {
1470
+ handle.transport.send({
1471
+ t: "rotate.ack",
1472
+ rotationId: pending.rotationId,
1473
+ generation: pending.generation,
1474
+ });
1475
+ return;
1476
+ }
1477
+ if (pending.inFlight)
1478
+ return;
1479
+ }
1480
+ else {
1481
+ if (this.forkReservations.has(message.to) ||
1482
+ this.sourceForkReservations.has(message.from)) {
1483
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1484
+ return;
1485
+ }
1486
+ try {
1487
+ // Rotate the runtime-local context before publishing the new Session
1488
+ // alias to routing or observers. This prevents the new identity from
1489
+ // briefly inheriting stale context after `/clear` or `/fork`.
1490
+ handle.sessionContext?.rotate(message.to);
1491
+ }
1492
+ catch (error) {
1493
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1494
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
1495
+ logTerminationFailure(handle, terminationError);
1496
+ });
1080
1497
  return;
1081
- this.handles.set(message.to, handle);
1082
- this.liveSessionKeys.add(message.to);
1083
- this.liveOptions.set(message.to, {
1084
- workspace: structuredClone(message.workspace),
1085
- execution: structuredClone(message.execution),
1498
+ }
1499
+ let releaseTarget;
1500
+ const targetReservation = new Promise((resolve) => {
1501
+ releaseTarget = resolve;
1086
1502
  });
1087
- for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
1088
- this.deliverForkBufferedMessage(entry.handle, entry.message);
1503
+ let releaseSource;
1504
+ const sourceReservation = new Promise((resolve) => {
1505
+ releaseSource = resolve;
1506
+ });
1507
+ pending = {
1508
+ rotationId: message.rotationId,
1509
+ generation,
1510
+ from: message.from,
1511
+ to: message.to,
1512
+ kind: message.kind,
1513
+ rotation: {
1514
+ from: message.from,
1515
+ to: message.to,
1516
+ kind: message.kind,
1517
+ workspace: structuredClone(message.workspace),
1518
+ execution: structuredClone(message.execution),
1519
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1520
+ },
1521
+ targetReservation,
1522
+ sourceReservation,
1523
+ releaseTarget,
1524
+ releaseSource,
1525
+ inFlight: false,
1526
+ logicalTargetPublished: false,
1527
+ publicationComplete: false,
1528
+ };
1529
+ handle.pendingRotation = pending;
1530
+ this.forkReservations.set(pending.to, targetReservation);
1531
+ this.sourceForkReservations.set(pending.from, sourceReservation);
1532
+ this.forkBufferedMessages.set(pending.to, []);
1533
+ this.forkBufferedTerminalInputs.set(pending.to, []);
1534
+ }
1535
+ pending.inFlight = true;
1536
+ void Promise.resolve().then(async () => {
1537
+ if (pending.logicalTargetPublished)
1538
+ return;
1539
+ await this.rotateListener?.(pending.rotation);
1540
+ if (handle.dead || handle.pendingRotation !== pending)
1541
+ return false;
1542
+ pending.logicalTargetPublished = true;
1543
+ return true;
1544
+ })
1545
+ .then((current) => {
1546
+ if (current === false || handle.dead || handle.pendingRotation !== pending)
1547
+ return false;
1548
+ return this.publishNativeRotationBinding(pending).then(() => true);
1549
+ })
1550
+ .then((current) => {
1551
+ if (current === false || handle.dead || handle.pendingRotation !== pending) {
1552
+ if (handle.pendingRotation === pending) {
1553
+ this.releasePendingNativeRotation(handle, pending);
1554
+ }
1555
+ return;
1089
1556
  }
1090
- for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
1091
- if (!entry.handle.dead)
1092
- entry.handle.transport.send(entry.message);
1557
+ pending.inFlight = false;
1558
+ pending.publicationComplete = true;
1559
+ // Durable publication grants the child permission to move ownership.
1560
+ // Keep both logical ids fenced until rotate.applied proves that the
1561
+ // child has actually transferred its terminal/runtime registries.
1562
+ try {
1563
+ handle.transport.send({
1564
+ t: "rotate.ack",
1565
+ rotationId: pending.rotationId,
1566
+ generation: pending.generation,
1567
+ });
1568
+ }
1569
+ catch (error) {
1570
+ this.releasePendingNativeRotation(handle, pending);
1571
+ void this.terminateHandle(handle, `runner Session rotation commit failed: ${errorMessage(error)}`).catch((terminationError) => {
1572
+ logTerminationFailure(handle, terminationError);
1573
+ });
1093
1574
  }
1094
1575
  })
1095
1576
  .catch((error) => {
1096
- void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
1097
- })
1098
- .finally(() => {
1099
- if (this.forkReservations.get(message.to) === targetReservation) {
1100
- this.forkReservations.delete(message.to);
1577
+ if (handle.pendingRotation !== pending)
1578
+ return;
1579
+ pending.inFlight = false;
1580
+ if (handle.dead) {
1581
+ this.releasePendingNativeRotation(handle, pending);
1582
+ return;
1101
1583
  }
1102
- if (this.sourceForkReservations.get(message.from) === sourceReservation) {
1103
- this.sourceForkReservations.delete(message.from);
1584
+ try {
1585
+ handle.transport.send({
1586
+ t: "rotate.nack",
1587
+ rotationId: pending.rotationId,
1588
+ generation: pending.generation,
1589
+ classification: mirrorNackClassification(error),
1590
+ message: errorMessage(error),
1591
+ });
1104
1592
  }
1105
- this.forkBufferedMessages.delete(message.to);
1106
- this.forkBufferedTerminalInputs.delete(message.to);
1107
- releaseTarget();
1108
- releaseSource();
1593
+ catch (sendError) {
1594
+ this.releasePendingNativeRotation(handle, pending);
1595
+ void this.terminateHandle(handle, `runner Session rotation NACK failed: ${errorMessage(sendError)}`).catch((terminationError) => {
1596
+ logTerminationFailure(handle, terminationError);
1597
+ });
1598
+ }
1599
+ });
1600
+ }
1601
+ /** Bridge ownership is a child-local binding, but the parent is the only
1602
+ * process that knows logical target publication committed. Mark that fact
1603
+ * durably before allowing the child to apply and expose the alias. */
1604
+ async publishNativeRotationBinding(pending) {
1605
+ await this.publishNativeRotationRecords(pending.from, pending.to, pending.kind);
1606
+ }
1607
+ async publishNativeRotationRecords(from, to, kind) {
1608
+ const target = await this.sessionStore.get(to);
1609
+ const source = await this.sessionStore.get(from);
1610
+ if (!target) {
1611
+ throw new AgentRuntimeError("native rotation target binding is missing", 422, "native_rotation_target_missing");
1612
+ }
1613
+ const publishedTarget = {
1614
+ ...target,
1615
+ nativeRotationSourceSessionId: from,
1616
+ nativeRotationKind: kind,
1617
+ nativeRotationPublished: true,
1618
+ updatedAt: new Date().toISOString(),
1619
+ };
1620
+ await this.sessionStore.set(publishedTarget);
1621
+ if (!source)
1622
+ return;
1623
+ await this.sessionStore.set({
1624
+ ...source,
1625
+ bridgeOwnerSessionId: `${from}-${kind}-retired-${to}`,
1626
+ nativeRotationTargetSessionId: to,
1627
+ updatedAt: new Date().toISOString(),
1109
1628
  });
1110
1629
  }
1630
+ deliverRotateAppliedMessage(handle, message) {
1631
+ const pending = handle.pendingRotation;
1632
+ if (pending &&
1633
+ pending.publicationComplete &&
1634
+ pending.rotationId === message.rotationId &&
1635
+ pending.generation === message.generation &&
1636
+ pending.from === message.from &&
1637
+ pending.to === message.to) {
1638
+ void this.finalizeNativeRotation(handle, pending);
1639
+ return;
1640
+ }
1641
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1642
+ handle.completedRotation.generation === message.generation &&
1643
+ handle.completedRotation.from === message.from &&
1644
+ handle.completedRotation.to === message.to) {
1645
+ this.acknowledgeRotateApplied(handle, message.rotationId, message.generation);
1646
+ return;
1647
+ }
1648
+ void this.terminateHandle(handle, "invalid Session rotation applied confirmation").catch((error) => logTerminationFailure(handle, error));
1649
+ }
1650
+ finalizeNativeRotation(handle, pending) {
1651
+ if (pending.finalization)
1652
+ return pending.finalization;
1653
+ const operation = (async () => {
1654
+ if (handle.pendingRotation !== pending)
1655
+ return false;
1656
+ pending.inFlight = true;
1657
+ try {
1658
+ // Change the active Session only after ownership transfer. In
1659
+ // this IPC topology rotate.applied is the child-owned equivalent proof.
1660
+ this.settleTerminalRotationHandoff(handle, pending.from, pending.to);
1661
+ handle.activeResponseIds.clear();
1662
+ for (const [key, candidate] of this.handles) {
1663
+ if (candidate !== handle)
1664
+ continue;
1665
+ this.handles.delete(key);
1666
+ this.liveSessionKeys.delete(key);
1667
+ this.liveOptions.delete(key);
1668
+ }
1669
+ handle.activeSessionId = pending.to;
1670
+ this.handles.set(pending.to, handle);
1671
+ this.liveSessionKeys.add(pending.to);
1672
+ this.liveOptions.set(pending.to, {
1673
+ workspace: structuredClone(pending.rotation.workspace),
1674
+ execution: structuredClone(pending.rotation.execution),
1675
+ });
1676
+ handle.completedRotation = {
1677
+ rotationId: pending.rotationId,
1678
+ generation: pending.generation,
1679
+ from: pending.from,
1680
+ to: pending.to,
1681
+ };
1682
+ // Old-Session presentation is ordered internally but must not hold the
1683
+ // ownership-transfer ACK. Each notice still receives the server queue's
1684
+ // bounded result window; the rotated target can proceed immediately.
1685
+ const noticePublication = this.publishNativeRotationNotice(pending);
1686
+ if (handle.dead)
1687
+ return false;
1688
+ this.acknowledgeRotateApplied(handle, pending.rotationId, pending.generation);
1689
+ for (const entry of this.forkBufferedMessages.get(pending.to) ?? []) {
1690
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
1691
+ }
1692
+ for (const entry of this.forkBufferedTerminalInputs.get(pending.to) ?? []) {
1693
+ if (!entry.handle.dead)
1694
+ entry.handle.transport.send(entry.message);
1695
+ }
1696
+ void noticePublication.catch((error) => {
1697
+ console.error(`Session rotation notice sequence failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1698
+ });
1699
+ return true;
1700
+ }
1701
+ catch (error) {
1702
+ void this.terminateHandle(handle, `runner Session rotation finalization failed: ${errorMessage(error)}`).catch((terminationError) => {
1703
+ logTerminationFailure(handle, terminationError);
1704
+ });
1705
+ return false;
1706
+ }
1707
+ finally {
1708
+ pending.inFlight = false;
1709
+ this.releasePendingNativeRotation(handle, pending);
1710
+ }
1711
+ })();
1712
+ pending.finalization = operation;
1713
+ return operation;
1714
+ }
1715
+ acknowledgeRotateApplied(handle, rotationId, generation) {
1716
+ try {
1717
+ handle.transport.send({ t: "rotate.applied.ack", rotationId, generation });
1718
+ }
1719
+ catch (error) {
1720
+ void this.terminateHandle(handle, `runner Session rotation applied ACK failed: ${errorMessage(error)}`).catch((terminationError) => {
1721
+ logTerminationFailure(handle, terminationError);
1722
+ });
1723
+ }
1724
+ }
1725
+ releasePendingNativeRotation(handle, pending) {
1726
+ if (handle.pendingRotation !== pending)
1727
+ return;
1728
+ delete handle.pendingRotation;
1729
+ if (this.forkReservations.get(pending.to) === pending.targetReservation) {
1730
+ this.forkReservations.delete(pending.to);
1731
+ }
1732
+ if (this.sourceForkReservations.get(pending.from) === pending.sourceReservation) {
1733
+ this.sourceForkReservations.delete(pending.from);
1734
+ }
1735
+ this.forkBufferedMessages.delete(pending.to);
1736
+ this.forkBufferedTerminalInputs.delete(pending.to);
1737
+ pending.releaseTarget();
1738
+ pending.releaseSource();
1739
+ }
1740
+ async publishNativeRotationNotice(pending) {
1741
+ // Only /clear supersedes the old Session. A /fork keeps
1742
+ // both conversations live and therefore emits no old-session redirect.
1743
+ if (pending.kind !== "clear" || pending.from === pending.to)
1744
+ return;
1745
+ this.mirrorSupersedeListener?.(pending.from);
1746
+ const noticeId = `msg_clear_${randomUUID().replaceAll("-", "")}`;
1747
+ const notice = {
1748
+ type: "response.output_item.done",
1749
+ responseId: noticeId,
1750
+ item: {
1751
+ id: noticeId,
1752
+ sessionId: pending.from,
1753
+ position: 0,
1754
+ responseId: noticeId,
1755
+ status: "completed",
1756
+ createdAt: Date.now(),
1757
+ type: "message",
1758
+ data: {
1759
+ role: "assistant",
1760
+ content: [{
1761
+ type: "output_text",
1762
+ text: "This session was ended by `/clear`. " +
1763
+ `Continue in [the new session](../${encodeURIComponent(pending.to)}). ` +
1764
+ "You can also send a message here to resume this session.",
1765
+ }],
1766
+ },
1767
+ },
1768
+ };
1769
+ const transient = {
1770
+ type: "session.rotated",
1771
+ sessionId: pending.from,
1772
+ newSessionId: pending.to,
1773
+ kind: "clear",
1774
+ };
1775
+ // Preserve the post-clear order: stop the old spinner, append a
1776
+ // durable assistant notice, then tell a live viewer to follow the target.
1777
+ const publications = [];
1778
+ for (const event of [
1779
+ {
1780
+ type: "session.status",
1781
+ sessionId: pending.from,
1782
+ status: "idle",
1783
+ backgroundTaskCount: 0,
1784
+ },
1785
+ notice,
1786
+ transient,
1787
+ ]) {
1788
+ try {
1789
+ // The server-side SessionMirrorQueue owns the per-record
1790
+ // per-record result deadline. A second enqueue-time timer here would
1791
+ // expire later records before they reach the head of that queue.
1792
+ publications.push(Promise.resolve(this.mirrorListener?.(pending.from, event)).catch((error) => {
1793
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1794
+ }));
1795
+ }
1796
+ catch (error) {
1797
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1798
+ }
1799
+ }
1800
+ await Promise.all(publications);
1801
+ }
1111
1802
  spawnHandle(key) {
1112
1803
  const args = this.runnerEntry.endsWith(".ts")
1113
1804
  ? ["--import", "tsx", this.runnerEntry]
@@ -1140,18 +1831,22 @@ export class RunnerManager {
1140
1831
  const transport = new StdioRunnerTransport(child.stdout, child.stdin);
1141
1832
  const handle = {
1142
1833
  key,
1834
+ activeSessionId: key,
1143
1835
  child,
1144
1836
  transport,
1145
1837
  stderr: [],
1146
1838
  lastUsedAt: this.now(),
1839
+ activeResponseIds: new Set(),
1840
+ terminalResponseIds: new Set(),
1147
1841
  dead: false,
1148
1842
  completion,
1149
- terminalCleanupRetryFailures: 0,
1150
1843
  processGroup,
1151
1844
  ...(sessionContext ? { sessionContext } : {}),
1152
1845
  caps: new Map(),
1153
1846
  terminals: new Map(),
1154
1847
  live: new Map(),
1848
+ imageTransfers: new Map(),
1849
+ mirrorDeliveries: new Map(),
1155
1850
  };
1156
1851
  this.childHandles.add(handle);
1157
1852
  void completion.then(() => this.childHandles.delete(handle));
@@ -1165,8 +1860,14 @@ export class RunnerManager {
1165
1860
  }
1166
1861
  });
1167
1862
  }
1168
- child.on("error", (error) => this.failHandle(handle, error.message));
1169
- child.on("exit", (code, signal) => this.failHandle(handle, `runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
1863
+ const failAndReap = (reason) => {
1864
+ this.failHandle(handle, reason);
1865
+ void this.terminateHandle(handle, reason).catch((error) => {
1866
+ logTerminationFailure(handle, error);
1867
+ });
1868
+ };
1869
+ child.on("error", (error) => failAndReap(error.message));
1870
+ child.on("exit", (code, signal) => failAndReap(`runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
1170
1871
  return handle;
1171
1872
  }
1172
1873
  onChildMessage(handle, msg) {
@@ -1187,55 +1888,287 @@ export class RunnerManager {
1187
1888
  }
1188
1889
  return;
1189
1890
  }
1190
- case "term.opened":
1191
- handle.terminals.get(msg.attachId)?._opened(msg.role);
1891
+ case "term.prepared":
1892
+ handle.terminals.get(msg.attachId)?._prepared(msg.role, msg.seedBytes, msg.cols, msg.rows);
1192
1893
  return;
1193
- case "term.data":
1194
- handle.terminals.get(msg.attachId)?._data(msg.dataB64);
1894
+ case "term.seed.chunk":
1895
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "seed", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1195
1896
  return;
1196
- case "term.exit": {
1197
- const terminal = handle.terminals.get(msg.attachId);
1198
- handle.terminals.delete(msg.attachId);
1199
- terminal?._exit(msg.exitCode);
1897
+ case "term.started":
1898
+ handle.terminals.get(msg.attachId)?._started(msg.reqId);
1899
+ return;
1900
+ case "term.chunk":
1901
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "read", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1902
+ return;
1903
+ case "term.dimensions":
1904
+ handle.terminals.get(msg.attachId)?._dimensions(msg.cols, msg.rows);
1905
+ return;
1906
+ case "term.reader.done":
1907
+ handle.terminals.get(msg.attachId)?._readerDone({
1908
+ finalOffset: msg.finalOffset,
1909
+ reason: msg.reason,
1910
+ exitCode: msg.exitCode,
1911
+ });
1912
+ return;
1913
+ case "term.ack":
1914
+ handle.terminals.get(msg.attachId)?._ack(msg.reqId, msg.operation);
1200
1915
  return;
1201
- }
1202
1916
  case "term.error": {
1203
1917
  const terminal = handle.terminals.get(msg.attachId);
1204
- handle.terminals.delete(msg.attachId);
1205
- terminal?._fail(msg.message, msg.code);
1918
+ if (!msg.reqId)
1919
+ handle.terminals.delete(msg.attachId);
1920
+ terminal?._fail(msg.message, msg.code, msg.reqId);
1206
1921
  return;
1207
1922
  }
1208
1923
  case "mirror":
1209
1924
  if (handle.dead)
1210
1925
  return;
1926
+ if (!msg.deliveryId ||
1927
+ !Number.isSafeInteger(msg.generation) ||
1928
+ !Number.isSafeInteger(msg.attempt) ||
1929
+ msg.attempt < 1) {
1930
+ void this.terminateHandle(handle, "invalid durable mirror delivery frame").catch((error) => logTerminationFailure(handle, error));
1931
+ return;
1932
+ }
1211
1933
  if (this.bufferForkTargetMessage(handle, msg))
1212
1934
  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");
1935
+ this.deliverMirrorMessage(handle, msg);
1936
+ return;
1937
+ case "mirror.abandon": {
1938
+ if (!msg.deliveryId || !Number.isSafeInteger(msg.generation)) {
1939
+ void this.terminateHandle(handle, "invalid mirror abandon frame").catch((error) => logTerminationFailure(handle, error));
1940
+ return;
1941
+ }
1942
+ const delivery = handle.mirrorDeliveries.get(msg.deliveryId);
1943
+ if (!delivery)
1944
+ return;
1945
+ delivery.abandonRequested = true;
1946
+ if (delivery.abandonToken) {
1947
+ this.mirrorAbandonListener?.(delivery.sessionId, delivery.abandonToken);
1948
+ this.releaseMirrorDelivery(handle, msg.deliveryId);
1949
+ }
1950
+ return;
1951
+ }
1952
+ case "mirror.image.begin": {
1953
+ if (!Number.isSafeInteger(msg.generation)) {
1954
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
1955
+ return;
1956
+ }
1957
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
1958
+ handle.transport.send({
1959
+ t: "mirror.image.ack",
1960
+ transferId: msg.transferId,
1961
+ seq: 0,
1962
+ generation: msg.generation,
1963
+ });
1964
+ return;
1965
+ }
1966
+ const existing = handle.imageTransfers.get(msg.transferId);
1967
+ if (existing) {
1968
+ const exactReplay = existing.sessionId === msg.sessionId &&
1969
+ existing.generation === msg.generation &&
1970
+ existing.totalChars === msg.totalChars &&
1971
+ JSON.stringify(existing.event) === JSON.stringify(msg.event);
1972
+ if (!exactReplay) {
1973
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer replay");
1974
+ return;
1975
+ }
1976
+ handle.transport.send({
1977
+ t: "mirror.image.ack",
1978
+ transferId: msg.transferId,
1979
+ seq: 0,
1980
+ generation: msg.generation,
1981
+ });
1982
+ return;
1983
+ }
1984
+ if (handle.dead ||
1985
+ !Number.isSafeInteger(msg.totalChars) ||
1986
+ msg.totalChars <= 0 ||
1987
+ msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
1988
+ !isDetachedGeneratedImageEvent(msg.event)) {
1989
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer start");
1990
+ return;
1991
+ }
1992
+ handle.imageTransfers.set(msg.transferId, {
1993
+ sessionId: msg.sessionId,
1994
+ generation: msg.generation,
1995
+ event: msg.event,
1996
+ totalChars: msg.totalChars,
1997
+ receivedChars: 0,
1998
+ nextSeq: 1,
1999
+ chunks: [],
2000
+ });
2001
+ handle.transport.send({
2002
+ t: "mirror.image.ack",
2003
+ transferId: msg.transferId,
2004
+ seq: 0,
2005
+ generation: msg.generation,
2006
+ });
2007
+ return;
2008
+ }
2009
+ case "mirror.image.chunk": {
2010
+ if (!Number.isSafeInteger(msg.generation)) {
2011
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
2012
+ return;
2013
+ }
2014
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
2015
+ handle.transport.send({
2016
+ t: "mirror.image.ack",
2017
+ transferId: msg.transferId,
2018
+ seq: msg.seq,
2019
+ generation: msg.generation,
2020
+ });
2021
+ return;
2022
+ }
2023
+ const transfer = handle.imageTransfers.get(msg.transferId);
2024
+ if (transfer &&
2025
+ msg.seq > 0 &&
2026
+ msg.seq < transfer.nextSeq &&
2027
+ transfer.chunks[msg.seq - 1] === msg.data) {
2028
+ handle.transport.send({
2029
+ t: "mirror.image.ack",
2030
+ transferId: msg.transferId,
2031
+ seq: msg.seq,
2032
+ generation: transfer.generation,
2033
+ });
2034
+ return;
2035
+ }
2036
+ if (handle.dead ||
2037
+ !transfer ||
2038
+ msg.seq !== transfer.nextSeq ||
2039
+ !msg.data ||
2040
+ msg.data.length > RUNNER_IMAGE_CHUNK_CHARS ||
2041
+ transfer.receivedChars + msg.data.length > transfer.totalChars) {
2042
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer chunk");
2043
+ return;
2044
+ }
2045
+ transfer.chunks.push(msg.data);
2046
+ transfer.receivedChars += msg.data.length;
2047
+ transfer.nextSeq += 1;
2048
+ handle.transport.send({
2049
+ t: "mirror.image.ack",
2050
+ transferId: msg.transferId,
2051
+ seq: msg.seq,
2052
+ generation: transfer.generation,
2053
+ });
2054
+ return;
2055
+ }
2056
+ case "mirror.image.commit": {
2057
+ if (!Number.isSafeInteger(msg.generation)) {
2058
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
2059
+ return;
2060
+ }
2061
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
2062
+ handle.imageTransfers.delete(msg.transferId);
2063
+ handle.transport.send({
2064
+ t: "mirror.image.ack",
2065
+ transferId: msg.transferId,
2066
+ seq: msg.seq,
2067
+ generation: msg.generation,
2068
+ });
2069
+ return;
2070
+ }
2071
+ const transfer = handle.imageTransfers.get(msg.transferId);
2072
+ if (handle.dead ||
2073
+ !transfer ||
2074
+ msg.seq !== transfer.nextSeq ||
2075
+ transfer.receivedChars !== transfer.totalChars) {
2076
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
2077
+ return;
2078
+ }
2079
+ if (transfer.commitSeq !== undefined) {
2080
+ if (transfer.commitSeq !== msg.seq) {
2081
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer commit");
2082
+ }
2083
+ // The original publication owns the eventual ACK/NACK. A replay can
2084
+ // arrive after its ACK timer elapsed; that eventual result resolves
2085
+ // the replacement waiter because transferId+seq are stable.
2086
+ return;
2087
+ }
2088
+ transfer.commitSeq = msg.seq;
2089
+ const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
2090
+ if (!event) {
2091
+ this.failMirrorImageTransfer(handle, "generated image transfer target changed");
2092
+ return;
2093
+ }
2094
+ const mirrored = {
2095
+ t: "mirror",
2096
+ sessionId: transfer.sessionId,
2097
+ event,
2098
+ transferId: msg.transferId,
2099
+ seq: msg.seq,
2100
+ generation: transfer.generation,
2101
+ };
2102
+ if (this.bufferForkTargetMessage(handle, mirrored)) {
2103
+ handle.transport.send({
2104
+ t: "mirror.image.hold",
2105
+ transferId: msg.transferId,
2106
+ seq: msg.seq,
2107
+ generation: transfer.generation,
2108
+ });
2109
+ return;
2110
+ }
2111
+ this.deliverMirrorMessage(handle, mirrored);
2112
+ return;
2113
+ }
2114
+ case "mirror.image.abandon": {
2115
+ if (!Number.isSafeInteger(msg.generation))
2116
+ return;
2117
+ const transfer = handle.imageTransfers.get(msg.transferId);
2118
+ if (transfer && transfer.generation === msg.generation) {
2119
+ handle.imageTransfers.delete(msg.transferId);
1219
2120
  }
1220
2121
  return;
2122
+ }
1221
2123
  case "rotate": {
1222
2124
  if (handle.dead)
1223
2125
  return;
2126
+ if (!msg.rotationId || !Number.isSafeInteger(msg.generation)) {
2127
+ void this.terminateHandle(handle, "invalid Session rotation frame").catch((error) => logTerminationFailure(handle, error));
2128
+ return;
2129
+ }
1224
2130
  if (this.bufferForkTargetMessage(handle, msg))
1225
2131
  return;
1226
2132
  this.deliverRotateMessage(handle, msg);
1227
2133
  return;
1228
2134
  }
2135
+ case "rotate.applied": {
2136
+ if (handle.dead)
2137
+ return;
2138
+ this.deliverRotateAppliedMessage(handle, msg);
2139
+ return;
2140
+ }
2141
+ case "terminal.lifecycle.ended": {
2142
+ if (msg.lifecycle === "required") {
2143
+ void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
2144
+ }
2145
+ return;
2146
+ }
2147
+ case "terminal.reaped": {
2148
+ const resolve = handle.live.get(msg.reqId);
2149
+ handle.live.delete(msg.reqId);
2150
+ resolve?.({ ok: msg.ok, error: msg.error });
2151
+ return;
2152
+ }
1229
2153
  case "live.ready":
1230
2154
  case "interrupted":
1231
2155
  case "injected": {
1232
2156
  const resolve = handle.live.get(msg.reqId);
1233
2157
  handle.live.delete(msg.reqId);
1234
2158
  resolve?.(msg.t === "injected"
1235
- ? { outcome: msg.outcome, error: msg.error }
2159
+ ? { result: msg.result, error: msg.error }
1236
2160
  : { ok: msg.ok, error: msg.error });
1237
2161
  return;
1238
2162
  }
2163
+ case "collaboration.updated": {
2164
+ const resolve = handle.live.get(msg.reqId);
2165
+ handle.live.delete(msg.reqId);
2166
+ resolve?.({
2167
+ collaborationUpdated: msg.ok,
2168
+ ...(msg.error ? { error: msg.error } : {}),
2169
+ });
2170
+ return;
2171
+ }
1239
2172
  case "interaction.resolved": {
1240
2173
  const resolve = handle.live.get(msg.reqId);
1241
2174
  handle.live.delete(msg.reqId);
@@ -1245,11 +2178,12 @@ export class RunnerManager {
1245
2178
  }
1246
2179
  }
1247
2180
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
1248
- failHandle(handle, reason) {
2181
+ failHandle(handle, reason, opts = {}) {
1249
2182
  if (handle.dead) {
1250
2183
  return;
1251
2184
  }
1252
2185
  handle.dead = true;
2186
+ handle.failureReason = reason;
1253
2187
  closeOwnedSessionContext(handle);
1254
2188
  // Drop every key mapping to this handle — its launch key AND any rotation
1255
2189
  // aliases (claude `/clear`·`/fork` terminal transfer).
@@ -1262,8 +2196,20 @@ export class RunnerManager {
1262
2196
  }
1263
2197
  }
1264
2198
  }
2199
+ if (handle.pendingRotation) {
2200
+ // A publication already inside its durable binding write owns both
2201
+ // reservations until that Promise settles. Releasing here would let a
2202
+ // replacement source child open against the old binding while the stale
2203
+ // chain can still commit target/source ownership records.
2204
+ if (!handle.pendingRotation.inFlight) {
2205
+ this.releasePendingNativeRotation(handle, handle.pendingRotation);
2206
+ }
2207
+ }
1265
2208
  const tail = handle.stderr.join("\n");
1266
2209
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
2210
+ if (opts.failActiveResponses !== false) {
2211
+ this.failActiveResponses(handle, "runner_crashed", message);
2212
+ }
1267
2213
  const error = new AgentRuntimeError(message, 500, "runner_crashed");
1268
2214
  for (const pending of handle.caps.values()) {
1269
2215
  pending.reject(error);
@@ -1272,20 +2218,23 @@ export class RunnerManager {
1272
2218
  terminal._fail(message);
1273
2219
  }
1274
2220
  for (const resolve of handle.live.values()) {
1275
- resolve({ ok: false, error: message });
2221
+ resolve({
2222
+ ok: false,
2223
+ error: { message, code: "runner_crashed", statusCode: 500 },
2224
+ });
1276
2225
  }
1277
2226
  handle.caps.clear();
1278
2227
  handle.terminals.clear();
1279
2228
  handle.live.clear();
2229
+ handle.imageTransfers.clear();
2230
+ for (const deliveryId of handle.mirrorDeliveries.keys()) {
2231
+ this.releaseMirrorDelivery(handle, deliveryId);
2232
+ }
1280
2233
  handle.transport.close();
1281
2234
  }
1282
2235
  terminateHandle(handle, reason) {
1283
2236
  if (handle.termination)
1284
2237
  return handle.termination;
1285
- if (handle.terminalCleanupRetryTimer) {
1286
- clearTimeout(handle.terminalCleanupRetryTimer);
1287
- delete handle.terminalCleanupRetryTimer;
1288
- }
1289
2238
  const attempt = (async () => {
1290
2239
  if (!handle.dead) {
1291
2240
  try {
@@ -1294,7 +2243,8 @@ export class RunnerManager {
1294
2243
  catch {
1295
2244
  // The process signal below remains the authoritative shutdown path.
1296
2245
  }
1297
- this.failHandle(handle, reason);
2246
+ this.interruptActiveResponses(handle);
2247
+ this.failHandle(handle, reason, { failActiveResponses: false });
1298
2248
  }
1299
2249
  this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1300
2250
  let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
@@ -1302,15 +2252,14 @@ export class RunnerManager {
1302
2252
  this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1303
2253
  childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1304
2254
  }
1305
- const terminalStopped = handle.key === CAP_KEY
1306
- ? true
1307
- : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
2255
+ if (handle.key !== CAP_KEY) {
2256
+ await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`, handle.child.pid)).catch((error) => {
2257
+ console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
2258
+ });
2259
+ }
1308
2260
  if (!childExited) {
1309
2261
  throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1310
2262
  }
1311
- if (!terminalStopped) {
1312
- throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1313
- }
1314
2263
  this.childHandles.delete(handle);
1315
2264
  })();
1316
2265
  handle.termination = attempt;
@@ -1319,34 +2268,246 @@ export class RunnerManager {
1319
2268
  }, () => {
1320
2269
  if (handle.termination === attempt)
1321
2270
  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.
2271
+ // A child that survives SIGKILL is still real activity. Release the
2272
+ // gate reservation but retain the handoff until a later explicit stop.
1326
2273
  this.preserveTerminalInputHandoffsAsActivity(handle);
1327
- this.scheduleTerminalInputCleanupRetry(handle);
1328
2274
  });
1329
2275
  return attempt;
1330
2276
  }
1331
2277
  reapIdle() {
2278
+ if (this.reapPromise)
2279
+ return this.reapPromise;
2280
+ const attempt = this.reapIdleOnce();
2281
+ this.reapPromise = attempt;
2282
+ const clear = () => {
2283
+ if (this.reapPromise === attempt)
2284
+ this.reapPromise = null;
2285
+ };
2286
+ void attempt.then(clear, clear);
2287
+ return attempt;
2288
+ }
2289
+ async reapIdleOnce() {
1332
2290
  const now = this.now();
1333
2291
  for (const [key, handle] of this.handles) {
1334
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
2292
+ if (this.isManagedNativeHandle(handle)) {
2293
+ await this.reapNativePane(handle, now);
1335
2294
  continue;
1336
2295
  }
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)) {
2296
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1340
2297
  continue;
1341
2298
  }
1342
- if (now - handle.lastUsedAt < this.idleTtlMs) {
2299
+ const idleForMs = now - handle.lastUsedAt;
2300
+ if (idleForMs < this.idleTtlMs) {
1343
2301
  continue;
1344
2302
  }
1345
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
2303
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
2304
+ if (hasActiveResponse)
2305
+ continue;
2306
+ const reason = this.liveSessionKeys.has(key)
2307
+ ? "idle live runner reaped"
2308
+ : "idle runner reaped";
2309
+ void this.terminateHandle(handle, reason).catch((error) => {
1346
2310
  logTerminationFailure(handle, error);
1347
2311
  });
1348
2312
  }
1349
2313
  }
2314
+ /** Provider-authoritative turn state is the lifecycle truth; attached clients
2315
+ * and tmux's own activity clock are independent busy evidence. There is no
2316
+ * "user was inactive for an hour" override for an active turn. */
2317
+ async reapNativePane(handle, now) {
2318
+ if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
2319
+ return;
2320
+ if (await this.isNativePaneBusy(handle)) {
2321
+ handle.nativePaneLastBusyAt = now;
2322
+ return;
2323
+ }
2324
+ const lastBusyAt = handle.nativePaneLastBusyAt;
2325
+ if (lastBusyAt === undefined) {
2326
+ // First idle observation starts a full grace window.
2327
+ handle.nativePaneLastBusyAt = now;
2328
+ return;
2329
+ }
2330
+ if (now - lastBusyAt < this.nativePaneIdleTtlMs)
2331
+ return;
2332
+ // Close the select→reap race: activity may begin after classification but
2333
+ // before teardown.
2334
+ if (await this.isNativePaneBusy(handle)) {
2335
+ handle.nativePaneLastBusyAt = this.now();
2336
+ return;
2337
+ }
2338
+ // A failed teardown must re-arm on the next scan instead of retrying on
2339
+ // every sweep forever.
2340
+ delete handle.nativePaneLastBusyAt;
2341
+ await this.reapNativeTerminal(handle).catch((error) => {
2342
+ logTerminationFailure(handle, error);
2343
+ });
2344
+ }
2345
+ reapNativeTerminal(handle) {
2346
+ const localThreadId = this.currentTerminalSessionId(handle, handle.key);
2347
+ const terminalId = `${localThreadId}-main`;
2348
+ const reqId = randomUUID();
2349
+ return new Promise((resolve, reject) => {
2350
+ const timeout = setTimeout(() => {
2351
+ if (!handle.live.delete(reqId))
2352
+ return;
2353
+ reject(new Error(`runner did not acknowledge idle terminal reap within ${this.liveReadyTimeoutMs}ms`));
2354
+ }, this.liveReadyTimeoutMs);
2355
+ timeout.unref?.();
2356
+ handle.live.set(reqId, (reply) => {
2357
+ clearTimeout(timeout);
2358
+ if (reply.ok) {
2359
+ resolve();
2360
+ return;
2361
+ }
2362
+ reject(fromWireError(reply.error ?? {
2363
+ message: "idle terminal reap failed",
2364
+ code: "terminal_reap_failed",
2365
+ statusCode: 500,
2366
+ }));
2367
+ });
2368
+ handle.transport.send({
2369
+ t: "terminal.reap",
2370
+ reqId,
2371
+ localThreadId,
2372
+ terminalId,
2373
+ });
2374
+ });
2375
+ }
2376
+ async isNativePaneBusy(handle) {
2377
+ if (handle.activeResponseIds.size > 0 ||
2378
+ handle.caps.size > 0 ||
2379
+ handle.live.size > 0 ||
2380
+ handle.terminals.size > 0) {
2381
+ return true;
2382
+ }
2383
+ const terminalName = `${handle.key}-main`;
2384
+ try {
2385
+ if (await this.terminalHasAttachedClient(terminalName, handle.child.pid))
2386
+ return true;
2387
+ }
2388
+ catch {
2389
+ // A failed probe contributes no busy evidence; check activity next.
2390
+ }
2391
+ let activityAt = null;
2392
+ try {
2393
+ activityAt = await this.terminalWindowActivityAt(terminalName, handle.child.pid);
2394
+ }
2395
+ catch {
2396
+ // A failed probe contributes no busy evidence.
2397
+ }
2398
+ return activityAt !== null &&
2399
+ this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
2400
+ }
2401
+ isManagedNativeHandle(handle) {
2402
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2403
+ const provider = this.liveOptions.get(sessionId)?.execution.provider;
2404
+ return isManagedNativeProvider(provider);
2405
+ }
2406
+ failActiveResponses(handle, code, message) {
2407
+ const responseIds = [...handle.activeResponseIds];
2408
+ handle.activeResponseIds.clear();
2409
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2410
+ for (const responseId of responseIds) {
2411
+ rememberTerminalResponse(handle, responseId);
2412
+ this.emitCompensatingMirror(handle, sessionId, {
2413
+ type: "response.failed",
2414
+ responseId,
2415
+ error: {
2416
+ source: "execution",
2417
+ code,
2418
+ message,
2419
+ },
2420
+ });
2421
+ }
2422
+ }
2423
+ interruptActiveResponses(handle) {
2424
+ const responseIds = [...handle.activeResponseIds];
2425
+ handle.activeResponseIds.clear();
2426
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2427
+ for (const responseId of responseIds) {
2428
+ this.emitCompensatingMirror(handle, sessionId, {
2429
+ type: "session.interrupted",
2430
+ sessionId,
2431
+ responseId,
2432
+ });
2433
+ }
2434
+ }
2435
+ /** Crash/termination compensation is intentionally best-effort because it is
2436
+ * a terminal/status edge rather than an ordinary transcript delivery.
2437
+ * Still consume an asynchronous persistence rejection so a dying child
2438
+ * cannot take the parent daemon down with an unhandled rejection. */
2439
+ emitCompensatingMirror(handle, sessionId, event) {
2440
+ if (!this.mirrorListener)
2441
+ return;
2442
+ try {
2443
+ const result = this.mirrorListener(sessionId, event);
2444
+ if (result && typeof result.then === "function") {
2445
+ void Promise.resolve(result).catch((error) => {
2446
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2447
+ });
2448
+ }
2449
+ }
2450
+ catch (error) {
2451
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2452
+ }
2453
+ }
2454
+ /** Track the provider-authoritative response lifecycle. Native-pane busy
2455
+ * classification consumes this level directly; output volume is separately
2456
+ * grounded in tmux's own activity clock. */
2457
+ observeHandleRuntimeEvent(handle, event) {
2458
+ switch (event.type) {
2459
+ case "response.created":
2460
+ case "response.output_text.delta":
2461
+ case "response.reasoning_summary_text.delta":
2462
+ case "response.function_call_output.delta":
2463
+ case "response.output_item.done":
2464
+ if (handle.terminalResponseIds.has(event.responseId))
2465
+ return;
2466
+ handle.activeResponseIds.add(event.responseId);
2467
+ return;
2468
+ case "session.interaction.requested":
2469
+ if (handle.terminalResponseIds.has(event.responseId))
2470
+ return;
2471
+ handle.activeResponseIds.add(event.responseId);
2472
+ return;
2473
+ case "response.completed":
2474
+ case "response.failed":
2475
+ case "session.interrupted":
2476
+ handle.activeResponseIds.delete(event.responseId);
2477
+ rememberTerminalResponse(handle, event.responseId);
2478
+ return;
2479
+ case "session.status":
2480
+ if (event.status === "running" && event.responseId) {
2481
+ if (handle.terminalResponseIds.has(event.responseId))
2482
+ return;
2483
+ handle.activeResponseIds.add(event.responseId);
2484
+ }
2485
+ else if (event.status !== "running") {
2486
+ if (event.responseId) {
2487
+ handle.activeResponseIds.delete(event.responseId);
2488
+ rememberTerminalResponse(handle, event.responseId);
2489
+ }
2490
+ else {
2491
+ for (const responseId of handle.activeResponseIds) {
2492
+ rememberTerminalResponse(handle, responseId);
2493
+ }
2494
+ handle.activeResponseIds.clear();
2495
+ }
2496
+ }
2497
+ return;
2498
+ case "session.rotated":
2499
+ handle.activeResponseIds.clear();
2500
+ return;
2501
+ default:
2502
+ return;
2503
+ }
2504
+ }
2505
+ isStaleMirrorGeneration(handle, generation) {
2506
+ const currentGeneration = handle.pendingRotation?.generation ??
2507
+ handle.completedRotation?.generation ??
2508
+ 0;
2509
+ return generation < currentGeneration;
2510
+ }
1350
2511
  openSessionContext(sessionId) {
1351
2512
  if (!this.sessionContextProvider)
1352
2513
  return undefined;
@@ -1365,6 +2526,16 @@ export class RunnerManager {
1365
2526
  }
1366
2527
  }
1367
2528
  }
2529
+ function rememberTerminalResponse(handle, responseId) {
2530
+ handle.terminalResponseIds.delete(responseId);
2531
+ handle.terminalResponseIds.add(responseId);
2532
+ while (handle.terminalResponseIds.size > MAX_TERMINAL_RESPONSE_IDS) {
2533
+ const oldest = handle.terminalResponseIds.values().next().value;
2534
+ if (!oldest)
2535
+ break;
2536
+ handle.terminalResponseIds.delete(oldest);
2537
+ }
2538
+ }
1368
2539
  function trackedTerminalSubmissions(tracker, data) {
1369
2540
  const pasteStart = "\u001b[200~";
1370
2541
  const pasteEnd = "\u001b[201~";
@@ -1408,8 +2579,8 @@ function trackedTerminalSubmissions(tracker, data) {
1408
2579
  if (command.length > 0 &&
1409
2580
  (command.includes("\u001b") ||
1410
2581
  !command.startsWith("/") ||
1411
- /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1412
- submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
2582
+ /^\/(?:branch|clear|fork)(?:\s|$)/u.test(command))) {
2583
+ submissions.push(/^\/(?:branch|clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1413
2584
  }
1414
2585
  continue;
1415
2586
  }
@@ -1503,9 +2674,61 @@ function closeSessionContext(context) {
1503
2674
  }));
1504
2675
  }
1505
2676
  }
2677
+ function isDetachedGeneratedImageEvent(event) {
2678
+ if (event.type !== "response.output_item.done" ||
2679
+ event.item.type !== "function_call_output") {
2680
+ return false;
2681
+ }
2682
+ const data = event.item.data;
2683
+ return Boolean(data.__rynxGeneratedImage && data.__rynxGeneratedImage.result === undefined);
2684
+ }
2685
+ function attachGeneratedImageResult(event, result) {
2686
+ if (!isDetachedGeneratedImageEvent(event))
2687
+ return undefined;
2688
+ const outputEvent = event;
2689
+ const item = outputEvent.item;
2690
+ if (item.type !== "function_call_output")
2691
+ return undefined;
2692
+ const data = item.data;
2693
+ return {
2694
+ ...outputEvent,
2695
+ item: {
2696
+ ...item,
2697
+ data: {
2698
+ ...data,
2699
+ __rynxGeneratedImage: {
2700
+ ...data.__rynxGeneratedImage,
2701
+ result,
2702
+ },
2703
+ },
2704
+ },
2705
+ };
2706
+ }
1506
2707
  function errorMessage(error) {
1507
2708
  return error instanceof Error ? error.message : String(error);
1508
2709
  }
2710
+ function mirrorNackClassification(error) {
2711
+ if (typeof error === "object" && error !== null &&
2712
+ error.mirrorClassification === "ambiguous")
2713
+ return "ambiguous";
2714
+ const statusCode = error instanceof AgentRuntimeError
2715
+ ? error.statusCode
2716
+ : typeof error === "object" && error !== null &&
2717
+ typeof error.statusCode === "number"
2718
+ ? error.statusCode
2719
+ : undefined;
2720
+ return statusCode !== undefined &&
2721
+ statusCode >= 400 && statusCode < 500 &&
2722
+ ![408, 409, 425, 429].includes(statusCode)
2723
+ ? "permanent"
2724
+ : "transient";
2725
+ }
2726
+ function mirrorAbandonToken(error) {
2727
+ if (typeof error !== "object" || error === null)
2728
+ return undefined;
2729
+ const token = error.abandonToken;
2730
+ return typeof token === "string" && token.length > 0 ? token : undefined;
2731
+ }
1509
2732
  function defaultRunnerEntry() {
1510
2733
  if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
1511
2734
  return process.env.RYNX_RUNNER_ENTRY.trim();
@@ -1580,6 +2803,18 @@ function logTerminationFailure(handle, error) {
1580
2803
  error: error instanceof Error ? error.message : String(error),
1581
2804
  }));
1582
2805
  }
2806
+ function logCompensatingMirrorFailure(handle, sessionId, eventType, error) {
2807
+ console.error(JSON.stringify({
2808
+ level: "error",
2809
+ type: "runner",
2810
+ event: "compensating_mirror_failed",
2811
+ key: handle.key,
2812
+ sessionId,
2813
+ eventType,
2814
+ pid: handle.child.pid,
2815
+ error: errorMessage(error),
2816
+ }));
2817
+ }
1583
2818
  function capabilityForkResult(result) {
1584
2819
  return result.ok
1585
2820
  ? { ok: true }