tmux-ide 2.9.6 → 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 (84) hide show
  1. package/bin/cli.js +2881 -1336
  2. package/package.json +2 -2
  3. package/packages/contracts/src/index.ts +5 -0
  4. package/packages/contracts/src/pane-stream.ts +54 -0
  5. package/packages/contracts/src/terminal-graphics.ts +118 -0
  6. package/packages/contracts/src/terminal-history-anchor.ts +61 -0
  7. package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
  8. package/packages/core/src/index.ts +1 -0
  9. package/packages/core/src/kitty-placeholders.ts +208 -0
  10. package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
  11. package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
  12. package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
  13. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
  14. package/packages/daemon/dist/lib/daemon-embed.js +21 -0
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
  16. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
  23. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +12 -4
  24. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +9 -1
  26. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +9 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
  30. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +699 -0
  31. package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
  32. package/packages/daemon/dist/terminal/mirror/mirror-service.js +7 -0
  33. package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
  34. package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
  35. package/packages/daemon/dist/terminal/mirror/session-channel.js +56 -4
  36. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +180 -1
  37. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
  38. package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
  39. package/packages/daemon/dist/terminal/session-runtime/registry.js +29 -0
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
  41. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +85 -1
  42. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
  43. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +53 -21
  44. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
  45. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
  47. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
  49. package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
  50. package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
  51. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  52. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
  53. package/packages/daemon/src/lib/daemon-embed.ts +26 -0
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
  55. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
  56. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  57. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
  58. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
  59. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +756 -0
  60. package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
  61. package/packages/daemon/src/terminal/mirror/mirror-service.ts +14 -0
  62. package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
  63. package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
  64. package/packages/daemon/src/terminal/mirror/session-channel.ts +75 -3
  65. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +199 -0
  66. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
  67. package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
  68. package/packages/daemon/src/terminal/session-runtime/registry.ts +38 -0
  69. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
  70. package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
  71. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +99 -1
  72. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
  73. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +58 -24
  74. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
  75. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
  76. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
  79. package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
  80. package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
  81. package/packages/daemon-client/src/pane-stream-client.ts +37 -0
  82. package/packages/daemon-client/src/tmux-server-client.ts +133 -6
  83. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
  84. /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
@@ -868,6 +868,16 @@ export class ControlChannelCore {
868
868
  }));
869
869
  }
870
870
  }
871
+ /**
872
+ * Server-wide, idempotent: `-q` keeps a stock tmux (no such option) quiet.
873
+ * Images reach this control client either way; the option only lets programs
874
+ * detect support, and the environment tells new panes' programs that this
875
+ * server's renderer shows placeholder images.
876
+ */
877
+ export const KITTY_GRAPHICS_SETUP = [
878
+ "set-option -gq kitty-graphics on",
879
+ "set-environment -g TMUX_IDE_GRAPHICS kitty",
880
+ ];
871
881
  export function mirrorControlAttachArgs(options, pauseAfterSeconds = DEFAULT_PAUSE_AFTER_SECONDS) {
872
882
  const attach = [
873
883
  "attach",
@@ -1009,7 +1019,13 @@ export class MirrorControlChannel {
1009
1019
  this.core.fail(String(err));
1010
1020
  reject(err);
1011
1021
  });
1012
- }).then(() => this.initializeNativeViewer());
1022
+ }).then(() => {
1023
+ // One command per send: tmux answers a `;` list with one block per command.
1024
+ if (this.opts.kittyGraphics)
1025
+ for (const command of KITTY_GRAPHICS_SETUP)
1026
+ this.send(command);
1027
+ return this.initializeNativeViewer();
1028
+ });
1013
1029
  }
1014
1030
  get nativeViewerIdentity() {
1015
1031
  return this.viewerIdentity ? { ...this.viewerIdentity } : null;
@@ -369,6 +369,12 @@ export class MirrorService {
369
369
  this.assertWindowOwnership(session);
370
370
  entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
371
371
  }
372
+ setCellPixels(session, width, height, precise) {
373
+ const entry = this.channels.get(session);
374
+ if (!entry || entry.retired)
375
+ return;
376
+ entry.channel.setCellPixels(width, height, precise);
377
+ }
372
378
  clearWindowViewports(session) {
373
379
  const entry = this.channels.get(session);
374
380
  if (entry && !entry.retired)
@@ -532,6 +538,7 @@ export class MirrorService {
532
538
  nativeServerIdentity: this.opts.nativeServerIdentity,
533
539
  configFile: this.opts.configFile,
534
540
  pauseAfterSeconds: this.opts.pauseAfterSeconds,
541
+ kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
535
542
  nowMicros: this.opts.nowMicros,
536
543
  nativeViewer: ownedViewer?.controlOptions(),
537
544
  nativeViewerReady: ownedViewer
@@ -1,3 +1,11 @@
1
+ /** Upstream tmux 3.8 reuses -R for a textual grid dump, not our JSON extension.
2
+ * Require the generated grid and first-row headers; arbitrary malformed native
3
+ * replies must remain errors rather than silently downgrading capture fidelity.
4
+ */
5
+ export function isStockRawGridCapture(lines) {
6
+ return (/^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
7
+ /^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? ""));
8
+ }
1
9
  const MAX_BYTES = 16 * 1024 * 1024;
2
10
  const MAX_ROWS = MAX_BYTES / 64;
3
11
  const MAX_CELLS = 1_000_000;
@@ -1,4 +1,4 @@
1
- import { decodeNativeGridCapture } from "./native-grid-capture.js";
1
+ import { decodeNativeGridCapture, isStockRawGridCapture, } from "./native-grid-capture.js";
2
2
  /** One experimental capability owner per control connection. */
3
3
  export class NativeGridCaptureReader {
4
4
  io;
@@ -57,6 +57,11 @@ export class NativeGridCaptureReader {
57
57
  finish({ status: "changed" });
58
58
  return;
59
59
  }
60
+ if (isStockRawGridCapture(reply.lines)) {
61
+ this.unsupported = true;
62
+ finish({ status: "unsupported" });
63
+ return;
64
+ }
60
65
  const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
61
66
  finish(snapshot
62
67
  ? {
@@ -1,7 +1,7 @@
1
1
  import { layoutContentRows } from "./layout-content-rows.js";
2
2
  import { decodeNativeAtomicSnapshot, decodeNativeAtomicDualSnapshot, nativeAtomicDualSnapshotPlan, nativeAtomicSnapshotPlan, } from "./native-atomic-snapshot.js";
3
3
  import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.js";
4
- import { decodeNativeGridCapture, isNativeBootstrapCapture, } from "./native-grid-capture.js";
4
+ import { decodeNativeGridCapture, isStockRawGridCapture, isNativeBootstrapCapture, } from "./native-grid-capture.js";
5
5
  /**
6
6
  * SessionChannel — one control-mode channel serving every pane subscription
7
7
  * of one tmux session (m43 card 1).
@@ -114,7 +114,8 @@ const RECOVERY_CURSOR_PROBE_FORMAT = [
114
114
  /** Cache only proven server capability; malformed or failed reads can recover. */
115
115
  function nativeBootstrapUnsupported(ok, lines, native) {
116
116
  if (ok)
117
- return native !== null && (native.version !== 2 || native.currentAttributes === undefined);
117
+ return (isStockRawGridCapture(lines) ||
118
+ (native !== null && (native.version !== 2 || native.currentAttributes === undefined)));
118
119
  return lines.some((line) => /^(?:parse error: )?(?:command capture-pane: )?unknown flag -R$/.test(line.trim()));
119
120
  }
120
121
  const FAILED_RESEED_RESULT = Object.freeze({
@@ -852,7 +853,50 @@ export class SessionChannel {
852
853
  this.input.flush();
853
854
  this.clearWindowViewports();
854
855
  this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
855
- 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()}`);
856
900
  }
857
901
  restoreWindowSizing(target) {
858
902
  if (!this.geometryParticipating)
@@ -893,7 +937,7 @@ export class SessionChannel {
893
937
  return;
894
938
  this.input.flush();
895
939
  this.restoreWindowSizing(window.runtimeId);
896
- 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()}`);
897
941
  this.fittedWindows.set(window.runtimeId, { cols, rows });
898
942
  }
899
943
  /** Must also run on geometry-owner handoff, before the next owner fits. */
@@ -1468,6 +1512,7 @@ export class SessionChannel {
1468
1512
  return;
1469
1513
  }
1470
1514
  recovery.attempts += 1;
1515
+ lease.attemptCharged = true;
1471
1516
  recovery.stage = "stock-pause";
1472
1517
  recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1473
1518
  // These rows were received before this pause and belong to the new seed,
@@ -1617,6 +1662,7 @@ export class SessionChannel {
1617
1662
  return;
1618
1663
  }
1619
1664
  recovery.attempts += 1;
1665
+ lease.attemptCharged = true;
1620
1666
  const ordinal = ++recovery.reseedOrdinal;
1621
1667
  const participants = [...pane.subs]
1622
1668
  .filter((sub) => !sub.closed && !sub.frozen)
@@ -2310,6 +2356,12 @@ export class SessionChannel {
2310
2356
  continue;
2311
2357
  const recovery = this.recoveries.get(pane.runtimeId);
2312
2358
  if (recovery?.lease && !recovery.lease.retired) {
2359
+ // A newer layout cancels this capture; it is not a failed snapshot.
2360
+ // Refund only this lease, retaining earlier failures and the original deadline.
2361
+ if (recovery.lease.attemptCharged) {
2362
+ recovery.lease.attemptCharged = false;
2363
+ recovery.attempts -= 1;
2364
+ }
2313
2365
  this.awaitSnapshotLayout(recovery);
2314
2366
  this.retrySnapshot(recovery);
2315
2367
  }
@@ -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, 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";
@@ -592,6 +593,25 @@ class PreAuthAdmission {
592
593
  socket.off("error", this.#onClose);
593
594
  }
594
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
+ }
614
+ const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
595
615
  export class PaneStreamLiveConnection {
596
616
  #clientId;
597
617
  #socket;
@@ -723,6 +743,11 @@ export class PaneStreamLiveConnection {
723
743
  frozenByWire: false,
724
744
  closed: false,
725
745
  batch: null,
746
+ graphicsAnnounced: false,
747
+ graphicsQueue: [],
748
+ graphicsSending: false,
749
+ graphicsServed: new Map(),
750
+ graphicsState: null,
726
751
  });
727
752
  }
728
753
  }
@@ -1097,6 +1122,7 @@ export class PaneStreamLiveConnection {
1097
1122
  await this.#sendTerminalDelivery(channel.semanticPaneId, message);
1098
1123
  pending.length = 0;
1099
1124
  markReady();
1125
+ this.#startGraphics(channel);
1100
1126
  }
1101
1127
  this.#closeIfAllPanesGone();
1102
1128
  }
@@ -1201,6 +1227,146 @@ export class PaneStreamLiveConnection {
1201
1227
  }
1202
1228
  await this.#awaitSemanticCredit(pane);
1203
1229
  }
1230
+ /** Announce the pane's kitty graphics state; the client pulls bytes it lacks. */
1231
+ #startGraphics(channel) {
1232
+ const graphics = channel.delivery?.graphics;
1233
+ if (!graphics)
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;
1244
+ graphics.subscribe((state) => {
1245
+ announced = true;
1246
+ if (this.#closed || channel.closed)
1247
+ return;
1248
+ channel.graphicsAnnounced = true;
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);
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
+ }
1285
+ }
1286
+ #requestGraphics(pane, digests) {
1287
+ const channel = this.#panes.get(pane);
1288
+ // Requests are only meaningful after an announcement; ignore stray ones.
1289
+ if (!channel || channel.closed || !channel.graphicsAnnounced)
1290
+ return;
1291
+ this.#queueGraphics(channel, digests);
1292
+ }
1293
+ #queueGraphics(channel, digests) {
1294
+ for (const digest of digests)
1295
+ if (!channel.graphicsQueue.includes(digest) &&
1296
+ channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS)
1297
+ channel.graphicsQueue.push(digest);
1298
+ if (!channel.graphicsSending)
1299
+ void this.#drainGraphics(channel);
1300
+ }
1301
+ /**
1302
+ * Blob bytes yield to terminal frames: each chunk waits until the socket's
1303
+ * buffered bytes fall below a small window, so images never trip the
1304
+ * socket-wide backpressure ceiling or delay a pane's next seed for long.
1305
+ */
1306
+ async #drainGraphics(channel) {
1307
+ channel.graphicsSending = true;
1308
+ try {
1309
+ while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1310
+ const digest = channel.graphicsQueue.shift();
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)
1334
+ continue;
1335
+ const format = raw ? (image.format === "rgba-zlib" ? "rgba" : "rgb") : image.format;
1336
+ const total = data.byteLength;
1337
+ for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1338
+ while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1339
+ if (this.#closed || channel.closed)
1340
+ return;
1341
+ await new Promise((resolve) => setTimeout(resolve, 5));
1342
+ }
1343
+ if (this.#closed || channel.closed)
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;
1349
+ const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1350
+ this.#sendFrame(null, {
1351
+ type: "terminal-graphics-blob",
1352
+ pane: channel.semanticPaneId,
1353
+ blob: {
1354
+ digest,
1355
+ format,
1356
+ width: image.width,
1357
+ height: image.height,
1358
+ total,
1359
+ offset,
1360
+ },
1361
+ data: Buffer.from(data.subarray(offset, end)).toString("base64"),
1362
+ });
1363
+ }
1364
+ }
1365
+ }
1366
+ finally {
1367
+ channel.graphicsSending = false;
1368
+ }
1369
+ }
1204
1370
  #awaitSemanticCredit(pane) {
1205
1371
  // Backpressure is pane-local. Aggregate pressure is guarded by the hard
1206
1372
  // socket ceiling in #drainNow, but must not let a noisy sibling prevent a
@@ -1774,6 +1940,19 @@ export class PaneStreamLiveConnection {
1774
1940
  this.#acceptSemanticIntent(frame.operationId, frame.intent);
1775
1941
  return;
1776
1942
  }
1943
+ if (frame.type === "terminal-graphics-request") {
1944
+ this.#requestGraphics(frame.pane, frame.digests);
1945
+ return;
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
+ }
1777
1956
  if (frame.type === "viewport") {
1778
1957
  if (!this.#descriptor.terminalDelivery ||
1779
1958
  this.#descriptor.viewerMode !== "interactive" ||
@@ -129,7 +129,6 @@ export function parseSessionPaneDescriptorReply(lines) {
129
129
  !bounded(type, 256) ||
130
130
  !bounded(currentCommand, 512) ||
131
131
  !bounded(cwd, 4_096) ||
132
- !bounded(title, 1_024) ||
133
132
  !bounded(windowName, 1_024) ||
134
133
  !bounded(name, 256) ||
135
134
  !bounded(missionStamp, 256) ||
@@ -148,7 +147,9 @@ export function parseSessionPaneDescriptorReply(lines) {
148
147
  type: nonempty(type),
149
148
  currentCommand: nonempty(currentCommand),
150
149
  cwd: nonempty(cwd),
151
- title: nonempty(title),
150
+ // Stock tmux turns any APC, including kitty graphics payloads, into the
151
+ // pane title; an overlong title names nothing and must not drop the pane.
152
+ title: bounded(title, 1_024) ? nonempty(title) : null,
152
153
  windowIndex,
153
154
  windowName: nonempty(windowName),
154
155
  windowId: /^@(?:0|[1-9][0-9]*)$/u.test(windowId) ? windowId : null,
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const MAX_TOKENS = 32;
3
+ const PROOF_TTL_MS = 5 * 60_000;
4
+ /** Owner-private row ordinals. Never changes terminal v1 payloads or hashes. */
5
+ export class HistoryAnchorOwner {
6
+ now;
7
+ #current = null;
8
+ #first = 0;
9
+ #length = 0;
10
+ #proofs = new Map();
11
+ #tokens = new Map();
12
+ constructor(now = () => performance.now()) {
13
+ this.now = now;
14
+ }
15
+ invalidate() {
16
+ this.#current = null;
17
+ this.#first = 0;
18
+ this.#length = 0;
19
+ this.#tokens.clear();
20
+ this.#proofs.clear();
21
+ }
22
+ observe(update) {
23
+ if (update.type === "terminal.tombstone") {
24
+ this.invalidate();
25
+ return;
26
+ }
27
+ if (update.type === "terminal.seed") {
28
+ this.invalidate();
29
+ this.#length = update.snapshot.history.length;
30
+ this.#current = {
31
+ workspaceName: update.workspaceName,
32
+ semanticPaneId: update.semanticPaneId,
33
+ generation: update.generation,
34
+ incarnation: update.incarnation,
35
+ revision: update.revision,
36
+ stateHash: update.stateHash,
37
+ cols: update.cols,
38
+ rows: update.rows,
39
+ alternate: update.snapshot.modes.alternateScreen,
40
+ };
41
+ this.#remember();
42
+ return;
43
+ }
44
+ const old = this.#current;
45
+ if (!old ||
46
+ old.workspaceName !== update.workspaceName ||
47
+ old.semanticPaneId !== update.semanticPaneId ||
48
+ old.generation !== update.generation ||
49
+ old.incarnation !== update.incarnation ||
50
+ old.revision !== update.baseRevision) {
51
+ this.invalidate();
52
+ return;
53
+ }
54
+ const delta = update.patch.historyDelta;
55
+ const alternate = update.patch.modes?.alternateScreen ?? old.alternate;
56
+ if (delta &&
57
+ (!Number.isSafeInteger(delta.trim) || delta.trim < 0 || delta.trim > this.#length)) {
58
+ this.invalidate();
59
+ return;
60
+ }
61
+ const length = update.patch.history?.length ??
62
+ this.#length - (delta?.trim ?? 0) + (delta?.append.length ?? 0);
63
+ const reset = update.patch.history !== undefined ||
64
+ update.cols !== old.cols ||
65
+ update.rows !== old.rows ||
66
+ alternate !== old.alternate;
67
+ if (reset) {
68
+ this.#tokens.clear();
69
+ this.#proofs.clear();
70
+ this.#first = 0;
71
+ }
72
+ else
73
+ this.#first += delta?.trim ?? 0;
74
+ if (!Number.isSafeInteger(this.#first + length)) {
75
+ this.invalidate();
76
+ return;
77
+ }
78
+ this.#length = length;
79
+ this.#current = {
80
+ workspaceName: update.workspaceName,
81
+ semanticPaneId: update.semanticPaneId,
82
+ generation: update.generation,
83
+ incarnation: update.incarnation,
84
+ revision: update.revision,
85
+ stateHash: update.stateHash,
86
+ cols: update.cols,
87
+ rows: update.rows,
88
+ alternate,
89
+ };
90
+ this.#prune();
91
+ this.#remember();
92
+ }
93
+ capture(proof, historyIndex) {
94
+ const failure = this.#admitIdentity(proof);
95
+ if (failure)
96
+ return failure;
97
+ this.#prune();
98
+ const known = this.#current && this.#key(proof) === this.#key(this.#current)
99
+ ? { first: this.#first, length: this.#length }
100
+ : this.#proofs.get(this.#key(proof));
101
+ if (!known)
102
+ return { status: "stale" };
103
+ if (!Number.isSafeInteger(historyIndex) || historyIndex < 0 || historyIndex >= known.length)
104
+ return { status: "lost" };
105
+ const ordinal = known.first + historyIndex;
106
+ if (ordinal < this.#first || ordinal >= this.#first + this.#length)
107
+ return { status: "lost" };
108
+ while (this.#tokens.size >= MAX_TOKENS)
109
+ this.#tokens.delete(this.#tokens.keys().next().value);
110
+ const token = randomUUID();
111
+ // Saved tabs retain identity until capacity eviction or actual history loss.
112
+ this.#tokens.set(token, { ordinal });
113
+ return { status: "captured", token };
114
+ }
115
+ resolve(proof, token) {
116
+ const failure = this.#admitIdentity(proof);
117
+ if (failure)
118
+ return failure;
119
+ this.#prune();
120
+ const entry = this.#tokens.get(token);
121
+ if (!entry)
122
+ return { status: "lost" };
123
+ const { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash } = this.#current;
124
+ return {
125
+ status: "resolved",
126
+ proof: { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash },
127
+ historyIndex: entry.ordinal - this.#first,
128
+ };
129
+ }
130
+ #admitIdentity(proof) {
131
+ const current = this.#current;
132
+ if (!current || current.alternate)
133
+ return { status: "unavailable" };
134
+ return current.workspaceName === proof.workspaceName &&
135
+ current.semanticPaneId === proof.semanticPaneId &&
136
+ current.generation === proof.generation &&
137
+ current.incarnation === proof.incarnation
138
+ ? null
139
+ : { status: "stale" };
140
+ }
141
+ #key(proof) {
142
+ return JSON.stringify([
143
+ proof.workspaceName,
144
+ proof.semanticPaneId,
145
+ proof.generation,
146
+ proof.incarnation,
147
+ proof.revision,
148
+ proof.stateHash,
149
+ ]);
150
+ }
151
+ #remember() {
152
+ if (!this.#current || this.#current.alternate)
153
+ return;
154
+ while (this.#proofs.size >= 64)
155
+ this.#proofs.delete(this.#proofs.keys().next().value);
156
+ this.#proofs.set(this.#key(this.#current), {
157
+ first: this.#first,
158
+ length: this.#length,
159
+ expires: this.now() + PROOF_TTL_MS,
160
+ });
161
+ }
162
+ #prune() {
163
+ const now = this.now();
164
+ for (const [key, value] of this.#proofs)
165
+ if (value.expires <= now)
166
+ this.#proofs.delete(key);
167
+ for (const [token, entry] of this.#tokens)
168
+ if (entry.ordinal < this.#first || entry.ordinal >= this.#first + this.#length)
169
+ this.#tokens.delete(token);
170
+ }
171
+ }
@@ -457,6 +457,12 @@ export class SessionRuntimeRegistry {
457
457
  async describeSession(session) {
458
458
  return await this.#runtime(session).describe();
459
459
  }
460
+ historyAnchor(session, request) {
461
+ const runtime = this.#sessions.get(session);
462
+ if (this.#disposed || !runtime)
463
+ return { status: "unavailable" };
464
+ return runtime.historyAnchor(request);
465
+ }
460
466
  async captureNativeBacking(session, semanticPaneId, expected) {
461
467
  const runtime = this.#sessions.get(session);
462
468
  if (this.#disposed || !runtime)
@@ -1128,6 +1134,11 @@ class SessionRuntime {
1128
1134
  throw new SessionRuntimeControllerLeaseError("invalid-client-capability", "The client does not own this geometry authority lease.");
1129
1135
  }
1130
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
+ }
1131
1142
  fitViewportWithAuthority(clientId, lease, cols, rows, semanticWindowId) {
1132
1143
  this.assertGeometryAuthority(clientId, lease);
1133
1144
  this.#mirror.setGeometryParticipation(this.session, true);
@@ -1222,6 +1233,19 @@ class SessionRuntime {
1222
1233
  throw error;
1223
1234
  }
1224
1235
  }
1236
+ historyAnchor(request) {
1237
+ if (this.#disposed)
1238
+ return { status: "unavailable" };
1239
+ const owner = this.#terminalReplicas.get(request.proof.semanticPaneId);
1240
+ if (!owner)
1241
+ return { status: "unavailable" };
1242
+ if (request.operation.kind === "capture") {
1243
+ if (!("revision" in request.proof) || !("stateHash" in request.proof))
1244
+ return { status: "stale" };
1245
+ return owner.captureHistoryAnchor(request.proof, request.operation.historyIndex);
1246
+ }
1247
+ return owner.resolveHistoryAnchor(request.proof, request.operation.token);
1248
+ }
1225
1249
  async captureNativeBacking(semanticPaneId, expected) {
1226
1250
  const owner = this.#terminalReplicas.get(semanticPaneId);
1227
1251
  if (!owner)
@@ -1552,6 +1576,10 @@ class SessionRuntimeConsumerImpl {
1552
1576
  this.#assertOpen();
1553
1577
  this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
1554
1578
  }
1579
+ setCellPixels(width, height, precise) {
1580
+ this.#assertOpen();
1581
+ this.#runtime.setCellPixels(this.clientId, width, height, precise);
1582
+ }
1555
1583
  fitViewportWithAuthority(lease, cols, rows, semanticWindowId) {
1556
1584
  this.#assertOpen();
1557
1585
  this.#runtime.fitViewportWithAuthority(this.clientId, lease, cols, rows, semanticWindowId);
@@ -1624,6 +1652,7 @@ class SessionRuntimeConsumerImpl {
1624
1652
  this.#deliveryConnections.delete(connection);
1625
1653
  await upstream.close();
1626
1654
  },
1655
+ ...(upstream.graphics ? { graphics: upstream.graphics } : {}),
1627
1656
  };
1628
1657
  this.#deliveryConnections.add(connection);
1629
1658
  return connection;