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
@@ -30,7 +30,7 @@ import { decodeNativeGridCapture, isNativeBootstrapCapture, } from "./native-gri
30
30
  * keys flush pending literals first — the shared {@link InputCoalescer}
31
31
  * discipline — leaving fire-and-forget via the channel.
32
32
  */
33
- import { createHash, randomBytes } from "node:crypto";
33
+ import { randomBytes } from "node:crypto";
34
34
  import { hostname } from "node:os";
35
35
  import { WINDOW_LINK_MAX_LINKS, WORKSPACE_SEMANTIC_PANE_OPTION, WORKSPACE_SEMANTIC_WINDOW_OPTION, WorkspaceIdSchemaZ, } from "@tmux-ide/contracts";
36
36
  import { textToHexKeys } from "../protocol/control.js";
@@ -45,6 +45,7 @@ import { WindowLinkAuthority, WindowLinkResolutionError } from "./window-link-au
45
45
  import { buildNativeWindowLinkGuard, buildNativeWindowLinkPaneSelectGuard, classifyNativeWindowLinkGuardResult, } from "../../lib/tmux-window-link-guard.js";
46
46
  import { FlowLedger } from "./flow-ledger.js";
47
47
  import { PaneFeed, captureLinesFromAnsiBytes, parseCursorProbe } from "./pane-feed.js";
48
+ import { StockPaneSnapshot } from "./stock-pane-snapshot.js";
48
49
  import { INTERNAL_READ_OPERATION_OPTION, registerInternalReadOperation, retireInternalReadOperation, } from "../../lib/tmux-interaction-options.js";
49
50
  const TMUX_SERVER_HOSTNAME = hostname();
50
51
  /** Notifications whose payload cannot be applied directly — fall back to the
@@ -69,20 +70,17 @@ const NATIVE_CLIENT_SUBSCRIPTION = "tmux-ide-native-clients";
69
70
  const SYNC_DEBOUNCE_MS = 40;
70
71
  /** Foreground-command labels follow output, but never turn a busy pane into a probe loop. */
71
72
  const DISPLAY_NAME_SYNC_INTERVAL_MS = 750;
72
- const RECOVERY_QUIET_MS = 40;
73
73
  const RECOVERY_COMMAND_DEADLINE_MS = 500;
74
74
  const RECOVERY_NO_PROGRESS_DEADLINE_MS = 3_000;
75
75
  const RECOVERY_ABSOLUTE_DEADLINE_MS = 5_000;
76
76
  const RECOVERY_MAX_ATTEMPTS = 4;
77
- const MAX_QUEUED_PLAIN_RESEEDS = 64;
77
+ const MAX_QUEUED_SNAPSHOTS = 64;
78
78
  const RECOVERY_CAPTURE_MAX_BYTES = 16 * 1024 * 1024;
79
79
  // Budget row bookkeeping separately from wire bytes (64 bytes per retained
80
80
  // line). A fixed 8192-row ceiling rejected small captures of ordinary history.
81
81
  // Both the wire-byte cap and this finite allocation bound remain enforced.
82
82
  const RECOVERY_CAPTURE_MAX_LINES = RECOVERY_CAPTURE_MAX_BYTES / 64;
83
83
  const RECOVERY_CURSOR_MAX_BYTES = 1_024;
84
- const MAX_CONTINUE_NOTIFICATION_QUEUE = 32;
85
- const MAX_CONTINUE_NOTIFICATION_DEBT = 65_536;
86
84
  const RECOVERY_CURSOR_PROBE_FORMAT = [
87
85
  "#{cursor_x}",
88
86
  "#{cursor_y}",
@@ -125,24 +123,6 @@ const FAILED_RESEED_RESULT = Object.freeze({
125
123
  publish: () => false,
126
124
  hold: () => { },
127
125
  });
128
- function snapshotFingerprint(captureLines, cursorLine, fallbackSize) {
129
- const hash = createHash("sha256");
130
- const append = (bytes) => {
131
- const length = Buffer.allocUnsafe(4);
132
- length.writeUInt32BE(bytes.byteLength);
133
- hash.update(length);
134
- hash.update(bytes);
135
- };
136
- hash.update("tmux-ide/recovery-snapshot/v1\0");
137
- const count = Buffer.allocUnsafe(4);
138
- count.writeUInt32BE(captureLines.length);
139
- hash.update(count);
140
- for (const line of captureLines)
141
- append(Buffer.from(line, "latin1"));
142
- append(Buffer.from(cursorLine, "utf8"));
143
- append(Buffer.from(fallbackSize ? `${fallbackSize.cols}x${fallbackSize.rows}` : "no-layout-fallback", "ascii"));
144
- return hash.digest("hex");
145
- }
146
126
  // tmux concatenates adjacent quoted/unquoted fragments; doubled single quotes
147
127
  // do not escape a quote. Preserve nested commands across both parser passes.
148
128
  function tmuxSingleQuote(value) {
@@ -163,7 +143,6 @@ export function defaultMirrorWindowId() {
163
143
  export class SessionChannel {
164
144
  nativeBootstrapUnavailable = false;
165
145
  nativeBootstrapConfirmed = false;
166
- nativeProbeRetried = new WeakSet();
167
146
  opts;
168
147
  io;
169
148
  ledger = new FlowLedger();
@@ -190,6 +169,7 @@ export class SessionChannel {
190
169
  fittedWindows = new Map();
191
170
  cancelSync = null;
192
171
  syncOrdinal = 0;
172
+ completedSyncOrdinal = 0;
193
173
  lastDisplayNameSyncAtMs = 0;
194
174
  disposed = false;
195
175
  nativeGrid;
@@ -200,12 +180,12 @@ export class SessionChannel {
200
180
  windowIdentityOrdinal = 0;
201
181
  paneIncarnation = 0;
202
182
  recoveryOrdinal = 0;
203
- plainReseedActive = null;
204
- plainReseedQueue = new Map();
183
+ snapshotQueue = new Map();
184
+ snapshotActive = null;
185
+ observedPaused = new Set();
205
186
  outputOrdinals = new Map();
206
187
  pendingLayoutOutput = new Map();
207
188
  recoveries = new Map();
208
- continueNotificationQueues = new Map();
209
189
  trustedInventoryFlight = null;
210
190
  trustedInventoryFlightSessionId = null;
211
191
  attachedIdentity = null;
@@ -218,6 +198,19 @@ export class SessionChannel {
218
198
  input = new InputCoalescer((action) => {
219
199
  const startedAtMicros = action.traceIds?.length ? Math.floor(performance.now() * 1_000) : 0;
220
200
  const pendingBeforeSend = action.traceIds?.length ? (this.io.pendingCount ?? 0) : 0;
201
+ const pane = action.traceIds?.length ? this.panesByRuntime.get(action.pane) : undefined;
202
+ const semanticPaneId = !this.disposed && pane?.descriptor ? pane.semanticId : null;
203
+ const paneIncarnation = pane?.incarnation;
204
+ const paneActive = pane?.active;
205
+ const birth = pane?.descriptor?.nativePaneBirthId;
206
+ let complete;
207
+ try {
208
+ if (action.traceIds?.length)
209
+ complete = this.opts.captureInputWrite?.(action, semanticPaneId);
210
+ }
211
+ catch {
212
+ // Optional diagnostics must never suppress a dispatch.
213
+ }
221
214
  const onReply = action.traceIds?.length
222
215
  ? (reply) => this.opts.onInputAccepted?.(action, Math.floor(performance.now() * 1_000), reply.ok)
223
216
  : undefined;
@@ -234,6 +227,22 @@ export class SessionChannel {
234
227
  else {
235
228
  this.io.send(`send-keys -t ${action.pane} ${action.key}`, onReply);
236
229
  }
230
+ try {
231
+ complete?.(startedAtMicros, Math.floor(performance.now() * 1_000), pendingBeforeSend, !this.disposed &&
232
+ pane !== undefined &&
233
+ semanticPaneId !== null &&
234
+ this.panesByRuntime.get(action.pane) === pane &&
235
+ this.panesBySemantic.get(semanticPaneId) === pane &&
236
+ pane.runtimeId === action.pane &&
237
+ pane.semanticId === semanticPaneId &&
238
+ pane.incarnation === paneIncarnation &&
239
+ pane.active === paneActive &&
240
+ pane.descriptor !== null &&
241
+ pane.descriptor.nativePaneBirthId === birth);
242
+ }
243
+ catch {
244
+ // In particular, never replay an accepted owned-native dispatch.
245
+ }
237
246
  if (action.traceIds?.length)
238
247
  this.opts.onInputWrite?.(action, startedAtMicros, Math.floor(performance.now() * 1_000), pendingBeforeSend);
239
248
  }, (flush) => queueMicrotask(flush));
@@ -308,8 +317,12 @@ export class SessionChannel {
308
317
  this.captureWithViewer(runtime, ["-R", "-S", "-"], (value) => {
309
318
  marker = value;
310
319
  }, (reply) => {
311
- if (!reply.ok && marker)
312
- this.retireInternalReadMarker(runtime, marker);
320
+ if (marker) {
321
+ if (reply.ok)
322
+ this.clearInternalReadMarkerOption(runtime, marker);
323
+ else
324
+ this.retireInternalReadMarker(runtime, marker);
325
+ }
313
326
  onReply(reply);
314
327
  }, limits);
315
328
  }
@@ -610,6 +623,7 @@ export class SessionChannel {
610
623
  frozen: false,
611
624
  closed: false,
612
625
  };
626
+ sub.feed.abortCurrent();
613
627
  pane.subs.add(sub);
614
628
  // A paused pane gains an unfrozen watcher: release the park before the
615
629
  // seed so the capture reflects a flowing pane.
@@ -657,6 +671,13 @@ export class SessionChannel {
657
671
  close: () => this.closeSub(sub),
658
672
  };
659
673
  }
674
+ qualificationListeners() {
675
+ return Object.freeze({
676
+ pane: [...this.panesByRuntime.values()].reduce((sum, pane) => sum + pane.subs.size, 0),
677
+ layout: this.layoutSubscribers.size,
678
+ layoutAuthority: this.layoutAuthoritySubscribers.size,
679
+ });
680
+ }
660
681
  /** Session geometry without a dummy pane feed or terminal-content seed. */
661
682
  subscribeLayout(onLayout) {
662
683
  if (this.disposed)
@@ -863,6 +884,10 @@ export class SessionChannel {
863
884
  if (this.disposed)
864
885
  return;
865
886
  this.disposed = true;
887
+ this.snapshotActive?.cleanup?.();
888
+ this.snapshotActive?.cancelFence?.();
889
+ this.snapshotActive = null;
890
+ this.snapshotQueue.clear();
866
891
  this.windowLinkAuthority?.dispose();
867
892
  this.nativeGrid.dispose();
868
893
  this.settleFirstJoin();
@@ -870,7 +895,6 @@ export class SessionChannel {
870
895
  this.cancelSync = null;
871
896
  for (const runtime of [...this.recoveries.keys()])
872
897
  this.cancelRecovery(runtime);
873
- this.continueNotificationQueues.clear();
874
898
  this.discovery.dispose();
875
899
  this.input.flush();
876
900
  this.opts.ownedViewer?.dispose();
@@ -879,7 +903,12 @@ export class SessionChannel {
879
903
  if (!sub.closed) {
880
904
  sub.closed = true;
881
905
  sub.cancelCapture?.();
882
- sub.onEvent({ type: "closed" });
906
+ try {
907
+ sub.onEvent({ type: "closed" });
908
+ }
909
+ catch {
910
+ // Close every sibling and the transport even if a consumer throws.
911
+ }
883
912
  }
884
913
  }
885
914
  pane.subs.clear();
@@ -892,6 +921,24 @@ export class SessionChannel {
892
921
  }
893
922
  // ── Byte routing ─────────────────────────────────────────────────────────
894
923
  onOutput(runtimePane, data, ageMs, timing) {
924
+ const stockRecovery = this.recoveries.get(runtimePane);
925
+ const stockPane = this.panesByRuntime.get(runtimePane);
926
+ if (stockRecovery?.stock && stockPane && stockRecovery.lease) {
927
+ const disposition = stockRecovery.stock.acceptOutput(runtimePane, data, this.snapshotContext(stockPane));
928
+ if (disposition !== "live" && disposition !== "unrelated") {
929
+ const ordinal = (this.outputOrdinals.get(runtimePane) ?? 0) + 1;
930
+ this.outputOrdinals.set(runtimePane, ordinal);
931
+ this.opts.onOutputObserved?.(stockPane.semanticId, ageMs, timing);
932
+ if (ageMs !== null) {
933
+ this.ageByRuntime.set(runtimePane, ageMs);
934
+ if (ageMs > this.maxAgeMs)
935
+ this.maxAgeMs = ageMs;
936
+ }
937
+ if (disposition === "invalid")
938
+ this.retrySnapshot(stockRecovery);
939
+ return;
940
+ }
941
+ }
895
942
  const windowId = this.panesByRuntime.get(runtimePane)?.windowRuntimeId;
896
943
  const pending = windowId ? this.pendingLayoutOutput.get(windowId) : undefined;
897
944
  if (pending) {
@@ -933,328 +980,26 @@ export class SessionChannel {
933
980
  }
934
981
  if (overflowed)
935
982
  this.restartRecoveryAfterOutputOverflow(pane);
936
- this.noteRecoveryOutput(pane, outputOrdinal);
937
983
  }
938
984
  // ── Seed / reseed (the atomic recipe) ────────────────────────────────────
939
- reseed(lease, onSettled, deferPublish = false, deadlineAt = this.recoveryNowMs() + RECOVERY_ABSOLUTE_DEADLINE_MS) {
940
- const { sub } = lease;
941
- if (sub.closed || sub.frozen || this.disposed) {
942
- onSettled?.(FAILED_RESEED_RESULT);
943
- return;
944
- }
945
- lease.cancelRecipe?.();
946
- const runtime = sub.pane.runtimeId;
947
- const epoch = sub.feed.beginReseed();
948
- let settled = false;
949
- let captureSucceeded = false;
950
- let markerRetired = false;
951
- let captureLines = null;
952
- let capturedNativeSize = null;
953
- let cancelDeadline = null;
954
- let resumeLayoutCapture = null;
955
- const clearLayoutWait = () => {
956
- if (sub.resumeLayoutCapture === resumeLayoutCapture)
957
- sub.resumeLayoutCapture = null;
958
- resumeLayoutCapture = null;
959
- };
960
- const settle = (result) => {
961
- if (settled)
962
- return;
963
- settled = true;
964
- clearLayoutWait();
965
- cancelDeadline?.();
966
- lease.cancelRecipe = null;
967
- onSettled?.(result);
968
- };
969
- // Keystroke ordering: pending coalesced input leaves before the probes.
970
- this.input.flush();
971
- const history = this.opts.historyLines ?? "";
972
- let internalReadMarker = null;
973
- const retireMarker = () => {
974
- if (markerRetired)
975
- return;
976
- markerRetired = true;
977
- if (internalReadMarker)
978
- this.retireInternalReadMarker(runtime, internalReadMarker);
979
- };
980
- lease.cancelRecipe = () => {
981
- if (settled)
982
- return;
983
- settled = true;
984
- clearLayoutWait();
985
- cancelDeadline?.();
986
- lease.cancelRecipe = null;
987
- sub.feed.abort(epoch);
988
- if (!captureSucceeded)
989
- retireMarker();
990
- };
991
- cancelDeadline = this.scheduleRecovery(() => {
992
- if (settled)
993
- return;
994
- sub.feed.abort(epoch);
995
- if (!captureSucceeded)
996
- retireMarker();
997
- settle(FAILED_RESEED_RESULT);
998
- }, Math.max(0, deadlineAt - this.recoveryNowMs()));
999
- // Keep retired reply slots in the control FIFO; their callbacks become
1000
- // no-ops so late responses cannot publish or consume a newer capture.
1001
- // Both probes ride one write burst; the FIFO reply order is the seam.
1002
- this.captureWithViewer(runtime, [...(sub.nativeBootstrap ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`], (marker) => {
1003
- internalReadMarker = marker;
1004
- }, (reply) => {
1005
- if (settled)
1006
- return;
1007
- const native = sub.nativeBootstrap && reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
1008
- if (sub.nativeBootstrap &&
1009
- (!native || !isNativeBootstrapCapture(native)) &&
1010
- !nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
1011
- if (!this.nativeBootstrapConfirmed &&
1012
- !this.nativeProbeRetried.has(sub) &&
1013
- this.recoveryNowMs() < deadlineAt) {
1014
- // An unknown server can swallow an unsupported command inside a
1015
- // dynamic hook. Retry the ordinary probe once, with a fresh FIFO
1016
- // seam and the original deadline, before invoking native recovery.
1017
- this.nativeProbeRetried.add(sub);
1018
- settled = true;
1019
- sub.feed.abort(epoch);
1020
- cancelDeadline?.();
1021
- lease.cancelRecipe = null;
1022
- retireMarker();
1023
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1024
- return;
1025
- }
1026
- sub.feed.abort(epoch);
1027
- retireMarker();
1028
- settle(FAILED_RESEED_RESULT);
1029
- return;
1030
- }
1031
- if (sub.nativeBootstrap && (!native || !isNativeBootstrapCapture(native))) {
1032
- // Unsupported/backing-only native exports fall back at a new FIFO
1033
- // capture seam. Never replay held bytes across these two captures.
1034
- settled = true;
1035
- retireMarker();
1036
- sub.feed.abort(epoch);
1037
- cancelDeadline?.();
1038
- lease.cancelRecipe = null;
1039
- sub.nativeBootstrap = false;
1040
- this.nativeBootstrapUnavailable = true;
1041
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1042
- return;
1043
- }
1044
- if (!reply.ok) {
1045
- // Successful captures consume the marker atomically inside the tmux
1046
- // after-capture-pane hook. The command-list also prevents a concurrent
1047
- // mirror from stealing the marker. Only failures need cleanup.
1048
- retireMarker();
1049
- sub.feed.abort(epoch);
1050
- settle(FAILED_RESEED_RESULT);
1051
- return;
1052
- }
1053
- captureSucceeded = true;
1054
- if (sub.closed || sub.frozen || this.disposed) {
1055
- sub.feed.abort(epoch);
1056
- settle(FAILED_RESEED_RESULT);
1057
- return;
1058
- }
1059
- captureLines = [...reply.lines];
1060
- if (native) {
1061
- this.nativeBootstrapConfirmed = true;
1062
- capturedNativeSize = { cols: native.cols, rows: native.rows };
1063
- }
1064
- if (native)
1065
- sub.feed.captureNativeReply(epoch, native);
1066
- else
1067
- sub.feed.captureReply(epoch, reply.lines);
1068
- });
1069
- this.io.commandInline(`display-message -p -t ${runtime} "${RECOVERY_CURSOR_PROBE_FORMAT}"`, (reply) => {
1070
- if (settled)
1071
- return;
1072
- if (sub.closed || sub.frozen || this.disposed) {
1073
- sub.feed.abort(epoch);
1074
- settle(FAILED_RESEED_RESULT);
1075
- return;
1076
- }
1077
- if (!reply.ok) {
1078
- if (!captureSucceeded)
1079
- retireMarker();
1080
- sub.feed.abort(epoch);
1081
- settle(FAILED_RESEED_RESULT);
1082
- return;
1083
- }
1084
- // Native/cursor replies can precede the paired layout/border reply.
1085
- // Never publish that candidate against old authority, nor replay held
1086
- // output over its snapshot. Recapture after the latest layout releases,
1087
- // retaining this recipe's original deadline and one queue lease.
1088
- const windowId = sub.pane.windowRuntimeId;
1089
- const probe = parseCursorProbe(reply.lines[0] ?? "");
1090
- const validCaptureGeometry = probe &&
1091
- Number.isSafeInteger(probe.x) &&
1092
- Number.isSafeInteger(probe.y) &&
1093
- probe.y < probe.rows &&
1094
- (!capturedNativeSize ||
1095
- (capturedNativeSize.cols === probe.cols && capturedNativeSize.rows === probe.rows));
1096
- const layoutSize = validCaptureGeometry ? this.layoutCaptureSizeFor(sub) : null;
1097
- const pendingLayout = windowId !== null && this.pendingLayoutOutput.has(windowId);
1098
- const aheadOfLayout = layoutSize && probe && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows);
1099
- if (windowId && layoutSize && validCaptureGeometry && (pendingLayout || aheadOfLayout)) {
1100
- sub.feed.abort(epoch);
1101
- captureLines = null;
1102
- const waitingAfterSyncOrdinal = this.syncOrdinal;
1103
- resumeLayoutCapture = (completedSyncOrdinal) => {
1104
- if (settled)
1105
- return;
1106
- if (this.recoveryNowMs() >= deadlineAt) {
1107
- settle(FAILED_RESEED_RESULT);
1108
- return;
1109
- }
1110
- const current = this.layoutCaptureSizeFor(sub);
1111
- // Only a successful truth sync started after this wait is a causal
1112
- // barrier, including resize-back to identical geometry. An older
1113
- // in-flight sync and cached layout emissions cannot qualify it.
1114
- if (!current)
1115
- return;
1116
- if (completedSyncOrdinal !== undefined) {
1117
- if (completedSyncOrdinal <= waitingAfterSyncOrdinal)
1118
- return;
1119
- }
1120
- else if (!pendingLayout &&
1121
- current.cols === layoutSize.cols &&
1122
- current.rows === layoutSize.rows)
1123
- return;
1124
- settled = true;
1125
- clearLayoutWait();
1126
- cancelDeadline?.();
1127
- lease.cancelRecipe = null;
1128
- this.reseed(lease, onSettled, deferPublish, deadlineAt);
1129
- };
1130
- sub.resumeLayoutCapture = resumeLayoutCapture;
1131
- if (!pendingLayout)
1132
- this.scheduleSync();
1133
- return;
1134
- }
1135
- const cursorLine = reply.lines[0] ?? "";
1136
- const historySize = Number(cursorLine.trim().split(/\s+/)[14]);
1137
- if (Number.isSafeInteger(historySize) && historySize >= 0)
1138
- sub.pane.historySize = historySize;
1139
- const fallbackSize = this.layoutSizeFor(runtime);
1140
- this.observeScrollOnClear(sub.pane, cursorLine);
1141
- const events = sub.feed.cursorReply(epoch, cursorLine, fallbackSize);
1142
- let published = false;
1143
- const publish = () => {
1144
- if (published)
1145
- return true;
1146
- if (sub.closed || sub.frozen || this.disposed)
1147
- return false;
1148
- published = true;
1149
- for (const event of events)
1150
- sub.onEvent(event);
1151
- return true;
1152
- };
1153
- const ok = events.length > 0 && !sub.closed && captureLines !== null;
1154
- const result = {
1155
- ok,
1156
- fingerprint: ok ? snapshotFingerprint(captureLines, cursorLine, fallbackSize) : null,
1157
- publish,
1158
- hold: () => sub.feed.quarantine(epoch),
1159
- };
1160
- if (!deferPublish)
1161
- publish();
1162
- settle(result);
1163
- });
1164
- }
1165
985
  reseedPlain(sub, resumeReason = null) {
1166
986
  if (sub.closed || sub.frozen || this.disposed)
1167
987
  return;
1168
- // Layout observers can request a reseed reentrantly during admission. The
1169
- // waiting recipe already owns that request and its absolute deadline.
1170
- if (sub.resumeLayoutCapture)
1171
- return;
1172
- if (this.plainReseedActive?.sub === sub)
1173
- sub.cancelCapture?.();
1174
- if (!this.plainReseedQueue.has(sub) && this.plainReseedQueue.size >= MAX_QUEUED_PLAIN_RESEEDS) {
1175
- this.failPlainReseed(sub);
1176
- return;
1177
- }
1178
- this.plainReseedQueue.set(sub, resumeReason);
1179
- if (this.plainReseedActive?.sub !== sub) {
1180
- // Output before this queued recipe starts is already in its future
1181
- // capture. Do not publish it using the prior geometry in the meantime.
1182
- sub.feed.beginReseed();
1183
- sub.cancelCapture = () => {
1184
- this.plainReseedQueue.delete(sub);
1185
- sub.cancelCapture = null;
1186
- sub.feed.abortCurrent();
1187
- };
1188
- }
1189
- this.drainPlainReseeds();
1190
- }
1191
- failPlainReseed(sub) {
1192
- if (sub.closed ||
1193
- sub.frozen ||
1194
- this.disposed ||
1195
- this.recoveries.has(sub.pane.runtimeId) ||
1196
- this.panesByRuntime.get(sub.pane.runtimeId) !== sub.pane)
1197
- return;
1198
- this.beginLocalOverflowRecovery(sub.pane);
1199
- }
1200
- drainPlainReseeds() {
1201
- if (this.plainReseedActive || this.disposed)
1202
- return;
1203
- const next = this.plainReseedQueue.entries().next().value;
1204
- if (!next)
1205
- return;
1206
- const [sub, resumeReason] = next;
1207
- this.plainReseedQueue.delete(sub);
1208
- if (sub.closed || sub.frozen) {
1209
- this.drainPlainReseeds();
1210
- return;
1211
- }
1212
- sub.cancelCapture?.();
1213
- const lease = { sub, cancelRecipe: null };
1214
- this.plainReseedActive = lease;
1215
- // The queue lease outlives individual native capability probes. Retrying
1216
- // or falling back replaces only cancelRecipe, never this queue owner.
1217
- sub.cancelCapture = () => {
1218
- if (this.plainReseedActive !== lease)
1219
- return;
1220
- lease.cancelRecipe?.();
1221
- sub.cancelCapture = null;
1222
- this.plainReseedActive = null;
1223
- this.drainPlainReseeds();
1224
- };
1225
- // Only the recipe actually submitted to the control FIFO owns a five
1226
- // second deadline. A large sibling projection cannot consume the waiting
1227
- // panes' entire capture budgets before their commands have been written.
1228
- this.reseed(lease, (result) => {
1229
- if (this.plainReseedActive !== lease)
1230
- return;
1231
- sub.cancelCapture = null;
1232
- this.plainReseedActive = null;
1233
- if (!result.ok) {
1234
- // A stalled FIFO cannot make progress for queued recipes either. Retire
1235
- // them together instead of granting each another wait behind that slot.
1236
- const waiting = [...this.plainReseedQueue.keys()];
1237
- this.plainReseedQueue.clear();
1238
- for (const queued of waiting)
1239
- queued.cancelCapture?.();
1240
- this.failPlainReseed(sub);
1241
- for (const queued of waiting)
1242
- this.failPlainReseed(queued);
1243
- return;
1244
- }
1245
- // Admit the next recipe before publishing this potentially large seed.
1246
- this.drainPlainReseeds();
1247
- result.publish();
1248
- if (resumeReason && !sub.closed && !sub.frozen)
1249
- sub.onEvent({ type: "flow", state: "resumed", reason: resumeReason });
1250
- }, true);
988
+ this.beginRecovery(sub.pane, resumeReason ?? "requested");
1251
989
  }
1252
990
  retireInternalReadMarker(runtime, marker) {
1253
991
  if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
1254
992
  throw new TypeError("internal read cleanup requires a runtime pane id");
1255
993
  retireInternalReadOperation(marker, runtime);
994
+ this.clearInternalReadMarkerOption(runtime, marker);
995
+ }
996
+ clearInternalReadMarkerOption(runtime, marker) {
997
+ if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
998
+ throw new TypeError("internal read cleanup requires a runtime pane id");
999
+ // Success must keep the in-memory proof redeemable by a delayed observer.
1000
+ // The server option must still be removed when no observer hook is installed.
1256
1001
  // Pane capture phases overlap under cancellation. Clear only the exact
1257
- // failed marker so a late A callback cannot erase the newer B authority.
1002
+ // owned marker so a late A callback cannot erase the newer B authority.
1258
1003
  // Both selected branches must emit one reply in addition to if-shell's
1259
1004
  // own reply. An empty false branch emits none and shifts the control FIFO
1260
1005
  // whenever the true branch runs during capture cancellation.
@@ -1301,6 +1046,10 @@ export class SessionChannel {
1301
1046
  return;
1302
1047
  sub.frozen = true;
1303
1048
  sub.cancelCapture?.();
1049
+ const activeRecovery = this.recoveries.get(sub.pane.runtimeId);
1050
+ if (activeRecovery &&
1051
+ [...sub.pane.subs].some((candidate) => !candidate.closed && !candidate.frozen))
1052
+ this.retrySnapshot(activeRecovery);
1304
1053
  sub.onEvent({ type: "flow", state: "paused", reason: "requested" });
1305
1054
  const pane = sub.pane;
1306
1055
  const allFrozen = [...pane.subs].every((candidate) => candidate.frozen || candidate.closed);
@@ -1376,40 +1125,49 @@ export class SessionChannel {
1376
1125
  if (!recovery)
1377
1126
  return;
1378
1127
  recovery.retired = true;
1379
- recovery.cancelQuiet?.();
1128
+ this.recoveries.delete(runtime);
1129
+ this.snapshotQueue.delete(runtime);
1380
1130
  recovery.cancelCommandDeadline?.();
1381
1131
  recovery.cancelNoProgressDeadline?.();
1382
1132
  recovery.cancelAbsoluteDeadline?.();
1383
- if (recovery.atomicCollectorNonce)
1384
- this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
1385
- recovery.atomicCollectorNonce = null;
1386
- this.recoveries.delete(runtime);
1387
- this.retireContinueNotificationOwner(recovery);
1388
1133
  const pane = this.panesByRuntime.get(runtime);
1389
1134
  if (pane?.incarnation === recovery.paneIncarnation)
1390
1135
  for (const sub of pane.subs)
1391
1136
  sub.feed.abortCurrent();
1137
+ if (recovery.lease)
1138
+ this.retireSnapshotLease(recovery.lease);
1139
+ else if (recovery.atomicCollectorNonce)
1140
+ this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
1141
+ recovery.atomicCollectorNonce = null;
1392
1142
  }
1393
1143
  beginRecovery(pane, reason) {
1394
- for (const sub of pane.subs)
1395
- sub.cancelCapture?.();
1396
- const runtime = pane.runtimeId;
1397
- this.cancelRecovery(runtime);
1144
+ if (this.disposed)
1145
+ return;
1146
+ const current = this.recoveries.get(pane.runtimeId);
1147
+ if (current && !current.retired) {
1148
+ if (this.snapshotQueue.get(pane.runtimeId) === current)
1149
+ return;
1150
+ this.retrySnapshot(current);
1151
+ return;
1152
+ }
1153
+ if (this.snapshotQueue.size >= MAX_QUEUED_SNAPSHOTS) {
1154
+ for (const sub of pane.subs) {
1155
+ sub.feed.abortCurrent();
1156
+ if (!sub.closed && !sub.frozen)
1157
+ sub.onEvent({ type: "fault", reason: "native-recovery-failed" });
1158
+ }
1159
+ return;
1160
+ }
1398
1161
  const recovery = {
1399
1162
  ordinal: ++this.recoveryOrdinal,
1400
- runtimeId: runtime,
1163
+ runtimeId: pane.runtimeId,
1401
1164
  paneIncarnation: pane.incarnation,
1402
1165
  reason,
1403
1166
  startedAtMs: this.recoveryNowMs(),
1404
1167
  retired: false,
1405
- continueReply: false,
1406
- continueNotify: false,
1407
- stage: "continue",
1168
+ stage: "queued",
1408
1169
  attempts: 0,
1409
1170
  reseedOrdinal: 0,
1410
- outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
1411
- candidateFingerprint: null,
1412
- confirmationFingerprint: null,
1413
1171
  confirmationOrdinal: 0,
1414
1172
  atomicCollectorNonce: null,
1415
1173
  collectorStarted: false,
@@ -1420,103 +1178,349 @@ export class SessionChannel {
1420
1178
  collectorStatusObserved: false,
1421
1179
  collectorObserverEmissionObserved: false,
1422
1180
  collectorFailureReason: null,
1423
- cancelQuiet: null,
1424
1181
  cancelCommandDeadline: null,
1425
1182
  cancelNoProgressDeadline: null,
1426
1183
  cancelAbsoluteDeadline: null,
1427
1184
  };
1428
- this.recoveries.set(runtime, recovery);
1429
- for (const sub of pane.subs) {
1430
- if (!sub.frozen && !sub.closed)
1431
- sub.feed.abortCurrent();
1185
+ this.recoveries.set(pane.runtimeId, recovery);
1186
+ this.snapshotQueue.set(pane.runtimeId, recovery);
1187
+ this.drainSnapshots();
1188
+ }
1189
+ beginLocalOverflowRecovery(pane) {
1190
+ this.beginRecovery(pane, "backpressure");
1191
+ }
1192
+ snapshotContext(pane) {
1193
+ return {
1194
+ paneId: pane.runtimeId,
1195
+ incarnation: pane.incarnation,
1196
+ layoutGeneration: pane.snapshotLayoutGeneration,
1197
+ participants: [...pane.subs].filter((sub) => !sub.closed && !sub.frozen),
1198
+ };
1199
+ }
1200
+ snapshotLeaseCurrent(lease) {
1201
+ return (this.snapshotActive === lease && !lease.retired && this.recoveryPane(lease.recovery) !== null);
1202
+ }
1203
+ drainSnapshots() {
1204
+ if (this.snapshotActive || this.disposed)
1205
+ return;
1206
+ for (const [runtime, recovery] of this.snapshotQueue) {
1207
+ if (recovery.waitForSyncAfter !== undefined &&
1208
+ this.completedSyncOrdinal <= recovery.waitForSyncAfter)
1209
+ continue;
1210
+ this.snapshotQueue.delete(runtime);
1211
+ const pane = this.recoveryPane(recovery);
1212
+ if (!pane || ![...pane.subs].some((sub) => !sub.closed && !sub.frozen)) {
1213
+ this.cancelRecovery(runtime);
1214
+ continue;
1215
+ }
1216
+ if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
1217
+ this.failRecovery(recovery, "attempts-exhausted");
1218
+ continue;
1219
+ }
1220
+ const lease = {
1221
+ recovery,
1222
+ retired: false,
1223
+ wireNonce: null,
1224
+ retry: false,
1225
+ fencePending: false,
1226
+ cancelFence: null,
1227
+ };
1228
+ this.snapshotActive = lease;
1229
+ recovery.lease = lease;
1230
+ if (!recovery.cancelAbsoluteDeadline) {
1231
+ recovery.startedAtMs = this.recoveryNowMs();
1232
+ this.beginRecoveryConvergence(recovery);
1233
+ }
1234
+ else
1235
+ this.noteRecoveryProgress(recovery);
1236
+ for (const sub of pane.subs)
1237
+ if (!sub.closed && !sub.frozen)
1238
+ sub.feed.abortCurrent();
1239
+ const target = this.nativeRecoveryTarget(pane);
1240
+ if (recovery.nativeOwner &&
1241
+ (target?.serverEpoch !== recovery.nativeOwner.serverEpoch ||
1242
+ target?.paneBirthId !== recovery.nativeOwner.paneBirthId)) {
1243
+ this.failRecovery(recovery, "command-error");
1244
+ return;
1245
+ }
1246
+ if (target) {
1247
+ recovery.nativeOwner ??= target;
1248
+ recovery.stock = undefined;
1249
+ this.recoverNativeAtomic(pane, recovery, target);
1250
+ }
1251
+ else
1252
+ this.startStockSnapshot(pane, recovery, lease);
1253
+ return;
1432
1254
  }
1433
- this.observeRecovery(pane, recovery, "pause");
1434
- const nativeTarget = this.nativeRecoveryTarget(pane);
1435
- if (nativeTarget) {
1436
- this.beginRecoveryConvergence(recovery);
1437
- this.recoverNativeAtomic(pane, recovery, nativeTarget);
1255
+ }
1256
+ releaseSnapshotLease(lease) {
1257
+ if (this.snapshotActive !== lease || lease.wireNonce || lease.fencePending)
1258
+ return;
1259
+ lease.cancelFence?.();
1260
+ lease.cancelFence = null;
1261
+ const pane = this.panesByRuntime.get(lease.recovery.runtimeId);
1262
+ if (lease.ownsPause &&
1263
+ pane?.incarnation === lease.recovery.paneIncarnation &&
1264
+ pane.subs.size === 0 &&
1265
+ !this.ledger.isRequested(pane.runtimeId)) {
1266
+ this.io.commandInline(`refresh-client -A '${pane.runtimeId}:continue'`, () => { });
1267
+ this.ledger.noteContinued(pane.runtimeId);
1268
+ }
1269
+ this.snapshotActive = null;
1270
+ if (lease.recovery.lease === lease)
1271
+ lease.recovery.lease = undefined;
1272
+ if (lease.retry && this.recoveryPane(lease.recovery)) {
1273
+ lease.recovery.stage = "queued";
1274
+ this.snapshotQueue.set(lease.recovery.runtimeId, lease.recovery);
1275
+ }
1276
+ this.drainSnapshots();
1277
+ }
1278
+ retireSnapshotLease(lease, retry = false) {
1279
+ if (this.snapshotActive !== lease)
1280
+ return;
1281
+ lease.retired = true;
1282
+ if (this.disposed) {
1283
+ lease.cancelFence?.();
1284
+ lease.cancelFence = null;
1285
+ this.snapshotActive = null;
1286
+ this.snapshotQueue.clear();
1438
1287
  return;
1439
1288
  }
1440
- this.observeRecovery(pane, recovery, "continue-request");
1441
- recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
1442
- if (this.recoveryPane(recovery) && !recovery.continueReply)
1443
- this.failRecovery(recovery, "command-timeout");
1444
- }, RECOVERY_COMMAND_DEADLINE_MS);
1445
- const queue = this.continueNotificationQueues.get(runtime) ?? [];
1446
- if (queue.length >= MAX_CONTINUE_NOTIFICATION_QUEUE) {
1447
- this.failRecovery(recovery, "notification-queue-overflow");
1289
+ lease.retry ||= retry;
1290
+ lease.recovery.stock?.invalidate();
1291
+ lease.cleanup?.();
1292
+ lease.cleanup = undefined;
1293
+ lease.recovery.cancelCommandDeadline?.();
1294
+ lease.recovery.cancelCommandDeadline = null;
1295
+ if (lease.wireNonce) {
1296
+ this.io.retireAtomicPaneSnapshotCollector?.(lease.wireNonce, "retired");
1448
1297
  return;
1449
1298
  }
1450
- queue.push({ kind: "owner", recovery });
1451
- this.continueNotificationQueues.set(runtime, queue);
1452
- this.io.send(`refresh-client -A '${runtime}:continue'`, (reply) => {
1453
- const current = this.recoveryPane(recovery);
1454
- if (!reply.ok) {
1455
- this.removeContinueNotificationOwner(recovery);
1456
- if (current)
1457
- this.failRecovery(recovery, "command-error");
1299
+ if (lease.fencePending)
1300
+ return;
1301
+ // Native wrapper/setup commands still own ordinary FIFO slots. A separate
1302
+ // bounded command proves they drained before another raw hook is admitted.
1303
+ lease.fencePending = true;
1304
+ const token = `tmux-ide-snapshot-admission:${randomBytes(24).toString("hex")}`;
1305
+ const failed = () => {
1306
+ if (this.snapshotActive !== lease)
1458
1307
  return;
1459
- }
1460
- recovery.continueReply = true;
1461
- if (!current) {
1462
- this.retireContinueNotificationOwner(recovery);
1308
+ lease.retry = false;
1309
+ lease.cancelFence?.();
1310
+ void this.io.dispose();
1311
+ };
1312
+ lease.cancelFence = this.scheduleRecovery(failed, RECOVERY_ABSOLUTE_DEADLINE_MS);
1313
+ const done = (reply) => {
1314
+ if (this.snapshotActive !== lease)
1315
+ return;
1316
+ if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== token) {
1317
+ failed();
1463
1318
  return;
1464
1319
  }
1465
- recovery.cancelCommandDeadline?.();
1466
- recovery.cancelCommandDeadline = null;
1467
- this.beginRecoveryConvergence(recovery);
1468
- this.observeRecovery(current, recovery, "continue-reply");
1469
- this.noteRecoveryProgress(recovery);
1470
- this.beginFinalRecovery(recovery);
1471
- });
1320
+ lease.fencePending = false;
1321
+ this.releaseSnapshotLease(lease);
1322
+ };
1323
+ if (this.io.commandBoundedInline)
1324
+ this.io.commandBoundedInline(`display-message -p -l ${token}`, { maxBytes: 256, maxLines: 1 }, done);
1325
+ else
1326
+ this.io.commandInline(`display-message -p -l ${token}`, done);
1472
1327
  }
1473
- beginLocalOverflowRecovery(pane) {
1328
+ awaitSnapshotLayout(recovery) {
1329
+ recovery.cancelNoProgressDeadline?.();
1330
+ recovery.cancelNoProgressDeadline = null;
1331
+ recovery.waitForSyncAfter = this.syncOrdinal;
1332
+ this.scheduleSync();
1333
+ }
1334
+ retrySnapshot(recovery) {
1335
+ const pane = this.recoveryPane(recovery);
1336
+ if (!pane)
1337
+ return;
1474
1338
  for (const sub of pane.subs)
1475
- sub.cancelCapture?.();
1476
- const runtime = pane.runtimeId;
1477
- this.cancelRecovery(runtime);
1478
- const recovery = {
1479
- ordinal: ++this.recoveryOrdinal,
1480
- runtimeId: runtime,
1481
- paneIncarnation: pane.incarnation,
1482
- reason: "backpressure",
1483
- startedAtMs: this.recoveryNowMs(),
1484
- retired: false,
1485
- continueReply: true,
1486
- continueNotify: true,
1487
- stage: "continue",
1488
- attempts: 0,
1489
- reseedOrdinal: 0,
1490
- outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
1491
- candidateFingerprint: null,
1492
- confirmationFingerprint: null,
1493
- confirmationOrdinal: 0,
1494
- atomicCollectorNonce: null,
1495
- collectorStarted: false,
1496
- collectorLastCompletedOrdinal: -1,
1497
- collectorCaptureLineCount: 0,
1498
- collectorCaptureByteCount: 0,
1499
- collectorContinueObserved: false,
1500
- collectorStatusObserved: false,
1501
- collectorObserverEmissionObserved: false,
1502
- collectorFailureReason: null,
1503
- cancelQuiet: null,
1504
- cancelCommandDeadline: null,
1505
- cancelNoProgressDeadline: null,
1506
- cancelAbsoluteDeadline: null,
1507
- };
1508
- this.recoveries.set(runtime, recovery);
1509
- for (const sub of pane.subs) {
1510
- if (!sub.frozen && !sub.closed)
1511
- sub.onEvent({ type: "flow", state: "paused", reason: "backpressure" });
1339
+ if (!sub.closed && !sub.frozen)
1340
+ sub.feed.abortCurrent();
1341
+ recovery.stock?.invalidate();
1342
+ if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS ||
1343
+ (recovery.cancelAbsoluteDeadline &&
1344
+ this.recoveryNowMs() - recovery.startedAtMs >= RECOVERY_ABSOLUTE_DEADLINE_MS)) {
1345
+ this.failRecovery(recovery, "attempts-exhausted");
1346
+ return;
1347
+ }
1348
+ if (recovery.lease)
1349
+ this.retireSnapshotLease(recovery.lease, true);
1350
+ else {
1351
+ recovery.stage = "queued";
1352
+ this.snapshotQueue.set(recovery.runtimeId, recovery);
1353
+ this.drainSnapshots();
1354
+ }
1355
+ }
1356
+ startStockSnapshot(pane, recovery, lease) {
1357
+ if (!this.io.armAtomicPaneSnapshotCollector || !this.io.retireAtomicPaneSnapshotCollector) {
1358
+ this.failRecovery(recovery, "command-error");
1359
+ return;
1360
+ }
1361
+ if (!this.nativeBootstrapConfirmed &&
1362
+ !this.nativeBootstrapUnavailable &&
1363
+ [...pane.subs].some((sub) => !sub.closed && !sub.frozen && sub.nativeBootstrap)) {
1364
+ // Negotiate the live server before placing -R in a NOHOOKS transaction:
1365
+ // an unsupported flag aborts that hook before its completion sentinel.
1366
+ recovery.nativeProbeAttempts = (recovery.nativeProbeAttempts ?? 0) + 1;
1367
+ let marker = null;
1368
+ let probeSettled = false;
1369
+ recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
1370
+ if (probeSettled || !this.snapshotLeaseCurrent(lease))
1371
+ return;
1372
+ probeSettled = true;
1373
+ if ((recovery.nativeProbeAttempts ?? 0) < 2)
1374
+ this.retrySnapshot(recovery);
1375
+ else
1376
+ this.failRecovery(recovery, "command-timeout");
1377
+ }, RECOVERY_COMMAND_DEADLINE_MS);
1378
+ this.captureWithViewer(pane.runtimeId, ["-R", "-S", "-"], (value) => {
1379
+ marker = value;
1380
+ }, (reply) => {
1381
+ if (marker)
1382
+ this.retireInternalReadMarker(pane.runtimeId, marker);
1383
+ if (probeSettled || !this.snapshotLeaseCurrent(lease))
1384
+ return;
1385
+ probeSettled = true;
1386
+ recovery.cancelCommandDeadline?.();
1387
+ recovery.cancelCommandDeadline = null;
1388
+ const native = reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
1389
+ if (native && isNativeBootstrapCapture(native))
1390
+ this.nativeBootstrapConfirmed = true;
1391
+ else if (nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
1392
+ this.nativeBootstrapUnavailable = true;
1393
+ for (const current of this.panesByRuntime.values())
1394
+ for (const sub of current.subs)
1395
+ sub.nativeBootstrap = false;
1396
+ }
1397
+ else {
1398
+ if ((recovery.nativeProbeAttempts ?? 0) < 2)
1399
+ this.retrySnapshot(recovery);
1400
+ else
1401
+ this.failRecovery(recovery, "command-error");
1402
+ return;
1403
+ }
1404
+ this.startStockSnapshot(pane, recovery, lease);
1405
+ }, { maxBytes: RECOVERY_CAPTURE_MAX_BYTES, maxLines: RECOVERY_CAPTURE_MAX_LINES });
1406
+ return;
1407
+ }
1408
+ recovery.attempts += 1;
1409
+ recovery.stage = "stock-pause";
1410
+ recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1411
+ // These rows were received before this pause and belong to the new seed,
1412
+ // never to a delayed old-geometry replay after it.
1413
+ const pending = pane.windowRuntimeId
1414
+ ? this.pendingLayoutOutput.get(pane.windowRuntimeId)
1415
+ : null;
1416
+ if (pending) {
1417
+ pending.records = pending.records.filter((record) => record.pane !== pane.runtimeId);
1418
+ pending.bytes = pending.records.reduce((bytes, record) => bytes + record.data.byteLength, 0);
1512
1419
  }
1513
1420
  this.observeRecovery(pane, recovery, "pause");
1514
- this.beginRecoveryConvergence(recovery);
1515
- const nativeTarget = this.nativeRecoveryTarget(pane);
1516
- if (nativeTarget)
1517
- this.recoverNativeAtomic(pane, recovery, nativeTarget);
1518
- else
1519
- this.beginFinalRecovery(recovery);
1421
+ const nonce = randomBytes(24).toString("hex");
1422
+ const hook = `@tmux_ide_pause_${nonce}`;
1423
+ const body = `display-message -p -l '%tmux-ide-atomic-v1 ${nonce} start'` +
1424
+ ` ; refresh-client -A '${pane.runtimeId}:pause'` +
1425
+ ` ; display-message -p -l '%tmux-ide-atomic-v1 ${nonce} complete'`;
1426
+ let settled = false;
1427
+ let accepted = false;
1428
+ const cleanup = () => this.io.commandListInline(`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
1429
+ `${tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${hook}`)} ` +
1430
+ `${tmuxSingleQuote("display-message -p -l pause-cleanup-skipped")}`, 2, 1, () => { });
1431
+ lease.cleanup = cleanup;
1432
+ const failed = () => {
1433
+ if (settled)
1434
+ return;
1435
+ settled = true;
1436
+ cleanup();
1437
+ if (this.snapshotLeaseCurrent(lease))
1438
+ this.retrySnapshot(recovery);
1439
+ };
1440
+ recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
1441
+ this.input.flush();
1442
+ this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${hook} ${tmuxSingleQuote(body)}`, (reply) => {
1443
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
1444
+ cleanup();
1445
+ return;
1446
+ }
1447
+ if (!reply.ok) {
1448
+ failed();
1449
+ return;
1450
+ }
1451
+ lease.wireNonce = nonce;
1452
+ const armed = this.io.armAtomicPaneSnapshotCollector({
1453
+ nonce,
1454
+ kind: "pause",
1455
+ runtimePaneId: pane.runtimeId,
1456
+ maxCaptureBytes: 1024,
1457
+ maxCaptureLines: 16,
1458
+ maxCursorBytes: 128,
1459
+ observerCommandCount: 0,
1460
+ onSettled: (result) => {
1461
+ cleanup();
1462
+ if (settled || !this.snapshotLeaseCurrent(lease))
1463
+ return;
1464
+ if (!result.ok) {
1465
+ failed();
1466
+ return;
1467
+ }
1468
+ // The authenticated NOHOOKS body targets the same live pane as
1469
+ // set-hook -Rp. A successful pause is a no-op only when already
1470
+ // paused; its complete guard therefore fences that case too.
1471
+ accepted = recovery.stock.observePause(this.snapshotContext(pane));
1472
+ if (!accepted) {
1473
+ failed();
1474
+ return;
1475
+ }
1476
+ lease.ownsPause = true;
1477
+ this.observedPaused.add(pane.runtimeId);
1478
+ this.ledger.notePause(pane.runtimeId);
1479
+ settled = true;
1480
+ recovery.cancelCommandDeadline?.();
1481
+ recovery.cancelCommandDeadline = null;
1482
+ },
1483
+ onDrained: (reason) => {
1484
+ if (lease.wireNonce === nonce)
1485
+ lease.wireNonce = null;
1486
+ if (reason === "channel-exit") {
1487
+ lease.retry = false;
1488
+ return;
1489
+ }
1490
+ if (!this.snapshotLeaseCurrent(lease) || !accepted) {
1491
+ this.releaseSnapshotLease(lease);
1492
+ return;
1493
+ }
1494
+ recovery.stage = "stock-capture";
1495
+ this.reseedRecoverySubscribersAtomic(pane, recovery, (result) => {
1496
+ if (!this.snapshotLeaseCurrent(lease))
1497
+ return;
1498
+ if (!result.ok || !result.publish())
1499
+ this.retrySnapshot(recovery);
1500
+ else
1501
+ this.convergeRecovery(pane, recovery);
1502
+ }, true);
1503
+ },
1504
+ }, Math.max(1, Math.floor(RECOVERY_ABSOLUTE_DEADLINE_MS - (this.recoveryNowMs() - recovery.startedAtMs))));
1505
+ if (!armed) {
1506
+ lease.wireNonce = null;
1507
+ failed();
1508
+ return;
1509
+ }
1510
+ // Once queued, this pause may execute even if its local callback is
1511
+ // cancelled. Return the owned pause after drain when no viewer remains.
1512
+ lease.ownsPause = true;
1513
+ // The option value is immutable for this attempt; compare it before
1514
+ // dispatch so another actor cannot replace the owned NOHOOKS body.
1515
+ this.io.commandListInline(`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
1516
+ `${tmuxSingleQuote(`set-hook -Rp -t ${pane.runtimeId} ${hook}`)} ` +
1517
+ `${tmuxSingleQuote("display-message -p -l pause-hook-rejected")}`, 2, 1, (result) => {
1518
+ if (settled || !this.snapshotLeaseCurrent(lease))
1519
+ return;
1520
+ if (!result.ok || result.lines.length)
1521
+ failed();
1522
+ });
1523
+ });
1520
1524
  }
1521
1525
  nativeRecoveryTarget(pane) {
1522
1526
  // Mixed/plain recovery requires an explicitly negotiated dual snapshot.
@@ -1536,7 +1540,8 @@ export class SessionChannel {
1536
1540
  : null;
1537
1541
  }
1538
1542
  recoverNativeAtomic(pane, recovery, target) {
1539
- if (this.recoveryPane(recovery) !== pane)
1543
+ const lease = recovery.lease;
1544
+ if (!lease || !this.snapshotLeaseCurrent(lease) || this.recoveryPane(recovery) !== pane)
1540
1545
  return;
1541
1546
  const currentTarget = this.nativeRecoveryTarget(pane);
1542
1547
  if (currentTarget?.serverEpoch !== target.serverEpoch ||
@@ -1558,6 +1563,7 @@ export class SessionChannel {
1558
1563
  const current = this.nativeRecoveryTarget(pane);
1559
1564
  const live = [...pane.subs].filter((sub) => !sub.closed && !sub.frozen);
1560
1565
  return (this.recoveryPane(recovery) === pane &&
1566
+ this.snapshotLeaseCurrent(lease) &&
1561
1567
  recovery.reseedOrdinal === ordinal &&
1562
1568
  current?.serverEpoch === target.serverEpoch &&
1563
1569
  current.paneBirthId === target.paneBirthId &&
@@ -1576,11 +1582,12 @@ export class SessionChannel {
1576
1582
  sub.feed.abortCurrent();
1577
1583
  // A malformed/late reply may follow a committed resume. Every retry
1578
1584
  // explicitly pauses first; never fall through to stock capture.
1579
- this.armRecoveryQuiet(recovery, () => this.recoverNativeAtomic(pane, recovery, target));
1585
+ this.retrySnapshot(recovery);
1580
1586
  };
1581
1587
  recovery.stage = "native-pause";
1582
1588
  recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
1583
1589
  this.input.flush();
1590
+ lease.ownsPause = true;
1584
1591
  this.io.commandInline(`refresh-client -A '${pane.runtimeId}:pause'`, (pauseReply) => {
1585
1592
  if (settled || !exact()) {
1586
1593
  failed();
@@ -1620,12 +1627,23 @@ export class SessionChannel {
1620
1627
  failed();
1621
1628
  return;
1622
1629
  }
1630
+ const probe = parseCursorProbe(result.cursorLine);
1631
+ const size = this.layoutCaptureSizeFor(participants[0].sub);
1632
+ if (!probe ||
1633
+ probe.y >= probe.rows ||
1634
+ result.capture.cols !== probe.cols ||
1635
+ result.capture.rows !== probe.rows) {
1636
+ failed();
1637
+ return;
1638
+ }
1639
+ if ((pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId)) ||
1640
+ (size && (size.cols !== probe.cols || size.rows !== probe.rows))) {
1641
+ this.awaitSnapshotLayout(recovery);
1642
+ failed();
1643
+ return;
1644
+ }
1623
1645
  this.nativeBootstrapConfirmed = true;
1624
- this.observeScrollOnClear(pane, result.cursorLine);
1625
- // The selected child reply owns its inline %continue. It adds no
1626
- // asynchronous notification debt to the legacy continue queue.
1627
- recovery.continueReply = true;
1628
- recovery.continueNotify = true;
1646
+ this.observeSnapshotMetadata(pane, result.cursorLine);
1629
1647
  const ansiLines = result.ansiCapture
1630
1648
  ? captureLinesFromAnsiBytes(result.ansiCapture)
1631
1649
  : null;
@@ -1681,10 +1699,13 @@ export class SessionChannel {
1681
1699
  if (!this.recoveryPane(recovery) || !recovery.cancelAbsoluteDeadline)
1682
1700
  return;
1683
1701
  recovery.cancelNoProgressDeadline?.();
1684
- recovery.cancelNoProgressDeadline = this.scheduleRecovery(() => {
1702
+ const cancel = this.scheduleRecovery(() => {
1703
+ if (recovery.cancelNoProgressDeadline !== cancel)
1704
+ return;
1685
1705
  if (this.recoveryPane(recovery))
1686
1706
  this.failRecovery(recovery, "no-progress");
1687
1707
  }, RECOVERY_NO_PROGRESS_DEADLINE_MS);
1708
+ recovery.cancelNoProgressDeadline = cancel;
1688
1709
  }
1689
1710
  noteAtomicCollectorProgress(recovery, nonce, progress) {
1690
1711
  if (this.recoveryPane(recovery) === null ||
@@ -1700,150 +1721,21 @@ export class SessionChannel {
1700
1721
  recovery.collectorObserverEmissionObserved ||= progress.observerEmissionObserved;
1701
1722
  this.noteRecoveryProgress(recovery);
1702
1723
  }
1703
- reseedRecoverySubscribers(pane, recovery, done, deferPublish = false) {
1704
- if (this.io.armAtomicPaneSnapshotCollector && this.io.retireAtomicPaneSnapshotCollector) {
1705
- this.reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish);
1706
- return;
1707
- }
1708
- const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
1709
- if (live.length === 0) {
1724
+ reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish) {
1725
+ const lease = recovery.lease;
1726
+ const stock = recovery.stock;
1727
+ if (!lease || !stock || !this.snapshotLeaseCurrent(lease)) {
1710
1728
  done(FAILED_RESEED_RESULT);
1711
1729
  return;
1712
1730
  }
1713
- const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
1714
- const nativeCapture = !this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
1715
- const reseedOrdinal = ++recovery.reseedOrdinal;
1716
- let settled = false;
1717
- let captureSucceeded = false;
1718
- let markerRetired = false;
1719
- let captureLines = null;
1720
- // One pane authority capture is enough for every subscriber. Per-feed
1721
- // epochs still independently fence delivery, while membership is frozen
1722
- // across both FIFO replies so no subscriber can join half a snapshot.
1723
- this.input.flush();
1724
- const history = this.opts.historyLines ?? "";
1725
- let internalReadMarker = null;
1726
- const participantsExact = () => {
1727
- if (this.recoveryPane(recovery) !== pane ||
1728
- recovery.reseedOrdinal !== reseedOrdinal ||
1729
- participants.some(({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub)))
1730
- return false;
1731
- const current = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
1732
- return (current.length === participants.length &&
1733
- current.every((sub) => participants.some((participant) => participant.sub === sub)));
1734
- };
1735
- const retireMarker = () => {
1736
- if (markerRetired)
1737
- return;
1738
- markerRetired = true;
1739
- if (internalReadMarker)
1740
- this.retireInternalReadMarker(pane.runtimeId, internalReadMarker);
1741
- };
1742
- const fail = () => {
1743
- if (settled)
1744
- return;
1745
- settled = true;
1746
- if (!captureSucceeded)
1747
- retireMarker();
1748
- for (const { sub } of participants)
1749
- sub.feed.abortCurrent();
1750
- done(FAILED_RESEED_RESULT);
1751
- };
1752
- this.captureWithViewer(pane.runtimeId, [...(nativeCapture ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`], (marker) => {
1753
- internalReadMarker = marker;
1754
- }, (reply) => {
1755
- if (!reply.ok) {
1756
- if (nativeCapture && nativeBootstrapUnsupported(false, reply.lines, null)) {
1757
- this.nativeBootstrapUnavailable = true;
1758
- for (const { sub } of participants)
1759
- sub.nativeBootstrap = false;
1760
- }
1761
- fail();
1762
- return;
1763
- }
1764
- captureSucceeded = true;
1765
- if (!participantsExact()) {
1766
- fail();
1767
- return;
1768
- }
1769
- captureLines = Object.freeze([...reply.lines]);
1770
- const native = nativeCapture ? decodeNativeGridCapture(captureLines.join("\n")) : null;
1771
- if (nativeCapture && (!native || !isNativeBootstrapCapture(native))) {
1772
- if (nativeBootstrapUnsupported(true, captureLines, native)) {
1773
- this.nativeBootstrapUnavailable = true;
1774
- for (const { sub } of participants)
1775
- sub.nativeBootstrap = false;
1776
- }
1777
- fail();
1778
- return;
1779
- }
1780
- if (native)
1781
- this.nativeBootstrapConfirmed = true;
1782
- for (const { sub, epoch } of participants) {
1783
- if (native)
1784
- sub.feed.captureNativeReply(epoch, native);
1785
- else
1786
- sub.feed.captureReply(epoch, captureLines);
1787
- }
1788
- });
1789
- this.io.commandInline(`display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"`, (reply) => {
1790
- if (settled)
1791
- return;
1792
- if (!participantsExact() || captureLines === null || !reply.ok) {
1793
- fail();
1794
- return;
1795
- }
1796
- const cursorLine = reply.lines[0] ?? "";
1797
- const fallbackSize = this.layoutSizeFor(pane.runtimeId);
1798
- this.observeScrollOnClear(pane, cursorLine);
1799
- const deliveries = participants.map(({ sub, epoch }) => ({
1800
- sub,
1801
- epoch,
1802
- events: sub.feed
1803
- .cursorReply(epoch, cursorLine, fallbackSize)
1804
- .map((event) => event.type === "seed" && sub.nativeBootstrap && !nativeCapture
1805
- ? { ...event, requiresNativeRecapture: true }
1806
- : event),
1807
- }));
1808
- if (!participantsExact() || deliveries.some(({ events }) => events.length === 0)) {
1809
- fail();
1810
- return;
1811
- }
1812
- let published = false;
1813
- const publish = () => {
1814
- if (published)
1815
- return true;
1816
- if (!participantsExact())
1817
- return false;
1818
- published = true;
1819
- for (const { sub, events } of deliveries)
1820
- for (const event of events)
1821
- sub.onEvent(event);
1822
- return participantsExact();
1823
- };
1824
- if (!deferPublish && !publish()) {
1825
- fail();
1826
- return;
1827
- }
1828
- for (const { sub, epoch } of deliveries)
1829
- sub.feed.quarantine(epoch);
1830
- settled = true;
1831
- done({
1832
- ok: true,
1833
- fingerprint: snapshotFingerprint(captureLines, cursorLine, fallbackSize),
1834
- publish,
1835
- hold: () => { },
1836
- });
1837
- });
1838
- }
1839
- reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish) {
1840
1731
  const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
1841
1732
  if (live.length === 0) {
1842
1733
  done(FAILED_RESEED_RESULT);
1843
1734
  return;
1844
1735
  }
1845
1736
  const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
1846
- const nativeCapture = !this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
1737
+ const nativeCapture = !this.nativeBootstrapUnavailable && participants.some(({ sub }) => sub.nativeBootstrap);
1738
+ const dualCapture = nativeCapture && participants.some(({ sub }) => !sub.nativeBootstrap);
1847
1739
  const reseedOrdinal = ++recovery.reseedOrdinal;
1848
1740
  const nonce = this.opts.generateAtomicHookNonce?.() ?? randomBytes(24).toString("hex");
1849
1741
  if (!/^[0-9a-f]{32,128}$/u.test(nonce)) {
@@ -1867,6 +1759,7 @@ export class SessionChannel {
1867
1759
  recovery.collectorFailureReason = null;
1868
1760
  const participantsExact = () => {
1869
1761
  if (this.recoveryPane(recovery) !== pane ||
1762
+ !this.snapshotLeaseCurrent(lease) ||
1870
1763
  recovery.reseedOrdinal !== reseedOrdinal ||
1871
1764
  participants.some(({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub)))
1872
1765
  return false;
@@ -1889,6 +1782,7 @@ export class SessionChannel {
1889
1782
  tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${ownerName}`) +
1890
1783
  ` ${tmuxSingleQuote(`display-message -p -t ${pane.runtimeId} tmux-ide-atomic-cleanup-owner-skip-v1:${nonce}`)}`, 2, 1, () => { });
1891
1784
  };
1785
+ lease.cleanup = cleanupHook;
1892
1786
  const fail = (statusObserved = false) => {
1893
1787
  if (settled)
1894
1788
  return;
@@ -1910,21 +1804,27 @@ export class SessionChannel {
1910
1804
  fail();
1911
1805
  return;
1912
1806
  }
1807
+ const observerRequired = this.opts.internalReadHookEmission !== undefined;
1913
1808
  const safeObserver = observer !== null &&
1914
1809
  /^[A-Za-z0-9._-]{1,256}$/u.test(observer.bufferName) &&
1915
1810
  /^[A-Za-z0-9._-]{1,256}$/u.test(observer.signalChannel) &&
1916
1811
  /^[A-Za-z0-9%:._|-]{1,1024}$/u.test(observer.record);
1917
- if (!safeObserver) {
1812
+ if (observerRequired && !safeObserver) {
1918
1813
  fail();
1919
1814
  return;
1920
1815
  }
1921
1816
  const sentinel = (kind) => `display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
1922
- const observerCommands = ` ; ${boundedTmuxInteractionAppendCommand(observer.bufferName, observer.record)}` +
1923
- ` ; wait-for -S ${observer.signalChannel}`;
1817
+ const observerCommands = safeObserver
1818
+ ? ` ; ${boundedTmuxInteractionAppendCommand(observer.bufferName, observer.record)}` +
1819
+ ` ; wait-for -S ${observer.signalChannel}`
1820
+ : "";
1924
1821
  const body = `set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
1925
1822
  ` ; ${sentinel("start")}` +
1926
1823
  ` ; capture-pane -p ${nativeCapture ? "-R" : "-e -J"} -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId}` +
1927
1824
  ` ; ${sentinel("capture-end")}` +
1825
+ (dualCapture
1826
+ ? ` ; capture-pane -p -e -J -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId} ; ${sentinel("ansi-capture-end")}`
1827
+ : "") +
1928
1828
  ` ; display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"` +
1929
1829
  ` ; ${sentinel("cursor-end")}` +
1930
1830
  ` ; refresh-client -A ${tmuxSingleQuote(`${pane.runtimeId}:continue`)}` +
@@ -1938,6 +1838,10 @@ export class SessionChannel {
1938
1838
  ` ; ${sentinel("complete")}`;
1939
1839
  this.input.flush();
1940
1840
  const invoke = (reply) => {
1841
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
1842
+ cleanupHook();
1843
+ return;
1844
+ }
1941
1845
  if (!reply.ok || !participantsExact()) {
1942
1846
  fail();
1943
1847
  return;
@@ -1947,13 +1851,25 @@ export class SessionChannel {
1947
1851
  fail();
1948
1852
  return;
1949
1853
  }
1854
+ lease.wireNonce = nonce;
1855
+ this.observedPaused.delete(pane.runtimeId);
1950
1856
  const armed = this.io.armAtomicPaneSnapshotCollector({
1951
1857
  nonce,
1952
1858
  runtimePaneId: pane.runtimeId,
1953
1859
  maxCaptureBytes: RECOVERY_CAPTURE_MAX_BYTES,
1954
1860
  maxCaptureLines: RECOVERY_CAPTURE_MAX_LINES,
1955
1861
  maxCursorBytes: RECOVERY_CURSOR_MAX_BYTES,
1956
- observerCommandCount: 2,
1862
+ observerCommandCount: safeObserver ? 2 : 0,
1863
+ dualCapture,
1864
+ onDrained: (reason) => {
1865
+ if (lease.wireNonce === nonce)
1866
+ lease.wireNonce = null;
1867
+ if (reason === "channel-exit") {
1868
+ lease.retry = false;
1869
+ return;
1870
+ }
1871
+ this.releaseSnapshotLease(lease);
1872
+ },
1957
1873
  onProgress: (progress) => this.noteAtomicCollectorProgress(recovery, nonce, progress),
1958
1874
  onSettled: (result) => {
1959
1875
  if (recovery.atomicCollectorNonce === nonce)
@@ -1985,16 +1901,35 @@ export class SessionChannel {
1985
1901
  fail(result.statusObserved);
1986
1902
  return;
1987
1903
  }
1904
+ if (dualCapture && !result.ansiCaptureLines) {
1905
+ fail(result.statusObserved);
1906
+ return;
1907
+ }
1988
1908
  if (native)
1989
1909
  this.nativeBootstrapConfirmed = true;
1990
1910
  for (const { sub, epoch } of participants) {
1991
- if (native)
1911
+ if (native && sub.nativeBootstrap)
1992
1912
  sub.feed.captureNativeReply(epoch, native);
1993
1913
  else
1994
- sub.feed.captureReply(epoch, captureLines);
1914
+ sub.feed.captureReply(epoch, result.ansiCaptureLines ?? captureLines);
1915
+ }
1916
+ const probe = parseCursorProbe(result.cursorLine);
1917
+ const layoutSize = this.layoutCaptureSizeFor(participants[0].sub);
1918
+ const pendingLayout = pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId);
1919
+ if (!probe ||
1920
+ probe.y >= probe.rows ||
1921
+ (native && (native.cols !== probe.cols || native.rows !== probe.rows))) {
1922
+ fail(result.statusObserved);
1923
+ return;
1924
+ }
1925
+ if (pendingLayout ||
1926
+ (layoutSize && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows))) {
1927
+ this.awaitSnapshotLayout(recovery);
1928
+ fail(result.statusObserved);
1929
+ return;
1995
1930
  }
1996
1931
  const fallbackSize = this.layoutSizeFor(pane.runtimeId);
1997
- this.observeScrollOnClear(pane, result.cursorLine);
1932
+ this.observeSnapshotMetadata(pane, result.cursorLine);
1998
1933
  const deliveries = participants.map(({ sub, epoch }) => ({
1999
1934
  sub,
2000
1935
  epoch,
@@ -2014,28 +1949,28 @@ export class SessionChannel {
2014
1949
  return true;
2015
1950
  if (!participantsExact())
2016
1951
  return false;
2017
- published = true;
2018
- for (const { sub, events } of deliveries)
2019
- for (const event of events)
2020
- sub.onEvent(event);
2021
- return participantsExact();
1952
+ published = stock.publish(deliveries.map(({ sub, events }) => ({ participant: sub, events })), () => {
1953
+ if (!participantsExact())
1954
+ throw new Error("snapshot lease retired");
1955
+ return this.snapshotContext(pane);
1956
+ }, (participant, event) => participant.onEvent(event));
1957
+ return published && participantsExact();
2022
1958
  };
2023
1959
  if (!deferPublish && !publish()) {
2024
1960
  fail(true);
2025
1961
  return;
2026
1962
  }
2027
- for (const { sub, epoch } of deliveries)
2028
- sub.feed.quarantine(epoch);
2029
1963
  settled = true;
2030
1964
  done({
2031
1965
  ok: true,
2032
- fingerprint: snapshotFingerprint(captureLines, result.cursorLine, fallbackSize),
1966
+ fingerprint: null,
2033
1967
  publish,
2034
1968
  hold: () => { },
2035
1969
  });
2036
1970
  },
2037
1971
  }, remaining);
2038
1972
  if (!armed) {
1973
+ lease.wireNonce = null;
2039
1974
  fail();
2040
1975
  return;
2041
1976
  }
@@ -2053,11 +1988,19 @@ export class SessionChannel {
2053
1988
  // ambiguity: every accepted step has its own ordered reply and any later
2054
1989
  // failure can conditionally retire exactly the already-created prefix.
2055
1990
  this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${ownerName} ${nonce}`, (ownerReply) => {
1991
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
1992
+ cleanupHook();
1993
+ return;
1994
+ }
2056
1995
  if (!ownerReply.ok || !participantsExact()) {
2057
1996
  fail();
2058
1997
  return;
2059
1998
  }
2060
1999
  this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${expectedName} ${tmuxSingleQuote(body)}`, (expectedReply) => {
2000
+ if (settled || !this.snapshotLeaseCurrent(lease)) {
2001
+ cleanupHook();
2002
+ return;
2003
+ }
2061
2004
  if (!expectedReply.ok || !participantsExact()) {
2062
2005
  fail();
2063
2006
  return;
@@ -2066,113 +2009,6 @@ export class SessionChannel {
2066
2009
  });
2067
2010
  });
2068
2011
  }
2069
- armRecoveryQuiet(recovery, callback) {
2070
- recovery.cancelQuiet?.();
2071
- recovery.cancelQuiet = this.scheduleRecovery(() => {
2072
- recovery.cancelQuiet = null;
2073
- if (this.recoveryPane(recovery))
2074
- callback();
2075
- }, RECOVERY_QUIET_MS);
2076
- }
2077
- beginFinalRecovery(recovery) {
2078
- const pane = this.recoveryPane(recovery);
2079
- if (!pane)
2080
- return;
2081
- if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
2082
- this.failRecovery(recovery, "attempts-exhausted");
2083
- return;
2084
- }
2085
- recovery.attempts += 1;
2086
- recovery.stage = "final";
2087
- this.noteRecoveryProgress(recovery);
2088
- // The final read is only a private candidate. Publishing it here can put
2089
- // expensive replica projection ahead of the confirmation timers and, more
2090
- // importantly, exposes a snapshot that has not yet survived the two-read
2091
- // authority proof.
2092
- this.reseedRecoverySubscribers(pane, recovery, ({ ok, fingerprint }) => {
2093
- const current = this.recoveryPane(recovery);
2094
- if (!current)
2095
- return;
2096
- if (!ok || fingerprint === null) {
2097
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2098
- return;
2099
- }
2100
- recovery.outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
2101
- recovery.candidateFingerprint = fingerprint;
2102
- recovery.confirmationFingerprint = null;
2103
- recovery.confirmationOrdinal = 0;
2104
- recovery.stage = "confirm";
2105
- this.observeRecovery(current, recovery, "final-reseed");
2106
- this.noteRecoveryProgress(recovery);
2107
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2108
- }, true);
2109
- }
2110
- confirmRecovery(recovery) {
2111
- const pane = this.recoveryPane(recovery);
2112
- if (!pane)
2113
- return;
2114
- if ((this.outputOrdinals.get(recovery.runtimeId) ?? 0) !== recovery.outputOrdinal) {
2115
- this.beginFinalRecovery(recovery);
2116
- return;
2117
- }
2118
- recovery.stage = "final";
2119
- this.noteRecoveryProgress(recovery);
2120
- this.reseedRecoverySubscribers(pane, recovery, ({ ok, fingerprint, publish }) => {
2121
- const current = this.recoveryPane(recovery);
2122
- if (!current)
2123
- return;
2124
- if (!ok || fingerprint === null) {
2125
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2126
- return;
2127
- }
2128
- const outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
2129
- const ordinalExact = outputOrdinal === recovery.outputOrdinal;
2130
- const candidateExact = ordinalExact && fingerprint === recovery.candidateFingerprint;
2131
- const consecutiveExact = candidateExact &&
2132
- recovery.confirmationFingerprint !== null &&
2133
- fingerprint === recovery.confirmationFingerprint;
2134
- recovery.confirmationOrdinal += 1;
2135
- this.observeRecovery(current, recovery, "confirmation-reseed", null, consecutiveExact);
2136
- this.noteRecoveryProgress(recovery);
2137
- if (!ordinalExact) {
2138
- recovery.stage = "confirm";
2139
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2140
- return;
2141
- }
2142
- if (!candidateExact) {
2143
- if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
2144
- this.failRecovery(recovery, "attempts-exhausted");
2145
- return;
2146
- }
2147
- // A changed confirmation replaces the private candidate without
2148
- // becoming observable. Two subsequent reads must confirm this truth.
2149
- recovery.attempts += 1;
2150
- recovery.candidateFingerprint = fingerprint;
2151
- recovery.confirmationFingerprint = null;
2152
- recovery.outputOrdinal = outputOrdinal;
2153
- recovery.stage = "confirm";
2154
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2155
- return;
2156
- }
2157
- if (!consecutiveExact) {
2158
- recovery.confirmationFingerprint = fingerprint;
2159
- recovery.outputOrdinal = outputOrdinal;
2160
- recovery.stage = "confirm";
2161
- this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
2162
- return;
2163
- }
2164
- // This publisher belongs to the current second matching read, not the
2165
- // older candidate. Publish exactly once while every participant is
2166
- // still fenced, then synchronously retire recovery before downstream
2167
- // projection work can run.
2168
- if (!publish()) {
2169
- recovery.stage = "confirm";
2170
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2171
- return;
2172
- }
2173
- this.convergeRecovery(current, recovery);
2174
- }, true);
2175
- }
2176
2012
  convergeRecovery(pane, recovery) {
2177
2013
  recovery.cancelCommandDeadline?.();
2178
2014
  recovery.cancelCommandDeadline = null;
@@ -2182,45 +2018,35 @@ export class SessionChannel {
2182
2018
  recovery.cancelAbsoluteDeadline = null;
2183
2019
  this.recoveries.delete(recovery.runtimeId);
2184
2020
  recovery.retired = true;
2185
- this.retireContinueNotificationOwner(recovery);
2186
2021
  this.ledger.noteContinued(recovery.runtimeId);
2187
2022
  if (recovery.reason === "requested")
2188
2023
  this.ledger.clearRequest(recovery.runtimeId);
2189
2024
  for (const sub of pane.subs) {
2190
2025
  if (!sub.frozen && !sub.closed) {
2191
2026
  sub.feed.releaseQuarantine();
2192
- sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
2027
+ try {
2028
+ sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
2029
+ }
2030
+ catch {
2031
+ // A consumer cannot strand shared snapshot admission for other panes.
2032
+ }
2193
2033
  }
2194
2034
  }
2195
- this.observeRecovery(pane, recovery, "converged", null, true);
2196
- }
2197
- noteRecoveryOutput(pane, outputOrdinal) {
2198
- const recovery = this.recoveries.get(pane.runtimeId);
2199
- if (!recovery || recovery.paneIncarnation !== pane.incarnation)
2200
- return;
2201
- recovery.outputOrdinal = outputOrdinal;
2202
- if (recovery.continueReply)
2203
- this.noteRecoveryProgress(recovery);
2204
- if (recovery.stage === "quiet")
2205
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2206
- else if (recovery.stage === "confirm")
2207
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2035
+ this.observeRecovery(pane, recovery, "converged", null);
2036
+ if (recovery.lease)
2037
+ this.retireSnapshotLease(recovery.lease);
2208
2038
  }
2209
2039
  restartRecoveryAfterOutputOverflow(pane) {
2210
2040
  const recovery = this.recoveries.get(pane.runtimeId);
2211
- if (recovery?.paneIncarnation === pane.incarnation) {
2212
- if (recovery.stage === "quiet" || recovery.stage === "confirm")
2213
- this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
2214
- return;
2215
- }
2216
- this.beginLocalOverflowRecovery(pane);
2041
+ if (recovery)
2042
+ this.retrySnapshot(recovery);
2043
+ else
2044
+ this.beginLocalOverflowRecovery(pane);
2217
2045
  }
2218
2046
  failRecovery(recovery, failureReason) {
2219
2047
  const pane = this.recoveryPane(recovery);
2220
2048
  if (!pane)
2221
2049
  return;
2222
- recovery.cancelQuiet?.();
2223
- recovery.cancelQuiet = null;
2224
2050
  recovery.cancelCommandDeadline?.();
2225
2051
  recovery.cancelCommandDeadline = null;
2226
2052
  recovery.cancelNoProgressDeadline?.();
@@ -2228,14 +2054,16 @@ export class SessionChannel {
2228
2054
  recovery.cancelAbsoluteDeadline?.();
2229
2055
  recovery.cancelAbsoluteDeadline = null;
2230
2056
  recovery.retired = true;
2057
+ this.snapshotQueue.delete(recovery.runtimeId);
2058
+ this.recoveries.delete(recovery.runtimeId);
2059
+ for (const sub of pane.subs)
2060
+ sub.feed.abortCurrent();
2061
+ if (recovery.lease)
2062
+ this.retireSnapshotLease(recovery.lease);
2231
2063
  const collectorNonce = recovery.atomicCollectorNonce;
2232
2064
  recovery.atomicCollectorNonce = null;
2233
2065
  if (collectorNonce)
2234
2066
  this.io.retireAtomicPaneSnapshotCollector?.(collectorNonce, "retired");
2235
- this.recoveries.delete(recovery.runtimeId);
2236
- this.retireContinueNotificationOwner(recovery);
2237
- for (const sub of pane.subs)
2238
- sub.feed.abortCurrent();
2239
2067
  this.observeRecovery(pane, recovery, "nonconverged", failureReason);
2240
2068
  for (const sub of pane.subs) {
2241
2069
  sub.cancelCapture?.();
@@ -2249,50 +2077,6 @@ export class SessionChannel {
2249
2077
  }
2250
2078
  }
2251
2079
  }
2252
- removeContinueNotificationOwner(recovery) {
2253
- const queue = this.continueNotificationQueues.get(recovery.runtimeId);
2254
- if (!queue)
2255
- return;
2256
- const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
2257
- if (index < 0)
2258
- return;
2259
- queue.splice(index, 1);
2260
- this.compactContinueNotificationQueue(recovery.runtimeId, queue);
2261
- }
2262
- retireContinueNotificationOwner(recovery) {
2263
- if (!recovery.continueReply)
2264
- return;
2265
- const queue = this.continueNotificationQueues.get(recovery.runtimeId);
2266
- if (!queue)
2267
- return;
2268
- const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
2269
- if (index < 0)
2270
- return;
2271
- queue.splice(index, 1, { kind: "debt", count: 1, saturated: false });
2272
- this.compactContinueNotificationQueue(recovery.runtimeId, queue);
2273
- }
2274
- compactContinueNotificationQueue(runtime, queue) {
2275
- for (let index = 1; index < queue.length;) {
2276
- const previous = queue[index - 1];
2277
- const current = queue[index];
2278
- if (previous?.kind !== "debt" || current?.kind !== "debt") {
2279
- index += 1;
2280
- continue;
2281
- }
2282
- const total = previous.count + current.count;
2283
- previous.count = Math.min(total, MAX_CONTINUE_NOTIFICATION_DEBT);
2284
- previous.saturated =
2285
- previous.saturated || current.saturated || total > MAX_CONTINUE_NOTIFICATION_DEBT;
2286
- queue.splice(index, 1);
2287
- }
2288
- if (queue.length === 0)
2289
- this.continueNotificationQueues.delete(runtime);
2290
- else
2291
- this.continueNotificationQueues.set(runtime, queue);
2292
- }
2293
- /** Continue + reseed EVERY backpressure-paused pane that still has an
2294
- * unfrozen subscriber. %pause is sticky and hits quiet panes after any
2295
- * stall — recovering only the noisy pane leaves siblings dark. */
2296
2080
  recoverSticky() {
2297
2081
  for (const runtime of this.ledger.stickyRecoverySet()) {
2298
2082
  if (this.recoveries.has(runtime))
@@ -2311,6 +2095,10 @@ export class SessionChannel {
2311
2095
  sub.cancelCapture?.();
2312
2096
  const pane = sub.pane;
2313
2097
  pane.subs.delete(sub);
2098
+ const activeRecovery = this.recoveries.get(pane.runtimeId);
2099
+ if (activeRecovery &&
2100
+ [...pane.subs].some((candidate) => !candidate.closed && !candidate.frozen))
2101
+ this.retrySnapshot(activeRecovery);
2314
2102
  if ([...pane.subs].every((candidate) => candidate.closed || candidate.frozen))
2315
2103
  this.cancelRecovery(pane.runtimeId);
2316
2104
  // Ticket return on departure: a pane parked by a now-gone subscriber must
@@ -2321,8 +2109,12 @@ export class SessionChannel {
2321
2109
  }
2322
2110
  }
2323
2111
  // ── Notifications (channel order is the invariant) ──────────────────────
2324
- observeScrollOnClear(pane, cursorLine) {
2325
- const value = cursorLine.trim().split(/\s+/)[22];
2112
+ observeSnapshotMetadata(pane, cursorLine) {
2113
+ const fields = cursorLine.trim().split(/\s+/);
2114
+ const historySize = Number(fields[14]);
2115
+ if (Number.isSafeInteger(historySize) && historySize >= 0)
2116
+ pane.historySize = historySize;
2117
+ const value = fields[22];
2326
2118
  pane.scrollOnClear = value === "0" || value === "1" ? value === "1" : undefined;
2327
2119
  }
2328
2120
  onNotify(name, rest) {
@@ -2368,9 +2160,16 @@ export class SessionChannel {
2368
2160
  return;
2369
2161
  }
2370
2162
  }
2371
- if (name === "subscription-changed" &&
2372
- (/^tmux-ide-pane-borders\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+-\s*:\s*(top|bottom|off)\s*$/u.test(rest) ||
2373
- /^tmux-ide-copy-keys\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+-\s*:\s*(emacs|vi)\s*$/u.test(rest))) {
2163
+ const windowOptionHint = name === "subscription-changed"
2164
+ ? (/^tmux-ide-pane-borders\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:top|bottom|off)\s*$/u.exec(rest) ??
2165
+ /^tmux-ide-copy-keys\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:emacs|vi)\s*$/u.exec(rest))
2166
+ : null;
2167
+ if (windowOptionHint) {
2168
+ // Initial option samples from sibling windows are not evidence that this
2169
+ // pane's capture context changed. Unknown membership stays conservative.
2170
+ for (const pane of this.panesByRuntime.values())
2171
+ if (!pane.windowRuntimeId || pane.windowRuntimeId === windowOptionHint[1])
2172
+ pane.snapshotLayoutGeneration += 1;
2374
2173
  this.windowAuthorityOrdinal += 1;
2375
2174
  this.windowIdentityOrdinal += 1;
2376
2175
  this.scheduleSync();
@@ -2381,6 +2180,9 @@ export class SessionChannel {
2381
2180
  name === "session-window-changed" ||
2382
2181
  STRUCTURAL_NOTIFICATIONS.has(name)) {
2383
2182
  this.windowAuthorityOrdinal += 1;
2183
+ // Structural and layout notifications remain conservative across all panes.
2184
+ for (const pane of this.panesByRuntime.values())
2185
+ pane.snapshotLayoutGeneration += 1;
2384
2186
  if (name !== "layout-change")
2385
2187
  this.windowIdentityOrdinal += 1;
2386
2188
  }
@@ -2395,8 +2197,19 @@ export class SessionChannel {
2395
2197
  const runtime = rest.trim().split(/\s+/)[0] ?? "";
2396
2198
  if (!runtime.startsWith("%"))
2397
2199
  return;
2398
- if (this.recoveries.get(runtime)?.stage === "native-pause") {
2200
+ this.observedPaused.add(runtime);
2201
+ const stockRecovery = this.recoveries.get(runtime);
2202
+ if (stockRecovery?.stock && stockRecovery.lease) {
2203
+ this.ledger.notePause(runtime);
2204
+ if (stockRecovery.stage !== "stock-pause")
2205
+ this.retrySnapshot(stockRecovery);
2206
+ return;
2207
+ }
2208
+ const nativeRecovery = this.recoveries.get(runtime);
2209
+ if (nativeRecovery) {
2399
2210
  this.ledger.notePause(runtime);
2211
+ if (nativeRecovery.stage !== "native-pause" && nativeRecovery.stage !== "queued")
2212
+ this.retrySnapshot(nativeRecovery);
2400
2213
  return;
2401
2214
  }
2402
2215
  this.cancelRecovery(runtime);
@@ -2414,28 +2227,7 @@ export class SessionChannel {
2414
2227
  }
2415
2228
  if (name === "continue") {
2416
2229
  const runtime = rest.trim().split(/\s+/)[0] ?? "";
2417
- if (runtime.startsWith("%")) {
2418
- const queue = this.continueNotificationQueues.get(runtime);
2419
- const entry = queue?.[0] ?? null;
2420
- if (entry?.kind === "debt") {
2421
- if (!entry.saturated) {
2422
- entry.count -= 1;
2423
- if (entry.count === 0)
2424
- queue.shift();
2425
- }
2426
- this.compactContinueNotificationQueue(runtime, queue);
2427
- }
2428
- else if (entry?.kind === "owner") {
2429
- queue.shift();
2430
- this.compactContinueNotificationQueue(runtime, queue);
2431
- const owner = entry.recovery;
2432
- const pane = this.recoveryPane(owner);
2433
- owner.continueNotify = true;
2434
- if (pane) {
2435
- this.observeRecovery(pane, owner, "continue-notify");
2436
- }
2437
- }
2438
- }
2230
+ this.observedPaused.delete(runtime);
2439
2231
  return;
2440
2232
  }
2441
2233
  if (name === "layout-change") {
@@ -2451,6 +2243,15 @@ export class SessionChannel {
2451
2243
  return;
2452
2244
  }
2453
2245
  this.layoutNotificationOrdinals.set(change.windowId, this.windowAuthorityOrdinal);
2246
+ for (const pane of this.panesByRuntime.values()) {
2247
+ if (pane.windowRuntimeId !== change.windowId)
2248
+ continue;
2249
+ const recovery = this.recoveries.get(pane.runtimeId);
2250
+ if (recovery?.lease && !recovery.lease.retired) {
2251
+ this.awaitSnapshotLayout(recovery);
2252
+ this.retrySnapshot(recovery);
2253
+ }
2254
+ }
2454
2255
  const pendingLayout = {
2455
2256
  ...parsed,
2456
2257
  zoomed: change.zoomed,
@@ -2752,6 +2553,8 @@ export class SessionChannel {
2752
2553
  for (const sub of pane.subs)
2753
2554
  sub.resumeLayoutCapture?.(syncOrdinal);
2754
2555
  }
2556
+ this.completedSyncOrdinal = Math.max(this.completedSyncOrdinal, syncOrdinal);
2557
+ this.drainSnapshots();
2755
2558
  this.discovery.discover(listed);
2756
2559
  }
2757
2560
  applyPaneTruth(truth) {
@@ -2777,11 +2580,12 @@ export class SessionChannel {
2777
2580
  const nextWindowRuntimeId = this.truthWindow.get(runtime) ?? pane.windowRuntimeId;
2778
2581
  if (nextWindowRuntimeId !== pane.windowRuntimeId && nextWindowRuntimeId !== null)
2779
2582
  movedWindowRuntimeIds.add(nextWindowRuntimeId);
2583
+ if (pane.windowRuntimeId !== nextWindowRuntimeId)
2584
+ pane.snapshotLayoutGeneration += 1;
2780
2585
  pane.windowRuntimeId = nextWindowRuntimeId;
2781
2586
  continue;
2782
2587
  }
2783
2588
  this.cancelRecovery(runtime);
2784
- this.continueNotificationQueues.delete(runtime);
2785
2589
  this.panesByRuntime.delete(runtime);
2786
2590
  this.outputOrdinals.delete(runtime);
2787
2591
  this.panesBySemantic.delete(pane.semanticId);
@@ -2791,7 +2595,12 @@ export class SessionChannel {
2791
2595
  if (!sub.closed) {
2792
2596
  sub.closed = true;
2793
2597
  sub.cancelCapture?.();
2794
- sub.onEvent({ type: "closed" });
2598
+ try {
2599
+ sub.onEvent({ type: "closed" });
2600
+ }
2601
+ catch {
2602
+ // Close every sibling and the transport even if a consumer throws.
2603
+ }
2795
2604
  }
2796
2605
  }
2797
2606
  pane.subs.clear();
@@ -3292,7 +3101,6 @@ export class SessionChannel {
3292
3101
  // address's bytes are a different pane's now.
3293
3102
  const retiredRuntime = existingBySemantic.runtimeId;
3294
3103
  this.cancelRecovery(retiredRuntime);
3295
- this.continueNotificationQueues.delete(retiredRuntime);
3296
3104
  this.panesByRuntime.delete(retiredRuntime);
3297
3105
  this.outputOrdinals.delete(retiredRuntime);
3298
3106
  this.ledger.forget(retiredRuntime);
@@ -3300,6 +3108,8 @@ export class SessionChannel {
3300
3108
  existingBySemantic.incarnation = ++this.paneIncarnation;
3301
3109
  existingBySemantic.descriptor = descriptor;
3302
3110
  existingBySemantic.active = verified.active;
3111
+ if (existingBySemantic.windowRuntimeId !== windowRuntimeId)
3112
+ existingBySemantic.snapshotLayoutGeneration += 1;
3303
3113
  existingBySemantic.windowRuntimeId = windowRuntimeId;
3304
3114
  this.panesByRuntime.set(verified.runtimePaneId, existingBySemantic);
3305
3115
  for (const sub of existingBySemantic.subs) {
@@ -3311,6 +3121,8 @@ export class SessionChannel {
3311
3121
  if (existingByRuntime && existingByRuntime.semanticId === verified.semanticPaneId) {
3312
3122
  existingByRuntime.descriptor = descriptor;
3313
3123
  existingByRuntime.active = verified.active;
3124
+ if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
3125
+ existingByRuntime.snapshotLayoutGeneration += 1;
3314
3126
  existingByRuntime.windowRuntimeId = windowRuntimeId;
3315
3127
  continue;
3316
3128
  }
@@ -3319,7 +3131,6 @@ export class SessionChannel {
3319
3131
  // resolution). The old semantic id is gone.
3320
3132
  this.panesBySemantic.delete(existingByRuntime.semanticId);
3321
3133
  this.cancelRecovery(existingByRuntime.runtimeId);
3322
- this.continueNotificationQueues.delete(existingByRuntime.runtimeId);
3323
3134
  this.ledger.forget(existingByRuntime.runtimeId);
3324
3135
  this.outputOrdinals.delete(existingByRuntime.runtimeId);
3325
3136
  for (const sub of existingByRuntime.subs) {
@@ -3328,13 +3139,20 @@ export class SessionChannel {
3328
3139
  sub.closed = true;
3329
3140
  sub.cancelCapture?.();
3330
3141
  sub.feed.abortCurrent();
3331
- sub.onEvent({ type: "closed" });
3142
+ try {
3143
+ sub.onEvent({ type: "closed" });
3144
+ }
3145
+ catch {
3146
+ // Close every sibling and the transport even if a consumer throws.
3147
+ }
3332
3148
  }
3333
3149
  existingByRuntime.subs.clear();
3334
3150
  existingByRuntime.incarnation = ++this.paneIncarnation;
3335
3151
  existingByRuntime.semanticId = verified.semanticPaneId;
3336
3152
  existingByRuntime.descriptor = descriptor;
3337
3153
  existingByRuntime.active = verified.active;
3154
+ if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
3155
+ existingByRuntime.snapshotLayoutGeneration += 1;
3338
3156
  existingByRuntime.windowRuntimeId = windowRuntimeId;
3339
3157
  this.panesBySemantic.set(verified.semanticPaneId, existingByRuntime);
3340
3158
  continue;
@@ -3347,6 +3165,7 @@ export class SessionChannel {
3347
3165
  windowRuntimeId,
3348
3166
  subs: new Set(),
3349
3167
  incarnation: ++this.paneIncarnation,
3168
+ snapshotLayoutGeneration: 0,
3350
3169
  };
3351
3170
  this.panesByRuntime.set(record.runtimeId, record);
3352
3171
  this.panesBySemantic.set(record.semanticId, record);
@@ -3438,19 +3257,28 @@ export class SessionChannel {
3438
3257
  onChannelExit() {
3439
3258
  if (this.disposed)
3440
3259
  return;
3260
+ this.snapshotActive?.cancelFence?.();
3261
+ this.snapshotActive = null;
3262
+ this.snapshotQueue.clear();
3263
+ for (const recovery of this.recoveries.values())
3264
+ recovery.lease = undefined;
3441
3265
  this.opts.ownedViewer?.dispose();
3442
3266
  this.windowLinkAuthority?.dispose();
3443
3267
  this.nativeGrid.dispose();
3444
3268
  this.settleFirstJoin();
3445
3269
  for (const runtime of [...this.recoveries.keys()])
3446
3270
  this.cancelRecovery(runtime);
3447
- this.continueNotificationQueues.clear();
3448
3271
  for (const pane of this.panesByRuntime.values()) {
3449
3272
  for (const sub of pane.subs) {
3450
3273
  if (!sub.closed) {
3451
3274
  sub.closed = true;
3452
3275
  sub.cancelCapture?.();
3453
- sub.onEvent({ type: "closed" });
3276
+ try {
3277
+ sub.onEvent({ type: "closed" });
3278
+ }
3279
+ catch {
3280
+ // Close every sibling and the transport even if a consumer throws.
3281
+ }
3454
3282
  }
3455
3283
  }
3456
3284
  pane.subs.clear();