tmux-ide 2.9.3 → 2.9.5

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +120 -139
  3. package/bin/cli.js +4847 -3585
  4. package/package.json +16 -9
  5. package/packages/contracts/src/session-runtime.ts +2 -0
  6. package/packages/contracts/src/terminal-delivery.ts +4 -0
  7. package/packages/core/src/terminal-compact-capability.ts +31 -1
  8. package/packages/core/src/terminal-delivery.ts +234 -32
  9. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  10. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  11. package/packages/core/src/terminal-replica.ts +43 -21
  12. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  13. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  14. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  20. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  25. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  26. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  32. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  33. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  34. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  35. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  36. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  37. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  41. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  42. package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
  43. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  44. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  45. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  47. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  49. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  50. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  52. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  53. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  55. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  57. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  58. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  59. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  60. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  61. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  62. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  63. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  65. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  66. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  69. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  71. package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
  72. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  73. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  74. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  75. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  76. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  77. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  79. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  80. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  81. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  82. package/packages/daemon-client/src/workspace-client.ts +78 -5
  83. package/packages/tmux-bridge/src/index.ts +1 -0
  84. package/packages/tmux-bridge/src/runner.ts +25 -1
  85. package/skill/SKILL.md +30 -30
@@ -41,7 +41,7 @@ import {
41
41
  * keys flush pending literals first — the shared {@link InputCoalescer}
42
42
  * discipline — leaving fire-and-forget via the channel.
43
43
  */
44
- import { createHash, randomBytes } from "node:crypto";
44
+ import { randomBytes } from "node:crypto";
45
45
  import { hostname } from "node:os";
46
46
  import {
47
47
  WINDOW_LINK_MAX_LINKS,
@@ -105,6 +105,7 @@ import {
105
105
  } from "../../lib/tmux-window-link-guard.ts";
106
106
  import { FlowLedger } from "./flow-ledger.ts";
107
107
  import { PaneFeed, captureLinesFromAnsiBytes, parseCursorProbe } from "./pane-feed.ts";
108
+ import { StockPaneSnapshot, type StockPaneSnapshotContext } from "./stock-pane-snapshot.ts";
108
109
  import type {
109
110
  TrustedMirrorPaneInventory,
110
111
  TrustedMirrorSessionInventory,
@@ -140,20 +141,17 @@ const NATIVE_CLIENT_SUBSCRIPTION = "tmux-ide-native-clients";
140
141
  const SYNC_DEBOUNCE_MS = 40;
141
142
  /** Foreground-command labels follow output, but never turn a busy pane into a probe loop. */
142
143
  const DISPLAY_NAME_SYNC_INTERVAL_MS = 750;
143
- const RECOVERY_QUIET_MS = 40;
144
144
  const RECOVERY_COMMAND_DEADLINE_MS = 500;
145
145
  const RECOVERY_NO_PROGRESS_DEADLINE_MS = 3_000;
146
146
  const RECOVERY_ABSOLUTE_DEADLINE_MS = 5_000;
147
147
  const RECOVERY_MAX_ATTEMPTS = 4;
148
- const MAX_QUEUED_PLAIN_RESEEDS = 64;
148
+ const MAX_QUEUED_SNAPSHOTS = 64;
149
149
  const RECOVERY_CAPTURE_MAX_BYTES = 16 * 1024 * 1024;
150
150
  // Budget row bookkeeping separately from wire bytes (64 bytes per retained
151
151
  // line). A fixed 8192-row ceiling rejected small captures of ordinary history.
152
152
  // Both the wire-byte cap and this finite allocation bound remain enforced.
153
153
  const RECOVERY_CAPTURE_MAX_LINES = RECOVERY_CAPTURE_MAX_BYTES / 64;
154
154
  const RECOVERY_CURSOR_MAX_BYTES = 1_024;
155
- const MAX_CONTINUE_NOTIFICATION_QUEUE = 32;
156
- const MAX_CONTINUE_NOTIFICATION_DEBT = 65_536;
157
155
  const RECOVERY_CURSOR_PROBE_FORMAT = [
158
156
  "#{cursor_x}",
159
157
  "#{cursor_y}",
@@ -269,6 +267,18 @@ export interface SessionChannelOptions {
269
267
  onExit?: () => void;
270
268
  /** Event-driven proof that a non-control tmux client is actively attached. */
271
269
  onNativeClientActivity?: () => void;
270
+ /** Optional diagnostic capture before dispatch; completion must not affect input. */
271
+ captureInputWrite?: (
272
+ action: InputAction,
273
+ semanticPaneId: string | null,
274
+ ) =>
275
+ | ((
276
+ startedAtMicros: number,
277
+ endedAtMicros: number,
278
+ pendingBeforeSend: number,
279
+ paneCurrent: boolean,
280
+ ) => void)
281
+ | undefined;
272
282
  onInputWrite?: (
273
283
  action: InputAction,
274
284
  startedAtMicros: number,
@@ -312,11 +322,6 @@ interface SubRecord {
312
322
  closed: boolean;
313
323
  }
314
324
 
315
- interface PlainReseedLease {
316
- readonly sub: SubRecord;
317
- cancelRecipe: (() => void) | null;
318
- }
319
-
320
325
  interface PaneRecord {
321
326
  historySize?: number;
322
327
  scrollOnClear?: boolean;
@@ -327,18 +332,36 @@ interface PaneRecord {
327
332
  windowRuntimeId: string | null;
328
333
  readonly subs: Set<SubRecord>;
329
334
  incarnation: number;
335
+ snapshotLayoutGeneration: number;
336
+ }
337
+
338
+ interface SnapshotLease {
339
+ readonly recovery: RecoveryRecord;
340
+ retired: boolean;
341
+ retry: boolean;
342
+ wireNonce: string | null;
343
+ fencePending: boolean;
344
+ cancelFence: (() => void) | null;
345
+ cleanup?: () => void;
346
+ ownsPause?: boolean;
330
347
  }
331
348
 
332
349
  interface RecoveryRecord {
350
+ nativeProbeAttempts?: number;
351
+ waitForSyncAfter?: number;
352
+ nativeOwner?: NativeAtomicSnapshotTarget;
353
+ lease?: SnapshotLease;
354
+ stock?: StockPaneSnapshot;
333
355
  readonly ordinal: number;
334
356
  readonly runtimeId: string;
335
357
  readonly paneIncarnation: number;
336
358
  readonly reason: "backpressure" | "requested";
337
- readonly startedAtMs: number;
359
+ startedAtMs: number;
338
360
  retired: boolean;
339
- continueReply: boolean;
340
- continueNotify: boolean;
341
361
  stage:
362
+ | "queued"
363
+ | "stock-pause"
364
+ | "stock-capture"
342
365
  | "native-pause"
343
366
  | "native-capture"
344
367
  | "continue"
@@ -349,9 +372,6 @@ interface RecoveryRecord {
349
372
  | "confirm";
350
373
  attempts: number;
351
374
  reseedOrdinal: number;
352
- outputOrdinal: number;
353
- candidateFingerprint: string | null;
354
- confirmationFingerprint: string | null;
355
375
  confirmationOrdinal: number;
356
376
  atomicCollectorNonce: string | null;
357
377
  collectorStarted: boolean;
@@ -362,7 +382,6 @@ interface RecoveryRecord {
362
382
  collectorStatusObserved: boolean;
363
383
  collectorObserverEmissionObserved: boolean;
364
384
  collectorFailureReason: AtomicPaneSnapshotFailureReason | null;
365
- cancelQuiet: (() => void) | null;
366
385
  cancelCommandDeadline: (() => void) | null;
367
386
  cancelNoProgressDeadline: (() => void) | null;
368
387
  cancelAbsoluteDeadline: (() => void) | null;
@@ -382,52 +401,12 @@ const FAILED_RESEED_RESULT: ReseedResult = Object.freeze({
382
401
  hold: () => {},
383
402
  });
384
403
 
385
- function snapshotFingerprint(
386
- captureLines: readonly string[],
387
- cursorLine: string,
388
- fallbackSize: { cols: number; rows: number } | null,
389
- ): string {
390
- const hash = createHash("sha256");
391
- const append = (bytes: Uint8Array): void => {
392
- const length = Buffer.allocUnsafe(4);
393
- length.writeUInt32BE(bytes.byteLength);
394
- hash.update(length);
395
- hash.update(bytes);
396
- };
397
- hash.update("tmux-ide/recovery-snapshot/v1\0");
398
- const count = Buffer.allocUnsafe(4);
399
- count.writeUInt32BE(captureLines.length);
400
- hash.update(count);
401
- for (const line of captureLines) append(Buffer.from(line, "latin1"));
402
- append(Buffer.from(cursorLine, "utf8"));
403
- append(
404
- Buffer.from(
405
- fallbackSize ? `${fallbackSize.cols}x${fallbackSize.rows}` : "no-layout-fallback",
406
- "ascii",
407
- ),
408
- );
409
- return hash.digest("hex");
410
- }
411
-
412
404
  // tmux concatenates adjacent quoted/unquoted fragments; doubled single quotes
413
405
  // do not escape a quote. Preserve nested commands across both parser passes.
414
406
  function tmuxSingleQuote(value: string): string {
415
407
  return `'${value.replaceAll("'", "'\\''")}'`;
416
408
  }
417
409
 
418
- interface ContinueNotificationOwner {
419
- readonly kind: "owner";
420
- readonly recovery: RecoveryRecord;
421
- }
422
-
423
- interface ContinueNotificationDebt {
424
- readonly kind: "debt";
425
- count: number;
426
- saturated: boolean;
427
- }
428
-
429
- type ContinueNotificationEntry = ContinueNotificationOwner | ContinueNotificationDebt;
430
-
431
410
  interface WindowRecord {
432
411
  runtimeId: string;
433
412
  semanticId: string | null;
@@ -472,7 +451,6 @@ export function defaultMirrorWindowId(): string {
472
451
  export class SessionChannel {
473
452
  private nativeBootstrapUnavailable = false;
474
453
  private nativeBootstrapConfirmed = false;
475
- private readonly nativeProbeRetried = new WeakSet<SubRecord>();
476
454
  private readonly opts: SessionChannelOptions;
477
455
  private readonly io: MirrorChannelIo;
478
456
  private readonly ledger = new FlowLedger();
@@ -504,6 +482,7 @@ export class SessionChannel {
504
482
  private readonly fittedWindows = new Map<string, { cols: number; rows: number }>();
505
483
  private cancelSync: (() => void) | null = null;
506
484
  private syncOrdinal = 0;
485
+ private completedSyncOrdinal = 0;
507
486
  private lastDisplayNameSyncAtMs = 0;
508
487
  private disposed = false;
509
488
  private readonly nativeGrid: NativeGridCaptureReader;
@@ -514,8 +493,9 @@ export class SessionChannel {
514
493
  private windowIdentityOrdinal = 0;
515
494
  private paneIncarnation = 0;
516
495
  private recoveryOrdinal = 0;
517
- private plainReseedActive: PlainReseedLease | null = null;
518
- private readonly plainReseedQueue = new Map<SubRecord, "requested" | null>();
496
+ private readonly snapshotQueue = new Map<string, RecoveryRecord>();
497
+ private snapshotActive: SnapshotLease | null = null;
498
+ private readonly observedPaused = new Set<string>();
519
499
  private readonly outputOrdinals = new Map<string, number>();
520
500
  private readonly pendingLayoutOutput = new Map<
521
501
  string,
@@ -532,7 +512,6 @@ export class SessionChannel {
532
512
  }
533
513
  >();
534
514
  private readonly recoveries = new Map<string, RecoveryRecord>();
535
- private readonly continueNotificationQueues = new Map<string, ContinueNotificationEntry[]>();
536
515
  private trustedInventoryFlight: Promise<TrustedMirrorSessionInventory> | null = null;
537
516
  private trustedInventoryFlightSessionId: string | null = null;
538
517
  private attachedIdentity: { sessionName: string; runtimeSessionId: string } | null = null;
@@ -546,6 +525,18 @@ export class SessionChannel {
546
525
  (action) => {
547
526
  const startedAtMicros = action.traceIds?.length ? Math.floor(performance.now() * 1_000) : 0;
548
527
  const pendingBeforeSend = action.traceIds?.length ? (this.io.pendingCount ?? 0) : 0;
528
+ const pane = action.traceIds?.length ? this.panesByRuntime.get(action.pane) : undefined;
529
+ const semanticPaneId = !this.disposed && pane?.descriptor ? pane.semanticId : null;
530
+ const paneIncarnation = pane?.incarnation;
531
+ const paneActive = pane?.active;
532
+ const birth = pane?.descriptor?.nativePaneBirthId;
533
+ let complete: ReturnType<NonNullable<SessionChannelOptions["captureInputWrite"]>>;
534
+ try {
535
+ if (action.traceIds?.length)
536
+ complete = this.opts.captureInputWrite?.(action, semanticPaneId);
537
+ } catch {
538
+ // Optional diagnostics must never suppress a dispatch.
539
+ }
549
540
  const onReply = action.traceIds?.length
550
541
  ? (reply: { ok: boolean }) =>
551
542
  this.opts.onInputAccepted?.(action, Math.floor(performance.now() * 1_000), reply.ok)
@@ -566,6 +557,26 @@ export class SessionChannel {
566
557
  } else {
567
558
  this.io.send(`send-keys -t ${action.pane} ${action.key}`, onReply);
568
559
  }
560
+ try {
561
+ complete?.(
562
+ startedAtMicros,
563
+ Math.floor(performance.now() * 1_000),
564
+ pendingBeforeSend,
565
+ !this.disposed &&
566
+ pane !== undefined &&
567
+ semanticPaneId !== null &&
568
+ this.panesByRuntime.get(action.pane) === pane &&
569
+ this.panesBySemantic.get(semanticPaneId) === pane &&
570
+ pane.runtimeId === action.pane &&
571
+ pane.semanticId === semanticPaneId &&
572
+ pane.incarnation === paneIncarnation &&
573
+ pane.active === paneActive &&
574
+ pane.descriptor !== null &&
575
+ pane.descriptor.nativePaneBirthId === birth,
576
+ );
577
+ } catch {
578
+ // In particular, never replay an accepted owned-native dispatch.
579
+ }
569
580
  if (action.traceIds?.length)
570
581
  this.opts.onInputWrite?.(
571
582
  action,
@@ -670,7 +681,10 @@ export class SessionChannel {
670
681
  marker = value;
671
682
  },
672
683
  (reply) => {
673
- if (!reply.ok && marker) this.retireInternalReadMarker(runtime, marker);
684
+ if (marker) {
685
+ if (reply.ok) this.clearInternalReadMarkerOption(runtime, marker);
686
+ else this.retireInternalReadMarker(runtime, marker);
687
+ }
674
688
  onReply(reply);
675
689
  },
676
690
  limits,
@@ -1013,6 +1027,7 @@ export class SessionChannel {
1013
1027
  frozen: false,
1014
1028
  closed: false,
1015
1029
  };
1030
+ sub.feed.abortCurrent();
1016
1031
  pane.subs.add(sub);
1017
1032
  // A paused pane gains an unfrozen watcher: release the park before the
1018
1033
  // seed so the capture reflects a flowing pane.
@@ -1069,6 +1084,14 @@ export class SessionChannel {
1069
1084
  };
1070
1085
  }
1071
1086
 
1087
+ qualificationListeners() {
1088
+ return Object.freeze({
1089
+ pane: [...this.panesByRuntime.values()].reduce((sum, pane) => sum + pane.subs.size, 0),
1090
+ layout: this.layoutSubscribers.size,
1091
+ layoutAuthority: this.layoutAuthoritySubscribers.size,
1092
+ });
1093
+ }
1094
+
1072
1095
  /** Session geometry without a dummy pane feed or terminal-content seed. */
1073
1096
  subscribeLayout(onLayout: (event: MirrorLayoutEvent) => void): LayoutSubscriptionHandle {
1074
1097
  if (this.disposed) throw new Error(`mirror session ${this.opts.session} is disposed`);
@@ -1299,13 +1322,16 @@ export class SessionChannel {
1299
1322
  async dispose(): Promise<void> {
1300
1323
  if (this.disposed) return;
1301
1324
  this.disposed = true;
1325
+ this.snapshotActive?.cleanup?.();
1326
+ this.snapshotActive?.cancelFence?.();
1327
+ this.snapshotActive = null;
1328
+ this.snapshotQueue.clear();
1302
1329
  this.windowLinkAuthority?.dispose();
1303
1330
  this.nativeGrid.dispose();
1304
1331
  this.settleFirstJoin();
1305
1332
  this.cancelSync?.();
1306
1333
  this.cancelSync = null;
1307
1334
  for (const runtime of [...this.recoveries.keys()]) this.cancelRecovery(runtime);
1308
- this.continueNotificationQueues.clear();
1309
1335
  this.discovery.dispose();
1310
1336
  this.input.flush();
1311
1337
  this.opts.ownedViewer?.dispose();
@@ -1314,7 +1340,11 @@ export class SessionChannel {
1314
1340
  if (!sub.closed) {
1315
1341
  sub.closed = true;
1316
1342
  sub.cancelCapture?.();
1317
- sub.onEvent({ type: "closed" });
1343
+ try {
1344
+ sub.onEvent({ type: "closed" });
1345
+ } catch {
1346
+ // Close every sibling and the transport even if a consumer throws.
1347
+ }
1318
1348
  }
1319
1349
  }
1320
1350
  pane.subs.clear();
@@ -1334,6 +1364,26 @@ export class SessionChannel {
1334
1364
  ageMs: number | null,
1335
1365
  timing?: MirrorOutputTiming,
1336
1366
  ): void {
1367
+ const stockRecovery = this.recoveries.get(runtimePane);
1368
+ const stockPane = this.panesByRuntime.get(runtimePane);
1369
+ if (stockRecovery?.stock && stockPane && stockRecovery.lease) {
1370
+ const disposition = stockRecovery.stock.acceptOutput(
1371
+ runtimePane,
1372
+ data,
1373
+ this.snapshotContext(stockPane),
1374
+ );
1375
+ if (disposition !== "live" && disposition !== "unrelated") {
1376
+ const ordinal = (this.outputOrdinals.get(runtimePane) ?? 0) + 1;
1377
+ this.outputOrdinals.set(runtimePane, ordinal);
1378
+ this.opts.onOutputObserved?.(stockPane.semanticId, ageMs, timing);
1379
+ if (ageMs !== null) {
1380
+ this.ageByRuntime.set(runtimePane, ageMs);
1381
+ if (ageMs > this.maxAgeMs) this.maxAgeMs = ageMs;
1382
+ }
1383
+ if (disposition === "invalid") this.retrySnapshot(stockRecovery);
1384
+ return;
1385
+ }
1386
+ }
1337
1387
  const windowId = this.panesByRuntime.get(runtimePane)?.windowRuntimeId;
1338
1388
  const pending = windowId ? this.pendingLayoutOutput.get(windowId) : undefined;
1339
1389
  if (pending) {
@@ -1367,334 +1417,29 @@ export class SessionChannel {
1367
1417
  if (sub.feed.takeOverflowed()) overflowed = true;
1368
1418
  }
1369
1419
  if (overflowed) this.restartRecoveryAfterOutputOverflow(pane);
1370
- this.noteRecoveryOutput(pane, outputOrdinal);
1371
1420
  }
1372
1421
 
1373
1422
  // ── Seed / reseed (the atomic recipe) ────────────────────────────────────
1374
1423
 
1375
- private reseed(
1376
- lease: PlainReseedLease,
1377
- onSettled?: (result: ReseedResult) => void,
1378
- deferPublish = false,
1379
- deadlineAt = this.recoveryNowMs() + RECOVERY_ABSOLUTE_DEADLINE_MS,
1380
- ): void {
1381
- const { sub } = lease;
1382
- if (sub.closed || sub.frozen || this.disposed) {
1383
- onSettled?.(FAILED_RESEED_RESULT);
1384
- return;
1385
- }
1386
- lease.cancelRecipe?.();
1387
- const runtime = sub.pane.runtimeId;
1388
- const epoch = sub.feed.beginReseed();
1389
- let settled = false;
1390
- let captureSucceeded = false;
1391
- let markerRetired = false;
1392
- let captureLines: readonly string[] | null = null;
1393
- let capturedNativeSize: { cols: number; rows: number } | null = null;
1394
- let cancelDeadline: (() => void) | null = null;
1395
- let resumeLayoutCapture: ((syncOrdinal?: number) => void) | null = null;
1396
- const clearLayoutWait = () => {
1397
- if (sub.resumeLayoutCapture === resumeLayoutCapture) sub.resumeLayoutCapture = null;
1398
- resumeLayoutCapture = null;
1399
- };
1400
- const settle = (result: ReseedResult) => {
1401
- if (settled) return;
1402
- settled = true;
1403
- clearLayoutWait();
1404
- cancelDeadline?.();
1405
- lease.cancelRecipe = null;
1406
- onSettled?.(result);
1407
- };
1408
- // Keystroke ordering: pending coalesced input leaves before the probes.
1409
- this.input.flush();
1410
- const history = this.opts.historyLines ?? "";
1411
- let internalReadMarker: string | null = null;
1412
- const retireMarker = (): void => {
1413
- if (markerRetired) return;
1414
- markerRetired = true;
1415
- if (internalReadMarker) this.retireInternalReadMarker(runtime, internalReadMarker);
1416
- };
1417
- lease.cancelRecipe = () => {
1418
- if (settled) return;
1419
- settled = true;
1420
- clearLayoutWait();
1421
- cancelDeadline?.();
1422
- lease.cancelRecipe = null;
1423
- sub.feed.abort(epoch);
1424
- if (!captureSucceeded) retireMarker();
1425
- };
1426
- cancelDeadline = this.scheduleRecovery(
1427
- () => {
1428
- if (settled) return;
1429
- sub.feed.abort(epoch);
1430
- if (!captureSucceeded) retireMarker();
1431
- settle(FAILED_RESEED_RESULT);
1432
- },
1433
- Math.max(0, deadlineAt - this.recoveryNowMs()),
1434
- );
1435
- // Keep retired reply slots in the control FIFO; their callbacks become
1436
- // no-ops so late responses cannot publish or consume a newer capture.
1437
- // Both probes ride one write burst; the FIFO reply order is the seam.
1438
- this.captureWithViewer(
1439
- runtime,
1440
- [...(sub.nativeBootstrap ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`],
1441
- (marker) => {
1442
- internalReadMarker = marker;
1443
- },
1444
- (reply) => {
1445
- if (settled) return;
1446
- const native =
1447
- sub.nativeBootstrap && reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
1448
- if (
1449
- sub.nativeBootstrap &&
1450
- (!native || !isNativeBootstrapCapture(native)) &&
1451
- !nativeBootstrapUnsupported(reply.ok, reply.lines, native)
1452
- ) {
1453
- if (
1454
- !this.nativeBootstrapConfirmed &&
1455
- !this.nativeProbeRetried.has(sub) &&
1456
- this.recoveryNowMs() < deadlineAt
1457
- ) {
1458
- // An unknown server can swallow an unsupported command inside a
1459
- // dynamic hook. Retry the ordinary probe once, with a fresh FIFO
1460
- // seam and the original deadline, before invoking native recovery.
1461
- this.nativeProbeRetried.add(sub);
1462
- settled = true;
1463
- sub.feed.abort(epoch);
1464
- cancelDeadline?.();
1465
- lease.cancelRecipe = null;
1466
- retireMarker();
1467
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1468
- return;
1469
- }
1470
- sub.feed.abort(epoch);
1471
- retireMarker();
1472
- settle(FAILED_RESEED_RESULT);
1473
- return;
1474
- }
1475
- if (sub.nativeBootstrap && (!native || !isNativeBootstrapCapture(native))) {
1476
- // Unsupported/backing-only native exports fall back at a new FIFO
1477
- // capture seam. Never replay held bytes across these two captures.
1478
- settled = true;
1479
- retireMarker();
1480
- sub.feed.abort(epoch);
1481
- cancelDeadline?.();
1482
- lease.cancelRecipe = null;
1483
- sub.nativeBootstrap = false;
1484
- this.nativeBootstrapUnavailable = true;
1485
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1486
- return;
1487
- }
1488
- if (!reply.ok) {
1489
- // Successful captures consume the marker atomically inside the tmux
1490
- // after-capture-pane hook. The command-list also prevents a concurrent
1491
- // mirror from stealing the marker. Only failures need cleanup.
1492
- retireMarker();
1493
- sub.feed.abort(epoch);
1494
- settle(FAILED_RESEED_RESULT);
1495
- return;
1496
- }
1497
- captureSucceeded = true;
1498
- if (sub.closed || sub.frozen || this.disposed) {
1499
- sub.feed.abort(epoch);
1500
- settle(FAILED_RESEED_RESULT);
1501
- return;
1502
- }
1503
- captureLines = [...reply.lines];
1504
- if (native) {
1505
- this.nativeBootstrapConfirmed = true;
1506
- capturedNativeSize = { cols: native.cols, rows: native.rows };
1507
- }
1508
- if (native) sub.feed.captureNativeReply(epoch, native);
1509
- else sub.feed.captureReply(epoch, reply.lines);
1510
- },
1511
- );
1512
- this.io.commandInline(
1513
- `display-message -p -t ${runtime} "${RECOVERY_CURSOR_PROBE_FORMAT}"`,
1514
- (reply) => {
1515
- if (settled) return;
1516
- if (sub.closed || sub.frozen || this.disposed) {
1517
- sub.feed.abort(epoch);
1518
- settle(FAILED_RESEED_RESULT);
1519
- return;
1520
- }
1521
- if (!reply.ok) {
1522
- if (!captureSucceeded) retireMarker();
1523
- sub.feed.abort(epoch);
1524
- settle(FAILED_RESEED_RESULT);
1525
- return;
1526
- }
1527
- // Native/cursor replies can precede the paired layout/border reply.
1528
- // Never publish that candidate against old authority, nor replay held
1529
- // output over its snapshot. Recapture after the latest layout releases,
1530
- // retaining this recipe's original deadline and one queue lease.
1531
- const windowId = sub.pane.windowRuntimeId;
1532
- const probe = parseCursorProbe(reply.lines[0] ?? "");
1533
- const validCaptureGeometry =
1534
- probe &&
1535
- Number.isSafeInteger(probe.x) &&
1536
- Number.isSafeInteger(probe.y) &&
1537
- probe.y < probe.rows &&
1538
- (!capturedNativeSize ||
1539
- (capturedNativeSize.cols === probe.cols && capturedNativeSize.rows === probe.rows));
1540
- const layoutSize = validCaptureGeometry ? this.layoutCaptureSizeFor(sub) : null;
1541
- const pendingLayout = windowId !== null && this.pendingLayoutOutput.has(windowId);
1542
- const aheadOfLayout =
1543
- layoutSize && probe && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows);
1544
- if (windowId && layoutSize && validCaptureGeometry && (pendingLayout || aheadOfLayout)) {
1545
- sub.feed.abort(epoch);
1546
- captureLines = null;
1547
- const waitingAfterSyncOrdinal = this.syncOrdinal;
1548
- resumeLayoutCapture = (completedSyncOrdinal) => {
1549
- if (settled) return;
1550
- if (this.recoveryNowMs() >= deadlineAt) {
1551
- settle(FAILED_RESEED_RESULT);
1552
- return;
1553
- }
1554
- const current = this.layoutCaptureSizeFor(sub);
1555
- // Only a successful truth sync started after this wait is a causal
1556
- // barrier, including resize-back to identical geometry. An older
1557
- // in-flight sync and cached layout emissions cannot qualify it.
1558
- if (!current) return;
1559
- if (completedSyncOrdinal !== undefined) {
1560
- if (completedSyncOrdinal <= waitingAfterSyncOrdinal) return;
1561
- } else if (
1562
- !pendingLayout &&
1563
- current.cols === layoutSize.cols &&
1564
- current.rows === layoutSize.rows
1565
- )
1566
- return;
1567
- settled = true;
1568
- clearLayoutWait();
1569
- cancelDeadline?.();
1570
- lease.cancelRecipe = null;
1571
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1572
- };
1573
- sub.resumeLayoutCapture = resumeLayoutCapture;
1574
- if (!pendingLayout) this.scheduleSync();
1575
- return;
1576
- }
1577
- const cursorLine = reply.lines[0] ?? "";
1578
- const historySize = Number(cursorLine.trim().split(/\s+/)[14]);
1579
- if (Number.isSafeInteger(historySize) && historySize >= 0)
1580
- sub.pane.historySize = historySize;
1581
- const fallbackSize = this.layoutSizeFor(runtime);
1582
- this.observeScrollOnClear(sub.pane, cursorLine);
1583
- const events = sub.feed.cursorReply(epoch, cursorLine, fallbackSize);
1584
- let published = false;
1585
- const publish = (): boolean => {
1586
- if (published) return true;
1587
- if (sub.closed || sub.frozen || this.disposed) return false;
1588
- published = true;
1589
- for (const event of events) sub.onEvent(event);
1590
- return true;
1591
- };
1592
- const ok = events.length > 0 && !sub.closed && captureLines !== null;
1593
- const result = {
1594
- ok,
1595
- fingerprint: ok ? snapshotFingerprint(captureLines!, cursorLine, fallbackSize) : null,
1596
- publish,
1597
- hold: () => sub.feed.quarantine(epoch),
1598
- } satisfies ReseedResult;
1599
- if (!deferPublish) publish();
1600
- settle(result);
1601
- },
1602
- );
1603
- }
1604
-
1605
1424
  private reseedPlain(sub: SubRecord, resumeReason: "requested" | null = null): void {
1606
1425
  if (sub.closed || sub.frozen || this.disposed) return;
1607
- // Layout observers can request a reseed reentrantly during admission. The
1608
- // waiting recipe already owns that request and its absolute deadline.
1609
- if (sub.resumeLayoutCapture) return;
1610
- if (this.plainReseedActive?.sub === sub) sub.cancelCapture?.();
1611
- if (!this.plainReseedQueue.has(sub) && this.plainReseedQueue.size >= MAX_QUEUED_PLAIN_RESEEDS) {
1612
- this.failPlainReseed(sub);
1613
- return;
1614
- }
1615
- this.plainReseedQueue.set(sub, resumeReason);
1616
- if (this.plainReseedActive?.sub !== sub) {
1617
- // Output before this queued recipe starts is already in its future
1618
- // capture. Do not publish it using the prior geometry in the meantime.
1619
- sub.feed.beginReseed();
1620
- sub.cancelCapture = () => {
1621
- this.plainReseedQueue.delete(sub);
1622
- sub.cancelCapture = null;
1623
- sub.feed.abortCurrent();
1624
- };
1625
- }
1626
- this.drainPlainReseeds();
1627
- }
1628
-
1629
- private failPlainReseed(sub: SubRecord): void {
1630
- if (
1631
- sub.closed ||
1632
- sub.frozen ||
1633
- this.disposed ||
1634
- this.recoveries.has(sub.pane.runtimeId) ||
1635
- this.panesByRuntime.get(sub.pane.runtimeId) !== sub.pane
1636
- )
1637
- return;
1638
- this.beginLocalOverflowRecovery(sub.pane);
1639
- }
1640
-
1641
- private drainPlainReseeds(): void {
1642
- if (this.plainReseedActive || this.disposed) return;
1643
- const next = this.plainReseedQueue.entries().next().value;
1644
- if (!next) return;
1645
- const [sub, resumeReason] = next;
1646
- this.plainReseedQueue.delete(sub);
1647
- if (sub.closed || sub.frozen) {
1648
- this.drainPlainReseeds();
1649
- return;
1650
- }
1651
- sub.cancelCapture?.();
1652
- const lease: PlainReseedLease = { sub, cancelRecipe: null };
1653
- this.plainReseedActive = lease;
1654
- // The queue lease outlives individual native capability probes. Retrying
1655
- // or falling back replaces only cancelRecipe, never this queue owner.
1656
- sub.cancelCapture = () => {
1657
- if (this.plainReseedActive !== lease) return;
1658
- lease.cancelRecipe?.();
1659
- sub.cancelCapture = null;
1660
- this.plainReseedActive = null;
1661
- this.drainPlainReseeds();
1662
- };
1663
- // Only the recipe actually submitted to the control FIFO owns a five
1664
- // second deadline. A large sibling projection cannot consume the waiting
1665
- // panes' entire capture budgets before their commands have been written.
1666
- this.reseed(
1667
- lease,
1668
- (result) => {
1669
- if (this.plainReseedActive !== lease) return;
1670
- sub.cancelCapture = null;
1671
- this.plainReseedActive = null;
1672
- if (!result.ok) {
1673
- // A stalled FIFO cannot make progress for queued recipes either. Retire
1674
- // them together instead of granting each another wait behind that slot.
1675
- const waiting = [...this.plainReseedQueue.keys()];
1676
- this.plainReseedQueue.clear();
1677
- for (const queued of waiting) queued.cancelCapture?.();
1678
- this.failPlainReseed(sub);
1679
- for (const queued of waiting) this.failPlainReseed(queued);
1680
- return;
1681
- }
1682
- // Admit the next recipe before publishing this potentially large seed.
1683
- this.drainPlainReseeds();
1684
- result.publish();
1685
- if (resumeReason && !sub.closed && !sub.frozen)
1686
- sub.onEvent({ type: "flow", state: "resumed", reason: resumeReason });
1687
- },
1688
- true,
1689
- );
1426
+ this.beginRecovery(sub.pane, resumeReason ?? "requested");
1690
1427
  }
1691
1428
 
1692
1429
  private retireInternalReadMarker(runtime: string, marker: string): void {
1693
1430
  if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
1694
1431
  throw new TypeError("internal read cleanup requires a runtime pane id");
1695
1432
  retireInternalReadOperation(marker, runtime);
1433
+ this.clearInternalReadMarkerOption(runtime, marker);
1434
+ }
1435
+
1436
+ private clearInternalReadMarkerOption(runtime: string, marker: string): void {
1437
+ if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
1438
+ throw new TypeError("internal read cleanup requires a runtime pane id");
1439
+ // Success must keep the in-memory proof redeemable by a delayed observer.
1440
+ // The server option must still be removed when no observer hook is installed.
1696
1441
  // Pane capture phases overlap under cancellation. Clear only the exact
1697
- // failed marker so a late A callback cannot erase the newer B authority.
1442
+ // owned marker so a late A callback cannot erase the newer B authority.
1698
1443
  // Both selected branches must emit one reply in addition to if-shell's
1699
1444
  // own reply. An empty false branch emits none and shifts the control FIFO
1700
1445
  // whenever the true branch runs during capture cancellation.
@@ -1750,6 +1495,12 @@ export class SessionChannel {
1750
1495
  if (sub.frozen || sub.closed) return;
1751
1496
  sub.frozen = true;
1752
1497
  sub.cancelCapture?.();
1498
+ const activeRecovery = this.recoveries.get(sub.pane.runtimeId);
1499
+ if (
1500
+ activeRecovery &&
1501
+ [...sub.pane.subs].some((candidate) => !candidate.closed && !candidate.frozen)
1502
+ )
1503
+ this.retrySnapshot(activeRecovery);
1753
1504
  sub.onEvent({ type: "flow", state: "paused", reason: "requested" });
1754
1505
  const pane = sub.pane;
1755
1506
  const allFrozen = [...pane.subs].every((candidate) => candidate.frozen || candidate.closed);
@@ -1838,39 +1589,46 @@ export class SessionChannel {
1838
1589
  const recovery = this.recoveries.get(runtime);
1839
1590
  if (!recovery) return;
1840
1591
  recovery.retired = true;
1841
- recovery.cancelQuiet?.();
1592
+ this.recoveries.delete(runtime);
1593
+ this.snapshotQueue.delete(runtime);
1842
1594
  recovery.cancelCommandDeadline?.();
1843
1595
  recovery.cancelNoProgressDeadline?.();
1844
1596
  recovery.cancelAbsoluteDeadline?.();
1845
- if (recovery.atomicCollectorNonce)
1846
- this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
1847
- recovery.atomicCollectorNonce = null;
1848
- this.recoveries.delete(runtime);
1849
- this.retireContinueNotificationOwner(recovery);
1850
1597
  const pane = this.panesByRuntime.get(runtime);
1851
1598
  if (pane?.incarnation === recovery.paneIncarnation)
1852
1599
  for (const sub of pane.subs) sub.feed.abortCurrent();
1600
+ if (recovery.lease) this.retireSnapshotLease(recovery.lease);
1601
+ else if (recovery.atomicCollectorNonce)
1602
+ this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
1603
+ recovery.atomicCollectorNonce = null;
1853
1604
  }
1854
1605
 
1855
1606
  private beginRecovery(pane: PaneRecord, reason: "backpressure" | "requested"): void {
1856
- for (const sub of pane.subs) sub.cancelCapture?.();
1857
- const runtime = pane.runtimeId;
1858
- this.cancelRecovery(runtime);
1607
+ if (this.disposed) return;
1608
+ const current = this.recoveries.get(pane.runtimeId);
1609
+ if (current && !current.retired) {
1610
+ if (this.snapshotQueue.get(pane.runtimeId) === current) return;
1611
+ this.retrySnapshot(current);
1612
+ return;
1613
+ }
1614
+ if (this.snapshotQueue.size >= MAX_QUEUED_SNAPSHOTS) {
1615
+ for (const sub of pane.subs) {
1616
+ sub.feed.abortCurrent();
1617
+ if (!sub.closed && !sub.frozen)
1618
+ sub.onEvent({ type: "fault", reason: "native-recovery-failed" });
1619
+ }
1620
+ return;
1621
+ }
1859
1622
  const recovery: RecoveryRecord = {
1860
1623
  ordinal: ++this.recoveryOrdinal,
1861
- runtimeId: runtime,
1624
+ runtimeId: pane.runtimeId,
1862
1625
  paneIncarnation: pane.incarnation,
1863
1626
  reason,
1864
1627
  startedAtMs: this.recoveryNowMs(),
1865
1628
  retired: false,
1866
- continueReply: false,
1867
- continueNotify: false,
1868
- stage: "continue",
1629
+ stage: "queued",
1869
1630
  attempts: 0,
1870
1631
  reseedOrdinal: 0,
1871
- outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
1872
- candidateFingerprint: null,
1873
- confirmationFingerprint: null,
1874
1632
  confirmationOrdinal: 0,
1875
1633
  atomicCollectorNonce: null,
1876
1634
  collectorStarted: false,
@@ -1881,99 +1639,375 @@ export class SessionChannel {
1881
1639
  collectorStatusObserved: false,
1882
1640
  collectorObserverEmissionObserved: false,
1883
1641
  collectorFailureReason: null,
1884
- cancelQuiet: null,
1885
1642
  cancelCommandDeadline: null,
1886
1643
  cancelNoProgressDeadline: null,
1887
1644
  cancelAbsoluteDeadline: null,
1888
1645
  };
1889
- this.recoveries.set(runtime, recovery);
1890
- for (const sub of pane.subs) {
1891
- if (!sub.frozen && !sub.closed) sub.feed.abortCurrent();
1646
+ this.recoveries.set(pane.runtimeId, recovery);
1647
+ this.snapshotQueue.set(pane.runtimeId, recovery);
1648
+ this.drainSnapshots();
1649
+ }
1650
+
1651
+ private beginLocalOverflowRecovery(pane: PaneRecord): void {
1652
+ this.beginRecovery(pane, "backpressure");
1653
+ }
1654
+
1655
+ private snapshotContext(pane: PaneRecord): StockPaneSnapshotContext {
1656
+ return {
1657
+ paneId: pane.runtimeId,
1658
+ incarnation: pane.incarnation,
1659
+ layoutGeneration: pane.snapshotLayoutGeneration,
1660
+ participants: [...pane.subs].filter((sub) => !sub.closed && !sub.frozen),
1661
+ };
1662
+ }
1663
+
1664
+ private snapshotLeaseCurrent(lease: SnapshotLease): boolean {
1665
+ return (
1666
+ this.snapshotActive === lease && !lease.retired && this.recoveryPane(lease.recovery) !== null
1667
+ );
1668
+ }
1669
+
1670
+ private drainSnapshots(): void {
1671
+ if (this.snapshotActive || this.disposed) return;
1672
+ for (const [runtime, recovery] of this.snapshotQueue) {
1673
+ if (
1674
+ recovery.waitForSyncAfter !== undefined &&
1675
+ this.completedSyncOrdinal <= recovery.waitForSyncAfter
1676
+ )
1677
+ continue;
1678
+ this.snapshotQueue.delete(runtime);
1679
+ const pane = this.recoveryPane(recovery);
1680
+ if (!pane || ![...pane.subs].some((sub) => !sub.closed && !sub.frozen)) {
1681
+ this.cancelRecovery(runtime);
1682
+ continue;
1683
+ }
1684
+ if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
1685
+ this.failRecovery(recovery, "attempts-exhausted");
1686
+ continue;
1687
+ }
1688
+ const lease: SnapshotLease = {
1689
+ recovery,
1690
+ retired: false,
1691
+ wireNonce: null,
1692
+ retry: false,
1693
+ fencePending: false,
1694
+ cancelFence: null,
1695
+ };
1696
+ this.snapshotActive = lease;
1697
+ recovery.lease = lease;
1698
+ if (!recovery.cancelAbsoluteDeadline) {
1699
+ recovery.startedAtMs = this.recoveryNowMs();
1700
+ this.beginRecoveryConvergence(recovery);
1701
+ } else this.noteRecoveryProgress(recovery);
1702
+ for (const sub of pane.subs) if (!sub.closed && !sub.frozen) sub.feed.abortCurrent();
1703
+ const target = this.nativeRecoveryTarget(pane);
1704
+ if (
1705
+ recovery.nativeOwner &&
1706
+ (target?.serverEpoch !== recovery.nativeOwner.serverEpoch ||
1707
+ target?.paneBirthId !== recovery.nativeOwner.paneBirthId)
1708
+ ) {
1709
+ this.failRecovery(recovery, "command-error");
1710
+ return;
1711
+ }
1712
+ if (target) {
1713
+ recovery.nativeOwner ??= target;
1714
+ recovery.stock = undefined;
1715
+ this.recoverNativeAtomic(pane, recovery, target);
1716
+ } else this.startStockSnapshot(pane, recovery, lease);
1717
+ return;
1892
1718
  }
1893
- this.observeRecovery(pane, recovery, "pause");
1894
- const nativeTarget = this.nativeRecoveryTarget(pane);
1895
- if (nativeTarget) {
1896
- this.beginRecoveryConvergence(recovery);
1897
- this.recoverNativeAtomic(pane, recovery, nativeTarget);
1719
+ }
1720
+
1721
+ private releaseSnapshotLease(lease: SnapshotLease): void {
1722
+ if (this.snapshotActive !== lease || lease.wireNonce || lease.fencePending) return;
1723
+ lease.cancelFence?.();
1724
+ lease.cancelFence = null;
1725
+ const pane = this.panesByRuntime.get(lease.recovery.runtimeId);
1726
+ if (
1727
+ lease.ownsPause &&
1728
+ pane?.incarnation === lease.recovery.paneIncarnation &&
1729
+ pane.subs.size === 0 &&
1730
+ !this.ledger.isRequested(pane.runtimeId)
1731
+ ) {
1732
+ this.io.commandInline(`refresh-client -A '${pane.runtimeId}:continue'`, () => {});
1733
+ this.ledger.noteContinued(pane.runtimeId);
1734
+ }
1735
+ this.snapshotActive = null;
1736
+ if (lease.recovery.lease === lease) lease.recovery.lease = undefined;
1737
+ if (lease.retry && this.recoveryPane(lease.recovery)) {
1738
+ lease.recovery.stage = "queued";
1739
+ this.snapshotQueue.set(lease.recovery.runtimeId, lease.recovery);
1740
+ }
1741
+ this.drainSnapshots();
1742
+ }
1743
+
1744
+ private retireSnapshotLease(lease: SnapshotLease, retry = false): void {
1745
+ if (this.snapshotActive !== lease) return;
1746
+ lease.retired = true;
1747
+ if (this.disposed) {
1748
+ lease.cancelFence?.();
1749
+ lease.cancelFence = null;
1750
+ this.snapshotActive = null;
1751
+ this.snapshotQueue.clear();
1898
1752
  return;
1899
1753
  }
1900
- this.observeRecovery(pane, recovery, "continue-request");
1901
- recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
1902
- if (this.recoveryPane(recovery) && !recovery.continueReply)
1903
- this.failRecovery(recovery, "command-timeout");
1904
- }, RECOVERY_COMMAND_DEADLINE_MS);
1905
- const queue = this.continueNotificationQueues.get(runtime) ?? [];
1906
- if (queue.length >= MAX_CONTINUE_NOTIFICATION_QUEUE) {
1907
- this.failRecovery(recovery, "notification-queue-overflow");
1754
+ lease.retry ||= retry;
1755
+ lease.recovery.stock?.invalidate();
1756
+ lease.cleanup?.();
1757
+ lease.cleanup = undefined;
1758
+ lease.recovery.cancelCommandDeadline?.();
1759
+ lease.recovery.cancelCommandDeadline = null;
1760
+ if (lease.wireNonce) {
1761
+ this.io.retireAtomicPaneSnapshotCollector?.(lease.wireNonce, "retired");
1908
1762
  return;
1909
1763
  }
1910
- queue.push({ kind: "owner", recovery });
1911
- this.continueNotificationQueues.set(runtime, queue);
1912
- this.io.send(`refresh-client -A '${runtime}:continue'`, (reply) => {
1913
- const current = this.recoveryPane(recovery);
1914
- if (!reply.ok) {
1915
- this.removeContinueNotificationOwner(recovery);
1916
- if (current) this.failRecovery(recovery, "command-error");
1917
- return;
1918
- }
1919
- recovery.continueReply = true;
1920
- if (!current) {
1921
- this.retireContinueNotificationOwner(recovery);
1764
+ if (lease.fencePending) return;
1765
+ // Native wrapper/setup commands still own ordinary FIFO slots. A separate
1766
+ // bounded command proves they drained before another raw hook is admitted.
1767
+ lease.fencePending = true;
1768
+ const token = `tmux-ide-snapshot-admission:${randomBytes(24).toString("hex")}`;
1769
+ const failed = () => {
1770
+ if (this.snapshotActive !== lease) return;
1771
+ lease.retry = false;
1772
+ lease.cancelFence?.();
1773
+ void this.io.dispose();
1774
+ };
1775
+ lease.cancelFence = this.scheduleRecovery(failed, RECOVERY_ABSOLUTE_DEADLINE_MS);
1776
+ const done = (reply: ControlReply) => {
1777
+ if (this.snapshotActive !== lease) return;
1778
+ if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== token) {
1779
+ failed();
1922
1780
  return;
1923
1781
  }
1924
- recovery.cancelCommandDeadline?.();
1925
- recovery.cancelCommandDeadline = null;
1926
- this.beginRecoveryConvergence(recovery);
1927
- this.observeRecovery(current, recovery, "continue-reply");
1928
- this.noteRecoveryProgress(recovery);
1929
- this.beginFinalRecovery(recovery);
1930
- });
1782
+ lease.fencePending = false;
1783
+ this.releaseSnapshotLease(lease);
1784
+ };
1785
+ if (this.io.commandBoundedInline)
1786
+ this.io.commandBoundedInline(
1787
+ `display-message -p -l ${token}`,
1788
+ { maxBytes: 256, maxLines: 1 },
1789
+ done,
1790
+ );
1791
+ else this.io.commandInline(`display-message -p -l ${token}`, done);
1931
1792
  }
1932
1793
 
1933
- private beginLocalOverflowRecovery(pane: PaneRecord): void {
1934
- for (const sub of pane.subs) sub.cancelCapture?.();
1935
- const runtime = pane.runtimeId;
1936
- this.cancelRecovery(runtime);
1937
- const recovery: RecoveryRecord = {
1938
- ordinal: ++this.recoveryOrdinal,
1939
- runtimeId: runtime,
1940
- paneIncarnation: pane.incarnation,
1941
- reason: "backpressure",
1942
- startedAtMs: this.recoveryNowMs(),
1943
- retired: false,
1944
- continueReply: true,
1945
- continueNotify: true,
1946
- stage: "continue",
1947
- attempts: 0,
1948
- reseedOrdinal: 0,
1949
- outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
1950
- candidateFingerprint: null,
1951
- confirmationFingerprint: null,
1952
- confirmationOrdinal: 0,
1953
- atomicCollectorNonce: null,
1954
- collectorStarted: false,
1955
- collectorLastCompletedOrdinal: -1,
1956
- collectorCaptureLineCount: 0,
1957
- collectorCaptureByteCount: 0,
1958
- collectorContinueObserved: false,
1959
- collectorStatusObserved: false,
1960
- collectorObserverEmissionObserved: false,
1961
- collectorFailureReason: null,
1962
- cancelQuiet: null,
1963
- cancelCommandDeadline: null,
1964
- cancelNoProgressDeadline: null,
1965
- cancelAbsoluteDeadline: null,
1966
- };
1967
- this.recoveries.set(runtime, recovery);
1968
- for (const sub of pane.subs) {
1969
- if (!sub.frozen && !sub.closed)
1970
- sub.onEvent({ type: "flow", state: "paused", reason: "backpressure" });
1794
+ private awaitSnapshotLayout(recovery: RecoveryRecord): void {
1795
+ recovery.cancelNoProgressDeadline?.();
1796
+ recovery.cancelNoProgressDeadline = null;
1797
+ recovery.waitForSyncAfter = this.syncOrdinal;
1798
+ this.scheduleSync();
1799
+ }
1800
+
1801
+ private retrySnapshot(recovery: RecoveryRecord): void {
1802
+ const pane = this.recoveryPane(recovery);
1803
+ if (!pane) return;
1804
+ for (const sub of pane.subs) if (!sub.closed && !sub.frozen) sub.feed.abortCurrent();
1805
+ recovery.stock?.invalidate();
1806
+ if (
1807
+ recovery.attempts >= RECOVERY_MAX_ATTEMPTS ||
1808
+ (recovery.cancelAbsoluteDeadline &&
1809
+ this.recoveryNowMs() - recovery.startedAtMs >= RECOVERY_ABSOLUTE_DEADLINE_MS)
1810
+ ) {
1811
+ this.failRecovery(recovery, "attempts-exhausted");
1812
+ return;
1813
+ }
1814
+ if (recovery.lease) this.retireSnapshotLease(recovery.lease, true);
1815
+ else {
1816
+ recovery.stage = "queued";
1817
+ this.snapshotQueue.set(recovery.runtimeId, recovery);
1818
+ this.drainSnapshots();
1819
+ }
1820
+ }
1821
+
1822
+ private startStockSnapshot(
1823
+ pane: PaneRecord,
1824
+ recovery: RecoveryRecord,
1825
+ lease: SnapshotLease,
1826
+ ): void {
1827
+ if (!this.io.armAtomicPaneSnapshotCollector || !this.io.retireAtomicPaneSnapshotCollector) {
1828
+ this.failRecovery(recovery, "command-error");
1829
+ return;
1830
+ }
1831
+ if (
1832
+ !this.nativeBootstrapConfirmed &&
1833
+ !this.nativeBootstrapUnavailable &&
1834
+ [...pane.subs].some((sub) => !sub.closed && !sub.frozen && sub.nativeBootstrap)
1835
+ ) {
1836
+ // Negotiate the live server before placing -R in a NOHOOKS transaction:
1837
+ // an unsupported flag aborts that hook before its completion sentinel.
1838
+ recovery.nativeProbeAttempts = (recovery.nativeProbeAttempts ?? 0) + 1;
1839
+ let marker: string | null = null;
1840
+ let probeSettled = false;
1841
+ recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
1842
+ if (probeSettled || !this.snapshotLeaseCurrent(lease)) return;
1843
+ probeSettled = true;
1844
+ if ((recovery.nativeProbeAttempts ?? 0) < 2) this.retrySnapshot(recovery);
1845
+ else this.failRecovery(recovery, "command-timeout");
1846
+ }, RECOVERY_COMMAND_DEADLINE_MS);
1847
+ this.captureWithViewer(
1848
+ pane.runtimeId,
1849
+ ["-R", "-S", "-"],
1850
+ (value) => {
1851
+ marker = value;
1852
+ },
1853
+ (reply) => {
1854
+ if (marker) this.retireInternalReadMarker(pane.runtimeId, marker);
1855
+ if (probeSettled || !this.snapshotLeaseCurrent(lease)) return;
1856
+ probeSettled = true;
1857
+ recovery.cancelCommandDeadline?.();
1858
+ recovery.cancelCommandDeadline = null;
1859
+ const native = reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
1860
+ if (native && isNativeBootstrapCapture(native)) this.nativeBootstrapConfirmed = true;
1861
+ else if (nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
1862
+ this.nativeBootstrapUnavailable = true;
1863
+ for (const current of this.panesByRuntime.values())
1864
+ for (const sub of current.subs) sub.nativeBootstrap = false;
1865
+ } else {
1866
+ if ((recovery.nativeProbeAttempts ?? 0) < 2) this.retrySnapshot(recovery);
1867
+ else this.failRecovery(recovery, "command-error");
1868
+ return;
1869
+ }
1870
+ this.startStockSnapshot(pane, recovery, lease);
1871
+ },
1872
+ { maxBytes: RECOVERY_CAPTURE_MAX_BYTES, maxLines: RECOVERY_CAPTURE_MAX_LINES },
1873
+ );
1874
+ return;
1875
+ }
1876
+ recovery.attempts += 1;
1877
+ recovery.stage = "stock-pause";
1878
+ recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1879
+ // These rows were received before this pause and belong to the new seed,
1880
+ // never to a delayed old-geometry replay after it.
1881
+ const pending = pane.windowRuntimeId
1882
+ ? this.pendingLayoutOutput.get(pane.windowRuntimeId)
1883
+ : null;
1884
+ if (pending) {
1885
+ pending.records = pending.records.filter((record) => record.pane !== pane.runtimeId);
1886
+ pending.bytes = pending.records.reduce((bytes, record) => bytes + record.data.byteLength, 0);
1971
1887
  }
1972
1888
  this.observeRecovery(pane, recovery, "pause");
1973
- this.beginRecoveryConvergence(recovery);
1974
- const nativeTarget = this.nativeRecoveryTarget(pane);
1975
- if (nativeTarget) this.recoverNativeAtomic(pane, recovery, nativeTarget);
1976
- else this.beginFinalRecovery(recovery);
1889
+ const nonce = randomBytes(24).toString("hex");
1890
+ const hook = `@tmux_ide_pause_${nonce}`;
1891
+ const body =
1892
+ `display-message -p -l '%tmux-ide-atomic-v1 ${nonce} start'` +
1893
+ ` ; refresh-client -A '${pane.runtimeId}:pause'` +
1894
+ ` ; display-message -p -l '%tmux-ide-atomic-v1 ${nonce} complete'`;
1895
+ let settled = false;
1896
+ let accepted = false;
1897
+ const cleanup = () =>
1898
+ this.io.commandListInline(
1899
+ `if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
1900
+ `${tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${hook}`)} ` +
1901
+ `${tmuxSingleQuote("display-message -p -l pause-cleanup-skipped")}`,
1902
+ 2,
1903
+ 1,
1904
+ () => {},
1905
+ );
1906
+ lease.cleanup = cleanup;
1907
+ const failed = () => {
1908
+ if (settled) return;
1909
+ settled = true;
1910
+ cleanup();
1911
+ if (this.snapshotLeaseCurrent(lease)) this.retrySnapshot(recovery);
1912
+ };
1913
+ recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
1914
+ this.input.flush();
1915
+ this.io.commandInline(
1916
+ `set-option -po -t ${pane.runtimeId} ${hook} ${tmuxSingleQuote(body)}`,
1917
+ (reply) => {
1918
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
1919
+ cleanup();
1920
+ return;
1921
+ }
1922
+ if (!reply.ok) {
1923
+ failed();
1924
+ return;
1925
+ }
1926
+ lease.wireNonce = nonce;
1927
+ const armed = this.io.armAtomicPaneSnapshotCollector!(
1928
+ {
1929
+ nonce,
1930
+ kind: "pause",
1931
+ runtimePaneId: pane.runtimeId,
1932
+ maxCaptureBytes: 1024,
1933
+ maxCaptureLines: 16,
1934
+ maxCursorBytes: 128,
1935
+ observerCommandCount: 0,
1936
+ onSettled: (result) => {
1937
+ cleanup();
1938
+ if (settled || !this.snapshotLeaseCurrent(lease)) return;
1939
+ if (!result.ok) {
1940
+ failed();
1941
+ return;
1942
+ }
1943
+ // The authenticated NOHOOKS body targets the same live pane as
1944
+ // set-hook -Rp. A successful pause is a no-op only when already
1945
+ // paused; its complete guard therefore fences that case too.
1946
+ accepted = recovery.stock!.observePause(this.snapshotContext(pane));
1947
+ if (!accepted) {
1948
+ failed();
1949
+ return;
1950
+ }
1951
+ lease.ownsPause = true;
1952
+ this.observedPaused.add(pane.runtimeId);
1953
+ this.ledger.notePause(pane.runtimeId);
1954
+ settled = true;
1955
+ recovery.cancelCommandDeadline?.();
1956
+ recovery.cancelCommandDeadline = null;
1957
+ },
1958
+ onDrained: (reason) => {
1959
+ if (lease.wireNonce === nonce) lease.wireNonce = null;
1960
+ if (reason === "channel-exit") {
1961
+ lease.retry = false;
1962
+ return;
1963
+ }
1964
+ if (!this.snapshotLeaseCurrent(lease) || !accepted) {
1965
+ this.releaseSnapshotLease(lease);
1966
+ return;
1967
+ }
1968
+ recovery.stage = "stock-capture";
1969
+ this.reseedRecoverySubscribersAtomic(
1970
+ pane,
1971
+ recovery,
1972
+ (result) => {
1973
+ if (!this.snapshotLeaseCurrent(lease)) return;
1974
+ if (!result.ok || !result.publish()) this.retrySnapshot(recovery);
1975
+ else this.convergeRecovery(pane, recovery);
1976
+ },
1977
+ true,
1978
+ );
1979
+ },
1980
+ },
1981
+ Math.max(
1982
+ 1,
1983
+ Math.floor(
1984
+ RECOVERY_ABSOLUTE_DEADLINE_MS - (this.recoveryNowMs() - recovery.startedAtMs),
1985
+ ),
1986
+ ),
1987
+ );
1988
+ if (!armed) {
1989
+ lease.wireNonce = null;
1990
+ failed();
1991
+ return;
1992
+ }
1993
+ // Once queued, this pause may execute even if its local callback is
1994
+ // cancelled. Return the owned pause after drain when no viewer remains.
1995
+ lease.ownsPause = true;
1996
+ // The option value is immutable for this attempt; compare it before
1997
+ // dispatch so another actor cannot replace the owned NOHOOKS body.
1998
+ this.io.commandListInline(
1999
+ `if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
2000
+ `${tmuxSingleQuote(`set-hook -Rp -t ${pane.runtimeId} ${hook}`)} ` +
2001
+ `${tmuxSingleQuote("display-message -p -l pause-hook-rejected")}`,
2002
+ 2,
2003
+ 1,
2004
+ (result) => {
2005
+ if (settled || !this.snapshotLeaseCurrent(lease)) return;
2006
+ if (!result.ok || result.lines.length) failed();
2007
+ },
2008
+ );
2009
+ },
2010
+ );
1977
2011
  }
1978
2012
 
1979
2013
  private nativeRecoveryTarget(
@@ -2000,7 +2034,8 @@ export class SessionChannel {
2000
2034
  recovery: RecoveryRecord,
2001
2035
  target: NativeAtomicSnapshotTarget & { representation: "native" | "dual" },
2002
2036
  ): void {
2003
- if (this.recoveryPane(recovery) !== pane) return;
2037
+ const lease = recovery.lease;
2038
+ if (!lease || !this.snapshotLeaseCurrent(lease) || this.recoveryPane(recovery) !== pane) return;
2004
2039
  const currentTarget = this.nativeRecoveryTarget(pane);
2005
2040
  if (
2006
2041
  currentTarget?.serverEpoch !== target.serverEpoch ||
@@ -2024,6 +2059,7 @@ export class SessionChannel {
2024
2059
  const live = [...pane.subs].filter((sub) => !sub.closed && !sub.frozen);
2025
2060
  return (
2026
2061
  this.recoveryPane(recovery) === pane &&
2062
+ this.snapshotLeaseCurrent(lease) &&
2027
2063
  recovery.reseedOrdinal === ordinal &&
2028
2064
  current?.serverEpoch === target.serverEpoch &&
2029
2065
  current.paneBirthId === target.paneBirthId &&
@@ -2042,11 +2078,12 @@ export class SessionChannel {
2042
2078
  for (const { sub } of participants) sub.feed.abortCurrent();
2043
2079
  // A malformed/late reply may follow a committed resume. Every retry
2044
2080
  // explicitly pauses first; never fall through to stock capture.
2045
- this.armRecoveryQuiet(recovery, () => this.recoverNativeAtomic(pane, recovery, target));
2081
+ this.retrySnapshot(recovery);
2046
2082
  };
2047
2083
  recovery.stage = "native-pause";
2048
2084
  recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
2049
2085
  this.input.flush();
2086
+ lease.ownsPause = true;
2050
2087
  this.io.commandInline(`refresh-client -A '${pane.runtimeId}:pause'`, (pauseReply) => {
2051
2088
  if (settled || !exact()) {
2052
2089
  failed();
@@ -2090,12 +2127,27 @@ export class SessionChannel {
2090
2127
  failed();
2091
2128
  return;
2092
2129
  }
2130
+ const probe = parseCursorProbe(result.cursorLine);
2131
+ const size = this.layoutCaptureSizeFor(participants[0]!.sub);
2132
+ if (
2133
+ !probe ||
2134
+ probe.y >= probe.rows ||
2135
+ result.capture.cols !== probe.cols ||
2136
+ result.capture.rows !== probe.rows
2137
+ ) {
2138
+ failed();
2139
+ return;
2140
+ }
2141
+ if (
2142
+ (pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId)) ||
2143
+ (size && (size.cols !== probe.cols || size.rows !== probe.rows))
2144
+ ) {
2145
+ this.awaitSnapshotLayout(recovery);
2146
+ failed();
2147
+ return;
2148
+ }
2093
2149
  this.nativeBootstrapConfirmed = true;
2094
- this.observeScrollOnClear(pane, result.cursorLine);
2095
- // The selected child reply owns its inline %continue. It adds no
2096
- // asynchronous notification debt to the legacy continue queue.
2097
- recovery.continueReply = true;
2098
- recovery.continueNotify = true;
2150
+ this.observeSnapshotMetadata(pane, result.cursorLine);
2099
2151
  const ansiLines = result.ansiCapture
2100
2152
  ? captureLinesFromAnsiBytes(result.ansiCapture)
2101
2153
  : null;
@@ -2151,9 +2203,11 @@ export class SessionChannel {
2151
2203
  private noteRecoveryProgress(recovery: RecoveryRecord): void {
2152
2204
  if (!this.recoveryPane(recovery) || !recovery.cancelAbsoluteDeadline) return;
2153
2205
  recovery.cancelNoProgressDeadline?.();
2154
- recovery.cancelNoProgressDeadline = this.scheduleRecovery(() => {
2206
+ const cancel = this.scheduleRecovery(() => {
2207
+ if (recovery.cancelNoProgressDeadline !== cancel) return;
2155
2208
  if (this.recoveryPane(recovery)) this.failRecovery(recovery, "no-progress");
2156
2209
  }, RECOVERY_NO_PROGRESS_DEADLINE_MS);
2210
+ recovery.cancelNoProgressDeadline = cancel;
2157
2211
  }
2158
2212
 
2159
2213
  private noteAtomicCollectorProgress(
@@ -2186,155 +2240,18 @@ export class SessionChannel {
2186
2240
  this.noteRecoveryProgress(recovery);
2187
2241
  }
2188
2242
 
2189
- private reseedRecoverySubscribers(
2243
+ private reseedRecoverySubscribersAtomic(
2190
2244
  pane: PaneRecord,
2191
2245
  recovery: RecoveryRecord,
2192
2246
  done: (result: ReseedResult) => void,
2193
- deferPublish = false,
2247
+ deferPublish: boolean,
2194
2248
  ): void {
2195
- if (this.io.armAtomicPaneSnapshotCollector && this.io.retireAtomicPaneSnapshotCollector) {
2196
- this.reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish);
2197
- return;
2198
- }
2199
- const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
2200
- if (live.length === 0) {
2249
+ const lease = recovery.lease;
2250
+ const stock = recovery.stock;
2251
+ if (!lease || !stock || !this.snapshotLeaseCurrent(lease)) {
2201
2252
  done(FAILED_RESEED_RESULT);
2202
2253
  return;
2203
2254
  }
2204
- const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
2205
- const nativeCapture =
2206
- !this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
2207
- const reseedOrdinal = ++recovery.reseedOrdinal;
2208
- let settled = false;
2209
- let captureSucceeded = false;
2210
- let markerRetired = false;
2211
- let captureLines: readonly string[] | null = null;
2212
- // One pane authority capture is enough for every subscriber. Per-feed
2213
- // epochs still independently fence delivery, while membership is frozen
2214
- // across both FIFO replies so no subscriber can join half a snapshot.
2215
- this.input.flush();
2216
- const history = this.opts.historyLines ?? "";
2217
- let internalReadMarker: string | null = null;
2218
- const participantsExact = (): boolean => {
2219
- if (
2220
- this.recoveryPane(recovery) !== pane ||
2221
- recovery.reseedOrdinal !== reseedOrdinal ||
2222
- participants.some(
2223
- ({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub),
2224
- )
2225
- )
2226
- return false;
2227
- const current = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
2228
- return (
2229
- current.length === participants.length &&
2230
- current.every((sub) => participants.some((participant) => participant.sub === sub))
2231
- );
2232
- };
2233
- const retireMarker = (): void => {
2234
- if (markerRetired) return;
2235
- markerRetired = true;
2236
- if (internalReadMarker) this.retireInternalReadMarker(pane.runtimeId, internalReadMarker);
2237
- };
2238
- const fail = (): void => {
2239
- if (settled) return;
2240
- settled = true;
2241
- if (!captureSucceeded) retireMarker();
2242
- for (const { sub } of participants) sub.feed.abortCurrent();
2243
- done(FAILED_RESEED_RESULT);
2244
- };
2245
- this.captureWithViewer(
2246
- pane.runtimeId,
2247
- [...(nativeCapture ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`],
2248
- (marker) => {
2249
- internalReadMarker = marker;
2250
- },
2251
- (reply) => {
2252
- if (!reply.ok) {
2253
- if (nativeCapture && nativeBootstrapUnsupported(false, reply.lines, null)) {
2254
- this.nativeBootstrapUnavailable = true;
2255
- for (const { sub } of participants) sub.nativeBootstrap = false;
2256
- }
2257
- fail();
2258
- return;
2259
- }
2260
- captureSucceeded = true;
2261
- if (!participantsExact()) {
2262
- fail();
2263
- return;
2264
- }
2265
- captureLines = Object.freeze([...reply.lines]);
2266
- const native = nativeCapture ? decodeNativeGridCapture(captureLines.join("\n")) : null;
2267
- if (nativeCapture && (!native || !isNativeBootstrapCapture(native))) {
2268
- if (nativeBootstrapUnsupported(true, captureLines, native)) {
2269
- this.nativeBootstrapUnavailable = true;
2270
- for (const { sub } of participants) sub.nativeBootstrap = false;
2271
- }
2272
- fail();
2273
- return;
2274
- }
2275
- if (native) this.nativeBootstrapConfirmed = true;
2276
- for (const { sub, epoch } of participants) {
2277
- if (native) sub.feed.captureNativeReply(epoch, native);
2278
- else sub.feed.captureReply(epoch, captureLines);
2279
- }
2280
- },
2281
- );
2282
- this.io.commandInline(
2283
- `display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"`,
2284
- (reply) => {
2285
- if (settled) return;
2286
- if (!participantsExact() || captureLines === null || !reply.ok) {
2287
- fail();
2288
- return;
2289
- }
2290
- const cursorLine = reply.lines[0] ?? "";
2291
- const fallbackSize = this.layoutSizeFor(pane.runtimeId);
2292
- this.observeScrollOnClear(pane, cursorLine);
2293
- const deliveries = participants.map(({ sub, epoch }) => ({
2294
- sub,
2295
- epoch,
2296
- events: sub.feed
2297
- .cursorReply(epoch, cursorLine, fallbackSize)
2298
- .map((event) =>
2299
- event.type === "seed" && sub.nativeBootstrap && !nativeCapture
2300
- ? { ...event, requiresNativeRecapture: true }
2301
- : event,
2302
- ),
2303
- }));
2304
- if (!participantsExact() || deliveries.some(({ events }) => events.length === 0)) {
2305
- fail();
2306
- return;
2307
- }
2308
- let published = false;
2309
- const publish = (): boolean => {
2310
- if (published) return true;
2311
- if (!participantsExact()) return false;
2312
- published = true;
2313
- for (const { sub, events } of deliveries) for (const event of events) sub.onEvent(event);
2314
- return participantsExact();
2315
- };
2316
- if (!deferPublish && !publish()) {
2317
- fail();
2318
- return;
2319
- }
2320
- for (const { sub, epoch } of deliveries) sub.feed.quarantine(epoch);
2321
- settled = true;
2322
- done({
2323
- ok: true,
2324
- fingerprint: snapshotFingerprint(captureLines, cursorLine, fallbackSize),
2325
- publish,
2326
- hold: () => {},
2327
- });
2328
- },
2329
- );
2330
- }
2331
-
2332
- private reseedRecoverySubscribersAtomic(
2333
- pane: PaneRecord,
2334
- recovery: RecoveryRecord,
2335
- done: (result: ReseedResult) => void,
2336
- deferPublish: boolean,
2337
- ): void {
2338
2255
  const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
2339
2256
  if (live.length === 0) {
2340
2257
  done(FAILED_RESEED_RESULT);
@@ -2342,7 +2259,8 @@ export class SessionChannel {
2342
2259
  }
2343
2260
  const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
2344
2261
  const nativeCapture =
2345
- !this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
2262
+ !this.nativeBootstrapUnavailable && participants.some(({ sub }) => sub.nativeBootstrap);
2263
+ const dualCapture = nativeCapture && participants.some(({ sub }) => !sub.nativeBootstrap);
2346
2264
  const reseedOrdinal = ++recovery.reseedOrdinal;
2347
2265
  const nonce = this.opts.generateAtomicHookNonce?.() ?? randomBytes(24).toString("hex");
2348
2266
  if (!/^[0-9a-f]{32,128}$/u.test(nonce)) {
@@ -2366,6 +2284,7 @@ export class SessionChannel {
2366
2284
  const participantsExact = (): boolean => {
2367
2285
  if (
2368
2286
  this.recoveryPane(recovery) !== pane ||
2287
+ !this.snapshotLeaseCurrent(lease) ||
2369
2288
  recovery.reseedOrdinal !== reseedOrdinal ||
2370
2289
  participants.some(
2371
2290
  ({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub),
@@ -2414,6 +2333,7 @@ export class SessionChannel {
2414
2333
  () => {},
2415
2334
  );
2416
2335
  };
2336
+ lease.cleanup = cleanupHook;
2417
2337
  const fail = (statusObserved = false): void => {
2418
2338
  if (settled) return;
2419
2339
  settled = true;
@@ -2435,25 +2355,30 @@ export class SessionChannel {
2435
2355
  fail();
2436
2356
  return;
2437
2357
  }
2358
+ const observerRequired = this.opts.internalReadHookEmission !== undefined;
2438
2359
  const safeObserver =
2439
2360
  observer !== null &&
2440
2361
  /^[A-Za-z0-9._-]{1,256}$/u.test(observer.bufferName) &&
2441
2362
  /^[A-Za-z0-9._-]{1,256}$/u.test(observer.signalChannel) &&
2442
2363
  /^[A-Za-z0-9%:._|-]{1,1024}$/u.test(observer.record);
2443
- if (!safeObserver) {
2364
+ if (observerRequired && !safeObserver) {
2444
2365
  fail();
2445
2366
  return;
2446
2367
  }
2447
2368
  const sentinel = (kind: string): string =>
2448
2369
  `display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
2449
- const observerCommands =
2450
- ` ; ${boundedTmuxInteractionAppendCommand(observer!.bufferName, observer!.record)}` +
2451
- ` ; wait-for -S ${observer!.signalChannel}`;
2370
+ const observerCommands = safeObserver
2371
+ ? ` ; ${boundedTmuxInteractionAppendCommand(observer!.bufferName, observer!.record)}` +
2372
+ ` ; wait-for -S ${observer!.signalChannel}`
2373
+ : "";
2452
2374
  const body =
2453
2375
  `set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
2454
2376
  ` ; ${sentinel("start")}` +
2455
2377
  ` ; capture-pane -p ${nativeCapture ? "-R" : "-e -J"} -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId}` +
2456
2378
  ` ; ${sentinel("capture-end")}` +
2379
+ (dualCapture
2380
+ ? ` ; capture-pane -p -e -J -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId} ; ${sentinel("ansi-capture-end")}`
2381
+ : "") +
2457
2382
  ` ; display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"` +
2458
2383
  ` ; ${sentinel("cursor-end")}` +
2459
2384
  ` ; refresh-client -A ${tmuxSingleQuote(`${pane.runtimeId}:continue`)}` +
@@ -2467,6 +2392,10 @@ export class SessionChannel {
2467
2392
  ` ; ${sentinel("complete")}`;
2468
2393
  this.input.flush();
2469
2394
  const invoke = (reply: { ok: boolean }): void => {
2395
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
2396
+ cleanupHook();
2397
+ return;
2398
+ }
2470
2399
  if (!reply.ok || !participantsExact()) {
2471
2400
  fail();
2472
2401
  return;
@@ -2478,6 +2407,8 @@ export class SessionChannel {
2478
2407
  fail();
2479
2408
  return;
2480
2409
  }
2410
+ lease.wireNonce = nonce;
2411
+ this.observedPaused.delete(pane.runtimeId);
2481
2412
  const armed = this.io.armAtomicPaneSnapshotCollector!(
2482
2413
  {
2483
2414
  nonce,
@@ -2485,7 +2416,16 @@ export class SessionChannel {
2485
2416
  maxCaptureBytes: RECOVERY_CAPTURE_MAX_BYTES,
2486
2417
  maxCaptureLines: RECOVERY_CAPTURE_MAX_LINES,
2487
2418
  maxCursorBytes: RECOVERY_CURSOR_MAX_BYTES,
2488
- observerCommandCount: 2,
2419
+ observerCommandCount: safeObserver ? 2 : 0,
2420
+ dualCapture,
2421
+ onDrained: (reason) => {
2422
+ if (lease.wireNonce === nonce) lease.wireNonce = null;
2423
+ if (reason === "channel-exit") {
2424
+ lease.retry = false;
2425
+ return;
2426
+ }
2427
+ this.releaseSnapshotLease(lease);
2428
+ },
2489
2429
  onProgress: (progress) => this.noteAtomicCollectorProgress(recovery, nonce, progress),
2490
2430
  onSettled: (result: AtomicPaneSnapshotResult) => {
2491
2431
  if (recovery.atomicCollectorNonce === nonce) recovery.atomicCollectorNonce = null;
@@ -2523,13 +2463,37 @@ export class SessionChannel {
2523
2463
  fail(result.statusObserved);
2524
2464
  return;
2525
2465
  }
2466
+ if (dualCapture && !result.ansiCaptureLines) {
2467
+ fail(result.statusObserved);
2468
+ return;
2469
+ }
2526
2470
  if (native) this.nativeBootstrapConfirmed = true;
2527
2471
  for (const { sub, epoch } of participants) {
2528
- if (native) sub.feed.captureNativeReply(epoch, native);
2529
- else sub.feed.captureReply(epoch, captureLines);
2472
+ if (native && sub.nativeBootstrap) sub.feed.captureNativeReply(epoch, native);
2473
+ else sub.feed.captureReply(epoch, result.ansiCaptureLines ?? captureLines);
2474
+ }
2475
+ const probe = parseCursorProbe(result.cursorLine);
2476
+ const layoutSize = this.layoutCaptureSizeFor(participants[0]!.sub);
2477
+ const pendingLayout =
2478
+ pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId);
2479
+ if (
2480
+ !probe ||
2481
+ probe.y >= probe.rows ||
2482
+ (native && (native.cols !== probe.cols || native.rows !== probe.rows))
2483
+ ) {
2484
+ fail(result.statusObserved);
2485
+ return;
2486
+ }
2487
+ if (
2488
+ pendingLayout ||
2489
+ (layoutSize && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows))
2490
+ ) {
2491
+ this.awaitSnapshotLayout(recovery);
2492
+ fail(result.statusObserved);
2493
+ return;
2530
2494
  }
2531
2495
  const fallbackSize = this.layoutSizeFor(pane.runtimeId);
2532
- this.observeScrollOnClear(pane, result.cursorLine!);
2496
+ this.observeSnapshotMetadata(pane, result.cursorLine!);
2533
2497
  const deliveries = participants.map(({ sub, epoch }) => ({
2534
2498
  sub,
2535
2499
  epoch,
@@ -2549,20 +2513,24 @@ export class SessionChannel {
2549
2513
  const publish = (): boolean => {
2550
2514
  if (published) return true;
2551
2515
  if (!participantsExact()) return false;
2552
- published = true;
2553
- for (const { sub, events } of deliveries)
2554
- for (const event of events) sub.onEvent(event);
2555
- return participantsExact();
2516
+ published = stock.publish(
2517
+ deliveries.map(({ sub, events }) => ({ participant: sub, events })),
2518
+ () => {
2519
+ if (!participantsExact()) throw new Error("snapshot lease retired");
2520
+ return this.snapshotContext(pane);
2521
+ },
2522
+ (participant, event) => (participant as SubRecord).onEvent(event),
2523
+ );
2524
+ return published && participantsExact();
2556
2525
  };
2557
2526
  if (!deferPublish && !publish()) {
2558
2527
  fail(true);
2559
2528
  return;
2560
2529
  }
2561
- for (const { sub, epoch } of deliveries) sub.feed.quarantine(epoch);
2562
2530
  settled = true;
2563
2531
  done({
2564
2532
  ok: true,
2565
- fingerprint: snapshotFingerprint(captureLines, result.cursorLine, fallbackSize),
2533
+ fingerprint: null,
2566
2534
  publish,
2567
2535
  hold: () => {},
2568
2536
  });
@@ -2571,6 +2539,7 @@ export class SessionChannel {
2571
2539
  remaining,
2572
2540
  );
2573
2541
  if (!armed) {
2542
+ lease.wireNonce = null;
2574
2543
  fail();
2575
2544
  return;
2576
2545
  }
@@ -2595,6 +2564,10 @@ export class SessionChannel {
2595
2564
  this.io.commandInline(
2596
2565
  `set-option -po -t ${pane.runtimeId} ${ownerName} ${nonce}`,
2597
2566
  (ownerReply) => {
2567
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
2568
+ cleanupHook();
2569
+ return;
2570
+ }
2598
2571
  if (!ownerReply.ok || !participantsExact()) {
2599
2572
  fail();
2600
2573
  return;
@@ -2602,6 +2575,10 @@ export class SessionChannel {
2602
2575
  this.io.commandInline(
2603
2576
  `set-option -po -t ${pane.runtimeId} ${expectedName} ${tmuxSingleQuote(body)}`,
2604
2577
  (expectedReply) => {
2578
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
2579
+ cleanupHook();
2580
+ return;
2581
+ }
2605
2582
  if (!expectedReply.ok || !participantsExact()) {
2606
2583
  fail();
2607
2584
  return;
@@ -2616,122 +2593,6 @@ export class SessionChannel {
2616
2593
  );
2617
2594
  }
2618
2595
 
2619
- private armRecoveryQuiet(recovery: RecoveryRecord, callback: () => void): void {
2620
- recovery.cancelQuiet?.();
2621
- recovery.cancelQuiet = this.scheduleRecovery(() => {
2622
- recovery.cancelQuiet = null;
2623
- if (this.recoveryPane(recovery)) callback();
2624
- }, RECOVERY_QUIET_MS);
2625
- }
2626
-
2627
- private beginFinalRecovery(recovery: RecoveryRecord): void {
2628
- const pane = this.recoveryPane(recovery);
2629
- if (!pane) return;
2630
- if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
2631
- this.failRecovery(recovery, "attempts-exhausted");
2632
- return;
2633
- }
2634
- recovery.attempts += 1;
2635
- recovery.stage = "final";
2636
- this.noteRecoveryProgress(recovery);
2637
- // The final read is only a private candidate. Publishing it here can put
2638
- // expensive replica projection ahead of the confirmation timers and, more
2639
- // importantly, exposes a snapshot that has not yet survived the two-read
2640
- // authority proof.
2641
- this.reseedRecoverySubscribers(
2642
- pane,
2643
- recovery,
2644
- ({ ok, fingerprint }) => {
2645
- const current = this.recoveryPane(recovery);
2646
- if (!current) return;
2647
- if (!ok || fingerprint === null) {
2648
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2649
- return;
2650
- }
2651
- recovery.outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
2652
- recovery.candidateFingerprint = fingerprint;
2653
- recovery.confirmationFingerprint = null;
2654
- recovery.confirmationOrdinal = 0;
2655
- recovery.stage = "confirm";
2656
- this.observeRecovery(current, recovery, "final-reseed");
2657
- this.noteRecoveryProgress(recovery);
2658
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2659
- },
2660
- true,
2661
- );
2662
- }
2663
-
2664
- private confirmRecovery(recovery: RecoveryRecord): void {
2665
- const pane = this.recoveryPane(recovery);
2666
- if (!pane) return;
2667
- if ((this.outputOrdinals.get(recovery.runtimeId) ?? 0) !== recovery.outputOrdinal) {
2668
- this.beginFinalRecovery(recovery);
2669
- return;
2670
- }
2671
- recovery.stage = "final";
2672
- this.noteRecoveryProgress(recovery);
2673
- this.reseedRecoverySubscribers(
2674
- pane,
2675
- recovery,
2676
- ({ ok, fingerprint, publish }) => {
2677
- const current = this.recoveryPane(recovery);
2678
- if (!current) return;
2679
- if (!ok || fingerprint === null) {
2680
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2681
- return;
2682
- }
2683
- const outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
2684
- const ordinalExact = outputOrdinal === recovery.outputOrdinal;
2685
- const candidateExact = ordinalExact && fingerprint === recovery.candidateFingerprint;
2686
- const consecutiveExact =
2687
- candidateExact &&
2688
- recovery.confirmationFingerprint !== null &&
2689
- fingerprint === recovery.confirmationFingerprint;
2690
- recovery.confirmationOrdinal += 1;
2691
- this.observeRecovery(current, recovery, "confirmation-reseed", null, consecutiveExact);
2692
- this.noteRecoveryProgress(recovery);
2693
- if (!ordinalExact) {
2694
- recovery.stage = "confirm";
2695
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2696
- return;
2697
- }
2698
- if (!candidateExact) {
2699
- if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
2700
- this.failRecovery(recovery, "attempts-exhausted");
2701
- return;
2702
- }
2703
- // A changed confirmation replaces the private candidate without
2704
- // becoming observable. Two subsequent reads must confirm this truth.
2705
- recovery.attempts += 1;
2706
- recovery.candidateFingerprint = fingerprint;
2707
- recovery.confirmationFingerprint = null;
2708
- recovery.outputOrdinal = outputOrdinal;
2709
- recovery.stage = "confirm";
2710
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2711
- return;
2712
- }
2713
- if (!consecutiveExact) {
2714
- recovery.confirmationFingerprint = fingerprint;
2715
- recovery.outputOrdinal = outputOrdinal;
2716
- recovery.stage = "confirm";
2717
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2718
- return;
2719
- }
2720
- // This publisher belongs to the current second matching read, not the
2721
- // older candidate. Publish exactly once while every participant is
2722
- // still fenced, then synchronously retire recovery before downstream
2723
- // projection work can run.
2724
- if (!publish()) {
2725
- recovery.stage = "confirm";
2726
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2727
- return;
2728
- }
2729
- this.convergeRecovery(current, recovery);
2730
- },
2731
- true,
2732
- );
2733
- }
2734
-
2735
2596
  private convergeRecovery(pane: PaneRecord, recovery: RecoveryRecord): void {
2736
2597
  recovery.cancelCommandDeadline?.();
2737
2598
  recovery.cancelCommandDeadline = null;
@@ -2741,37 +2602,26 @@ export class SessionChannel {
2741
2602
  recovery.cancelAbsoluteDeadline = null;
2742
2603
  this.recoveries.delete(recovery.runtimeId);
2743
2604
  recovery.retired = true;
2744
- this.retireContinueNotificationOwner(recovery);
2745
2605
  this.ledger.noteContinued(recovery.runtimeId);
2746
2606
  if (recovery.reason === "requested") this.ledger.clearRequest(recovery.runtimeId);
2747
2607
  for (const sub of pane.subs) {
2748
2608
  if (!sub.frozen && !sub.closed) {
2749
2609
  sub.feed.releaseQuarantine();
2750
- sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
2610
+ try {
2611
+ sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
2612
+ } catch {
2613
+ // A consumer cannot strand shared snapshot admission for other panes.
2614
+ }
2751
2615
  }
2752
2616
  }
2753
- this.observeRecovery(pane, recovery, "converged", null, true);
2754
- }
2755
-
2756
- private noteRecoveryOutput(pane: PaneRecord, outputOrdinal: number): void {
2757
- const recovery = this.recoveries.get(pane.runtimeId);
2758
- if (!recovery || recovery.paneIncarnation !== pane.incarnation) return;
2759
- recovery.outputOrdinal = outputOrdinal;
2760
- if (recovery.continueReply) this.noteRecoveryProgress(recovery);
2761
- if (recovery.stage === "quiet")
2762
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2763
- else if (recovery.stage === "confirm")
2764
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2617
+ this.observeRecovery(pane, recovery, "converged", null);
2618
+ if (recovery.lease) this.retireSnapshotLease(recovery.lease);
2765
2619
  }
2766
2620
 
2767
2621
  private restartRecoveryAfterOutputOverflow(pane: PaneRecord): void {
2768
2622
  const recovery = this.recoveries.get(pane.runtimeId);
2769
- if (recovery?.paneIncarnation === pane.incarnation) {
2770
- if (recovery.stage === "quiet" || recovery.stage === "confirm")
2771
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2772
- return;
2773
- }
2774
- this.beginLocalOverflowRecovery(pane);
2623
+ if (recovery) this.retrySnapshot(recovery);
2624
+ else this.beginLocalOverflowRecovery(pane);
2775
2625
  }
2776
2626
 
2777
2627
  private failRecovery(
@@ -2780,8 +2630,6 @@ export class SessionChannel {
2780
2630
  ): void {
2781
2631
  const pane = this.recoveryPane(recovery);
2782
2632
  if (!pane) return;
2783
- recovery.cancelQuiet?.();
2784
- recovery.cancelQuiet = null;
2785
2633
  recovery.cancelCommandDeadline?.();
2786
2634
  recovery.cancelCommandDeadline = null;
2787
2635
  recovery.cancelNoProgressDeadline?.();
@@ -2789,12 +2637,13 @@ export class SessionChannel {
2789
2637
  recovery.cancelAbsoluteDeadline?.();
2790
2638
  recovery.cancelAbsoluteDeadline = null;
2791
2639
  recovery.retired = true;
2640
+ this.snapshotQueue.delete(recovery.runtimeId);
2641
+ this.recoveries.delete(recovery.runtimeId);
2642
+ for (const sub of pane.subs) sub.feed.abortCurrent();
2643
+ if (recovery.lease) this.retireSnapshotLease(recovery.lease);
2792
2644
  const collectorNonce = recovery.atomicCollectorNonce;
2793
2645
  recovery.atomicCollectorNonce = null;
2794
2646
  if (collectorNonce) this.io.retireAtomicPaneSnapshotCollector?.(collectorNonce, "retired");
2795
- this.recoveries.delete(recovery.runtimeId);
2796
- this.retireContinueNotificationOwner(recovery);
2797
- for (const sub of pane.subs) sub.feed.abortCurrent();
2798
2647
  this.observeRecovery(pane, recovery, "nonconverged", failureReason);
2799
2648
  for (const sub of pane.subs) {
2800
2649
  sub.cancelCapture?.();
@@ -2807,49 +2656,6 @@ export class SessionChannel {
2807
2656
  }
2808
2657
  }
2809
2658
 
2810
- private removeContinueNotificationOwner(recovery: RecoveryRecord): void {
2811
- const queue = this.continueNotificationQueues.get(recovery.runtimeId);
2812
- if (!queue) return;
2813
- const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
2814
- if (index < 0) return;
2815
- queue.splice(index, 1);
2816
- this.compactContinueNotificationQueue(recovery.runtimeId, queue);
2817
- }
2818
-
2819
- private retireContinueNotificationOwner(recovery: RecoveryRecord): void {
2820
- if (!recovery.continueReply) return;
2821
- const queue = this.continueNotificationQueues.get(recovery.runtimeId);
2822
- if (!queue) return;
2823
- const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
2824
- if (index < 0) return;
2825
- queue.splice(index, 1, { kind: "debt", count: 1, saturated: false });
2826
- this.compactContinueNotificationQueue(recovery.runtimeId, queue);
2827
- }
2828
-
2829
- private compactContinueNotificationQueue(
2830
- runtime: string,
2831
- queue: ContinueNotificationEntry[],
2832
- ): void {
2833
- for (let index = 1; index < queue.length; ) {
2834
- const previous = queue[index - 1];
2835
- const current = queue[index];
2836
- if (previous?.kind !== "debt" || current?.kind !== "debt") {
2837
- index += 1;
2838
- continue;
2839
- }
2840
- const total = previous.count + current.count;
2841
- previous.count = Math.min(total, MAX_CONTINUE_NOTIFICATION_DEBT);
2842
- previous.saturated =
2843
- previous.saturated || current.saturated || total > MAX_CONTINUE_NOTIFICATION_DEBT;
2844
- queue.splice(index, 1);
2845
- }
2846
- if (queue.length === 0) this.continueNotificationQueues.delete(runtime);
2847
- else this.continueNotificationQueues.set(runtime, queue);
2848
- }
2849
-
2850
- /** Continue + reseed EVERY backpressure-paused pane that still has an
2851
- * unfrozen subscriber. %pause is sticky and hits quiet panes after any
2852
- * stall — recovering only the noisy pane leaves siblings dark. */
2853
2659
  private recoverSticky(): void {
2854
2660
  for (const runtime of this.ledger.stickyRecoverySet()) {
2855
2661
  if (this.recoveries.has(runtime)) continue;
@@ -2866,6 +2672,12 @@ export class SessionChannel {
2866
2672
  sub.cancelCapture?.();
2867
2673
  const pane = sub.pane;
2868
2674
  pane.subs.delete(sub);
2675
+ const activeRecovery = this.recoveries.get(pane.runtimeId);
2676
+ if (
2677
+ activeRecovery &&
2678
+ [...pane.subs].some((candidate) => !candidate.closed && !candidate.frozen)
2679
+ )
2680
+ this.retrySnapshot(activeRecovery);
2869
2681
  if ([...pane.subs].every((candidate) => candidate.closed || candidate.frozen))
2870
2682
  this.cancelRecovery(pane.runtimeId);
2871
2683
  // Ticket return on departure: a pane parked by a now-gone subscriber must
@@ -2878,8 +2690,11 @@ export class SessionChannel {
2878
2690
 
2879
2691
  // ── Notifications (channel order is the invariant) ──────────────────────
2880
2692
 
2881
- private observeScrollOnClear(pane: PaneRecord, cursorLine: string): void {
2882
- const value = cursorLine.trim().split(/\s+/)[22];
2693
+ private observeSnapshotMetadata(pane: PaneRecord, cursorLine: string): void {
2694
+ const fields = cursorLine.trim().split(/\s+/);
2695
+ const historySize = Number(fields[14]);
2696
+ if (Number.isSafeInteger(historySize) && historySize >= 0) pane.historySize = historySize;
2697
+ const value = fields[22];
2883
2698
  pane.scrollOnClear = value === "0" || value === "1" ? value === "1" : undefined;
2884
2699
  }
2885
2700
 
@@ -2932,13 +2747,21 @@ export class SessionChannel {
2932
2747
  return;
2933
2748
  }
2934
2749
  }
2935
- if (
2936
- name === "subscription-changed" &&
2937
- (/^tmux-ide-pane-borders\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+-\s*:\s*(top|bottom|off)\s*$/u.test(
2938
- rest,
2939
- ) ||
2940
- /^tmux-ide-copy-keys\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+-\s*:\s*(emacs|vi)\s*$/u.test(rest))
2941
- ) {
2750
+ const windowOptionHint =
2751
+ name === "subscription-changed"
2752
+ ? (/^tmux-ide-pane-borders\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:top|bottom|off)\s*$/u.exec(
2753
+ rest,
2754
+ ) ??
2755
+ /^tmux-ide-copy-keys\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:emacs|vi)\s*$/u.exec(
2756
+ rest,
2757
+ ))
2758
+ : null;
2759
+ if (windowOptionHint) {
2760
+ // Initial option samples from sibling windows are not evidence that this
2761
+ // pane's capture context changed. Unknown membership stays conservative.
2762
+ for (const pane of this.panesByRuntime.values())
2763
+ if (!pane.windowRuntimeId || pane.windowRuntimeId === windowOptionHint[1])
2764
+ pane.snapshotLayoutGeneration += 1;
2942
2765
  this.windowAuthorityOrdinal += 1;
2943
2766
  this.windowIdentityOrdinal += 1;
2944
2767
  this.scheduleSync();
@@ -2951,6 +2774,8 @@ export class SessionChannel {
2951
2774
  STRUCTURAL_NOTIFICATIONS.has(name)
2952
2775
  ) {
2953
2776
  this.windowAuthorityOrdinal += 1;
2777
+ // Structural and layout notifications remain conservative across all panes.
2778
+ for (const pane of this.panesByRuntime.values()) pane.snapshotLayoutGeneration += 1;
2954
2779
  if (name !== "layout-change") this.windowIdentityOrdinal += 1;
2955
2780
  }
2956
2781
  // Layout changes remain a second honest wake-up: a native resize can arrive
@@ -2965,8 +2790,18 @@ export class SessionChannel {
2965
2790
  if (name === "pause") {
2966
2791
  const runtime = rest.trim().split(/\s+/)[0] ?? "";
2967
2792
  if (!runtime.startsWith("%")) return;
2968
- if (this.recoveries.get(runtime)?.stage === "native-pause") {
2793
+ this.observedPaused.add(runtime);
2794
+ const stockRecovery = this.recoveries.get(runtime);
2795
+ if (stockRecovery?.stock && stockRecovery.lease) {
2796
+ this.ledger.notePause(runtime);
2797
+ if (stockRecovery.stage !== "stock-pause") this.retrySnapshot(stockRecovery);
2798
+ return;
2799
+ }
2800
+ const nativeRecovery = this.recoveries.get(runtime);
2801
+ if (nativeRecovery) {
2969
2802
  this.ledger.notePause(runtime);
2803
+ if (nativeRecovery.stage !== "native-pause" && nativeRecovery.stage !== "queued")
2804
+ this.retrySnapshot(nativeRecovery);
2970
2805
  return;
2971
2806
  }
2972
2807
  this.cancelRecovery(runtime);
@@ -2984,26 +2819,7 @@ export class SessionChannel {
2984
2819
  }
2985
2820
  if (name === "continue") {
2986
2821
  const runtime = rest.trim().split(/\s+/)[0] ?? "";
2987
- if (runtime.startsWith("%")) {
2988
- const queue = this.continueNotificationQueues.get(runtime);
2989
- const entry = queue?.[0] ?? null;
2990
- if (entry?.kind === "debt") {
2991
- if (!entry.saturated) {
2992
- entry.count -= 1;
2993
- if (entry.count === 0) queue!.shift();
2994
- }
2995
- this.compactContinueNotificationQueue(runtime, queue!);
2996
- } else if (entry?.kind === "owner") {
2997
- queue!.shift();
2998
- this.compactContinueNotificationQueue(runtime, queue!);
2999
- const owner = entry.recovery;
3000
- const pane = this.recoveryPane(owner);
3001
- owner.continueNotify = true;
3002
- if (pane) {
3003
- this.observeRecovery(pane, owner, "continue-notify");
3004
- }
3005
- }
3006
- }
2822
+ this.observedPaused.delete(runtime);
3007
2823
  return;
3008
2824
  }
3009
2825
  if (name === "layout-change") {
@@ -3019,6 +2835,14 @@ export class SessionChannel {
3019
2835
  return;
3020
2836
  }
3021
2837
  this.layoutNotificationOrdinals.set(change.windowId, this.windowAuthorityOrdinal);
2838
+ for (const pane of this.panesByRuntime.values()) {
2839
+ if (pane.windowRuntimeId !== change.windowId) continue;
2840
+ const recovery = this.recoveries.get(pane.runtimeId);
2841
+ if (recovery?.lease && !recovery.lease.retired) {
2842
+ this.awaitSnapshotLayout(recovery);
2843
+ this.retrySnapshot(recovery);
2844
+ }
2845
+ }
3022
2846
  const pendingLayout = {
3023
2847
  ...parsed,
3024
2848
  zoomed: change.zoomed,
@@ -3341,6 +3165,8 @@ export class SessionChannel {
3341
3165
  if (pane.windowRuntimeId && this.pendingLayoutOutput.has(pane.windowRuntimeId)) continue;
3342
3166
  for (const sub of pane.subs) sub.resumeLayoutCapture?.(syncOrdinal);
3343
3167
  }
3168
+ this.completedSyncOrdinal = Math.max(this.completedSyncOrdinal, syncOrdinal);
3169
+ this.drainSnapshots();
3344
3170
  this.discovery.discover(listed);
3345
3171
  }
3346
3172
 
@@ -3372,11 +3198,11 @@ export class SessionChannel {
3372
3198
  const nextWindowRuntimeId = this.truthWindow.get(runtime) ?? pane.windowRuntimeId;
3373
3199
  if (nextWindowRuntimeId !== pane.windowRuntimeId && nextWindowRuntimeId !== null)
3374
3200
  movedWindowRuntimeIds.add(nextWindowRuntimeId);
3201
+ if (pane.windowRuntimeId !== nextWindowRuntimeId) pane.snapshotLayoutGeneration += 1;
3375
3202
  pane.windowRuntimeId = nextWindowRuntimeId;
3376
3203
  continue;
3377
3204
  }
3378
3205
  this.cancelRecovery(runtime);
3379
- this.continueNotificationQueues.delete(runtime);
3380
3206
  this.panesByRuntime.delete(runtime);
3381
3207
  this.outputOrdinals.delete(runtime);
3382
3208
  this.panesBySemantic.delete(pane.semanticId);
@@ -3386,7 +3212,11 @@ export class SessionChannel {
3386
3212
  if (!sub.closed) {
3387
3213
  sub.closed = true;
3388
3214
  sub.cancelCapture?.();
3389
- sub.onEvent({ type: "closed" });
3215
+ try {
3216
+ sub.onEvent({ type: "closed" });
3217
+ } catch {
3218
+ // Close every sibling and the transport even if a consumer throws.
3219
+ }
3390
3220
  }
3391
3221
  }
3392
3222
  pane.subs.clear();
@@ -3997,7 +3827,6 @@ export class SessionChannel {
3997
3827
  // address's bytes are a different pane's now.
3998
3828
  const retiredRuntime = existingBySemantic.runtimeId;
3999
3829
  this.cancelRecovery(retiredRuntime);
4000
- this.continueNotificationQueues.delete(retiredRuntime);
4001
3830
  this.panesByRuntime.delete(retiredRuntime);
4002
3831
  this.outputOrdinals.delete(retiredRuntime);
4003
3832
  this.ledger.forget(retiredRuntime);
@@ -4005,6 +3834,8 @@ export class SessionChannel {
4005
3834
  existingBySemantic.incarnation = ++this.paneIncarnation;
4006
3835
  existingBySemantic.descriptor = descriptor;
4007
3836
  existingBySemantic.active = verified.active;
3837
+ if (existingBySemantic.windowRuntimeId !== windowRuntimeId)
3838
+ existingBySemantic.snapshotLayoutGeneration += 1;
4008
3839
  existingBySemantic.windowRuntimeId = windowRuntimeId;
4009
3840
  this.panesByRuntime.set(verified.runtimePaneId, existingBySemantic);
4010
3841
  for (const sub of existingBySemantic.subs) {
@@ -4015,6 +3846,8 @@ export class SessionChannel {
4015
3846
  if (existingByRuntime && existingByRuntime.semanticId === verified.semanticPaneId) {
4016
3847
  existingByRuntime.descriptor = descriptor;
4017
3848
  existingByRuntime.active = verified.active;
3849
+ if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
3850
+ existingByRuntime.snapshotLayoutGeneration += 1;
4018
3851
  existingByRuntime.windowRuntimeId = windowRuntimeId;
4019
3852
  continue;
4020
3853
  }
@@ -4023,7 +3856,6 @@ export class SessionChannel {
4023
3856
  // resolution). The old semantic id is gone.
4024
3857
  this.panesBySemantic.delete(existingByRuntime.semanticId);
4025
3858
  this.cancelRecovery(existingByRuntime.runtimeId);
4026
- this.continueNotificationQueues.delete(existingByRuntime.runtimeId);
4027
3859
  this.ledger.forget(existingByRuntime.runtimeId);
4028
3860
  this.outputOrdinals.delete(existingByRuntime.runtimeId);
4029
3861
  for (const sub of existingByRuntime.subs) {
@@ -4031,13 +3863,19 @@ export class SessionChannel {
4031
3863
  sub.closed = true;
4032
3864
  sub.cancelCapture?.();
4033
3865
  sub.feed.abortCurrent();
4034
- sub.onEvent({ type: "closed" });
3866
+ try {
3867
+ sub.onEvent({ type: "closed" });
3868
+ } catch {
3869
+ // Close every sibling and the transport even if a consumer throws.
3870
+ }
4035
3871
  }
4036
3872
  existingByRuntime.subs.clear();
4037
3873
  existingByRuntime.incarnation = ++this.paneIncarnation;
4038
3874
  existingByRuntime.semanticId = verified.semanticPaneId;
4039
3875
  existingByRuntime.descriptor = descriptor;
4040
3876
  existingByRuntime.active = verified.active;
3877
+ if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
3878
+ existingByRuntime.snapshotLayoutGeneration += 1;
4041
3879
  existingByRuntime.windowRuntimeId = windowRuntimeId;
4042
3880
  this.panesBySemantic.set(verified.semanticPaneId, existingByRuntime);
4043
3881
  continue;
@@ -4050,6 +3888,7 @@ export class SessionChannel {
4050
3888
  windowRuntimeId,
4051
3889
  subs: new Set(),
4052
3890
  incarnation: ++this.paneIncarnation,
3891
+ snapshotLayoutGeneration: 0,
4053
3892
  };
4054
3893
  this.panesByRuntime.set(record.runtimeId, record);
4055
3894
  this.panesBySemantic.set(record.semanticId, record);
@@ -4160,18 +3999,25 @@ export class SessionChannel {
4160
3999
 
4161
4000
  private onChannelExit(): void {
4162
4001
  if (this.disposed) return;
4002
+ this.snapshotActive?.cancelFence?.();
4003
+ this.snapshotActive = null;
4004
+ this.snapshotQueue.clear();
4005
+ for (const recovery of this.recoveries.values()) recovery.lease = undefined;
4163
4006
  this.opts.ownedViewer?.dispose();
4164
4007
  this.windowLinkAuthority?.dispose();
4165
4008
  this.nativeGrid.dispose();
4166
4009
  this.settleFirstJoin();
4167
4010
  for (const runtime of [...this.recoveries.keys()]) this.cancelRecovery(runtime);
4168
- this.continueNotificationQueues.clear();
4169
4011
  for (const pane of this.panesByRuntime.values()) {
4170
4012
  for (const sub of pane.subs) {
4171
4013
  if (!sub.closed) {
4172
4014
  sub.closed = true;
4173
4015
  sub.cancelCapture?.();
4174
- sub.onEvent({ type: "closed" });
4016
+ try {
4017
+ sub.onEvent({ type: "closed" });
4018
+ } catch {
4019
+ // Close every sibling and the transport even if a consumer throws.
4020
+ }
4175
4021
  }
4176
4022
  }
4177
4023
  pane.subs.clear();