@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.51

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 +460 -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 +1571 -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 +183 -46
  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 +96 -3
  20. package/dist/codex-app-server/forwarder.js +542 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +228 -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 +68 -7
  28. package/dist/codex-app-server/transport.d.ts +2 -2
  29. package/dist/codex-app-server/transport.js +4 -0
  30. package/dist/codex-app-server/ws-channel.d.ts +8 -1
  31. package/dist/codex-app-server/ws-channel.js +113 -28
  32. package/dist/codex-child-env.js +3 -32
  33. package/dist/codex-home.d.ts +35 -3
  34. package/dist/codex-home.js +323 -18
  35. package/dist/codex-session-store.d.ts +23 -0
  36. package/dist/codex-session-store.js +21 -0
  37. package/dist/host.d.ts +106 -48
  38. package/dist/host.js +2024 -640
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +1 -1
  41. package/dist/input-resources.d.ts +4 -0
  42. package/dist/input-resources.js +21 -5
  43. package/dist/models-catalog.js +12 -3
  44. package/dist/runner/child.d.ts +105 -28
  45. package/dist/runner/child.js +1625 -100
  46. package/dist/runner/manager.d.ts +114 -46
  47. package/dist/runner/manager.js +1523 -446
  48. package/dist/runner/protocol.d.ts +212 -24
  49. package/dist/runner/protocol.js +5 -0
  50. package/dist/runner/startup-policy.d.ts +7 -0
  51. package/dist/runner/startup-policy.js +10 -0
  52. package/dist/runner/transport.d.ts +18 -2
  53. package/dist/runner/transport.js +82 -3
  54. package/dist/runner-main.js +8 -3
  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
@@ -18,13 +18,17 @@
18
18
  import { spawn as nodeSpawn } from "node:child_process";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import readline from "node:readline";
21
+ import { join } from "node:path";
22
+ import { diagnosticEvents, sessionDiagnosticDir, teeDiagnosticStream } from "@rynx-ai/core";
21
23
  import { fileURLToPath } from "node:url";
22
24
  import { AgentRuntimeError, } from "@rynx-ai/core";
23
25
  import { listRuntimeModels } from "../models-catalog.js";
24
26
  import { probeRuntimeStatus } from "../runtime-status.js";
25
- import { terminateTmuxServer } from "../terminal/tmux.js";
26
- import { fromWireError, } from "./protocol.js";
27
+ import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
28
+ import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, fromWireError, } from "./protocol.js";
29
+ import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
27
30
  import { StdioRunnerTransport } from "./transport.js";
31
+ import { cloneClaudeTranscript } from "../claude/transcript-clone.js";
28
32
  /** Routing key for the shared capability runner (slash-command RPCs). */
29
33
  const CAP_KEY = "__cap__";
30
34
  /** Max stderr lines retained per handle for the crash exit-report tail. */
@@ -33,23 +37,26 @@ const STDERR_TAIL_LINES = 40;
33
37
  const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
34
38
  /** Time allowed for exit after SIGKILL before shutdown reports failure. */
35
39
  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. */
40
+ /** Legacy setup-pane acknowledgement deadline for providers that do not own
41
+ * phase-specific startup errors. Codex/Traex are deliberately excluded. */
42
+ const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
43
+ /** Legacy fallback for a provider without a native startup policy. */
39
44
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
40
- /** A submitted TUI command should become a mirrored turn or native rotation
41
- * quickly. If it does not, the runner is fenced by a verified process-tree
42
- * shutdown before maintenance may treat the submission as settled. */
43
- const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
44
- /** Retry transient terminal-server cleanup without spinning forever. */
45
- const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
46
- const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
45
+ /** Claude has no app-server bridge and keeps a parent-owned SessionStart
46
+ * deadline. Codex-lineage startup instead acknowledges pane/observer startup
47
+ * and lets thread discovery race injection. */
48
+ const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
49
+ /** Full idle window before an inactive native pane becomes reapable. */
50
+ const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
51
+ /** tmux output this recent independently proves that a native pane is busy. */
52
+ const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
47
53
  /** Keep per-Session spawn context small enough to remain an environment handoff,
48
54
  * not an unbounded transport. */
49
55
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
50
56
  const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
51
57
  const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
52
58
  const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
59
+ const MAX_TERMINAL_RESPONSE_IDS = 2_000;
53
60
  /** A typed attach failure so transport adapters can distinguish an expected
54
61
  * absent pane from an infrastructure failure without matching error strings. */
55
62
  export class TerminalOpenError extends Error {
@@ -65,11 +72,16 @@ class ManagedTerminal {
65
72
  sendMsg;
66
73
  onClose;
67
74
  ready;
68
- dataListener;
69
- exitListener;
75
+ readerDone;
76
+ resizeListener;
77
+ dimensions;
78
+ role = "read-only";
79
+ seedBytes = 0;
70
80
  resolveReady;
71
81
  rejectReady;
82
+ resolveReaderDone;
72
83
  state = "opening";
84
+ pending = new Map();
73
85
  constructor(attachId, sendMsg, onClose) {
74
86
  this.attachId = attachId;
75
87
  this.sendMsg = sendMsg;
@@ -78,20 +90,75 @@ class ManagedTerminal {
78
90
  this.resolveReady = resolve;
79
91
  this.rejectReady = reject;
80
92
  });
93
+ this.readerDone = new Promise((resolve) => {
94
+ this.resolveReaderDone = resolve;
95
+ });
81
96
  // Never surface an unhandled rejection if no caller awaits `ready`.
82
97
  void this.ready.catch(() => undefined);
83
98
  }
84
- onData(listener) {
85
- this.dataListener = listener;
99
+ onResize(listener) {
100
+ this.resizeListener = listener;
101
+ if (this.dimensions)
102
+ queueMicrotask(() => listener(this.dimensions));
103
+ }
104
+ readSeed(offset, maxBytes) {
105
+ if (this.state !== "prepared")
106
+ return Promise.reject(new Error("terminal is not prepared"));
107
+ return this.requestRead("seed", {
108
+ t: "term.seed.read",
109
+ attachId: this.attachId,
110
+ reqId: randomUUID(),
111
+ offset,
112
+ maxBytes,
113
+ });
114
+ }
115
+ start() {
116
+ if (this.state !== "prepared")
117
+ return Promise.reject(new Error("terminal is not prepared"));
118
+ this.state = "starting";
119
+ const reqId = randomUUID();
120
+ return this.request("start", { t: "term.start", attachId: this.attachId, reqId })
121
+ .then(() => {
122
+ if (this.state === "starting")
123
+ this.state = "started";
124
+ });
86
125
  }
87
- onExit(listener) {
88
- this.exitListener = listener;
126
+ read(offset, maxBytes) {
127
+ if (this.state !== "started")
128
+ return Promise.reject(new Error("terminal has not started"));
129
+ return this.requestRead("read", {
130
+ t: "term.read",
131
+ attachId: this.attachId,
132
+ reqId: randomUUID(),
133
+ offset,
134
+ maxBytes,
135
+ });
89
136
  }
90
137
  write(data) {
91
- this.sendMsg({ t: "term.input", attachId: this.attachId, dataB64: Buffer.from(data, "utf8").toString("base64") });
138
+ if (this.state !== "started" || this.role !== "owner")
139
+ return Promise.resolve();
140
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
141
+ if (bytes.byteLength === 0)
142
+ return Promise.resolve();
143
+ const reqId = randomUUID();
144
+ return this.request("input", {
145
+ t: "term.input",
146
+ attachId: this.attachId,
147
+ reqId,
148
+ dataB64: bytes.toString("base64"),
149
+ }).then(() => undefined);
92
150
  }
93
151
  resize(cols, rows) {
94
- this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
152
+ if (this.state !== "started")
153
+ return Promise.resolve();
154
+ const reqId = randomUUID();
155
+ return this.request("resize", {
156
+ t: "term.resize",
157
+ attachId: this.attachId,
158
+ reqId,
159
+ cols,
160
+ rows,
161
+ }).then(() => undefined);
95
162
  }
96
163
  close() {
97
164
  if (this.state === "closed")
@@ -100,43 +167,96 @@ class ManagedTerminal {
100
167
  this.state = "closed";
101
168
  this.sendMsg({ t: "term.close", attachId: this.attachId });
102
169
  this.onClose();
170
+ this.rejectPending(new Error("terminal attachment was closed"));
103
171
  if (wasOpening) {
104
172
  this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
105
173
  }
174
+ this.resolveReaderDone({ finalOffset: 0, reason: "client_closed", exitCode: 0 });
106
175
  }
107
176
  // ── internal (driven by onChildMessage) ──
108
- _opened(role) {
177
+ _prepared(role, seedBytes, cols, rows) {
109
178
  if (this.state !== "opening")
110
179
  return;
111
- this.state = "opened";
112
- this.resolveReady({ role });
113
- }
114
- _data(dataB64) {
115
- this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
180
+ this.state = "prepared";
181
+ this.role = role;
182
+ this.seedBytes = seedBytes;
183
+ this.dimensions = cols !== undefined && rows !== undefined ? { cols, rows } : undefined;
184
+ this.resolveReady({
185
+ role,
186
+ seedBytes,
187
+ ...(this.dimensions ? { dimensions: this.dimensions } : {}),
188
+ });
116
189
  }
117
- _exit(exitCode) {
190
+ _dimensions(cols, rows) {
118
191
  if (this.state === "closed")
119
192
  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
- }
193
+ this.dimensions = { cols, rows };
194
+ this.resizeListener?.(this.dimensions);
195
+ }
196
+ _started(reqId) {
197
+ this.resolvePending(reqId, "start", undefined);
198
+ }
199
+ _chunk(reqId, operation, dataB64, nextOffset, done, finalOffset) {
200
+ this.resolvePending(reqId, operation, {
201
+ data: Uint8Array.from(Buffer.from(dataB64, "base64")),
202
+ nextOffset,
203
+ done,
204
+ ...(finalOffset === undefined ? {} : { finalOffset }),
205
+ });
206
+ }
207
+ _ack(reqId, operation) {
208
+ this.resolvePending(reqId, operation, undefined);
128
209
  }
129
- _fail(message, code = "terminal_open_failed") {
210
+ _readerDone(info) {
211
+ this.resolveReaderDone(info);
212
+ }
213
+ _fail(message, code = "terminal_open_failed", reqId) {
130
214
  if (this.state === "closed")
131
215
  return;
216
+ if (reqId) {
217
+ const pending = this.pending.get(reqId);
218
+ if (pending) {
219
+ this.pending.delete(reqId);
220
+ pending.reject(new TerminalOpenError(message, code));
221
+ return;
222
+ }
223
+ }
132
224
  const wasOpening = this.state === "opening";
133
225
  this.state = "closed";
226
+ this.rejectPending(new TerminalOpenError(message, code));
134
227
  if (wasOpening) {
135
228
  this.rejectReady(new TerminalOpenError(message, code));
136
229
  }
137
- else {
138
- this.exitListener?.({ exitCode: 1 });
230
+ this.resolveReaderDone({ finalOffset: 0, reason: "internal", exitCode: 1 });
231
+ }
232
+ requestRead(operation, message) {
233
+ return this.request(operation, message);
234
+ }
235
+ request(operation, message) {
236
+ return new Promise((resolve, reject) => {
237
+ this.pending.set(message.reqId, { operation, resolve, reject });
238
+ try {
239
+ this.sendMsg(message);
240
+ }
241
+ catch (error) {
242
+ this.pending.delete(message.reqId);
243
+ reject(error instanceof Error ? error : new Error(String(error)));
244
+ }
245
+ });
246
+ }
247
+ resolvePending(reqId, operation, value) {
248
+ const pending = this.pending.get(reqId);
249
+ if (!pending || pending.operation !== operation) {
250
+ this._fail(`terminal response ${reqId} is duplicate or out of order`);
251
+ return;
139
252
  }
253
+ this.pending.delete(reqId);
254
+ pending.resolve(value);
255
+ }
256
+ rejectPending(error) {
257
+ for (const pending of this.pending.values())
258
+ pending.reject(error);
259
+ this.pending.clear();
140
260
  }
141
261
  }
142
262
  export class RunnerManager {
@@ -144,6 +264,8 @@ export class RunnerManager {
144
264
  sessionStore;
145
265
  runnerEntry;
146
266
  idleTtlMs;
267
+ nativePaneIdleTtlMs;
268
+ nativePaneOutputBusyWindowMs;
147
269
  spawn;
148
270
  childEnv;
149
271
  sessionContextProvider;
@@ -160,15 +282,27 @@ export class RunnerManager {
160
282
  shutdownKillGraceMs;
161
283
  signalChild;
162
284
  terminateTerminalServer;
285
+ terminalWindowActivityAt;
286
+ terminalHasAttachedClient;
287
+ wallNow;
163
288
  liveStartTimeoutMs;
164
289
  liveReadyTimeoutMs;
290
+ nativeLiveStartTimeoutMs;
165
291
  liveInterruptTimeoutMs;
166
- terminalInputHandoffTimeoutMs;
167
- terminalInputCleanupRetryMs;
292
+ reapPromise = null;
168
293
  stopping = false;
169
294
  stopPromise;
170
295
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
171
296
  mirrorListener = null;
297
+ /** Settles process-local projections only when an ordinary durable item has
298
+ * exhausted its permanent retry budget. */
299
+ mirrorAbandonListener = null;
300
+ /** Clears process-live optimistic state when /clear permanently moves the
301
+ * native pane away from the old Session. */
302
+ mirrorSupersedeListener = null;
303
+ /** Synchronous persistence hook for native collaboration-mode reflection.
304
+ * It runs before the event reaches the Session bus. */
305
+ collaborationModeListener = null;
172
306
  /** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
173
307
  rotateListener = null;
174
308
  /** Session keys with a live codex forwarder — never reaped while present. */
@@ -190,33 +324,37 @@ export class RunnerManager {
190
324
  forkOperations = new Map();
191
325
  forkBufferedMessages = new Map();
192
326
  forkBufferedTerminalInputs = new Map();
193
- /** Owner TUI submissions accepted by the parent but not yet represented by a
194
- * mirrored response or a durably published native rotation. */
195
- terminalInputHandoffs = new Map();
196
327
  constructor(opts) {
197
328
  this.config = opts.config;
198
329
  this.sessionStore = opts.sessionStore;
199
330
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
200
331
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
332
+ this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
333
+ this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
201
334
  this.spawn = opts.spawn ?? nodeSpawn;
202
335
  this.childEnv = opts.childEnv ?? {};
203
336
  this.sessionContextProvider = opts.sessionContextProvider;
204
337
  this.admissionOpen = opts.admissionOpen ?? (() => true);
205
338
  this.admissionReserve = opts.admissionReserve;
206
339
  this.now = opts.now ?? (() => Date.now());
340
+ this.wallNow = opts.wallNow ?? (() => Date.now());
207
341
  this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
208
342
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
209
343
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
210
344
  this.signalChild = opts.signalChild ?? signalRunnerChild;
211
- this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
345
+ this.terminateTerminalServer = opts.terminateTerminalServer ??
346
+ ((name, ownerPid) => terminateTmuxServer(name, undefined, ownerPid));
347
+ this.terminalWindowActivityAt = opts.terminalWindowActivityAt ??
348
+ ((name, ownerPid) => tmuxWindowActivityAt(name, undefined, ownerPid));
349
+ this.terminalHasAttachedClient = opts.terminalHasAttachedClient ??
350
+ ((name, ownerPid) => tmuxHasAttachedClient(name, undefined, ownerPid));
212
351
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
213
352
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
353
+ this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
214
354
  this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
215
- this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
216
- this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
217
355
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
218
356
  if (reapIntervalMs > 0) {
219
- this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
357
+ this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
220
358
  this.reapTimer.unref?.();
221
359
  }
222
360
  else {
@@ -227,9 +365,8 @@ export class RunnerManager {
227
365
  /**
228
366
  * Open a live terminal on the session's runner child (spawning it if needed).
229
367
  * 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.
368
+ * single attach client with the requested `owner` (read-write) or `read-only`
369
+ * role. Not part of `AgentExecutor`; the WS bridge calls it directly.
233
370
  */
234
371
  openTerminal(localThreadId, opts) {
235
372
  const handle = this.getOrSpawn(localThreadId);
@@ -254,24 +391,22 @@ export class RunnerManager {
254
391
  openTerminalOnHandle(handle, localThreadId, opts) {
255
392
  handle.lastUsedAt = this.now();
256
393
  const attachId = randomUUID();
257
- const inputTracker = {
258
- buffer: "",
259
- previousWasCarriageReturn: false,
260
- bracketedPaste: false,
261
- pendingEscape: "",
262
- };
263
394
  const terminal = new ManagedTerminal(attachId, (msg) => {
264
- const handoffs = msg.t === "term.input" && opts.role === "owner"
265
- ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
- : [];
395
+ if (msg.t === "term.input")
396
+ handle.lastUsedAt = this.now();
397
+ if (msg.t === "term.input" && opts.role === "owner") {
398
+ handle.terminalWrites.set(msg.reqId, {
399
+ attachId,
400
+ reservation: this.reserveAdmission(),
401
+ });
402
+ }
267
403
  const send = () => {
268
404
  try {
269
405
  handle.transport.send(msg);
270
406
  }
271
407
  catch (error) {
272
- for (const handoff of handoffs) {
273
- this.finishTerminalInputHandoff(handle, handoff);
274
- }
408
+ if (msg.t === "term.input")
409
+ this.finishTerminalWrite(handle, msg.reqId);
275
410
  throw error;
276
411
  }
277
412
  };
@@ -329,162 +464,58 @@ export class RunnerManager {
329
464
  onMirror(listener) {
330
465
  this.mirrorListener = listener;
331
466
  }
467
+ onMirrorAbandon(listener) {
468
+ this.mirrorAbandonListener = listener;
469
+ }
470
+ onMirrorSupersede(listener) {
471
+ this.mirrorSupersedeListener = listener;
472
+ }
473
+ onCollaborationMode(listener) {
474
+ this.collaborationModeListener = listener;
475
+ }
332
476
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
333
477
  * records the new session's meta (carry-over agent/model/title). */
334
478
  onRotate(listener) {
335
479
  this.rotateListener = listener;
336
480
  }
337
- /** Accepted TUI submissions that have not crossed into an observable runtime
338
- * state. The daemon folds this into runningTurns after closing admission. */
339
- pendingTerminalInputCount() {
481
+ /** Actual transport writes and ownership transfers, independent of turn lifecycle. */
482
+ pendingNativeOperationCount() {
340
483
  let count = 0;
341
- for (const handoffs of this.terminalInputHandoffs.values()) {
342
- count += handoffs.length;
484
+ for (const handle of this.childHandles) {
485
+ count += handle.terminalWrites.size;
486
+ if (handle.pendingRotation)
487
+ count += 1;
488
+ if (handle.dead)
489
+ count += 1;
343
490
  }
344
491
  return count;
345
492
  }
346
- beginTerminalInputHandoffs(handle, sessionId, kinds) {
347
- if (kinds.length === 0)
348
- return [];
349
- const created = [];
350
- try {
351
- for (const kind of kinds) {
352
- const handoff = {
353
- sessionId,
354
- kind,
355
- reservation: this.reserveAdmission(),
356
- timer: undefined,
357
- };
358
- handoff.timer = setTimeout(() => {
359
- this.expireTerminalInputHandoffs(handle);
360
- }, this.terminalInputHandoffTimeoutMs);
361
- handoff.timer.unref?.();
362
- const handoffs = this.terminalInputHandoffs.get(handle);
363
- if (handoffs)
364
- handoffs.push(handoff);
365
- else
366
- this.terminalInputHandoffs.set(handle, [handoff]);
367
- created.push(handoff);
368
- }
369
- return created;
370
- }
371
- catch (error) {
372
- for (const handoff of created) {
373
- this.finishTerminalInputHandoff(handle, handoff);
374
- }
375
- throw error;
376
- }
493
+ finishTerminalWrite(handle, reqId) {
494
+ const write = handle.terminalWrites.get(reqId);
495
+ handle.terminalWrites.delete(reqId);
496
+ write?.reservation.release();
377
497
  }
378
- settleTerminalInputHandoff(handle, sessionId, kind) {
379
- const handoff = this.terminalInputHandoffs
380
- .get(handle)
381
- ?.find((candidate) => candidate.sessionId === sessionId);
382
- // Runtime observations settle accepted input in submission order. In
383
- // particular, a response from the source Session while /clear or /fork is
384
- // still publishing must not skip that rotation and release a later turn
385
- // which has not yet been rebound or delivered.
386
- if (handoff?.kind === kind)
387
- this.finishTerminalInputHandoff(handle, handoff);
388
- }
389
- settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
390
- const handoffs = this.terminalInputHandoffs.get(handle);
391
- const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
392
- if (!handoffs || rotationIndex < 0)
393
- return;
394
- // Turns submitted before /clear or /fork are superseded once the rotation
395
- // is published. Inputs accepted afterwards belong to the transferred pane
396
- // and must follow it to the target Session.
397
- const rotation = handoffs[rotationIndex];
398
- for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
399
- if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
400
- this.finishTerminalInputHandoff(handle, handoff);
498
+ finishTerminalWrites(handle, attachId) {
499
+ for (const [reqId, write] of handle.terminalWrites) {
500
+ if (attachId === undefined || write.attachId === attachId) {
501
+ this.finishTerminalWrite(handle, reqId);
401
502
  }
402
503
  }
403
- if (rotation)
404
- this.finishTerminalInputHandoff(handle, rotation);
405
- for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
406
- if (handoff.sessionId === sourceSessionId)
407
- handoff.sessionId = targetSessionId;
408
- }
409
504
  }
410
505
  currentTerminalSessionId(handle, fallback) {
411
506
  for (const [sessionId, candidate] of this.handles) {
412
507
  if (candidate === handle && this.liveSessionKeys.has(sessionId))
413
508
  return sessionId;
414
509
  }
415
- return fallback;
416
- }
417
- finishTerminalInputHandoff(handle, handoff) {
418
- if (handoff.timer) {
419
- clearTimeout(handoff.timer);
420
- handoff.timer = undefined;
421
- }
422
- handoff.reservation?.release();
423
- handoff.reservation = undefined;
424
- const handoffs = this.terminalInputHandoffs.get(handle);
425
- if (!handoffs)
426
- return;
427
- const index = handoffs.indexOf(handoff);
428
- if (index >= 0)
429
- handoffs.splice(index, 1);
430
- if (handoffs.length === 0)
431
- this.terminalInputHandoffs.delete(handle);
432
- }
433
- finishAllTerminalInputHandoffs(handle) {
434
- if (handle.terminalCleanupRetryTimer) {
435
- clearTimeout(handle.terminalCleanupRetryTimer);
436
- delete handle.terminalCleanupRetryTimer;
437
- }
438
- handle.terminalCleanupRetryFailures = 0;
439
- for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
440
- this.finishTerminalInputHandoff(handle, handoff);
441
- }
442
- }
443
- preserveTerminalInputHandoffsAsActivity(handle) {
444
- for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
445
- if (handoff.timer) {
446
- clearTimeout(handoff.timer);
447
- handoff.timer = undefined;
448
- }
449
- handoff.reservation?.release();
450
- handoff.reservation = undefined;
451
- }
452
- }
453
- scheduleTerminalInputCleanupRetry(handle) {
454
- if (handle.terminalCleanupRetryTimer ||
455
- !this.terminalInputHandoffs.has(handle))
456
- return;
457
- const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
458
- const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
459
- handle.terminalCleanupRetryTimer = setTimeout(() => {
460
- delete handle.terminalCleanupRetryTimer;
461
- if (!this.terminalInputHandoffs.has(handle))
462
- return;
463
- void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
464
- logTerminationFailure(handle, error);
465
- });
466
- }, delay);
467
- handle.terminalCleanupRetryTimer.unref?.();
468
- }
469
- expireTerminalInputHandoffs(handle) {
470
- const handoffs = this.terminalInputHandoffs.get(handle);
471
- if (!handoffs?.length)
472
- return;
473
- for (const handoff of handoffs) {
474
- if (handoff.timer) {
475
- clearTimeout(handoff.timer);
476
- handoff.timer = undefined;
477
- }
478
- }
479
- void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
480
- logTerminationFailure(handle, error);
481
- });
510
+ return handle.activeSessionId || fallback;
482
511
  }
483
512
  /**
484
513
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
485
514
  * 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).
515
+ * needed. Idempotent. For Codex-lineage sessions, resolves after backend
516
+ * preload and pane launch; known-thread observer attach and fresh-thread
517
+ * discovery continue in the background. Other providers retain their
518
+ * readiness gate.
488
519
  */
489
520
  ensureLiveSession(localThreadId, opts) {
490
521
  const admission = this.reserveAdmission();
@@ -523,42 +554,66 @@ export class RunnerManager {
523
554
  this.liveSessionKeys.add(localThreadId);
524
555
  const reqId = randomUUID();
525
556
  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?.();
557
+ const provider = opts.execution.provider;
558
+ const timeoutMs = isCodexLineageProvider(provider)
559
+ ? null
560
+ : !waitForReady
561
+ ? this.liveStartTimeoutMs
562
+ : provider === "claude"
563
+ ? this.nativeLiveStartTimeoutMs
564
+ : this.liveReadyTimeoutMs;
565
+ const timeout = timeoutMs === null
566
+ ? undefined
567
+ : setTimeout(() => {
568
+ if (!handle.live.delete(reqId))
569
+ return;
570
+ const finishTimeout = () => {
571
+ const reason = !waitForReady
572
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
573
+ : provider === "claude"
574
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
575
+ : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
576
+ this.liveErrors.set(localThreadId, {
577
+ message: reason,
578
+ code: provider === "claude"
579
+ ? "claude_session_start_timeout"
580
+ : "native_runner_ack_timeout",
581
+ statusCode: 503,
582
+ });
583
+ this.liveSessionKeys.delete(localThreadId);
584
+ // Legacy providers without phase-owned startup errors retain the
585
+ // bounded control guard. Codex/Traex never enter this branch.
586
+ void this.terminateHandle(handle, reason).catch((error) => {
587
+ logTerminationFailure(handle, error);
588
+ });
589
+ return false;
590
+ };
591
+ const reservation = allowReservedForkTarget
592
+ ? undefined
593
+ : this.forkReservations.get(localThreadId);
594
+ if (reservation) {
595
+ void reservation
596
+ .then(() => finishTimeout())
597
+ .then(resolve, () => resolve(false));
598
+ return;
599
+ }
600
+ resolve(finishTimeout());
601
+ }, timeoutMs);
602
+ timeout?.unref?.();
553
603
  handle.live.set(reqId, (res) => {
554
- clearTimeout(timeout);
604
+ if (timeout)
605
+ clearTimeout(timeout);
555
606
  const ok = res.ok ?? false;
556
607
  const finish = () => {
557
608
  if (ok) {
558
609
  this.liveErrors.delete(localThreadId);
559
610
  }
560
611
  else {
561
- this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
612
+ this.liveErrors.set(localThreadId, res.error ?? {
613
+ message: "live session did not become ready",
614
+ code: "native_readiness_failed",
615
+ statusCode: 503,
616
+ });
562
617
  }
563
618
  return ok;
564
619
  };
@@ -586,7 +641,11 @@ export class RunnerManager {
586
641
  });
587
642
  }
588
643
  lastLiveSessionError(localThreadId) {
589
- return this.liveErrors.get(localThreadId);
644
+ return this.liveErrors.get(localThreadId)?.message;
645
+ }
646
+ lastLiveSessionFailure(localThreadId) {
647
+ const failure = this.liveErrors.get(localThreadId);
648
+ return failure ? { ...failure } : undefined;
590
649
  }
591
650
  /**
592
651
  * Inject a user turn into a session's live codex thread — reference implementation's
@@ -594,47 +653,63 @@ export class RunnerManager {
594
653
  * all output. Resolves true when the app-server accepted the turn, false when
595
654
  * the session has no live forwarder (caller falls back to the run path).
596
655
  */
597
- injectMessage(localThreadId, input) {
656
+ injectMessage(localThreadId, input, options) {
598
657
  const admission = this.reserveAdmission();
599
- return this.injectMessageAdmitted(localThreadId, input)
658
+ return this.injectMessageAdmitted(localThreadId, input, options)
600
659
  .finally(() => admission.release());
601
660
  }
602
- injectMessageAdmitted(localThreadId, input) {
661
+ injectMessageAdmitted(localThreadId, input, options) {
662
+ const rotatingHandle = this.handles.get(localThreadId);
663
+ if (rotatingHandle?.pendingRotation?.from === localThreadId &&
664
+ !rotatingHandle.dead) {
665
+ // Reject input addressed to the old active Session during
666
+ // cutover. Do not wait and then silently reopen the retired Session: the
667
+ // user can retry after the client follows session.rotated.
668
+ return Promise.resolve({ outcome: "notReady" });
669
+ }
603
670
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
604
671
  if (sourceReservation) {
605
- return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
672
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
606
673
  }
607
674
  const reservation = this.forkReservations.get(localThreadId);
608
675
  if (reservation) {
609
- return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
676
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
610
677
  }
611
678
  const handle = this.getOrSpawn(localThreadId);
612
679
  handle.lastUsedAt = this.now();
613
680
  const reqId = randomUUID();
614
681
  return new Promise((resolve) => {
615
682
  handle.live.set(reqId, (res) => {
616
- const outcome = res.outcome ?? "failed";
683
+ const result = res.result ?? { outcome: "failed" };
617
684
  const finish = () => {
618
- if (outcome === "injected") {
685
+ if (result.outcome === "injected" || result.outcome === "steered") {
619
686
  this.liveErrors.delete(localThreadId);
620
687
  }
621
688
  else {
622
- this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
689
+ this.liveErrors.set(localThreadId, res.error ?? {
690
+ message: `live injection ${result.outcome}`,
691
+ code: result.outcome === "notReady"
692
+ ? "native_thread_not_ready"
693
+ : result.outcome === "notLive"
694
+ ? "native_session_not_live"
695
+ : "native_message_injection_failed",
696
+ statusCode: 503,
697
+ });
623
698
  }
624
- return outcome;
699
+ return result;
625
700
  };
626
701
  const reservation = this.forkReservations.get(localThreadId);
627
702
  if (reservation) {
628
703
  void reservation
629
704
  .then(() => finish())
630
- .then(resolve, () => resolve("failed"));
705
+ .then(resolve, () => resolve({ outcome: "failed" }));
631
706
  return;
632
707
  }
633
708
  resolve(finish());
634
709
  });
635
710
  handle.transport.send(typeof input === "string"
636
- ? { t: "inject", reqId, localThreadId, text: input }
637
- : { t: "inject", reqId, localThreadId, input });
711
+ ? { t: "inject", reqId, localThreadId, text: input, ...(options ? { options } : {}) }
712
+ : { t: "inject", reqId, localThreadId, input, ...(options ? { options } : {}) });
638
713
  });
639
714
  }
640
715
  /**
@@ -674,7 +749,7 @@ export class RunnerManager {
674
749
  handle.live.set(reqId, (reply) => {
675
750
  resolve(reply.interactionResult ?? {
676
751
  disposition: "invalid",
677
- message: reply.error ?? "runner did not return an interaction result",
752
+ message: reply.error?.message ?? "runner did not return an interaction result",
678
753
  });
679
754
  });
680
755
  handle.transport.send({
@@ -686,6 +761,34 @@ export class RunnerManager {
686
761
  });
687
762
  });
688
763
  }
764
+ /** Update a loaded Codex-lineage native thread without spawning a runner. */
765
+ updateCollaborationMode(localThreadId, mode) {
766
+ const handle = this.handles.get(localThreadId);
767
+ if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
768
+ return Promise.reject(new AgentRuntimeError("Native Session is not live", 409, "native_session_not_live"));
769
+ }
770
+ handle.lastUsedAt = this.now();
771
+ const reqId = randomUUID();
772
+ return new Promise((resolve, reject) => {
773
+ handle.live.set(reqId, (reply) => {
774
+ if (reply.collaborationUpdated) {
775
+ resolve();
776
+ return;
777
+ }
778
+ reject(fromWireError(reply.error ?? {
779
+ message: "Native collaboration mode update failed",
780
+ code: "collaboration_mode_update_failed",
781
+ statusCode: 503,
782
+ }));
783
+ });
784
+ handle.transport.send({
785
+ t: "collaboration.update",
786
+ reqId,
787
+ localThreadId,
788
+ mode,
789
+ });
790
+ });
791
+ }
689
792
  // ── AgentCapabilities ──────────────────────────────────────────────────────
690
793
  async listModels(runtime) {
691
794
  return listRuntimeModels(this.config, runtime ?? this.defaultRuntime);
@@ -813,10 +916,7 @@ export class RunnerManager {
813
916
  if (this.reapTimer) {
814
917
  clearInterval(this.reapTimer);
815
918
  }
816
- const children = new Set([
817
- ...this.childHandles,
818
- ...this.terminalInputHandoffs.keys(),
819
- ]);
919
+ const children = new Set(this.childHandles);
820
920
  const attempt = (async () => {
821
921
  const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
822
922
  this.handles.clear();
@@ -824,6 +924,7 @@ export class RunnerManager {
824
924
  this.liveErrors.clear();
825
925
  this.liveOptions.clear();
826
926
  this.mirrorListener = null;
927
+ this.mirrorAbandonListener = null;
827
928
  this.rotateListener = null;
828
929
  const errors = results
829
930
  .filter((result) => result.status === "rejected")
@@ -843,8 +944,7 @@ export class RunnerManager {
843
944
  // ── internals ──────────────────────────────────────────────────────────────
844
945
  handleForCleanup(localThreadId) {
845
946
  return this.handles.get(localThreadId) ??
846
- [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
847
- handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
947
+ [...this.childHandles].find((handle) => handle.key === localThreadId || handle.activeSessionId === localThreadId);
848
948
  }
849
949
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
850
950
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
@@ -869,7 +969,6 @@ export class RunnerManager {
869
969
  !allowReservedForkTarget) {
870
970
  throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
871
971
  }
872
- this.reapIdle();
873
972
  const existing = this.handles.get(key);
874
973
  if (existing && !existing.dead) {
875
974
  existing.lastUsedAt = this.now();
@@ -946,29 +1045,67 @@ export class RunnerManager {
946
1045
  }
947
1046
  async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
948
1047
  const source = await this.sessionStore.get(currentLocalThreadId);
1048
+ const getIntent = this.sessionStore.getClaudeForkIntent?.bind(this.sessionStore);
949
1049
  const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
950
1050
  const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
951
- if (!source?.codexSessionId || !setIntent || !deleteIntent) {
1051
+ if (!source?.codexSessionId || !getIntent || !setIntent || !deleteIntent) {
952
1052
  return {
953
1053
  ok: false,
954
1054
  reason: "unsupported",
955
1055
  message: "Claude fork persistence is unavailable",
956
1056
  };
957
1057
  }
958
- const targetClaudeSessionId = randomUUID();
1058
+ const persistedIntent = await getIntent(newLocalThreadId);
1059
+ if (persistedIntent &&
1060
+ persistedIntent.sourceSessionId !== currentLocalThreadId) {
1061
+ return {
1062
+ ok: false,
1063
+ reason: "error",
1064
+ message: "target Session already has a different Claude fork intent",
1065
+ };
1066
+ }
1067
+ // A durable fork operation may be replayed after clone+intent persistence
1068
+ // but before SessionStart commits the Provider binding. Reuse that exact
1069
+ // target id/path/prefix: re-cloning would move the fork boundary if the
1070
+ // source advanced (or silently lose history if it disappeared).
1071
+ const targetClaudeSessionId = persistedIntent?.targetClaudeSessionId ?? randomUUID();
959
1072
  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, {
1073
+ if (!persistedIntent) {
1074
+ let clonedTranscript = null;
1075
+ try {
1076
+ clonedTranscript = await cloneClaudeTranscript({
1077
+ sourceClaudeSessionId: source.codexSessionId,
1078
+ targetClaudeSessionId,
1079
+ targetCwd: options.workspace.cwd,
1080
+ });
1081
+ }
1082
+ catch (error) {
1083
+ console.error(`[claude-fork] could not clone source transcript for ${newLocalThreadId}; target will start without Provider history: ${error instanceof Error ? error.message : String(error)}`);
1084
+ }
1085
+ await setIntent({
1086
+ targetSessionId: newLocalThreadId,
1087
+ sourceSessionId: currentLocalThreadId,
1088
+ sourceClaudeSessionId: source.codexSessionId,
1089
+ targetClaudeSessionId,
1090
+ ...(clonedTranscript
1091
+ ? {
1092
+ forkTranscriptPath: clonedTranscript.transcriptPath,
1093
+ ...(clonedTranscript.prefixBytes === undefined
1094
+ ? {}
1095
+ : { forkTranscriptPrefixBytes: clonedTranscript.prefixBytes }),
1096
+ }
1097
+ : {}),
1098
+ updatedAt: new Date().toISOString(),
1099
+ });
1100
+ }
1101
+ await this.requestLiveSession(newLocalThreadId, {
968
1102
  workspace: structuredClone(options.workspace),
969
1103
  execution: structuredClone(options.execution),
970
1104
  }, true, true);
971
- const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
1105
+ // SessionStart commits the binding before the child reports live.ready.
1106
+ // If that final ACK is lost (or the child exits in between), the durable
1107
+ // binding is authoritative and must not be rolled back.
1108
+ const target = await this.sessionStore.get(newLocalThreadId);
972
1109
  if (!target ||
973
1110
  target.parentSessionId !== currentLocalThreadId ||
974
1111
  target.codexSessionId !== targetClaudeSessionId) {
@@ -978,10 +1115,8 @@ export class RunnerManager {
978
1115
  return { ok: true, data: undefined };
979
1116
  }
980
1117
  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);
1118
+ if (!persistedIntent) {
1119
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
985
1120
  }
986
1121
  const handle = this.handles.get(newLocalThreadId);
987
1122
  if (handle && !handle.dead) {
@@ -1011,104 +1146,552 @@ export class RunnerManager {
1011
1146
  if (handle.dead)
1012
1147
  return;
1013
1148
  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
- }
1149
+ this.deliverMirrorMessage(handle, message);
1018
1150
  return;
1019
1151
  }
1020
1152
  this.deliverRotateMessage(handle, message);
1021
1153
  }
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));
1154
+ deliverMirrorMessage(handle, message) {
1155
+ if (handle.dead)
1156
+ return;
1157
+ if (this.isStaleMirrorGeneration(handle, message.generation)) {
1158
+ if ("deliveryId" in message) {
1159
+ handle.transport.send({
1160
+ t: "mirror.ack",
1161
+ deliveryId: message.deliveryId,
1162
+ generation: message.generation,
1163
+ });
1164
+ }
1165
+ else {
1166
+ handle.imageTransfers.delete(message.transferId);
1167
+ handle.transport.send({
1168
+ t: "mirror.image.ack",
1169
+ transferId: message.transferId,
1170
+ seq: message.seq,
1171
+ generation: message.generation,
1172
+ });
1173
+ }
1026
1174
  return;
1027
1175
  }
1176
+ if ("deliveryId" in message) {
1177
+ const existing = handle.mirrorDeliveries.get(message.deliveryId);
1178
+ if (existing && existing.sessionId !== message.sessionId) {
1179
+ void this.terminateHandle(handle, "conflicting ordinary mirror delivery replay").catch((error) => logTerminationFailure(handle, error));
1180
+ return;
1181
+ }
1182
+ if (!existing) {
1183
+ handle.mirrorDeliveries.set(message.deliveryId, {
1184
+ sessionId: message.sessionId,
1185
+ abandonRequested: false,
1186
+ });
1187
+ }
1188
+ }
1189
+ let observationCommitted = false;
1190
+ const commitObservation = () => {
1191
+ if (observationCommitted)
1192
+ return;
1193
+ observationCommitted = true;
1194
+ if (this.isStaleMirrorGeneration(handle, message.generation))
1195
+ return;
1196
+ if (message.event.type === "session.collaboration_mode") {
1197
+ this.collaborationModeListener?.(message.sessionId, message.event.mode);
1198
+ }
1199
+ this.observeHandleRuntimeEvent(handle, message.event);
1200
+ };
1201
+ const commitDeliveredObservation = () => {
1202
+ commitObservation();
1203
+ if (handle.dead) {
1204
+ // The listener may have persisted/observed this event before its
1205
+ // Promise settled, while failHandle still saw no committed active id.
1206
+ // Close that newly visible response instead of leaving server runtime
1207
+ // state permanently running.
1208
+ this.failActiveResponses(handle, "runner_crashed", handle.failureReason ?? "runner exited during mirror publication");
1209
+ }
1210
+ };
1211
+ let delivered;
1028
1212
  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);
1213
+ const result = this.mirrorListener?.(message.sessionId, message.event);
1214
+ if (result && typeof result.then === "function") {
1215
+ delivered = Promise.resolve(result);
1216
+ }
1217
+ else {
1218
+ commitObservation();
1219
+ delivered = Promise.resolve();
1220
+ }
1033
1221
  }
1034
1222
  catch (error) {
1035
- const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1036
- void this.terminateHandle(handle, reason).catch((terminationError) => {
1037
- logTerminationFailure(handle, terminationError);
1223
+ delivered = Promise.reject(error);
1224
+ }
1225
+ if ("transferId" in message) {
1226
+ const transfer = handle.imageTransfers.get(message.transferId);
1227
+ if (transfer)
1228
+ transfer.publication = delivered;
1229
+ void delivered.then(() => {
1230
+ commitDeliveredObservation();
1231
+ if (!handle.dead) {
1232
+ if (handle.imageTransfers.get(message.transferId) === transfer) {
1233
+ handle.imageTransfers.delete(message.transferId);
1234
+ }
1235
+ handle.transport.send({
1236
+ t: "mirror.image.ack",
1237
+ transferId: message.transferId,
1238
+ seq: message.seq,
1239
+ generation: message.generation,
1240
+ });
1241
+ }
1242
+ }, (error) => {
1243
+ if (handle.dead)
1244
+ return;
1245
+ if (handle.imageTransfers.get(message.transferId) === transfer && transfer) {
1246
+ delete transfer.publication;
1247
+ delete transfer.commitSeq;
1248
+ }
1249
+ handle.transport.send({
1250
+ t: "mirror.image.nack",
1251
+ transferId: message.transferId,
1252
+ seq: message.seq,
1253
+ generation: message.generation,
1254
+ classification: mirrorNackClassification(error),
1255
+ message: errorMessage(error),
1256
+ });
1038
1257
  });
1039
- return;
1040
1258
  }
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;
1259
+ else {
1260
+ void delivered.then(() => {
1261
+ commitDeliveredObservation();
1262
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1263
+ if (!handle.dead) {
1264
+ handle.transport.send({
1265
+ t: "mirror.ack",
1266
+ deliveryId: message.deliveryId,
1267
+ generation: message.generation,
1268
+ });
1269
+ }
1270
+ }, (error) => {
1271
+ const abandonToken = mirrorAbandonToken(error);
1272
+ if (handle.dead) {
1273
+ // The child can disappear while SQLite publication is still in
1274
+ // flight. A later exact pending-input failure still owns cleanup;
1275
+ // only the wire NACK is impossible after process death.
1276
+ if (abandonToken) {
1277
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1278
+ }
1279
+ return;
1280
+ }
1281
+ {
1282
+ const classification = mirrorNackClassification(error);
1283
+ const delivery = handle.mirrorDeliveries.get(message.deliveryId);
1284
+ if (delivery && abandonToken)
1285
+ delivery.abandonToken = abandonToken;
1286
+ if (classification === "permanent" &&
1287
+ message.attempt >= 3 &&
1288
+ abandonToken) {
1289
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1290
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1291
+ }
1292
+ else if (delivery?.abandonRequested && abandonToken) {
1293
+ this.mirrorAbandonListener?.(delivery.sessionId, abandonToken);
1294
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1295
+ }
1296
+ else if (!abandonToken) {
1297
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1298
+ }
1299
+ else if (delivery && !delivery.cleanupTimer) {
1300
+ // A timely NACK may be retried with a fresh delivery id. Retain
1301
+ // the old association briefly for the timeout/NACK race, then
1302
+ // release it if the child never reports an ambiguous outcome.
1303
+ const timer = setTimeout(() => {
1304
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1305
+ }, 60_000);
1306
+ timer.unref?.();
1307
+ delivery.cleanupTimer = timer;
1308
+ }
1309
+ handle.transport.send({
1310
+ t: "mirror.nack",
1311
+ deliveryId: message.deliveryId,
1312
+ generation: message.generation,
1313
+ classification,
1314
+ message: errorMessage(error),
1315
+ });
1316
+ }
1317
+ });
1318
+ }
1319
+ }
1320
+ releaseMirrorDelivery(handle, deliveryId) {
1321
+ const delivery = handle.mirrorDeliveries.get(deliveryId);
1322
+ if (!delivery)
1323
+ return;
1324
+ if (delivery.cleanupTimer)
1325
+ clearTimeout(delivery.cleanupTimer);
1326
+ handle.mirrorDeliveries.delete(deliveryId);
1327
+ }
1328
+ failMirrorImageTransfer(handle, reason) {
1329
+ handle.imageTransfers.clear();
1330
+ void this.terminateHandle(handle, reason).catch((error) => {
1331
+ logTerminationFailure(handle, error);
1048
1332
  });
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)
1333
+ }
1334
+ deliverRotateMessage(handle, message) {
1335
+ const generation = message.generation;
1336
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1337
+ handle.completedRotation.generation === generation &&
1338
+ handle.completedRotation.from === message.from &&
1339
+ handle.completedRotation.to === message.to) {
1340
+ handle.transport.send({
1341
+ t: "rotate.ack",
1342
+ rotationId: message.rotationId,
1343
+ generation,
1344
+ });
1345
+ return;
1346
+ }
1347
+ let pending = handle.pendingRotation;
1348
+ if (pending) {
1349
+ if (pending.rotationId !== message.rotationId ||
1350
+ pending.generation !== generation ||
1351
+ pending.from !== message.from ||
1352
+ pending.to !== message.to ||
1353
+ pending.kind !== message.kind) {
1354
+ void this.terminateHandle(handle, "conflicting Session rotation replay").catch((error) => logTerminationFailure(handle, error));
1080
1355
  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),
1356
+ }
1357
+ if (pending.publicationComplete) {
1358
+ handle.transport.send({
1359
+ t: "rotate.ack",
1360
+ rotationId: pending.rotationId,
1361
+ generation: pending.generation,
1362
+ });
1363
+ return;
1364
+ }
1365
+ if (pending.inFlight)
1366
+ return;
1367
+ }
1368
+ else {
1369
+ if (this.forkReservations.has(message.to) ||
1370
+ this.sourceForkReservations.has(message.from)) {
1371
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1372
+ return;
1373
+ }
1374
+ try {
1375
+ // Rotate the runtime-local context before publishing the new Session
1376
+ // alias to routing or observers. This prevents the new identity from
1377
+ // briefly inheriting stale context after `/clear` or `/fork`.
1378
+ handle.sessionContext?.rotate(message.to);
1379
+ }
1380
+ catch (error) {
1381
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1382
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
1383
+ logTerminationFailure(handle, terminationError);
1384
+ });
1385
+ return;
1386
+ }
1387
+ let releaseTarget;
1388
+ const targetReservation = new Promise((resolve) => {
1389
+ releaseTarget = resolve;
1086
1390
  });
1087
- for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
1088
- this.deliverForkBufferedMessage(entry.handle, entry.message);
1391
+ let releaseSource;
1392
+ const sourceReservation = new Promise((resolve) => {
1393
+ releaseSource = resolve;
1394
+ });
1395
+ pending = {
1396
+ rotationId: message.rotationId,
1397
+ generation,
1398
+ from: message.from,
1399
+ to: message.to,
1400
+ kind: message.kind,
1401
+ rotation: {
1402
+ from: message.from,
1403
+ to: message.to,
1404
+ kind: message.kind,
1405
+ workspace: structuredClone(message.workspace),
1406
+ execution: structuredClone(message.execution),
1407
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1408
+ },
1409
+ targetReservation,
1410
+ sourceReservation,
1411
+ releaseTarget,
1412
+ releaseSource,
1413
+ inFlight: false,
1414
+ logicalTargetPublished: false,
1415
+ publicationComplete: false,
1416
+ admission: this.admissionReserve?.(),
1417
+ };
1418
+ handle.pendingRotation = pending;
1419
+ this.forkReservations.set(pending.to, targetReservation);
1420
+ this.sourceForkReservations.set(pending.from, sourceReservation);
1421
+ this.forkBufferedMessages.set(pending.to, []);
1422
+ this.forkBufferedTerminalInputs.set(pending.to, []);
1423
+ }
1424
+ pending.inFlight = true;
1425
+ void Promise.resolve().then(async () => {
1426
+ if (pending.logicalTargetPublished)
1427
+ return;
1428
+ await this.rotateListener?.(pending.rotation);
1429
+ if (handle.dead || handle.pendingRotation !== pending)
1430
+ return false;
1431
+ pending.logicalTargetPublished = true;
1432
+ return true;
1433
+ })
1434
+ .then((current) => {
1435
+ if (current === false || handle.dead || handle.pendingRotation !== pending)
1436
+ return false;
1437
+ return this.publishNativeRotationBinding(pending).then(() => true);
1438
+ })
1439
+ .then((current) => {
1440
+ if (current === false || handle.dead || handle.pendingRotation !== pending) {
1441
+ if (handle.pendingRotation === pending) {
1442
+ this.releasePendingNativeRotation(handle, pending);
1443
+ }
1444
+ return;
1089
1445
  }
1090
- for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
1091
- if (!entry.handle.dead)
1092
- entry.handle.transport.send(entry.message);
1446
+ pending.inFlight = false;
1447
+ pending.publicationComplete = true;
1448
+ // Durable publication grants the child permission to move ownership.
1449
+ // Keep both logical ids fenced until rotate.applied proves that the
1450
+ // child has actually transferred its terminal/runtime registries.
1451
+ try {
1452
+ handle.transport.send({
1453
+ t: "rotate.ack",
1454
+ rotationId: pending.rotationId,
1455
+ generation: pending.generation,
1456
+ });
1457
+ }
1458
+ catch (error) {
1459
+ this.releasePendingNativeRotation(handle, pending);
1460
+ void this.terminateHandle(handle, `runner Session rotation commit failed: ${errorMessage(error)}`).catch((terminationError) => {
1461
+ logTerminationFailure(handle, terminationError);
1462
+ });
1093
1463
  }
1094
1464
  })
1095
1465
  .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);
1466
+ if (handle.pendingRotation !== pending)
1467
+ return;
1468
+ pending.inFlight = false;
1469
+ if (handle.dead) {
1470
+ this.releasePendingNativeRotation(handle, pending);
1471
+ return;
1101
1472
  }
1102
- if (this.sourceForkReservations.get(message.from) === sourceReservation) {
1103
- this.sourceForkReservations.delete(message.from);
1473
+ try {
1474
+ handle.transport.send({
1475
+ t: "rotate.nack",
1476
+ rotationId: pending.rotationId,
1477
+ generation: pending.generation,
1478
+ classification: mirrorNackClassification(error),
1479
+ message: errorMessage(error),
1480
+ });
1104
1481
  }
1105
- this.forkBufferedMessages.delete(message.to);
1106
- this.forkBufferedTerminalInputs.delete(message.to);
1107
- releaseTarget();
1108
- releaseSource();
1482
+ catch (sendError) {
1483
+ this.releasePendingNativeRotation(handle, pending);
1484
+ void this.terminateHandle(handle, `runner Session rotation NACK failed: ${errorMessage(sendError)}`).catch((terminationError) => {
1485
+ logTerminationFailure(handle, terminationError);
1486
+ });
1487
+ }
1488
+ });
1489
+ }
1490
+ /** Bridge ownership is a child-local binding, but the parent is the only
1491
+ * process that knows logical target publication committed. Mark that fact
1492
+ * durably before allowing the child to apply and expose the alias. */
1493
+ async publishNativeRotationBinding(pending) {
1494
+ await this.publishNativeRotationRecords(pending.from, pending.to, pending.kind);
1495
+ }
1496
+ async publishNativeRotationRecords(from, to, kind) {
1497
+ const target = await this.sessionStore.get(to);
1498
+ const source = await this.sessionStore.get(from);
1499
+ if (!target) {
1500
+ throw new AgentRuntimeError("native rotation target binding is missing", 422, "native_rotation_target_missing");
1501
+ }
1502
+ const publishedTarget = {
1503
+ ...target,
1504
+ nativeRotationSourceSessionId: from,
1505
+ nativeRotationKind: kind,
1506
+ nativeRotationPublished: true,
1507
+ updatedAt: new Date().toISOString(),
1508
+ };
1509
+ await this.sessionStore.set(publishedTarget);
1510
+ if (!source)
1511
+ return;
1512
+ await this.sessionStore.set({
1513
+ ...source,
1514
+ bridgeOwnerSessionId: `${from}-${kind}-retired-${to}`,
1515
+ nativeRotationTargetSessionId: to,
1516
+ updatedAt: new Date().toISOString(),
1109
1517
  });
1110
1518
  }
1519
+ deliverRotateAppliedMessage(handle, message) {
1520
+ const pending = handle.pendingRotation;
1521
+ if (pending &&
1522
+ pending.publicationComplete &&
1523
+ pending.rotationId === message.rotationId &&
1524
+ pending.generation === message.generation &&
1525
+ pending.from === message.from &&
1526
+ pending.to === message.to) {
1527
+ void this.finalizeNativeRotation(handle, pending);
1528
+ return;
1529
+ }
1530
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1531
+ handle.completedRotation.generation === message.generation &&
1532
+ handle.completedRotation.from === message.from &&
1533
+ handle.completedRotation.to === message.to) {
1534
+ this.acknowledgeRotateApplied(handle, message.rotationId, message.generation);
1535
+ return;
1536
+ }
1537
+ void this.terminateHandle(handle, "invalid Session rotation applied confirmation").catch((error) => logTerminationFailure(handle, error));
1538
+ }
1539
+ finalizeNativeRotation(handle, pending) {
1540
+ if (pending.finalization)
1541
+ return pending.finalization;
1542
+ const operation = (async () => {
1543
+ if (handle.pendingRotation !== pending)
1544
+ return false;
1545
+ pending.inFlight = true;
1546
+ try {
1547
+ // Change the active Session only after ownership transfer. In
1548
+ // this IPC topology rotate.applied is the child-owned equivalent proof.
1549
+ handle.activeResponseIds.clear();
1550
+ for (const [key, candidate] of this.handles) {
1551
+ if (candidate !== handle)
1552
+ continue;
1553
+ this.handles.delete(key);
1554
+ this.liveSessionKeys.delete(key);
1555
+ this.liveOptions.delete(key);
1556
+ }
1557
+ handle.activeSessionId = pending.to;
1558
+ this.handles.set(pending.to, handle);
1559
+ this.liveSessionKeys.add(pending.to);
1560
+ this.liveOptions.set(pending.to, {
1561
+ workspace: structuredClone(pending.rotation.workspace),
1562
+ execution: structuredClone(pending.rotation.execution),
1563
+ });
1564
+ handle.completedRotation = {
1565
+ rotationId: pending.rotationId,
1566
+ generation: pending.generation,
1567
+ from: pending.from,
1568
+ to: pending.to,
1569
+ };
1570
+ // Old-Session presentation is ordered internally but must not hold the
1571
+ // ownership-transfer ACK. Each notice still receives the server queue's
1572
+ // bounded result window; the rotated target can proceed immediately.
1573
+ const noticePublication = this.publishNativeRotationNotice(pending);
1574
+ if (handle.dead)
1575
+ return false;
1576
+ this.acknowledgeRotateApplied(handle, pending.rotationId, pending.generation);
1577
+ for (const entry of this.forkBufferedMessages.get(pending.to) ?? []) {
1578
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
1579
+ }
1580
+ for (const entry of this.forkBufferedTerminalInputs.get(pending.to) ?? []) {
1581
+ if (!entry.handle.dead)
1582
+ entry.handle.transport.send(entry.message);
1583
+ }
1584
+ void noticePublication.catch((error) => {
1585
+ console.error(`Session rotation notice sequence failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1586
+ });
1587
+ return true;
1588
+ }
1589
+ catch (error) {
1590
+ void this.terminateHandle(handle, `runner Session rotation finalization failed: ${errorMessage(error)}`).catch((terminationError) => {
1591
+ logTerminationFailure(handle, terminationError);
1592
+ });
1593
+ return false;
1594
+ }
1595
+ finally {
1596
+ pending.inFlight = false;
1597
+ this.releasePendingNativeRotation(handle, pending);
1598
+ }
1599
+ })();
1600
+ pending.finalization = operation;
1601
+ return operation;
1602
+ }
1603
+ acknowledgeRotateApplied(handle, rotationId, generation) {
1604
+ try {
1605
+ handle.transport.send({ t: "rotate.applied.ack", rotationId, generation });
1606
+ }
1607
+ catch (error) {
1608
+ void this.terminateHandle(handle, `runner Session rotation applied ACK failed: ${errorMessage(error)}`).catch((terminationError) => {
1609
+ logTerminationFailure(handle, terminationError);
1610
+ });
1611
+ }
1612
+ }
1613
+ releasePendingNativeRotation(handle, pending) {
1614
+ if (handle.pendingRotation !== pending)
1615
+ return;
1616
+ delete handle.pendingRotation;
1617
+ if (this.forkReservations.get(pending.to) === pending.targetReservation) {
1618
+ this.forkReservations.delete(pending.to);
1619
+ }
1620
+ if (this.sourceForkReservations.get(pending.from) === pending.sourceReservation) {
1621
+ this.sourceForkReservations.delete(pending.from);
1622
+ }
1623
+ this.forkBufferedMessages.delete(pending.to);
1624
+ this.forkBufferedTerminalInputs.delete(pending.to);
1625
+ pending.releaseTarget();
1626
+ pending.releaseSource();
1627
+ pending.admission?.release();
1628
+ }
1629
+ async publishNativeRotationNotice(pending) {
1630
+ // Only /clear supersedes the old Session. A /fork keeps
1631
+ // both conversations live and therefore emits no old-session redirect.
1632
+ if (pending.kind !== "clear" || pending.from === pending.to)
1633
+ return;
1634
+ this.mirrorSupersedeListener?.(pending.from);
1635
+ const noticeId = `msg_clear_${randomUUID().replaceAll("-", "")}`;
1636
+ const notice = {
1637
+ type: "response.output_item.done",
1638
+ responseId: noticeId,
1639
+ item: {
1640
+ id: noticeId,
1641
+ sessionId: pending.from,
1642
+ position: 0,
1643
+ responseId: noticeId,
1644
+ status: "completed",
1645
+ createdAt: Date.now(),
1646
+ type: "message",
1647
+ data: {
1648
+ role: "assistant",
1649
+ content: [{
1650
+ type: "output_text",
1651
+ text: "This session was ended by `/clear`. " +
1652
+ `Continue in [the new session](../${encodeURIComponent(pending.to)}). ` +
1653
+ "You can also send a message here to resume this session.",
1654
+ }],
1655
+ },
1656
+ },
1657
+ };
1658
+ const transient = {
1659
+ type: "session.rotated",
1660
+ sessionId: pending.from,
1661
+ newSessionId: pending.to,
1662
+ kind: "clear",
1663
+ };
1664
+ // Preserve the post-clear order: stop the old spinner, append a
1665
+ // durable assistant notice, then tell a live viewer to follow the target.
1666
+ const publications = [];
1667
+ for (const event of [
1668
+ {
1669
+ type: "session.status",
1670
+ sessionId: pending.from,
1671
+ status: "idle",
1672
+ backgroundTaskCount: 0,
1673
+ },
1674
+ notice,
1675
+ transient,
1676
+ ]) {
1677
+ try {
1678
+ // The server-side SessionMirrorQueue owns the per-record
1679
+ // per-record result deadline. A second enqueue-time timer here would
1680
+ // expire later records before they reach the head of that queue.
1681
+ publications.push(Promise.resolve(this.mirrorListener?.(pending.from, event)).catch((error) => {
1682
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1683
+ }));
1684
+ }
1685
+ catch (error) {
1686
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1687
+ }
1688
+ }
1689
+ await Promise.all(publications);
1690
+ }
1111
1691
  spawnHandle(key) {
1692
+ const logDir = join(sessionDiagnosticDir("runtime", key), "instances", randomUUID());
1693
+ const event = diagnosticEvents(join(logDir, "process-events.jsonl"), { sessionId: key });
1694
+ event("runner.spawn");
1112
1695
  const args = this.runnerEntry.endsWith(".ts")
1113
1696
  ? ["--import", "tsx", this.runnerEntry]
1114
1697
  : [this.runnerEntry];
@@ -1129,6 +1712,7 @@ export class RunnerManager {
1129
1712
  ...this.childEnv,
1130
1713
  ...(sessionContext?.childEnv ?? {}),
1131
1714
  RYNX_RUNNER_SESSION: key,
1715
+ RYNX_RUNTIME_LOG_DIR: logDir,
1132
1716
  },
1133
1717
  });
1134
1718
  }
@@ -1137,24 +1721,36 @@ export class RunnerManager {
1137
1721
  throw error;
1138
1722
  }
1139
1723
  const completion = observeChildCompletion(child);
1724
+ teeDiagnosticStream(child.stderr, join(logDir, "runner.stderr.log"));
1725
+ child.on("spawn", () => event("runner.started", { pid: child.pid }));
1726
+ child.on("error", (error) => event("runner.error", { message: error.message }));
1727
+ child.on("exit", (code, signal) => event("runner.exit", { code, signal }));
1140
1728
  const transport = new StdioRunnerTransport(child.stdout, child.stdin);
1141
1729
  const handle = {
1142
1730
  key,
1731
+ activeSessionId: key,
1143
1732
  child,
1144
1733
  transport,
1145
1734
  stderr: [],
1146
1735
  lastUsedAt: this.now(),
1736
+ activeResponseIds: new Set(),
1737
+ terminalResponseIds: new Set(),
1738
+ terminalWrites: new Map(),
1147
1739
  dead: false,
1148
1740
  completion,
1149
- terminalCleanupRetryFailures: 0,
1150
1741
  processGroup,
1151
1742
  ...(sessionContext ? { sessionContext } : {}),
1152
1743
  caps: new Map(),
1153
1744
  terminals: new Map(),
1154
1745
  live: new Map(),
1746
+ imageTransfers: new Map(),
1747
+ mirrorDeliveries: new Map(),
1155
1748
  };
1156
1749
  this.childHandles.add(handle);
1157
- void completion.then(() => this.childHandles.delete(handle));
1750
+ void completion.then(() => {
1751
+ this.finishTerminalWrites(handle);
1752
+ this.childHandles.delete(handle);
1753
+ });
1158
1754
  transport.onMessage((msg) => this.onChildMessage(handle, msg));
1159
1755
  if (child.stderr) {
1160
1756
  const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
@@ -1165,8 +1761,14 @@ export class RunnerManager {
1165
1761
  }
1166
1762
  });
1167
1763
  }
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}` : ""})`));
1764
+ const failAndReap = (reason) => {
1765
+ this.failHandle(handle, reason);
1766
+ void this.terminateHandle(handle, reason).catch((error) => {
1767
+ logTerminationFailure(handle, error);
1768
+ });
1769
+ };
1770
+ child.on("error", (error) => failAndReap(error.message));
1771
+ child.on("exit", (code, signal) => failAndReap(`runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
1170
1772
  return handle;
1171
1773
  }
1172
1774
  onChildMessage(handle, msg) {
@@ -1187,55 +1789,293 @@ export class RunnerManager {
1187
1789
  }
1188
1790
  return;
1189
1791
  }
1190
- case "term.opened":
1191
- handle.terminals.get(msg.attachId)?._opened(msg.role);
1792
+ case "term.prepared":
1793
+ handle.terminals.get(msg.attachId)?._prepared(msg.role, msg.seedBytes, msg.cols, msg.rows);
1192
1794
  return;
1193
- case "term.data":
1194
- handle.terminals.get(msg.attachId)?._data(msg.dataB64);
1795
+ case "term.seed.chunk":
1796
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "seed", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1195
1797
  return;
1196
- case "term.exit": {
1197
- const terminal = handle.terminals.get(msg.attachId);
1198
- handle.terminals.delete(msg.attachId);
1199
- terminal?._exit(msg.exitCode);
1798
+ case "term.started":
1799
+ handle.terminals.get(msg.attachId)?._started(msg.reqId);
1800
+ return;
1801
+ case "term.chunk":
1802
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "read", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1803
+ return;
1804
+ case "term.dimensions":
1805
+ handle.terminals.get(msg.attachId)?._dimensions(msg.cols, msg.rows);
1806
+ return;
1807
+ case "term.reader.done":
1808
+ handle.terminals.get(msg.attachId)?._readerDone({
1809
+ finalOffset: msg.finalOffset,
1810
+ reason: msg.reason,
1811
+ exitCode: msg.exitCode,
1812
+ });
1813
+ return;
1814
+ case "term.ack":
1815
+ if (msg.operation === "input")
1816
+ this.finishTerminalWrite(handle, msg.reqId);
1817
+ handle.terminals.get(msg.attachId)?._ack(msg.reqId, msg.operation);
1200
1818
  return;
1201
- }
1202
1819
  case "term.error": {
1820
+ if (msg.reqId)
1821
+ this.finishTerminalWrite(handle, msg.reqId);
1822
+ else
1823
+ this.finishTerminalWrites(handle, msg.attachId);
1203
1824
  const terminal = handle.terminals.get(msg.attachId);
1204
- handle.terminals.delete(msg.attachId);
1205
- terminal?._fail(msg.message, msg.code);
1825
+ if (!msg.reqId)
1826
+ handle.terminals.delete(msg.attachId);
1827
+ terminal?._fail(msg.message, msg.code, msg.reqId);
1206
1828
  return;
1207
1829
  }
1208
1830
  case "mirror":
1209
1831
  if (handle.dead)
1210
1832
  return;
1833
+ if (!msg.deliveryId ||
1834
+ !Number.isSafeInteger(msg.generation) ||
1835
+ !Number.isSafeInteger(msg.attempt) ||
1836
+ msg.attempt < 1) {
1837
+ void this.terminateHandle(handle, "invalid durable mirror delivery frame").catch((error) => logTerminationFailure(handle, error));
1838
+ return;
1839
+ }
1211
1840
  if (this.bufferForkTargetMessage(handle, msg))
1212
1841
  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");
1842
+ this.deliverMirrorMessage(handle, msg);
1843
+ return;
1844
+ case "mirror.abandon": {
1845
+ if (!msg.deliveryId || !Number.isSafeInteger(msg.generation)) {
1846
+ void this.terminateHandle(handle, "invalid mirror abandon frame").catch((error) => logTerminationFailure(handle, error));
1847
+ return;
1848
+ }
1849
+ const delivery = handle.mirrorDeliveries.get(msg.deliveryId);
1850
+ if (!delivery)
1851
+ return;
1852
+ delivery.abandonRequested = true;
1853
+ if (delivery.abandonToken) {
1854
+ this.mirrorAbandonListener?.(delivery.sessionId, delivery.abandonToken);
1855
+ this.releaseMirrorDelivery(handle, msg.deliveryId);
1219
1856
  }
1220
1857
  return;
1858
+ }
1859
+ case "mirror.image.begin": {
1860
+ if (!Number.isSafeInteger(msg.generation)) {
1861
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
1862
+ return;
1863
+ }
1864
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
1865
+ handle.transport.send({
1866
+ t: "mirror.image.ack",
1867
+ transferId: msg.transferId,
1868
+ seq: 0,
1869
+ generation: msg.generation,
1870
+ });
1871
+ return;
1872
+ }
1873
+ const existing = handle.imageTransfers.get(msg.transferId);
1874
+ if (existing) {
1875
+ const exactReplay = existing.sessionId === msg.sessionId &&
1876
+ existing.generation === msg.generation &&
1877
+ existing.totalChars === msg.totalChars &&
1878
+ JSON.stringify(existing.event) === JSON.stringify(msg.event);
1879
+ if (!exactReplay) {
1880
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer replay");
1881
+ return;
1882
+ }
1883
+ handle.transport.send({
1884
+ t: "mirror.image.ack",
1885
+ transferId: msg.transferId,
1886
+ seq: 0,
1887
+ generation: msg.generation,
1888
+ });
1889
+ return;
1890
+ }
1891
+ if (handle.dead ||
1892
+ !Number.isSafeInteger(msg.totalChars) ||
1893
+ msg.totalChars <= 0 ||
1894
+ msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
1895
+ !isDetachedGeneratedImageEvent(msg.event)) {
1896
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer start");
1897
+ return;
1898
+ }
1899
+ handle.imageTransfers.set(msg.transferId, {
1900
+ sessionId: msg.sessionId,
1901
+ generation: msg.generation,
1902
+ event: msg.event,
1903
+ totalChars: msg.totalChars,
1904
+ receivedChars: 0,
1905
+ nextSeq: 1,
1906
+ chunks: [],
1907
+ });
1908
+ handle.transport.send({
1909
+ t: "mirror.image.ack",
1910
+ transferId: msg.transferId,
1911
+ seq: 0,
1912
+ generation: msg.generation,
1913
+ });
1914
+ return;
1915
+ }
1916
+ case "mirror.image.chunk": {
1917
+ if (!Number.isSafeInteger(msg.generation)) {
1918
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
1919
+ return;
1920
+ }
1921
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
1922
+ handle.transport.send({
1923
+ t: "mirror.image.ack",
1924
+ transferId: msg.transferId,
1925
+ seq: msg.seq,
1926
+ generation: msg.generation,
1927
+ });
1928
+ return;
1929
+ }
1930
+ const transfer = handle.imageTransfers.get(msg.transferId);
1931
+ if (transfer &&
1932
+ msg.seq > 0 &&
1933
+ msg.seq < transfer.nextSeq &&
1934
+ transfer.chunks[msg.seq - 1] === msg.data) {
1935
+ handle.transport.send({
1936
+ t: "mirror.image.ack",
1937
+ transferId: msg.transferId,
1938
+ seq: msg.seq,
1939
+ generation: transfer.generation,
1940
+ });
1941
+ return;
1942
+ }
1943
+ if (handle.dead ||
1944
+ !transfer ||
1945
+ msg.seq !== transfer.nextSeq ||
1946
+ !msg.data ||
1947
+ msg.data.length > RUNNER_IMAGE_CHUNK_CHARS ||
1948
+ transfer.receivedChars + msg.data.length > transfer.totalChars) {
1949
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer chunk");
1950
+ return;
1951
+ }
1952
+ transfer.chunks.push(msg.data);
1953
+ transfer.receivedChars += msg.data.length;
1954
+ transfer.nextSeq += 1;
1955
+ handle.transport.send({
1956
+ t: "mirror.image.ack",
1957
+ transferId: msg.transferId,
1958
+ seq: msg.seq,
1959
+ generation: transfer.generation,
1960
+ });
1961
+ return;
1962
+ }
1963
+ case "mirror.image.commit": {
1964
+ if (!Number.isSafeInteger(msg.generation)) {
1965
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
1966
+ return;
1967
+ }
1968
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
1969
+ handle.imageTransfers.delete(msg.transferId);
1970
+ handle.transport.send({
1971
+ t: "mirror.image.ack",
1972
+ transferId: msg.transferId,
1973
+ seq: msg.seq,
1974
+ generation: msg.generation,
1975
+ });
1976
+ return;
1977
+ }
1978
+ const transfer = handle.imageTransfers.get(msg.transferId);
1979
+ if (handle.dead ||
1980
+ !transfer ||
1981
+ msg.seq !== transfer.nextSeq ||
1982
+ transfer.receivedChars !== transfer.totalChars) {
1983
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
1984
+ return;
1985
+ }
1986
+ if (transfer.commitSeq !== undefined) {
1987
+ if (transfer.commitSeq !== msg.seq) {
1988
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer commit");
1989
+ }
1990
+ // The original publication owns the eventual ACK/NACK. A replay can
1991
+ // arrive after its ACK timer elapsed; that eventual result resolves
1992
+ // the replacement waiter because transferId+seq are stable.
1993
+ return;
1994
+ }
1995
+ transfer.commitSeq = msg.seq;
1996
+ const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
1997
+ if (!event) {
1998
+ this.failMirrorImageTransfer(handle, "generated image transfer target changed");
1999
+ return;
2000
+ }
2001
+ const mirrored = {
2002
+ t: "mirror",
2003
+ sessionId: transfer.sessionId,
2004
+ event,
2005
+ transferId: msg.transferId,
2006
+ seq: msg.seq,
2007
+ generation: transfer.generation,
2008
+ };
2009
+ if (this.bufferForkTargetMessage(handle, mirrored)) {
2010
+ handle.transport.send({
2011
+ t: "mirror.image.hold",
2012
+ transferId: msg.transferId,
2013
+ seq: msg.seq,
2014
+ generation: transfer.generation,
2015
+ });
2016
+ return;
2017
+ }
2018
+ this.deliverMirrorMessage(handle, mirrored);
2019
+ return;
2020
+ }
2021
+ case "mirror.image.abandon": {
2022
+ if (!Number.isSafeInteger(msg.generation))
2023
+ return;
2024
+ const transfer = handle.imageTransfers.get(msg.transferId);
2025
+ if (transfer && transfer.generation === msg.generation) {
2026
+ handle.imageTransfers.delete(msg.transferId);
2027
+ }
2028
+ return;
2029
+ }
1221
2030
  case "rotate": {
1222
2031
  if (handle.dead)
1223
2032
  return;
2033
+ if (!msg.rotationId || !Number.isSafeInteger(msg.generation)) {
2034
+ void this.terminateHandle(handle, "invalid Session rotation frame").catch((error) => logTerminationFailure(handle, error));
2035
+ return;
2036
+ }
1224
2037
  if (this.bufferForkTargetMessage(handle, msg))
1225
2038
  return;
1226
2039
  this.deliverRotateMessage(handle, msg);
1227
2040
  return;
1228
2041
  }
2042
+ case "rotate.applied": {
2043
+ if (handle.dead)
2044
+ return;
2045
+ this.deliverRotateAppliedMessage(handle, msg);
2046
+ return;
2047
+ }
2048
+ case "terminal.lifecycle.ended": {
2049
+ if (msg.lifecycle === "required") {
2050
+ void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
2051
+ }
2052
+ return;
2053
+ }
2054
+ case "terminal.reaped": {
2055
+ const resolve = handle.live.get(msg.reqId);
2056
+ handle.live.delete(msg.reqId);
2057
+ resolve?.({ ok: msg.ok, error: msg.error });
2058
+ return;
2059
+ }
1229
2060
  case "live.ready":
1230
2061
  case "interrupted":
1231
2062
  case "injected": {
1232
2063
  const resolve = handle.live.get(msg.reqId);
1233
2064
  handle.live.delete(msg.reqId);
1234
2065
  resolve?.(msg.t === "injected"
1235
- ? { outcome: msg.outcome, error: msg.error }
2066
+ ? { result: msg.result, error: msg.error }
1236
2067
  : { ok: msg.ok, error: msg.error });
1237
2068
  return;
1238
2069
  }
2070
+ case "collaboration.updated": {
2071
+ const resolve = handle.live.get(msg.reqId);
2072
+ handle.live.delete(msg.reqId);
2073
+ resolve?.({
2074
+ collaborationUpdated: msg.ok,
2075
+ ...(msg.error ? { error: msg.error } : {}),
2076
+ });
2077
+ return;
2078
+ }
1239
2079
  case "interaction.resolved": {
1240
2080
  const resolve = handle.live.get(msg.reqId);
1241
2081
  handle.live.delete(msg.reqId);
@@ -1245,11 +2085,12 @@ export class RunnerManager {
1245
2085
  }
1246
2086
  }
1247
2087
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
1248
- failHandle(handle, reason) {
2088
+ failHandle(handle, reason, opts = {}) {
1249
2089
  if (handle.dead) {
1250
2090
  return;
1251
2091
  }
1252
2092
  handle.dead = true;
2093
+ handle.failureReason = reason;
1253
2094
  closeOwnedSessionContext(handle);
1254
2095
  // Drop every key mapping to this handle — its launch key AND any rotation
1255
2096
  // aliases (claude `/clear`·`/fork` terminal transfer).
@@ -1262,8 +2103,20 @@ export class RunnerManager {
1262
2103
  }
1263
2104
  }
1264
2105
  }
2106
+ if (handle.pendingRotation) {
2107
+ // A publication already inside its durable binding write owns both
2108
+ // reservations until that Promise settles. Releasing here would let a
2109
+ // replacement source child open against the old binding while the stale
2110
+ // chain can still commit target/source ownership records.
2111
+ if (!handle.pendingRotation.inFlight) {
2112
+ this.releasePendingNativeRotation(handle, handle.pendingRotation);
2113
+ }
2114
+ }
1265
2115
  const tail = handle.stderr.join("\n");
1266
2116
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
2117
+ if (opts.failActiveResponses !== false) {
2118
+ this.failActiveResponses(handle, "runner_crashed", message);
2119
+ }
1267
2120
  const error = new AgentRuntimeError(message, 500, "runner_crashed");
1268
2121
  for (const pending of handle.caps.values()) {
1269
2122
  pending.reject(error);
@@ -1272,20 +2125,23 @@ export class RunnerManager {
1272
2125
  terminal._fail(message);
1273
2126
  }
1274
2127
  for (const resolve of handle.live.values()) {
1275
- resolve({ ok: false, error: message });
2128
+ resolve({
2129
+ ok: false,
2130
+ error: { message, code: "runner_crashed", statusCode: 500 },
2131
+ });
1276
2132
  }
1277
2133
  handle.caps.clear();
1278
2134
  handle.terminals.clear();
1279
2135
  handle.live.clear();
2136
+ handle.imageTransfers.clear();
2137
+ for (const deliveryId of handle.mirrorDeliveries.keys()) {
2138
+ this.releaseMirrorDelivery(handle, deliveryId);
2139
+ }
1280
2140
  handle.transport.close();
1281
2141
  }
1282
2142
  terminateHandle(handle, reason) {
1283
2143
  if (handle.termination)
1284
2144
  return handle.termination;
1285
- if (handle.terminalCleanupRetryTimer) {
1286
- clearTimeout(handle.terminalCleanupRetryTimer);
1287
- delete handle.terminalCleanupRetryTimer;
1288
- }
1289
2145
  const attempt = (async () => {
1290
2146
  if (!handle.dead) {
1291
2147
  try {
@@ -1294,7 +2150,8 @@ export class RunnerManager {
1294
2150
  catch {
1295
2151
  // The process signal below remains the authoritative shutdown path.
1296
2152
  }
1297
- this.failHandle(handle, reason);
2153
+ this.interruptActiveResponses(handle);
2154
+ this.failHandle(handle, reason, { failActiveResponses: false });
1298
2155
  }
1299
2156
  this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1300
2157
  let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
@@ -1302,51 +2159,261 @@ export class RunnerManager {
1302
2159
  this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1303
2160
  childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1304
2161
  }
1305
- const terminalStopped = handle.key === CAP_KEY
1306
- ? true
1307
- : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
2162
+ if (handle.key !== CAP_KEY) {
2163
+ await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`, handle.child.pid)).catch((error) => {
2164
+ console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
2165
+ });
2166
+ }
1308
2167
  if (!childExited) {
1309
2168
  throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1310
2169
  }
1311
- if (!terminalStopped) {
1312
- throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1313
- }
1314
2170
  this.childHandles.delete(handle);
1315
2171
  })();
1316
2172
  handle.termination = attempt;
1317
2173
  void attempt.then(() => {
1318
- this.finishAllTerminalInputHandoffs(handle);
2174
+ this.finishTerminalWrites(handle);
1319
2175
  }, () => {
1320
2176
  if (handle.termination === attempt)
1321
2177
  delete handle.termination;
1322
- handle.terminalCleanupRetryFailures += 1;
1323
- // A failed cleanup must not pin closeAndDrain forever. Keep the
1324
- // submission in the daemon activity snapshot, but release its gate
1325
- // reservation so maintenance returns a structured busy result.
1326
- this.preserveTerminalInputHandoffsAsActivity(handle);
1327
- this.scheduleTerminalInputCleanupRetry(handle);
2178
+ // An unexited dead handle remains visible in pendingNativeOperationCount.
2179
+ this.finishTerminalWrites(handle);
1328
2180
  });
1329
2181
  return attempt;
1330
2182
  }
1331
2183
  reapIdle() {
2184
+ if (this.reapPromise)
2185
+ return this.reapPromise;
2186
+ const attempt = this.reapIdleOnce();
2187
+ this.reapPromise = attempt;
2188
+ const clear = () => {
2189
+ if (this.reapPromise === attempt)
2190
+ this.reapPromise = null;
2191
+ };
2192
+ void attempt.then(clear, clear);
2193
+ return attempt;
2194
+ }
2195
+ async reapIdleOnce() {
1332
2196
  const now = this.now();
1333
2197
  for (const [key, handle] of this.handles) {
1334
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
2198
+ if (this.isManagedNativeHandle(handle)) {
2199
+ await this.reapNativePane(handle, now);
1335
2200
  continue;
1336
2201
  }
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)) {
2202
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1340
2203
  continue;
1341
2204
  }
1342
- if (now - handle.lastUsedAt < this.idleTtlMs) {
2205
+ const idleForMs = now - handle.lastUsedAt;
2206
+ if (idleForMs < this.idleTtlMs) {
1343
2207
  continue;
1344
2208
  }
1345
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
2209
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
2210
+ if (hasActiveResponse)
2211
+ continue;
2212
+ const reason = this.liveSessionKeys.has(key)
2213
+ ? "idle live runner reaped"
2214
+ : "idle runner reaped";
2215
+ void this.terminateHandle(handle, reason).catch((error) => {
1346
2216
  logTerminationFailure(handle, error);
1347
2217
  });
1348
2218
  }
1349
2219
  }
2220
+ /** Provider-authoritative turn state is the lifecycle truth; attached clients
2221
+ * and tmux's own activity clock are independent busy evidence. There is no
2222
+ * "user was inactive for an hour" override for an active turn. */
2223
+ async reapNativePane(handle, now) {
2224
+ if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
2225
+ return;
2226
+ if (await this.isNativePaneBusy(handle)) {
2227
+ handle.nativePaneLastBusyAt = now;
2228
+ return;
2229
+ }
2230
+ const lastBusyAt = handle.nativePaneLastBusyAt;
2231
+ if (lastBusyAt === undefined) {
2232
+ // First idle observation starts a full grace window.
2233
+ handle.nativePaneLastBusyAt = now;
2234
+ return;
2235
+ }
2236
+ if (now - lastBusyAt < this.nativePaneIdleTtlMs)
2237
+ return;
2238
+ // Close the select→reap race: activity may begin after classification but
2239
+ // before teardown.
2240
+ if (await this.isNativePaneBusy(handle)) {
2241
+ handle.nativePaneLastBusyAt = this.now();
2242
+ return;
2243
+ }
2244
+ // A failed teardown must re-arm on the next scan instead of retrying on
2245
+ // every sweep forever.
2246
+ delete handle.nativePaneLastBusyAt;
2247
+ await this.reapNativeTerminal(handle).catch((error) => {
2248
+ logTerminationFailure(handle, error);
2249
+ });
2250
+ }
2251
+ reapNativeTerminal(handle) {
2252
+ const localThreadId = this.currentTerminalSessionId(handle, handle.key);
2253
+ const terminalId = `${localThreadId}-main`;
2254
+ const reqId = randomUUID();
2255
+ return new Promise((resolve, reject) => {
2256
+ const timeout = setTimeout(() => {
2257
+ if (!handle.live.delete(reqId))
2258
+ return;
2259
+ reject(new Error(`runner did not acknowledge idle terminal reap within ${this.liveReadyTimeoutMs}ms`));
2260
+ }, this.liveReadyTimeoutMs);
2261
+ timeout.unref?.();
2262
+ handle.live.set(reqId, (reply) => {
2263
+ clearTimeout(timeout);
2264
+ if (reply.ok) {
2265
+ resolve();
2266
+ return;
2267
+ }
2268
+ reject(fromWireError(reply.error ?? {
2269
+ message: "idle terminal reap failed",
2270
+ code: "terminal_reap_failed",
2271
+ statusCode: 500,
2272
+ }));
2273
+ });
2274
+ handle.transport.send({
2275
+ t: "terminal.reap",
2276
+ reqId,
2277
+ localThreadId,
2278
+ terminalId,
2279
+ });
2280
+ });
2281
+ }
2282
+ async isNativePaneBusy(handle) {
2283
+ if (handle.activeResponseIds.size > 0 ||
2284
+ handle.caps.size > 0 ||
2285
+ handle.live.size > 0 ||
2286
+ handle.terminals.size > 0) {
2287
+ return true;
2288
+ }
2289
+ const terminalName = `${handle.key}-main`;
2290
+ try {
2291
+ if (await this.terminalHasAttachedClient(terminalName, handle.child.pid))
2292
+ return true;
2293
+ }
2294
+ catch {
2295
+ // A failed probe contributes no busy evidence; check activity next.
2296
+ }
2297
+ let activityAt = null;
2298
+ try {
2299
+ activityAt = await this.terminalWindowActivityAt(terminalName, handle.child.pid);
2300
+ }
2301
+ catch {
2302
+ // A failed probe contributes no busy evidence.
2303
+ }
2304
+ return activityAt !== null &&
2305
+ this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
2306
+ }
2307
+ isManagedNativeHandle(handle) {
2308
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2309
+ const provider = this.liveOptions.get(sessionId)?.execution.provider;
2310
+ return isManagedNativeProvider(provider);
2311
+ }
2312
+ failActiveResponses(handle, code, message) {
2313
+ const responseIds = [...handle.activeResponseIds];
2314
+ handle.activeResponseIds.clear();
2315
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2316
+ for (const responseId of responseIds) {
2317
+ rememberTerminalResponse(handle, responseId);
2318
+ this.emitCompensatingMirror(handle, sessionId, {
2319
+ type: "response.failed",
2320
+ responseId,
2321
+ error: {
2322
+ source: "execution",
2323
+ code,
2324
+ message,
2325
+ },
2326
+ });
2327
+ }
2328
+ }
2329
+ interruptActiveResponses(handle) {
2330
+ const responseIds = [...handle.activeResponseIds];
2331
+ handle.activeResponseIds.clear();
2332
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
2333
+ for (const responseId of responseIds) {
2334
+ this.emitCompensatingMirror(handle, sessionId, {
2335
+ type: "session.interrupted",
2336
+ sessionId,
2337
+ responseId,
2338
+ });
2339
+ }
2340
+ }
2341
+ /** Crash/termination compensation is intentionally best-effort because it is
2342
+ * a terminal/status edge rather than an ordinary transcript delivery.
2343
+ * Still consume an asynchronous persistence rejection so a dying child
2344
+ * cannot take the parent daemon down with an unhandled rejection. */
2345
+ emitCompensatingMirror(handle, sessionId, event) {
2346
+ if (!this.mirrorListener)
2347
+ return;
2348
+ try {
2349
+ const result = this.mirrorListener(sessionId, event);
2350
+ if (result && typeof result.then === "function") {
2351
+ void Promise.resolve(result).catch((error) => {
2352
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2353
+ });
2354
+ }
2355
+ }
2356
+ catch (error) {
2357
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2358
+ }
2359
+ }
2360
+ /** Track the provider-authoritative response lifecycle. Native-pane busy
2361
+ * classification consumes this level directly; output volume is separately
2362
+ * grounded in tmux's own activity clock. */
2363
+ observeHandleRuntimeEvent(handle, event) {
2364
+ switch (event.type) {
2365
+ case "response.created":
2366
+ case "response.output_text.delta":
2367
+ case "response.reasoning_summary_text.delta":
2368
+ case "response.function_call_output.delta":
2369
+ case "response.output_item.done":
2370
+ if (handle.terminalResponseIds.has(event.responseId))
2371
+ return;
2372
+ handle.activeResponseIds.add(event.responseId);
2373
+ return;
2374
+ case "session.interaction.requested":
2375
+ if (handle.terminalResponseIds.has(event.responseId))
2376
+ return;
2377
+ handle.activeResponseIds.add(event.responseId);
2378
+ return;
2379
+ case "response.completed":
2380
+ case "response.failed":
2381
+ case "session.interrupted":
2382
+ handle.activeResponseIds.delete(event.responseId);
2383
+ rememberTerminalResponse(handle, event.responseId);
2384
+ return;
2385
+ case "session.status":
2386
+ if (event.status === "running" && event.responseId) {
2387
+ if (handle.terminalResponseIds.has(event.responseId))
2388
+ return;
2389
+ handle.activeResponseIds.add(event.responseId);
2390
+ }
2391
+ else if (event.status !== "running") {
2392
+ if (event.responseId) {
2393
+ handle.activeResponseIds.delete(event.responseId);
2394
+ rememberTerminalResponse(handle, event.responseId);
2395
+ }
2396
+ else {
2397
+ for (const responseId of handle.activeResponseIds) {
2398
+ rememberTerminalResponse(handle, responseId);
2399
+ }
2400
+ handle.activeResponseIds.clear();
2401
+ }
2402
+ }
2403
+ return;
2404
+ case "session.rotated":
2405
+ handle.activeResponseIds.clear();
2406
+ return;
2407
+ default:
2408
+ return;
2409
+ }
2410
+ }
2411
+ isStaleMirrorGeneration(handle, generation) {
2412
+ const currentGeneration = handle.pendingRotation?.generation ??
2413
+ handle.completedRotation?.generation ??
2414
+ 0;
2415
+ return generation < currentGeneration;
2416
+ }
1350
2417
  openSessionContext(sessionId) {
1351
2418
  if (!this.sessionContextProvider)
1352
2419
  return undefined;
@@ -1365,69 +2432,15 @@ export class RunnerManager {
1365
2432
  }
1366
2433
  }
1367
2434
  }
1368
- function trackedTerminalSubmissions(tracker, data) {
1369
- const pasteStart = "\u001b[200~";
1370
- const pasteEnd = "\u001b[201~";
1371
- const input = tracker.pendingEscape + data;
1372
- tracker.pendingEscape = "";
1373
- const submissions = [];
1374
- for (let index = 0; index < input.length; index += 1) {
1375
- const character = input[index] ?? "";
1376
- if (character === "\u001b") {
1377
- const remaining = input.slice(index);
1378
- if (remaining.startsWith(pasteStart)) {
1379
- tracker.bracketedPaste = true;
1380
- tracker.previousWasCarriageReturn = false;
1381
- index += pasteStart.length - 1;
1382
- continue;
1383
- }
1384
- if (remaining.startsWith(pasteEnd)) {
1385
- tracker.bracketedPaste = false;
1386
- tracker.previousWasCarriageReturn = false;
1387
- index += pasteEnd.length - 1;
1388
- continue;
1389
- }
1390
- if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
1391
- tracker.pendingEscape = remaining;
1392
- break;
1393
- }
1394
- }
1395
- if (tracker.bracketedPaste) {
1396
- tracker.buffer += character;
1397
- tracker.previousWasCarriageReturn = false;
1398
- continue;
1399
- }
1400
- if (character === "\n" && tracker.previousWasCarriageReturn) {
1401
- tracker.previousWasCarriageReturn = false;
1402
- continue;
1403
- }
1404
- tracker.previousWasCarriageReturn = character === "\r";
1405
- if (character === "\r" || character === "\n") {
1406
- const command = tracker.buffer.trim();
1407
- tracker.buffer = "";
1408
- if (command.length > 0 &&
1409
- (command.includes("\u001b") ||
1410
- !command.startsWith("/") ||
1411
- /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1412
- submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1413
- }
1414
- continue;
1415
- }
1416
- if (character === "\b" || character === "\u007f") {
1417
- tracker.buffer = tracker.buffer.slice(0, -1);
1418
- continue;
1419
- }
1420
- if (character === "\u0015" || character === "\u0003") {
1421
- tracker.buffer = "";
1422
- continue;
1423
- }
1424
- // Printable text and tabs are enough to distinguish work-producing prompts
1425
- // from local TUI slash commands. Other control sequences are ignored.
1426
- if (character === "\u001b" || character === "\t" || character >= " ") {
1427
- tracker.buffer += character;
1428
- }
2435
+ function rememberTerminalResponse(handle, responseId) {
2436
+ handle.terminalResponseIds.delete(responseId);
2437
+ handle.terminalResponseIds.add(responseId);
2438
+ while (handle.terminalResponseIds.size > MAX_TERMINAL_RESPONSE_IDS) {
2439
+ const oldest = handle.terminalResponseIds.values().next().value;
2440
+ if (!oldest)
2441
+ break;
2442
+ handle.terminalResponseIds.delete(oldest);
1429
2443
  }
1430
- return submissions;
1431
2444
  }
1432
2445
  function validateSessionContext(context) {
1433
2446
  if (!context || typeof context !== "object") {
@@ -1503,9 +2516,61 @@ function closeSessionContext(context) {
1503
2516
  }));
1504
2517
  }
1505
2518
  }
2519
+ function isDetachedGeneratedImageEvent(event) {
2520
+ if (event.type !== "response.output_item.done" ||
2521
+ event.item.type !== "function_call_output") {
2522
+ return false;
2523
+ }
2524
+ const data = event.item.data;
2525
+ return Boolean(data.__rynxGeneratedImage && data.__rynxGeneratedImage.result === undefined);
2526
+ }
2527
+ function attachGeneratedImageResult(event, result) {
2528
+ if (!isDetachedGeneratedImageEvent(event))
2529
+ return undefined;
2530
+ const outputEvent = event;
2531
+ const item = outputEvent.item;
2532
+ if (item.type !== "function_call_output")
2533
+ return undefined;
2534
+ const data = item.data;
2535
+ return {
2536
+ ...outputEvent,
2537
+ item: {
2538
+ ...item,
2539
+ data: {
2540
+ ...data,
2541
+ __rynxGeneratedImage: {
2542
+ ...data.__rynxGeneratedImage,
2543
+ result,
2544
+ },
2545
+ },
2546
+ },
2547
+ };
2548
+ }
1506
2549
  function errorMessage(error) {
1507
2550
  return error instanceof Error ? error.message : String(error);
1508
2551
  }
2552
+ function mirrorNackClassification(error) {
2553
+ if (typeof error === "object" && error !== null &&
2554
+ error.mirrorClassification === "ambiguous")
2555
+ return "ambiguous";
2556
+ const statusCode = error instanceof AgentRuntimeError
2557
+ ? error.statusCode
2558
+ : typeof error === "object" && error !== null &&
2559
+ typeof error.statusCode === "number"
2560
+ ? error.statusCode
2561
+ : undefined;
2562
+ return statusCode !== undefined &&
2563
+ statusCode >= 400 && statusCode < 500 &&
2564
+ ![408, 409, 425, 429].includes(statusCode)
2565
+ ? "permanent"
2566
+ : "transient";
2567
+ }
2568
+ function mirrorAbandonToken(error) {
2569
+ if (typeof error !== "object" || error === null)
2570
+ return undefined;
2571
+ const token = error.abandonToken;
2572
+ return typeof token === "string" && token.length > 0 ? token : undefined;
2573
+ }
1509
2574
  function defaultRunnerEntry() {
1510
2575
  if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
1511
2576
  return process.env.RYNX_RUNNER_ENTRY.trim();
@@ -1580,6 +2645,18 @@ function logTerminationFailure(handle, error) {
1580
2645
  error: error instanceof Error ? error.message : String(error),
1581
2646
  }));
1582
2647
  }
2648
+ function logCompensatingMirrorFailure(handle, sessionId, eventType, error) {
2649
+ console.error(JSON.stringify({
2650
+ level: "error",
2651
+ type: "runner",
2652
+ event: "compensating_mirror_failed",
2653
+ key: handle.key,
2654
+ sessionId,
2655
+ eventType,
2656
+ pid: handle.child.pid,
2657
+ error: errorMessage(error),
2658
+ }));
2659
+ }
1583
2660
  function capabilityForkResult(result) {
1584
2661
  return result.ok
1585
2662
  ? { ok: true }