@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39

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.
@@ -26,6 +26,7 @@ import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } fro
26
26
  import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, fromWireError, } from "./protocol.js";
27
27
  import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
28
28
  import { StdioRunnerTransport } from "./transport.js";
29
+ import { cloneClaudeTranscript } from "../claude/transcript-clone.js";
29
30
  /** Routing key for the shared capability runner (slash-command RPCs). */
30
31
  const CAP_KEY = "__cap__";
31
32
  /** Max stderr lines retained per handle for the crash exit-report tail. */
@@ -57,6 +58,7 @@ const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
57
58
  const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
58
59
  const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
59
60
  const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
61
+ const MAX_TERMINAL_RESPONSE_IDS = 2_000;
60
62
  /** A typed attach failure so transport adapters can distinguish an expected
61
63
  * absent pane from an infrastructure failure without matching error strings. */
62
64
  export class TerminalOpenError extends Error {
@@ -295,6 +297,12 @@ export class RunnerManager {
295
297
  stopPromise;
296
298
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
297
299
  mirrorListener = null;
300
+ /** Settles process-local projections only when an ordinary durable item has
301
+ * exhausted its permanent retry budget. */
302
+ mirrorAbandonListener = null;
303
+ /** Clears process-live optimistic state when /clear permanently moves the
304
+ * native pane away from the old Session. */
305
+ mirrorSupersedeListener = null;
298
306
  /** Synchronous persistence hook for native collaboration-mode reflection.
299
307
  * It runs before the event reaches the Session bus. */
300
308
  collaborationModeListener = null;
@@ -467,6 +475,12 @@ export class RunnerManager {
467
475
  onMirror(listener) {
468
476
  this.mirrorListener = listener;
469
477
  }
478
+ onMirrorAbandon(listener) {
479
+ this.mirrorAbandonListener = listener;
480
+ }
481
+ onMirrorSupersede(listener) {
482
+ this.mirrorSupersedeListener = listener;
483
+ }
470
484
  onCollaborationMode(listener) {
471
485
  this.collaborationModeListener = listener;
472
486
  }
@@ -553,7 +567,7 @@ export class RunnerManager {
553
567
  if (candidate === handle && this.liveSessionKeys.has(sessionId))
554
568
  return sessionId;
555
569
  }
556
- return fallback;
570
+ return handle.activeSessionId || fallback;
557
571
  }
558
572
  finishTerminalInputHandoff(handle, handoff) {
559
573
  if (handoff.timer) {
@@ -750,6 +764,14 @@ export class RunnerManager {
750
764
  .finally(() => admission.release());
751
765
  }
752
766
  injectMessageAdmitted(localThreadId, input, options) {
767
+ const rotatingHandle = this.handles.get(localThreadId);
768
+ if (rotatingHandle?.pendingRotation?.from === localThreadId &&
769
+ !rotatingHandle.dead) {
770
+ // Reject input addressed to the old active Session during
771
+ // cutover. Do not wait and then silently reopen the retired Session: the
772
+ // user can retry after the client follows session.rotated.
773
+ return Promise.resolve({ outcome: "notReady" });
774
+ }
753
775
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
754
776
  if (sourceReservation) {
755
777
  return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
@@ -1010,6 +1032,7 @@ export class RunnerManager {
1010
1032
  this.liveErrors.clear();
1011
1033
  this.liveOptions.clear();
1012
1034
  this.mirrorListener = null;
1035
+ this.mirrorAbandonListener = null;
1013
1036
  this.rotateListener = null;
1014
1037
  const errors = results
1015
1038
  .filter((result) => result.status === "rejected")
@@ -1131,29 +1154,67 @@ export class RunnerManager {
1131
1154
  }
1132
1155
  async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
1133
1156
  const source = await this.sessionStore.get(currentLocalThreadId);
1157
+ const getIntent = this.sessionStore.getClaudeForkIntent?.bind(this.sessionStore);
1134
1158
  const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
1135
1159
  const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
1136
- if (!source?.codexSessionId || !setIntent || !deleteIntent) {
1160
+ if (!source?.codexSessionId || !getIntent || !setIntent || !deleteIntent) {
1137
1161
  return {
1138
1162
  ok: false,
1139
1163
  reason: "unsupported",
1140
1164
  message: "Claude fork persistence is unavailable",
1141
1165
  };
1142
1166
  }
1143
- const targetClaudeSessionId = randomUUID();
1167
+ const persistedIntent = await getIntent(newLocalThreadId);
1168
+ if (persistedIntent &&
1169
+ persistedIntent.sourceSessionId !== currentLocalThreadId) {
1170
+ return {
1171
+ ok: false,
1172
+ reason: "error",
1173
+ message: "target Session already has a different Claude fork intent",
1174
+ };
1175
+ }
1176
+ // A durable fork operation may be replayed after clone+intent persistence
1177
+ // but before SessionStart commits the Provider binding. Reuse that exact
1178
+ // target id/path/prefix: re-cloning would move the fork boundary if the
1179
+ // source advanced (or silently lose history if it disappeared).
1180
+ const targetClaudeSessionId = persistedIntent?.targetClaudeSessionId ?? randomUUID();
1144
1181
  try {
1145
- await setIntent({
1146
- targetSessionId: newLocalThreadId,
1147
- sourceSessionId: currentLocalThreadId,
1148
- sourceClaudeSessionId: source.codexSessionId,
1149
- targetClaudeSessionId,
1150
- updatedAt: new Date().toISOString(),
1151
- });
1152
- const ready = await this.requestLiveSession(newLocalThreadId, {
1182
+ if (!persistedIntent) {
1183
+ let clonedTranscript = null;
1184
+ try {
1185
+ clonedTranscript = await cloneClaudeTranscript({
1186
+ sourceClaudeSessionId: source.codexSessionId,
1187
+ targetClaudeSessionId,
1188
+ targetCwd: options.workspace.cwd,
1189
+ });
1190
+ }
1191
+ catch (error) {
1192
+ console.error(`[claude-fork] could not clone source transcript for ${newLocalThreadId}; target will start without Provider history: ${error instanceof Error ? error.message : String(error)}`);
1193
+ }
1194
+ await setIntent({
1195
+ targetSessionId: newLocalThreadId,
1196
+ sourceSessionId: currentLocalThreadId,
1197
+ sourceClaudeSessionId: source.codexSessionId,
1198
+ targetClaudeSessionId,
1199
+ ...(clonedTranscript
1200
+ ? {
1201
+ forkTranscriptPath: clonedTranscript.transcriptPath,
1202
+ ...(clonedTranscript.prefixBytes === undefined
1203
+ ? {}
1204
+ : { forkTranscriptPrefixBytes: clonedTranscript.prefixBytes }),
1205
+ }
1206
+ : {}),
1207
+ updatedAt: new Date().toISOString(),
1208
+ });
1209
+ }
1210
+ await this.requestLiveSession(newLocalThreadId, {
1153
1211
  workspace: structuredClone(options.workspace),
1154
1212
  execution: structuredClone(options.execution),
1155
1213
  }, true, true);
1156
- const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
1214
+ // SessionStart commits the binding before the child reports live.ready.
1215
+ // If that final ACK is lost (or the child exits in between), the durable
1216
+ // binding is authoritative and must not be rolled back.
1217
+ const target = await this.sessionStore.get(newLocalThreadId);
1157
1218
  if (!target ||
1158
1219
  target.parentSessionId !== currentLocalThreadId ||
1159
1220
  target.codexSessionId !== targetClaudeSessionId) {
@@ -1163,10 +1224,8 @@ export class RunnerManager {
1163
1224
  return { ok: true, data: undefined };
1164
1225
  }
1165
1226
  catch (error) {
1166
- await deleteIntent(newLocalThreadId).catch(() => undefined);
1167
- const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
1168
- if (target?.parentSessionId === currentLocalThreadId) {
1169
- await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
1227
+ if (!persistedIntent) {
1228
+ await deleteIntent(newLocalThreadId).catch(() => undefined);
1170
1229
  }
1171
1230
  const handle = this.handles.get(newLocalThreadId);
1172
1231
  if (handle && !handle.dead) {
@@ -1204,42 +1263,180 @@ export class RunnerManager {
1204
1263
  deliverMirrorMessage(handle, message) {
1205
1264
  if (handle.dead)
1206
1265
  return;
1207
- if (message.event.type === "session.collaboration_mode") {
1208
- this.collaborationModeListener?.(message.sessionId, message.event.mode);
1266
+ if (this.isStaleMirrorGeneration(handle, message.generation)) {
1267
+ if ("deliveryId" in message) {
1268
+ handle.transport.send({
1269
+ t: "mirror.ack",
1270
+ deliveryId: message.deliveryId,
1271
+ generation: message.generation,
1272
+ });
1273
+ }
1274
+ else {
1275
+ handle.imageTransfers.delete(message.transferId);
1276
+ handle.transport.send({
1277
+ t: "mirror.image.ack",
1278
+ transferId: message.transferId,
1279
+ seq: message.seq,
1280
+ generation: message.generation,
1281
+ });
1282
+ }
1283
+ return;
1209
1284
  }
1210
- this.observeHandleRuntimeEvent(handle, message.event);
1285
+ if ("deliveryId" in message) {
1286
+ const existing = handle.mirrorDeliveries.get(message.deliveryId);
1287
+ if (existing && existing.sessionId !== message.sessionId) {
1288
+ void this.terminateHandle(handle, "conflicting ordinary mirror delivery replay").catch((error) => logTerminationFailure(handle, error));
1289
+ return;
1290
+ }
1291
+ if (!existing) {
1292
+ handle.mirrorDeliveries.set(message.deliveryId, {
1293
+ sessionId: message.sessionId,
1294
+ abandonRequested: false,
1295
+ });
1296
+ }
1297
+ }
1298
+ let observationCommitted = false;
1299
+ const commitObservation = () => {
1300
+ if (observationCommitted)
1301
+ return;
1302
+ observationCommitted = true;
1303
+ if (this.isStaleMirrorGeneration(handle, message.generation))
1304
+ return;
1305
+ if (message.event.type === "session.collaboration_mode") {
1306
+ this.collaborationModeListener?.(message.sessionId, message.event.mode);
1307
+ }
1308
+ this.observeHandleRuntimeEvent(handle, message.event);
1309
+ if (message.event.type === "response.created") {
1310
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1311
+ }
1312
+ };
1313
+ const commitDeliveredObservation = () => {
1314
+ commitObservation();
1315
+ if (handle.dead) {
1316
+ // The listener may have persisted/observed this event before its
1317
+ // Promise settled, while failHandle still saw no committed active id.
1318
+ // Close that newly visible response instead of leaving server runtime
1319
+ // state permanently running.
1320
+ this.failActiveResponses(handle, "runner_crashed", handle.failureReason ?? "runner exited during mirror publication");
1321
+ }
1322
+ };
1211
1323
  let delivered;
1212
1324
  try {
1213
- delivered = Promise.resolve(this.mirrorListener?.(message.sessionId, message.event));
1325
+ const result = this.mirrorListener?.(message.sessionId, message.event);
1326
+ if (result && typeof result.then === "function") {
1327
+ delivered = Promise.resolve(result);
1328
+ }
1329
+ else {
1330
+ commitObservation();
1331
+ delivered = Promise.resolve();
1332
+ }
1214
1333
  }
1215
1334
  catch (error) {
1216
1335
  delivered = Promise.reject(error);
1217
1336
  }
1218
- if (message.event.type === "response.created") {
1219
- // The listener projects response.created into SessionRuntimeIndex
1220
- // synchronously. Only then may the accepted terminal reservation drain
1221
- // into a maintenance activity snapshot.
1222
- this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1223
- }
1224
- if (message.transferId && message.seq !== undefined) {
1337
+ if ("transferId" in message) {
1338
+ const transfer = handle.imageTransfers.get(message.transferId);
1339
+ if (transfer)
1340
+ transfer.publication = delivered;
1225
1341
  void delivered.then(() => {
1342
+ commitDeliveredObservation();
1226
1343
  if (!handle.dead) {
1344
+ if (handle.imageTransfers.get(message.transferId) === transfer) {
1345
+ handle.imageTransfers.delete(message.transferId);
1346
+ }
1227
1347
  handle.transport.send({
1228
1348
  t: "mirror.image.ack",
1229
1349
  transferId: message.transferId,
1230
1350
  seq: message.seq,
1351
+ generation: message.generation,
1231
1352
  });
1232
1353
  }
1233
1354
  }, (error) => {
1234
- this.failMirrorImageTransfer(handle, `generated image publication failed: ${errorMessage(error)}`);
1355
+ if (handle.dead)
1356
+ return;
1357
+ if (handle.imageTransfers.get(message.transferId) === transfer && transfer) {
1358
+ delete transfer.publication;
1359
+ delete transfer.commitSeq;
1360
+ }
1361
+ handle.transport.send({
1362
+ t: "mirror.image.nack",
1363
+ transferId: message.transferId,
1364
+ seq: message.seq,
1365
+ generation: message.generation,
1366
+ classification: mirrorNackClassification(error),
1367
+ message: errorMessage(error),
1368
+ });
1235
1369
  });
1236
1370
  }
1237
1371
  else {
1238
- void delivered.catch((error) => {
1239
- console.error(`Session mirror publication failed: ${errorMessage(error)}`);
1372
+ void delivered.then(() => {
1373
+ commitDeliveredObservation();
1374
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1375
+ if (!handle.dead) {
1376
+ handle.transport.send({
1377
+ t: "mirror.ack",
1378
+ deliveryId: message.deliveryId,
1379
+ generation: message.generation,
1380
+ });
1381
+ }
1382
+ }, (error) => {
1383
+ const abandonToken = mirrorAbandonToken(error);
1384
+ if (handle.dead) {
1385
+ // The child can disappear while SQLite publication is still in
1386
+ // flight. A later exact pending-input failure still owns cleanup;
1387
+ // only the wire NACK is impossible after process death.
1388
+ if (abandonToken) {
1389
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1390
+ }
1391
+ return;
1392
+ }
1393
+ {
1394
+ const classification = mirrorNackClassification(error);
1395
+ const delivery = handle.mirrorDeliveries.get(message.deliveryId);
1396
+ if (delivery && abandonToken)
1397
+ delivery.abandonToken = abandonToken;
1398
+ if (classification === "permanent" &&
1399
+ message.attempt >= 3 &&
1400
+ abandonToken) {
1401
+ this.mirrorAbandonListener?.(message.sessionId, abandonToken);
1402
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1403
+ }
1404
+ else if (delivery?.abandonRequested && abandonToken) {
1405
+ this.mirrorAbandonListener?.(delivery.sessionId, abandonToken);
1406
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1407
+ }
1408
+ else if (!abandonToken) {
1409
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1410
+ }
1411
+ else if (delivery && !delivery.cleanupTimer) {
1412
+ // A timely NACK may be retried with a fresh delivery id. Retain
1413
+ // the old association briefly for the timeout/NACK race, then
1414
+ // release it if the child never reports an ambiguous outcome.
1415
+ const timer = setTimeout(() => {
1416
+ this.releaseMirrorDelivery(handle, message.deliveryId);
1417
+ }, 60_000);
1418
+ timer.unref?.();
1419
+ delivery.cleanupTimer = timer;
1420
+ }
1421
+ handle.transport.send({
1422
+ t: "mirror.nack",
1423
+ deliveryId: message.deliveryId,
1424
+ generation: message.generation,
1425
+ classification,
1426
+ message: errorMessage(error),
1427
+ });
1428
+ }
1240
1429
  });
1241
1430
  }
1242
1431
  }
1432
+ releaseMirrorDelivery(handle, deliveryId) {
1433
+ const delivery = handle.mirrorDeliveries.get(deliveryId);
1434
+ if (!delivery)
1435
+ return;
1436
+ if (delivery.cleanupTimer)
1437
+ clearTimeout(delivery.cleanupTimer);
1438
+ handle.mirrorDeliveries.delete(deliveryId);
1439
+ }
1243
1440
  failMirrorImageTransfer(handle, reason) {
1244
1441
  handle.imageTransfers.clear();
1245
1442
  void this.terminateHandle(handle, reason).catch((error) => {
@@ -1247,95 +1444,361 @@ export class RunnerManager {
1247
1444
  });
1248
1445
  }
1249
1446
  deliverRotateMessage(handle, message) {
1250
- if (this.forkReservations.has(message.to) ||
1251
- this.sourceForkReservations.has(message.from)) {
1252
- void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1253
- return;
1254
- }
1255
- try {
1256
- // Rotate the runtime-local context before publishing the new Session
1257
- // alias to routing or observers. This prevents the new identity from
1258
- // briefly inheriting stale context after `/clear` or `/fork`.
1259
- handle.sessionContext?.rotate(message.to);
1260
- }
1261
- catch (error) {
1262
- const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1263
- void this.terminateHandle(handle, reason).catch((terminationError) => {
1264
- logTerminationFailure(handle, terminationError);
1447
+ const generation = message.generation;
1448
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1449
+ handle.completedRotation.generation === generation &&
1450
+ handle.completedRotation.from === message.from &&
1451
+ handle.completedRotation.to === message.to) {
1452
+ handle.transport.send({
1453
+ t: "rotate.ack",
1454
+ rotationId: message.rotationId,
1455
+ generation,
1265
1456
  });
1266
1457
  return;
1267
1458
  }
1268
- let releaseTarget;
1269
- const targetReservation = new Promise((resolve) => {
1270
- releaseTarget = resolve;
1271
- });
1272
- let releaseSource;
1273
- const sourceReservation = new Promise((resolve) => {
1274
- releaseSource = resolve;
1275
- });
1276
- this.forkReservations.set(message.to, targetReservation);
1277
- this.sourceForkReservations.set(message.from, sourceReservation);
1278
- this.forkBufferedMessages.set(message.to, []);
1279
- this.forkBufferedTerminalInputs.set(message.to, []);
1280
- // The physical pane has already rotated. Remove every old routing alias now:
1281
- // a later request for the source must spawn a fresh runner that resumes the
1282
- // source Provider binding, while existing terminal attachments continue to
1283
- // follow the transferred pane.
1284
- for (const [key, candidate] of this.handles) {
1285
- if (candidate !== handle)
1286
- continue;
1287
- this.handles.delete(key);
1288
- this.liveSessionKeys.delete(key);
1289
- this.liveOptions.delete(key);
1290
- }
1291
- const rotation = {
1292
- from: message.from,
1293
- to: message.to,
1294
- kind: message.kind,
1295
- workspace: message.workspace,
1296
- execution: message.execution,
1297
- ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1298
- };
1299
- void Promise.resolve(this.rotateListener?.(rotation))
1300
- .then(() => {
1301
- // The server's rotation listener resolves only after the target Session
1302
- // is published. Release the terminal admission handoff afterwards so a
1303
- // maintenance snapshot cannot observe a gap with neither source work
1304
- // nor the target Session.
1305
- this.settleTerminalRotationHandoff(handle, message.from, message.to);
1306
- if (handle.dead)
1459
+ let pending = handle.pendingRotation;
1460
+ if (pending) {
1461
+ if (pending.rotationId !== message.rotationId ||
1462
+ pending.generation !== generation ||
1463
+ pending.from !== message.from ||
1464
+ pending.to !== message.to ||
1465
+ pending.kind !== message.kind) {
1466
+ void this.terminateHandle(handle, "conflicting Session rotation replay").catch((error) => logTerminationFailure(handle, error));
1467
+ return;
1468
+ }
1469
+ if (pending.publicationComplete) {
1470
+ handle.transport.send({
1471
+ t: "rotate.ack",
1472
+ rotationId: pending.rotationId,
1473
+ generation: pending.generation,
1474
+ });
1475
+ return;
1476
+ }
1477
+ if (pending.inFlight)
1478
+ return;
1479
+ }
1480
+ else {
1481
+ if (this.forkReservations.has(message.to) ||
1482
+ this.sourceForkReservations.has(message.from)) {
1483
+ void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
1307
1484
  return;
1308
- handle.activeResponseIds.clear();
1309
- this.handles.set(message.to, handle);
1310
- this.liveSessionKeys.add(message.to);
1311
- this.liveOptions.set(message.to, {
1312
- workspace: structuredClone(message.workspace),
1313
- execution: structuredClone(message.execution),
1485
+ }
1486
+ try {
1487
+ // Rotate the runtime-local context before publishing the new Session
1488
+ // alias to routing or observers. This prevents the new identity from
1489
+ // briefly inheriting stale context after `/clear` or `/fork`.
1490
+ handle.sessionContext?.rotate(message.to);
1491
+ }
1492
+ catch (error) {
1493
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
1494
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
1495
+ logTerminationFailure(handle, terminationError);
1496
+ });
1497
+ return;
1498
+ }
1499
+ let releaseTarget;
1500
+ const targetReservation = new Promise((resolve) => {
1501
+ releaseTarget = resolve;
1314
1502
  });
1315
- for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
1316
- this.deliverForkBufferedMessage(entry.handle, entry.message);
1503
+ let releaseSource;
1504
+ const sourceReservation = new Promise((resolve) => {
1505
+ releaseSource = resolve;
1506
+ });
1507
+ pending = {
1508
+ rotationId: message.rotationId,
1509
+ generation,
1510
+ from: message.from,
1511
+ to: message.to,
1512
+ kind: message.kind,
1513
+ rotation: {
1514
+ from: message.from,
1515
+ to: message.to,
1516
+ kind: message.kind,
1517
+ workspace: structuredClone(message.workspace),
1518
+ execution: structuredClone(message.execution),
1519
+ ...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
1520
+ },
1521
+ targetReservation,
1522
+ sourceReservation,
1523
+ releaseTarget,
1524
+ releaseSource,
1525
+ inFlight: false,
1526
+ logicalTargetPublished: false,
1527
+ publicationComplete: false,
1528
+ };
1529
+ handle.pendingRotation = pending;
1530
+ this.forkReservations.set(pending.to, targetReservation);
1531
+ this.sourceForkReservations.set(pending.from, sourceReservation);
1532
+ this.forkBufferedMessages.set(pending.to, []);
1533
+ this.forkBufferedTerminalInputs.set(pending.to, []);
1534
+ }
1535
+ pending.inFlight = true;
1536
+ void Promise.resolve().then(async () => {
1537
+ if (pending.logicalTargetPublished)
1538
+ return;
1539
+ await this.rotateListener?.(pending.rotation);
1540
+ if (handle.dead || handle.pendingRotation !== pending)
1541
+ return false;
1542
+ pending.logicalTargetPublished = true;
1543
+ return true;
1544
+ })
1545
+ .then((current) => {
1546
+ if (current === false || handle.dead || handle.pendingRotation !== pending)
1547
+ return false;
1548
+ return this.publishNativeRotationBinding(pending).then(() => true);
1549
+ })
1550
+ .then((current) => {
1551
+ if (current === false || handle.dead || handle.pendingRotation !== pending) {
1552
+ if (handle.pendingRotation === pending) {
1553
+ this.releasePendingNativeRotation(handle, pending);
1554
+ }
1555
+ return;
1317
1556
  }
1318
- for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
1319
- if (!entry.handle.dead)
1320
- entry.handle.transport.send(entry.message);
1557
+ pending.inFlight = false;
1558
+ pending.publicationComplete = true;
1559
+ // Durable publication grants the child permission to move ownership.
1560
+ // Keep both logical ids fenced until rotate.applied proves that the
1561
+ // child has actually transferred its terminal/runtime registries.
1562
+ try {
1563
+ handle.transport.send({
1564
+ t: "rotate.ack",
1565
+ rotationId: pending.rotationId,
1566
+ generation: pending.generation,
1567
+ });
1568
+ }
1569
+ catch (error) {
1570
+ this.releasePendingNativeRotation(handle, pending);
1571
+ void this.terminateHandle(handle, `runner Session rotation commit failed: ${errorMessage(error)}`).catch((terminationError) => {
1572
+ logTerminationFailure(handle, terminationError);
1573
+ });
1321
1574
  }
1322
1575
  })
1323
1576
  .catch((error) => {
1324
- void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
1325
- })
1326
- .finally(() => {
1327
- if (this.forkReservations.get(message.to) === targetReservation) {
1328
- this.forkReservations.delete(message.to);
1577
+ if (handle.pendingRotation !== pending)
1578
+ return;
1579
+ pending.inFlight = false;
1580
+ if (handle.dead) {
1581
+ this.releasePendingNativeRotation(handle, pending);
1582
+ return;
1329
1583
  }
1330
- if (this.sourceForkReservations.get(message.from) === sourceReservation) {
1331
- this.sourceForkReservations.delete(message.from);
1584
+ try {
1585
+ handle.transport.send({
1586
+ t: "rotate.nack",
1587
+ rotationId: pending.rotationId,
1588
+ generation: pending.generation,
1589
+ classification: mirrorNackClassification(error),
1590
+ message: errorMessage(error),
1591
+ });
1332
1592
  }
1333
- this.forkBufferedMessages.delete(message.to);
1334
- this.forkBufferedTerminalInputs.delete(message.to);
1335
- releaseTarget();
1336
- releaseSource();
1593
+ catch (sendError) {
1594
+ this.releasePendingNativeRotation(handle, pending);
1595
+ void this.terminateHandle(handle, `runner Session rotation NACK failed: ${errorMessage(sendError)}`).catch((terminationError) => {
1596
+ logTerminationFailure(handle, terminationError);
1597
+ });
1598
+ }
1599
+ });
1600
+ }
1601
+ /** Bridge ownership is a child-local binding, but the parent is the only
1602
+ * process that knows logical target publication committed. Mark that fact
1603
+ * durably before allowing the child to apply and expose the alias. */
1604
+ async publishNativeRotationBinding(pending) {
1605
+ await this.publishNativeRotationRecords(pending.from, pending.to, pending.kind);
1606
+ }
1607
+ async publishNativeRotationRecords(from, to, kind) {
1608
+ const target = await this.sessionStore.get(to);
1609
+ const source = await this.sessionStore.get(from);
1610
+ if (!target) {
1611
+ throw new AgentRuntimeError("native rotation target binding is missing", 422, "native_rotation_target_missing");
1612
+ }
1613
+ const publishedTarget = {
1614
+ ...target,
1615
+ nativeRotationSourceSessionId: from,
1616
+ nativeRotationKind: kind,
1617
+ nativeRotationPublished: true,
1618
+ updatedAt: new Date().toISOString(),
1619
+ };
1620
+ await this.sessionStore.set(publishedTarget);
1621
+ if (!source)
1622
+ return;
1623
+ await this.sessionStore.set({
1624
+ ...source,
1625
+ bridgeOwnerSessionId: `${from}-${kind}-retired-${to}`,
1626
+ nativeRotationTargetSessionId: to,
1627
+ updatedAt: new Date().toISOString(),
1337
1628
  });
1338
1629
  }
1630
+ deliverRotateAppliedMessage(handle, message) {
1631
+ const pending = handle.pendingRotation;
1632
+ if (pending &&
1633
+ pending.publicationComplete &&
1634
+ pending.rotationId === message.rotationId &&
1635
+ pending.generation === message.generation &&
1636
+ pending.from === message.from &&
1637
+ pending.to === message.to) {
1638
+ void this.finalizeNativeRotation(handle, pending);
1639
+ return;
1640
+ }
1641
+ if (handle.completedRotation?.rotationId === message.rotationId &&
1642
+ handle.completedRotation.generation === message.generation &&
1643
+ handle.completedRotation.from === message.from &&
1644
+ handle.completedRotation.to === message.to) {
1645
+ this.acknowledgeRotateApplied(handle, message.rotationId, message.generation);
1646
+ return;
1647
+ }
1648
+ void this.terminateHandle(handle, "invalid Session rotation applied confirmation").catch((error) => logTerminationFailure(handle, error));
1649
+ }
1650
+ finalizeNativeRotation(handle, pending) {
1651
+ if (pending.finalization)
1652
+ return pending.finalization;
1653
+ const operation = (async () => {
1654
+ if (handle.pendingRotation !== pending)
1655
+ return false;
1656
+ pending.inFlight = true;
1657
+ try {
1658
+ // Change the active Session only after ownership transfer. In
1659
+ // this IPC topology rotate.applied is the child-owned equivalent proof.
1660
+ this.settleTerminalRotationHandoff(handle, pending.from, pending.to);
1661
+ handle.activeResponseIds.clear();
1662
+ for (const [key, candidate] of this.handles) {
1663
+ if (candidate !== handle)
1664
+ continue;
1665
+ this.handles.delete(key);
1666
+ this.liveSessionKeys.delete(key);
1667
+ this.liveOptions.delete(key);
1668
+ }
1669
+ handle.activeSessionId = pending.to;
1670
+ this.handles.set(pending.to, handle);
1671
+ this.liveSessionKeys.add(pending.to);
1672
+ this.liveOptions.set(pending.to, {
1673
+ workspace: structuredClone(pending.rotation.workspace),
1674
+ execution: structuredClone(pending.rotation.execution),
1675
+ });
1676
+ handle.completedRotation = {
1677
+ rotationId: pending.rotationId,
1678
+ generation: pending.generation,
1679
+ from: pending.from,
1680
+ to: pending.to,
1681
+ };
1682
+ // Old-Session presentation is ordered internally but must not hold the
1683
+ // ownership-transfer ACK. Each notice still receives the server queue's
1684
+ // bounded result window; the rotated target can proceed immediately.
1685
+ const noticePublication = this.publishNativeRotationNotice(pending);
1686
+ if (handle.dead)
1687
+ return false;
1688
+ this.acknowledgeRotateApplied(handle, pending.rotationId, pending.generation);
1689
+ for (const entry of this.forkBufferedMessages.get(pending.to) ?? []) {
1690
+ this.deliverForkBufferedMessage(entry.handle, entry.message);
1691
+ }
1692
+ for (const entry of this.forkBufferedTerminalInputs.get(pending.to) ?? []) {
1693
+ if (!entry.handle.dead)
1694
+ entry.handle.transport.send(entry.message);
1695
+ }
1696
+ void noticePublication.catch((error) => {
1697
+ console.error(`Session rotation notice sequence failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1698
+ });
1699
+ return true;
1700
+ }
1701
+ catch (error) {
1702
+ void this.terminateHandle(handle, `runner Session rotation finalization failed: ${errorMessage(error)}`).catch((terminationError) => {
1703
+ logTerminationFailure(handle, terminationError);
1704
+ });
1705
+ return false;
1706
+ }
1707
+ finally {
1708
+ pending.inFlight = false;
1709
+ this.releasePendingNativeRotation(handle, pending);
1710
+ }
1711
+ })();
1712
+ pending.finalization = operation;
1713
+ return operation;
1714
+ }
1715
+ acknowledgeRotateApplied(handle, rotationId, generation) {
1716
+ try {
1717
+ handle.transport.send({ t: "rotate.applied.ack", rotationId, generation });
1718
+ }
1719
+ catch (error) {
1720
+ void this.terminateHandle(handle, `runner Session rotation applied ACK failed: ${errorMessage(error)}`).catch((terminationError) => {
1721
+ logTerminationFailure(handle, terminationError);
1722
+ });
1723
+ }
1724
+ }
1725
+ releasePendingNativeRotation(handle, pending) {
1726
+ if (handle.pendingRotation !== pending)
1727
+ return;
1728
+ delete handle.pendingRotation;
1729
+ if (this.forkReservations.get(pending.to) === pending.targetReservation) {
1730
+ this.forkReservations.delete(pending.to);
1731
+ }
1732
+ if (this.sourceForkReservations.get(pending.from) === pending.sourceReservation) {
1733
+ this.sourceForkReservations.delete(pending.from);
1734
+ }
1735
+ this.forkBufferedMessages.delete(pending.to);
1736
+ this.forkBufferedTerminalInputs.delete(pending.to);
1737
+ pending.releaseTarget();
1738
+ pending.releaseSource();
1739
+ }
1740
+ async publishNativeRotationNotice(pending) {
1741
+ // Only /clear supersedes the old Session. A /fork keeps
1742
+ // both conversations live and therefore emits no old-session redirect.
1743
+ if (pending.kind !== "clear" || pending.from === pending.to)
1744
+ return;
1745
+ this.mirrorSupersedeListener?.(pending.from);
1746
+ const noticeId = `msg_clear_${randomUUID().replaceAll("-", "")}`;
1747
+ const notice = {
1748
+ type: "response.output_item.done",
1749
+ responseId: noticeId,
1750
+ item: {
1751
+ id: noticeId,
1752
+ sessionId: pending.from,
1753
+ position: 0,
1754
+ responseId: noticeId,
1755
+ status: "completed",
1756
+ createdAt: Date.now(),
1757
+ type: "message",
1758
+ data: {
1759
+ role: "assistant",
1760
+ content: [{
1761
+ type: "output_text",
1762
+ text: "This session was ended by `/clear`. " +
1763
+ `Continue in [the new session](../${encodeURIComponent(pending.to)}). ` +
1764
+ "You can also send a message here to resume this session.",
1765
+ }],
1766
+ },
1767
+ },
1768
+ };
1769
+ const transient = {
1770
+ type: "session.rotated",
1771
+ sessionId: pending.from,
1772
+ newSessionId: pending.to,
1773
+ kind: "clear",
1774
+ };
1775
+ // Preserve the post-clear order: stop the old spinner, append a
1776
+ // durable assistant notice, then tell a live viewer to follow the target.
1777
+ const publications = [];
1778
+ for (const event of [
1779
+ {
1780
+ type: "session.status",
1781
+ sessionId: pending.from,
1782
+ status: "idle",
1783
+ backgroundTaskCount: 0,
1784
+ },
1785
+ notice,
1786
+ transient,
1787
+ ]) {
1788
+ try {
1789
+ // The server-side SessionMirrorQueue owns the per-record
1790
+ // per-record result deadline. A second enqueue-time timer here would
1791
+ // expire later records before they reach the head of that queue.
1792
+ publications.push(Promise.resolve(this.mirrorListener?.(pending.from, event)).catch((error) => {
1793
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1794
+ }));
1795
+ }
1796
+ catch (error) {
1797
+ console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
1798
+ }
1799
+ }
1800
+ await Promise.all(publications);
1801
+ }
1339
1802
  spawnHandle(key) {
1340
1803
  const args = this.runnerEntry.endsWith(".ts")
1341
1804
  ? ["--import", "tsx", this.runnerEntry]
@@ -1368,11 +1831,13 @@ export class RunnerManager {
1368
1831
  const transport = new StdioRunnerTransport(child.stdout, child.stdin);
1369
1832
  const handle = {
1370
1833
  key,
1834
+ activeSessionId: key,
1371
1835
  child,
1372
1836
  transport,
1373
1837
  stderr: [],
1374
1838
  lastUsedAt: this.now(),
1375
1839
  activeResponseIds: new Set(),
1840
+ terminalResponseIds: new Set(),
1376
1841
  dead: false,
1377
1842
  completion,
1378
1843
  processGroup,
@@ -1381,6 +1846,7 @@ export class RunnerManager {
1381
1846
  terminals: new Map(),
1382
1847
  live: new Map(),
1383
1848
  imageTransfers: new Map(),
1849
+ mirrorDeliveries: new Map(),
1384
1850
  };
1385
1851
  this.childHandles.add(handle);
1386
1852
  void completion.then(() => this.childHandles.delete(handle));
@@ -1457,13 +1923,65 @@ export class RunnerManager {
1457
1923
  case "mirror":
1458
1924
  if (handle.dead)
1459
1925
  return;
1926
+ if (!msg.deliveryId ||
1927
+ !Number.isSafeInteger(msg.generation) ||
1928
+ !Number.isSafeInteger(msg.attempt) ||
1929
+ msg.attempt < 1) {
1930
+ void this.terminateHandle(handle, "invalid durable mirror delivery frame").catch((error) => logTerminationFailure(handle, error));
1931
+ return;
1932
+ }
1460
1933
  if (this.bufferForkTargetMessage(handle, msg))
1461
1934
  return;
1462
1935
  this.deliverMirrorMessage(handle, msg);
1463
1936
  return;
1937
+ case "mirror.abandon": {
1938
+ if (!msg.deliveryId || !Number.isSafeInteger(msg.generation)) {
1939
+ void this.terminateHandle(handle, "invalid mirror abandon frame").catch((error) => logTerminationFailure(handle, error));
1940
+ return;
1941
+ }
1942
+ const delivery = handle.mirrorDeliveries.get(msg.deliveryId);
1943
+ if (!delivery)
1944
+ return;
1945
+ delivery.abandonRequested = true;
1946
+ if (delivery.abandonToken) {
1947
+ this.mirrorAbandonListener?.(delivery.sessionId, delivery.abandonToken);
1948
+ this.releaseMirrorDelivery(handle, msg.deliveryId);
1949
+ }
1950
+ return;
1951
+ }
1464
1952
  case "mirror.image.begin": {
1953
+ if (!Number.isSafeInteger(msg.generation)) {
1954
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
1955
+ return;
1956
+ }
1957
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
1958
+ handle.transport.send({
1959
+ t: "mirror.image.ack",
1960
+ transferId: msg.transferId,
1961
+ seq: 0,
1962
+ generation: msg.generation,
1963
+ });
1964
+ return;
1965
+ }
1966
+ const existing = handle.imageTransfers.get(msg.transferId);
1967
+ if (existing) {
1968
+ const exactReplay = existing.sessionId === msg.sessionId &&
1969
+ existing.generation === msg.generation &&
1970
+ existing.totalChars === msg.totalChars &&
1971
+ JSON.stringify(existing.event) === JSON.stringify(msg.event);
1972
+ if (!exactReplay) {
1973
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer replay");
1974
+ return;
1975
+ }
1976
+ handle.transport.send({
1977
+ t: "mirror.image.ack",
1978
+ transferId: msg.transferId,
1979
+ seq: 0,
1980
+ generation: msg.generation,
1981
+ });
1982
+ return;
1983
+ }
1465
1984
  if (handle.dead ||
1466
- handle.imageTransfers.has(msg.transferId) ||
1467
1985
  !Number.isSafeInteger(msg.totalChars) ||
1468
1986
  msg.totalChars <= 0 ||
1469
1987
  msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
@@ -1473,17 +1991,48 @@ export class RunnerManager {
1473
1991
  }
1474
1992
  handle.imageTransfers.set(msg.transferId, {
1475
1993
  sessionId: msg.sessionId,
1994
+ generation: msg.generation,
1476
1995
  event: msg.event,
1477
1996
  totalChars: msg.totalChars,
1478
1997
  receivedChars: 0,
1479
1998
  nextSeq: 1,
1480
1999
  chunks: [],
1481
2000
  });
1482
- handle.transport.send({ t: "mirror.image.ack", transferId: msg.transferId, seq: 0 });
2001
+ handle.transport.send({
2002
+ t: "mirror.image.ack",
2003
+ transferId: msg.transferId,
2004
+ seq: 0,
2005
+ generation: msg.generation,
2006
+ });
1483
2007
  return;
1484
2008
  }
1485
2009
  case "mirror.image.chunk": {
2010
+ if (!Number.isSafeInteger(msg.generation)) {
2011
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
2012
+ return;
2013
+ }
2014
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
2015
+ handle.transport.send({
2016
+ t: "mirror.image.ack",
2017
+ transferId: msg.transferId,
2018
+ seq: msg.seq,
2019
+ generation: msg.generation,
2020
+ });
2021
+ return;
2022
+ }
1486
2023
  const transfer = handle.imageTransfers.get(msg.transferId);
2024
+ if (transfer &&
2025
+ msg.seq > 0 &&
2026
+ msg.seq < transfer.nextSeq &&
2027
+ transfer.chunks[msg.seq - 1] === msg.data) {
2028
+ handle.transport.send({
2029
+ t: "mirror.image.ack",
2030
+ transferId: msg.transferId,
2031
+ seq: msg.seq,
2032
+ generation: transfer.generation,
2033
+ });
2034
+ return;
2035
+ }
1487
2036
  if (handle.dead ||
1488
2037
  !transfer ||
1489
2038
  msg.seq !== transfer.nextSeq ||
@@ -1500,10 +2049,25 @@ export class RunnerManager {
1500
2049
  t: "mirror.image.ack",
1501
2050
  transferId: msg.transferId,
1502
2051
  seq: msg.seq,
2052
+ generation: transfer.generation,
1503
2053
  });
1504
2054
  return;
1505
2055
  }
1506
2056
  case "mirror.image.commit": {
2057
+ if (!Number.isSafeInteger(msg.generation)) {
2058
+ this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
2059
+ return;
2060
+ }
2061
+ if (this.isStaleMirrorGeneration(handle, msg.generation)) {
2062
+ handle.imageTransfers.delete(msg.transferId);
2063
+ handle.transport.send({
2064
+ t: "mirror.image.ack",
2065
+ transferId: msg.transferId,
2066
+ seq: msg.seq,
2067
+ generation: msg.generation,
2068
+ });
2069
+ return;
2070
+ }
1507
2071
  const transfer = handle.imageTransfers.get(msg.transferId);
1508
2072
  if (handle.dead ||
1509
2073
  !transfer ||
@@ -1512,7 +2076,16 @@ export class RunnerManager {
1512
2076
  this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
1513
2077
  return;
1514
2078
  }
1515
- handle.imageTransfers.delete(msg.transferId);
2079
+ if (transfer.commitSeq !== undefined) {
2080
+ if (transfer.commitSeq !== msg.seq) {
2081
+ this.failMirrorImageTransfer(handle, "conflicting generated image transfer commit");
2082
+ }
2083
+ // The original publication owns the eventual ACK/NACK. A replay can
2084
+ // arrive after its ACK timer elapsed; that eventual result resolves
2085
+ // the replacement waiter because transferId+seq are stable.
2086
+ return;
2087
+ }
2088
+ transfer.commitSeq = msg.seq;
1516
2089
  const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
1517
2090
  if (!event) {
1518
2091
  this.failMirrorImageTransfer(handle, "generated image transfer target changed");
@@ -1524,26 +2097,47 @@ export class RunnerManager {
1524
2097
  event,
1525
2098
  transferId: msg.transferId,
1526
2099
  seq: msg.seq,
2100
+ generation: transfer.generation,
1527
2101
  };
1528
2102
  if (this.bufferForkTargetMessage(handle, mirrored)) {
1529
2103
  handle.transport.send({
1530
2104
  t: "mirror.image.hold",
1531
2105
  transferId: msg.transferId,
1532
2106
  seq: msg.seq,
2107
+ generation: transfer.generation,
1533
2108
  });
1534
2109
  return;
1535
2110
  }
1536
2111
  this.deliverMirrorMessage(handle, mirrored);
1537
2112
  return;
1538
2113
  }
2114
+ case "mirror.image.abandon": {
2115
+ if (!Number.isSafeInteger(msg.generation))
2116
+ return;
2117
+ const transfer = handle.imageTransfers.get(msg.transferId);
2118
+ if (transfer && transfer.generation === msg.generation) {
2119
+ handle.imageTransfers.delete(msg.transferId);
2120
+ }
2121
+ return;
2122
+ }
1539
2123
  case "rotate": {
1540
2124
  if (handle.dead)
1541
2125
  return;
2126
+ if (!msg.rotationId || !Number.isSafeInteger(msg.generation)) {
2127
+ void this.terminateHandle(handle, "invalid Session rotation frame").catch((error) => logTerminationFailure(handle, error));
2128
+ return;
2129
+ }
1542
2130
  if (this.bufferForkTargetMessage(handle, msg))
1543
2131
  return;
1544
2132
  this.deliverRotateMessage(handle, msg);
1545
2133
  return;
1546
2134
  }
2135
+ case "rotate.applied": {
2136
+ if (handle.dead)
2137
+ return;
2138
+ this.deliverRotateAppliedMessage(handle, msg);
2139
+ return;
2140
+ }
1547
2141
  case "terminal.lifecycle.ended": {
1548
2142
  if (msg.lifecycle === "required") {
1549
2143
  void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
@@ -1589,6 +2183,7 @@ export class RunnerManager {
1589
2183
  return;
1590
2184
  }
1591
2185
  handle.dead = true;
2186
+ handle.failureReason = reason;
1592
2187
  closeOwnedSessionContext(handle);
1593
2188
  // Drop every key mapping to this handle — its launch key AND any rotation
1594
2189
  // aliases (claude `/clear`·`/fork` terminal transfer).
@@ -1601,6 +2196,15 @@ export class RunnerManager {
1601
2196
  }
1602
2197
  }
1603
2198
  }
2199
+ if (handle.pendingRotation) {
2200
+ // A publication already inside its durable binding write owns both
2201
+ // reservations until that Promise settles. Releasing here would let a
2202
+ // replacement source child open against the old binding while the stale
2203
+ // chain can still commit target/source ownership records.
2204
+ if (!handle.pendingRotation.inFlight) {
2205
+ this.releasePendingNativeRotation(handle, handle.pendingRotation);
2206
+ }
2207
+ }
1604
2208
  const tail = handle.stderr.join("\n");
1605
2209
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
1606
2210
  if (opts.failActiveResponses !== false) {
@@ -1623,6 +2227,9 @@ export class RunnerManager {
1623
2227
  handle.terminals.clear();
1624
2228
  handle.live.clear();
1625
2229
  handle.imageTransfers.clear();
2230
+ for (const deliveryId of handle.mirrorDeliveries.keys()) {
2231
+ this.releaseMirrorDelivery(handle, deliveryId);
2232
+ }
1626
2233
  handle.transport.close();
1627
2234
  }
1628
2235
  terminateHandle(handle, reason) {
@@ -1801,7 +2408,8 @@ export class RunnerManager {
1801
2408
  handle.activeResponseIds.clear();
1802
2409
  const sessionId = this.currentTerminalSessionId(handle, handle.key);
1803
2410
  for (const responseId of responseIds) {
1804
- this.mirrorListener?.(sessionId, {
2411
+ rememberTerminalResponse(handle, responseId);
2412
+ this.emitCompensatingMirror(handle, sessionId, {
1805
2413
  type: "response.failed",
1806
2414
  responseId,
1807
2415
  error: {
@@ -1817,13 +2425,32 @@ export class RunnerManager {
1817
2425
  handle.activeResponseIds.clear();
1818
2426
  const sessionId = this.currentTerminalSessionId(handle, handle.key);
1819
2427
  for (const responseId of responseIds) {
1820
- this.mirrorListener?.(sessionId, {
2428
+ this.emitCompensatingMirror(handle, sessionId, {
1821
2429
  type: "session.interrupted",
1822
2430
  sessionId,
1823
2431
  responseId,
1824
2432
  });
1825
2433
  }
1826
2434
  }
2435
+ /** Crash/termination compensation is intentionally best-effort because it is
2436
+ * a terminal/status edge rather than an ordinary transcript delivery.
2437
+ * Still consume an asynchronous persistence rejection so a dying child
2438
+ * cannot take the parent daemon down with an unhandled rejection. */
2439
+ emitCompensatingMirror(handle, sessionId, event) {
2440
+ if (!this.mirrorListener)
2441
+ return;
2442
+ try {
2443
+ const result = this.mirrorListener(sessionId, event);
2444
+ if (result && typeof result.then === "function") {
2445
+ void Promise.resolve(result).catch((error) => {
2446
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2447
+ });
2448
+ }
2449
+ }
2450
+ catch (error) {
2451
+ logCompensatingMirrorFailure(handle, sessionId, event.type, error);
2452
+ }
2453
+ }
1827
2454
  /** Track the provider-authoritative response lifecycle. Native-pane busy
1828
2455
  * classification consumes this level directly; output volume is separately
1829
2456
  * grounded in tmux's own activity clock. */
@@ -1834,25 +2461,38 @@ export class RunnerManager {
1834
2461
  case "response.reasoning_summary_text.delta":
1835
2462
  case "response.function_call_output.delta":
1836
2463
  case "response.output_item.done":
2464
+ if (handle.terminalResponseIds.has(event.responseId))
2465
+ return;
1837
2466
  handle.activeResponseIds.add(event.responseId);
1838
2467
  return;
1839
2468
  case "session.interaction.requested":
2469
+ if (handle.terminalResponseIds.has(event.responseId))
2470
+ return;
1840
2471
  handle.activeResponseIds.add(event.responseId);
1841
2472
  return;
1842
2473
  case "response.completed":
1843
2474
  case "response.failed":
1844
2475
  case "session.interrupted":
1845
2476
  handle.activeResponseIds.delete(event.responseId);
2477
+ rememberTerminalResponse(handle, event.responseId);
1846
2478
  return;
1847
2479
  case "session.status":
1848
2480
  if (event.status === "running" && event.responseId) {
2481
+ if (handle.terminalResponseIds.has(event.responseId))
2482
+ return;
1849
2483
  handle.activeResponseIds.add(event.responseId);
1850
2484
  }
1851
2485
  else if (event.status !== "running") {
1852
- if (event.responseId)
2486
+ if (event.responseId) {
1853
2487
  handle.activeResponseIds.delete(event.responseId);
1854
- else
2488
+ rememberTerminalResponse(handle, event.responseId);
2489
+ }
2490
+ else {
2491
+ for (const responseId of handle.activeResponseIds) {
2492
+ rememberTerminalResponse(handle, responseId);
2493
+ }
1855
2494
  handle.activeResponseIds.clear();
2495
+ }
1856
2496
  }
1857
2497
  return;
1858
2498
  case "session.rotated":
@@ -1862,6 +2502,12 @@ export class RunnerManager {
1862
2502
  return;
1863
2503
  }
1864
2504
  }
2505
+ isStaleMirrorGeneration(handle, generation) {
2506
+ const currentGeneration = handle.pendingRotation?.generation ??
2507
+ handle.completedRotation?.generation ??
2508
+ 0;
2509
+ return generation < currentGeneration;
2510
+ }
1865
2511
  openSessionContext(sessionId) {
1866
2512
  if (!this.sessionContextProvider)
1867
2513
  return undefined;
@@ -1880,6 +2526,16 @@ export class RunnerManager {
1880
2526
  }
1881
2527
  }
1882
2528
  }
2529
+ function rememberTerminalResponse(handle, responseId) {
2530
+ handle.terminalResponseIds.delete(responseId);
2531
+ handle.terminalResponseIds.add(responseId);
2532
+ while (handle.terminalResponseIds.size > MAX_TERMINAL_RESPONSE_IDS) {
2533
+ const oldest = handle.terminalResponseIds.values().next().value;
2534
+ if (!oldest)
2535
+ break;
2536
+ handle.terminalResponseIds.delete(oldest);
2537
+ }
2538
+ }
1883
2539
  function trackedTerminalSubmissions(tracker, data) {
1884
2540
  const pasteStart = "\u001b[200~";
1885
2541
  const pasteEnd = "\u001b[201~";
@@ -1923,8 +2579,8 @@ function trackedTerminalSubmissions(tracker, data) {
1923
2579
  if (command.length > 0 &&
1924
2580
  (command.includes("\u001b") ||
1925
2581
  !command.startsWith("/") ||
1926
- /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1927
- submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
2582
+ /^\/(?:branch|clear|fork)(?:\s|$)/u.test(command))) {
2583
+ submissions.push(/^\/(?:branch|clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1928
2584
  }
1929
2585
  continue;
1930
2586
  }
@@ -2051,6 +2707,28 @@ function attachGeneratedImageResult(event, result) {
2051
2707
  function errorMessage(error) {
2052
2708
  return error instanceof Error ? error.message : String(error);
2053
2709
  }
2710
+ function mirrorNackClassification(error) {
2711
+ if (typeof error === "object" && error !== null &&
2712
+ error.mirrorClassification === "ambiguous")
2713
+ return "ambiguous";
2714
+ const statusCode = error instanceof AgentRuntimeError
2715
+ ? error.statusCode
2716
+ : typeof error === "object" && error !== null &&
2717
+ typeof error.statusCode === "number"
2718
+ ? error.statusCode
2719
+ : undefined;
2720
+ return statusCode !== undefined &&
2721
+ statusCode >= 400 && statusCode < 500 &&
2722
+ ![408, 409, 425, 429].includes(statusCode)
2723
+ ? "permanent"
2724
+ : "transient";
2725
+ }
2726
+ function mirrorAbandonToken(error) {
2727
+ if (typeof error !== "object" || error === null)
2728
+ return undefined;
2729
+ const token = error.abandonToken;
2730
+ return typeof token === "string" && token.length > 0 ? token : undefined;
2731
+ }
2054
2732
  function defaultRunnerEntry() {
2055
2733
  if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
2056
2734
  return process.env.RYNX_RUNNER_ENTRY.trim();
@@ -2125,6 +2803,18 @@ function logTerminationFailure(handle, error) {
2125
2803
  error: error instanceof Error ? error.message : String(error),
2126
2804
  }));
2127
2805
  }
2806
+ function logCompensatingMirrorFailure(handle, sessionId, eventType, error) {
2807
+ console.error(JSON.stringify({
2808
+ level: "error",
2809
+ type: "runner",
2810
+ event: "compensating_mirror_failed",
2811
+ key: handle.key,
2812
+ sessionId,
2813
+ eventType,
2814
+ pid: handle.child.pid,
2815
+ error: errorMessage(error),
2816
+ }));
2817
+ }
2128
2818
  function capabilityForkResult(result) {
2129
2819
  return result.ok
2130
2820
  ? { ok: true }