tmux-ide 2.9.7 → 2.9.8

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 (34) hide show
  1. package/bin/cli.js +322 -52
  2. package/package.json +2 -2
  3. package/packages/contracts/src/pane-stream.ts +17 -0
  4. package/packages/contracts/src/terminal-graphics.ts +29 -3
  5. package/packages/core/src/kitty-placeholders.ts +29 -11
  6. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  7. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  8. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  9. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  10. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +6 -2
  11. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  12. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +6 -2
  13. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  14. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +6 -2
  15. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  16. package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
  17. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +146 -25
  18. package/packages/daemon/dist/terminal/mirror/mirror-service.js +6 -0
  19. package/packages/daemon/dist/terminal/mirror/session-channel.js +45 -2
  20. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +109 -7
  21. package/packages/daemon/dist/terminal/session-runtime/registry.js +9 -0
  22. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +19 -2
  23. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +4 -0
  24. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  25. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
  26. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
  27. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +143 -28
  28. package/packages/daemon/src/terminal/mirror/mirror-service.ts +11 -0
  29. package/packages/daemon/src/terminal/mirror/session-channel.ts +61 -2
  30. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +123 -6
  31. package/packages/daemon/src/terminal/session-runtime/registry.ts +18 -0
  32. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +18 -2
  33. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +5 -0
  34. package/packages/daemon-client/src/pane-stream-client.ts +8 -0
@@ -853,7 +853,50 @@ export class SessionChannel {
853
853
  this.input.flush();
854
854
  this.clearWindowViewports();
855
855
  this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
856
- this.io.send(`refresh-client -C ${cols}x${rows}`);
856
+ this.io.send(`refresh-client -C ${cols}x${rows}${this.cellPixelSuffix()}`);
857
+ this.clientViewport = { cols, rows };
858
+ }
859
+ clientViewport = null;
860
+ cellPixels = null;
861
+ /** "/WxH" for tmux's refresh-client -C (patched tmux; stock ignores it). */
862
+ cellPixelSuffix() {
863
+ if (!this.cellPixels)
864
+ return "";
865
+ const { width, height, precise } = this.cellPixels;
866
+ return `/${width}x${height}` + (precise ? `/${precise.width}x${precise.height}` : "");
867
+ }
868
+ /**
869
+ * The renderer's cell size in device pixels. tmux answers CSI 14t/16t and
870
+ * sets TIOCGWINSZ pixels from it, so image programs render 1:1 for the
871
+ * window. Re-applies the current viewport when the size changes.
872
+ */
873
+ setCellPixels(width, height, precise) {
874
+ if (!Number.isSafeInteger(width) ||
875
+ !Number.isSafeInteger(height) ||
876
+ width < 1 ||
877
+ height < 1 ||
878
+ width > 1024 ||
879
+ height > 1024)
880
+ throw new RangeError("cell pixel size out of range");
881
+ if (precise &&
882
+ [precise.width, precise.height].some((value) => !Number.isSafeInteger(value) || value < 1000 || value > 1024000))
883
+ throw new RangeError("precise cell pixel size out of range");
884
+ if (this.cellPixels?.width === width &&
885
+ this.cellPixels.height === height &&
886
+ this.cellPixels.precise?.width === precise?.width &&
887
+ this.cellPixels.precise?.height === precise?.height)
888
+ return;
889
+ this.cellPixels = { width, height, ...(precise ? { precise } : {}) };
890
+ if (!this.geometryParticipating)
891
+ return;
892
+ this.input.flush();
893
+ if (this.fittedWindows.size > 0) {
894
+ for (const [runtimeId, size] of this.fittedWindows)
895
+ if (this.windowsByRuntime.has(runtimeId))
896
+ this.io.send(`refresh-client -C ${runtimeId}:${size.cols}x${size.rows}${this.cellPixelSuffix()}`);
897
+ }
898
+ else if (this.clientViewport)
899
+ this.io.send(`refresh-client -C ${this.clientViewport.cols}x${this.clientViewport.rows}${this.cellPixelSuffix()}`);
857
900
  }
858
901
  restoreWindowSizing(target) {
859
902
  if (!this.geometryParticipating)
@@ -894,7 +937,7 @@ export class SessionChannel {
894
937
  return;
895
938
  this.input.flush();
896
939
  this.restoreWindowSizing(window.runtimeId);
897
- this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}`);
940
+ this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}${this.cellPixelSuffix()}`);
898
941
  this.fittedWindows.set(window.runtimeId, { cols, rows });
899
942
  }
900
943
  /** Must also run on geometry-owner handoff, before the next owner fits. */
@@ -1,6 +1,7 @@
1
+ import { inflateSync } from "node:zlib";
1
2
  import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
2
3
  import { z } from "zod";
3
- import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
4
+ import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
4
5
  import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
5
6
  import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
6
7
  import { terminalDeliveryObservationOrdinal } from "../session-runtime/terminal-delivery-observation-identity.js";
@@ -593,6 +594,23 @@ class PreAuthAdmission {
593
594
  }
594
595
  }
595
596
  const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
597
+ /** Graphics state as renderers without zlib support must see it. */
598
+ export function rawGraphicsState(state) {
599
+ const legacy = state.images.map((image) => image.format === "rgba-zlib" || image.format === "rgb-zlib"
600
+ ? {
601
+ ...image,
602
+ format: image.format === "rgba-zlib" ? "rgba" : "rgb",
603
+ bytes: image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3),
604
+ }
605
+ : image);
606
+ const images = legacy.filter((image) => image.bytes <= TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES);
607
+ const kept = new Set(images.map((image) => image.digest));
608
+ return {
609
+ ...state,
610
+ images,
611
+ placements: state.placements.filter((placement) => kept.has(placement.digest)),
612
+ };
613
+ }
596
614
  const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
597
615
  export class PaneStreamLiveConnection {
598
616
  #clientId;
@@ -728,6 +746,8 @@ export class PaneStreamLiveConnection {
728
746
  graphicsAnnounced: false,
729
747
  graphicsQueue: [],
730
748
  graphicsSending: false,
749
+ graphicsServed: new Map(),
750
+ graphicsState: null,
731
751
  });
732
752
  }
733
753
  }
@@ -1212,18 +1232,65 @@ export class PaneStreamLiveConnection {
1212
1232
  const graphics = channel.delivery?.graphics;
1213
1233
  if (!graphics)
1214
1234
  return;
1235
+ // Renderers that cannot inflate see raw formats; their bytes are inflated
1236
+ // when served.
1237
+ const zlib = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION) === true;
1238
+ channel.graphicsZlib = zlib;
1239
+ // Renderers that report cell pixels learn up front that this daemon
1240
+ // accepts them, before any image exists.
1241
+ const cellPixels = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION) === true;
1242
+ const preciseCellPixels = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION) === true;
1243
+ let announced = false;
1215
1244
  graphics.subscribe((state) => {
1245
+ announced = true;
1216
1246
  if (this.#closed || channel.closed)
1217
1247
  return;
1218
1248
  channel.graphicsAnnounced = true;
1219
- this.#sendFrame(null, { type: "terminal-graphics", pane: channel.semanticPaneId, state });
1249
+ channel.graphicsState = state;
1250
+ // A program redrawing one image id (every frame of an animation or a
1251
+ // page) needs the new bytes at once: push them instead of waiting for
1252
+ // the renderer's round trip.
1253
+ const pushed = [];
1254
+ for (const image of state.images) {
1255
+ const served = channel.graphicsServed.get(image.imageId);
1256
+ if (served !== undefined && served !== image.digest)
1257
+ pushed.push(image.digest);
1258
+ }
1259
+ this.#sendFrame(null, {
1260
+ type: "terminal-graphics",
1261
+ pane: channel.semanticPaneId,
1262
+ state: {
1263
+ ...(zlib ? state : rawGraphicsState(state)),
1264
+ ...(cellPixels ? { cellPixels: true } : {}),
1265
+ ...(preciseCellPixels ? { preciseCellPixels: true } : {}),
1266
+ },
1267
+ });
1268
+ if (pushed.length > 0)
1269
+ this.#queueGraphics(channel, pushed);
1220
1270
  });
1271
+ if (cellPixels && !announced && !this.#closed && !channel.closed) {
1272
+ channel.graphicsAnnounced = true;
1273
+ this.#sendFrame(null, {
1274
+ type: "terminal-graphics",
1275
+ pane: channel.semanticPaneId,
1276
+ state: {
1277
+ revision: 0,
1278
+ images: [],
1279
+ placements: [],
1280
+ cellPixels: true,
1281
+ ...(preciseCellPixels ? { preciseCellPixels: true } : {}),
1282
+ },
1283
+ });
1284
+ }
1221
1285
  }
1222
1286
  #requestGraphics(pane, digests) {
1223
1287
  const channel = this.#panes.get(pane);
1224
1288
  // Requests are only meaningful after an announcement; ignore stray ones.
1225
1289
  if (!channel || channel.closed || !channel.graphicsAnnounced)
1226
1290
  return;
1291
+ this.#queueGraphics(channel, digests);
1292
+ }
1293
+ #queueGraphics(channel, digests) {
1227
1294
  for (const digest of digests)
1228
1295
  if (!channel.graphicsQueue.includes(digest) &&
1229
1296
  channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS)
@@ -1241,10 +1308,32 @@ export class PaneStreamLiveConnection {
1241
1308
  try {
1242
1309
  while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1243
1310
  const digest = channel.graphicsQueue.shift();
1244
- const image = channel.delivery?.graphics?.image(digest);
1245
- if (!image)
1311
+ const graphics = channel.delivery?.graphics;
1312
+ const image = graphics?.image(digest);
1313
+ if (!graphics || !image)
1314
+ continue;
1315
+ for (const entry of channel.graphicsState?.images ?? [])
1316
+ if (entry.digest === digest)
1317
+ channel.graphicsServed.set(entry.imageId, digest);
1318
+ const raw = !channel.graphicsZlib && image.format.endsWith("-zlib");
1319
+ let data = image.data;
1320
+ if (raw) {
1321
+ const expected = image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3);
1322
+ if (expected < 1 || expected > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
1323
+ continue;
1324
+ try {
1325
+ data = inflateSync(image.data, { maxOutputLength: expected });
1326
+ }
1327
+ catch {
1328
+ continue;
1329
+ }
1330
+ if (data.byteLength !== expected)
1331
+ continue;
1332
+ }
1333
+ if (!channel.graphicsZlib && data.byteLength > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
1246
1334
  continue;
1247
- const total = image.data.byteLength;
1335
+ const format = raw ? (image.format === "rgba-zlib" ? "rgba" : "rgb") : image.format;
1336
+ const total = data.byteLength;
1248
1337
  for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1249
1338
  while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1250
1339
  if (this.#closed || channel.closed)
@@ -1253,19 +1342,23 @@ export class PaneStreamLiveConnection {
1253
1342
  }
1254
1343
  if (this.#closed || channel.closed)
1255
1344
  return;
1345
+ // A superseded frame (the id was redrawn) is abandoned mid-transfer;
1346
+ // the renderer drops the partial image and shows the newer one.
1347
+ if (offset > 0 && graphics.image(digest) !== image)
1348
+ break;
1256
1349
  const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1257
1350
  this.#sendFrame(null, {
1258
1351
  type: "terminal-graphics-blob",
1259
1352
  pane: channel.semanticPaneId,
1260
1353
  blob: {
1261
1354
  digest,
1262
- format: image.format,
1355
+ format,
1263
1356
  width: image.width,
1264
1357
  height: image.height,
1265
1358
  total,
1266
1359
  offset,
1267
1360
  },
1268
- data: Buffer.from(image.data.subarray(offset, end)).toString("base64"),
1361
+ data: Buffer.from(data.subarray(offset, end)).toString("base64"),
1269
1362
  });
1270
1363
  }
1271
1364
  }
@@ -1851,6 +1944,15 @@ export class PaneStreamLiveConnection {
1851
1944
  this.#requestGraphics(frame.pane, frame.digests);
1852
1945
  return;
1853
1946
  }
1947
+ if (frame.type === "terminal-cell-pixels") {
1948
+ try {
1949
+ this.#sessionRuntimeBinding?.setCellPixels?.(frame.width, frame.height, frame.precise);
1950
+ }
1951
+ catch {
1952
+ // A retired runtime simply keeps tmux's default cell size.
1953
+ }
1954
+ return;
1955
+ }
1854
1956
  if (frame.type === "viewport") {
1855
1957
  if (!this.#descriptor.terminalDelivery ||
1856
1958
  this.#descriptor.viewerMode !== "interactive" ||
@@ -1134,6 +1134,11 @@ class SessionRuntime {
1134
1134
  throw new SessionRuntimeControllerLeaseError("invalid-client-capability", "The client does not own this geometry authority lease.");
1135
1135
  }
1136
1136
  }
1137
+ /** Renderer cell size in device pixels; benign, so any connected client may set it. */
1138
+ setCellPixels(clientId, width, height, precise) {
1139
+ this.#assertConnected(clientId);
1140
+ this.#mirror.setCellPixels?.(this.session, width, height, precise);
1141
+ }
1137
1142
  fitViewportWithAuthority(clientId, lease, cols, rows, semanticWindowId) {
1138
1143
  this.assertGeometryAuthority(clientId, lease);
1139
1144
  this.#mirror.setGeometryParticipation(this.session, true);
@@ -1571,6 +1576,10 @@ class SessionRuntimeConsumerImpl {
1571
1576
  this.#assertOpen();
1572
1577
  this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
1573
1578
  }
1579
+ setCellPixels(width, height, precise) {
1580
+ this.#assertOpen();
1581
+ this.#runtime.setCellPixels(this.clientId, width, height, precise);
1582
+ }
1574
1583
  fitViewportWithAuthority(lease, cols, rows, semanticWindowId) {
1575
1584
  this.#assertOpen();
1576
1585
  this.#runtime.fitViewportWithAuthority(this.clientId, lease, cols, rows, semanticWindowId);
@@ -250,8 +250,17 @@ export class TerminalReplicaInterpreter {
250
250
  const previous = this.#backend;
251
251
  this.#backend = replacement;
252
252
  this.#attachKitty(replacement);
253
- // tmux never saw cursor-anchored placements; a capture cannot restore them.
254
- this.#kitty.dropCursorPlacements();
253
+ // A capture replay does not erase graphics already observed by this owner.
254
+ // Keep anchors only when both backends agree on their coordinate basis;
255
+ // reflow, history replacement and authoritative stream resets are ambiguous.
256
+ const sameGraphicsBasis = operation.bootstrap === "painted-capture" &&
257
+ previous.cols === replacement.cols &&
258
+ previous.rows === replacement.rows &&
259
+ previous.graphicsViewportTop?.() !== undefined &&
260
+ previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
261
+ previous.modes().alternateScreen === replacement.modes().alternateScreen;
262
+ if (!sameGraphicsBasis)
263
+ this.#kitty.dropCursorPlacements();
255
264
  this.#nativeReseedRequested = false;
256
265
  this.#scrollback = scrollback;
257
266
  this.#bootstrap = {
@@ -290,6 +299,8 @@ export class TerminalReplicaInterpreter {
290
299
  return;
291
300
  }
292
301
  if (operation.type === "resize") {
302
+ if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
303
+ this.#kitty.dropCursorPlacements();
293
304
  this.#causalCell?.fail("geometry-drift");
294
305
  if (this.#backend.modes().synchronizedOutput) {
295
306
  // Geometry is part of the admitted FIFO even while publication is
@@ -344,6 +355,12 @@ export class TerminalReplicaInterpreter {
344
355
  // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
345
356
  // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
346
357
  // be all image payload that must never reach the VT parser as text.
358
+ // A chunk entirely inside image payload changes nothing the parser can
359
+ // see. Skipping it matters: every parser write settles on a later timer
360
+ // turn, so a frame split into dozens of %output chunks would otherwise
361
+ // serialize dozens of turns and fall behind a 30 fps image stream.
362
+ if (scanned.forward.byteLength === 0)
363
+ return;
347
364
  await this.#writeToBackend(this.#backend, scanned.forward);
348
365
  if (this.#observability.enabled)
349
366
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
@@ -297,6 +297,10 @@ export class SessionRuntimeTransportBinding {
297
297
  throw error;
298
298
  }
299
299
  }
300
+ setCellPixels(width, height, precise) {
301
+ this.#assertOpen();
302
+ this.#shared.consumer.setCellPixels?.(width, height, precise);
303
+ }
300
304
  fitViewport(expectedLease, cols, rows, semanticWindowId) {
301
305
  this.#assertOpen();
302
306
  if (this.#shared.geometryTransportLeaseIds.at(-1) !== this.#transportLeaseId) {
@@ -918,6 +918,11 @@ function parsePaneSnapshot(
918
918
  * application-shell inventory. Names are resolved exactly to `$session_id`
919
919
  * before any pane query, and a second identity/topology snapshot closes races.
920
920
  */
921
+ const INVENTORY_FENCE_ATTEMPTS = 4;
922
+ /** 25, 50, 100 ms: enough for a mutation's identity stamps, bounded for every reader. */
923
+ const settleDelay = (attempt: number) =>
924
+ new Promise<void>((resolve) => setTimeout(resolve, 25 * 2 ** (attempt - 1)));
925
+
921
926
  export async function discoverWorkspaceRegistryTerminalInventory(
922
927
  registry: WorkspaceRegistry,
923
928
  runner: NativeTerminalInventoryReadRunner,
@@ -941,12 +946,6 @@ export async function discoverWorkspaceRegistryTerminalInventory(
941
946
  const argv = ["list-panes", "-s", "-t", identity.id, "-F", PANE_FORMAT] as const;
942
947
  const before = await requiredTmuxResult(runner, argv, signal);
943
948
  if (before === null) continue;
944
- const first = parsePaneSnapshot(before, identity);
945
- const panes = first.panes;
946
- const after = await requiredTmuxResult(runner, argv, signal);
947
- if (after === null) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
948
- const second = parsePaneSnapshot(after, identity);
949
- const latest = second.panes;
950
949
  // Foreground commands, titles, focus and paths can change while the same
951
950
  // pane remains valid. Fence binding/topology only, then publish one complete
952
951
  // latest snapshot rather than mixing presentation values across reads.
@@ -961,14 +960,30 @@ export async function discoverWorkspaceRegistryTerminalInventory(
961
960
  "windowStamp",
962
961
  "index",
963
962
  ] as const;
964
- if (
965
- first.linksProof !== second.linksProof ||
966
- panes.length !== latest.length ||
967
- panes.some((pane, index) => proofKeys.some((key) => pane[key] !== latest[index]![key]))
968
- ) {
969
- throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
963
+ const matches = (
964
+ first: ReturnType<typeof parsePaneSnapshot>,
965
+ second: ReturnType<typeof parsePaneSnapshot>,
966
+ ) =>
967
+ first.linksProof === second.linksProof &&
968
+ first.panes.length === second.panes.length &&
969
+ first.panes.every((pane, index) =>
970
+ proofKeys.every((key) => pane[key] === second.panes[index]![key]),
971
+ );
972
+ // A just-created window or split is still being stamped, so topology can
973
+ // move between two reads. Let it settle for a bounded number of re-reads;
974
+ // only two consecutive matching reads are ever published.
975
+ let previous = parsePaneSnapshot(before, identity);
976
+ let stable: ReturnType<typeof parsePaneSnapshot> | null = null;
977
+ for (let attempt = 0; attempt < INVENTORY_FENCE_ATTEMPTS && !stable; attempt += 1) {
978
+ if (attempt > 0) await settleDelay(attempt);
979
+ const after = await requiredTmuxResult(runner, argv, signal);
980
+ if (after === null) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
981
+ const next = parsePaneSnapshot(after, identity);
982
+ if (matches(previous, next)) stable = next;
983
+ else previous = next;
970
984
  }
971
- bySessionName.set(sessionName, latest);
985
+ if (!stable) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
986
+ bySessionName.set(sessionName, stable.panes);
972
987
  }
973
988
 
974
989
  const panes: NativeTerminalInventoryPaneSnapshot[] = [];