tmux-ide 2.9.6 → 2.9.7

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 (80) hide show
  1. package/bin/cli.js +2597 -1322
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +5 -0
  4. package/packages/contracts/src/pane-stream.ts +37 -0
  5. package/packages/contracts/src/terminal-graphics.ts +92 -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 +190 -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 +8 -4
  24. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  26. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +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/graphics/kitty-graphics.js +578 -0
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
  32. package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
  33. package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
  34. package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
  35. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
  36. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
  37. package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
  38. package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
  41. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
  42. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
  43. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
  44. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
  45. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
  48. package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
  49. package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
  50. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  51. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
  52. package/packages/daemon/src/lib/daemon-embed.ts +26 -0
  53. package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
  54. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
  55. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
  57. package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
  58. package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
  59. package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
  60. package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
  61. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
  62. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
  63. package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
  65. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
  66. package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
  69. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
  71. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
  74. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
  75. package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
  76. package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
  77. package/packages/daemon-client/src/pane-stream-client.ts +29 -0
  78. package/packages/daemon-client/src/tmux-server-client.ts +133 -6
  79. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
  80. /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
@@ -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({
@@ -1468,6 +1469,7 @@ export class SessionChannel {
1468
1469
  return;
1469
1470
  }
1470
1471
  recovery.attempts += 1;
1472
+ lease.attemptCharged = true;
1471
1473
  recovery.stage = "stock-pause";
1472
1474
  recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1473
1475
  // These rows were received before this pause and belong to the new seed,
@@ -1617,6 +1619,7 @@ export class SessionChannel {
1617
1619
  return;
1618
1620
  }
1619
1621
  recovery.attempts += 1;
1622
+ lease.attemptCharged = true;
1620
1623
  const ordinal = ++recovery.reseedOrdinal;
1621
1624
  const participants = [...pane.subs]
1622
1625
  .filter((sub) => !sub.closed && !sub.frozen)
@@ -2310,6 +2313,12 @@ export class SessionChannel {
2310
2313
  continue;
2311
2314
  const recovery = this.recoveries.get(pane.runtimeId);
2312
2315
  if (recovery?.lease && !recovery.lease.retired) {
2316
+ // A newer layout cancels this capture; it is not a failed snapshot.
2317
+ // Refund only this lease, retaining earlier failures and the original deadline.
2318
+ if (recovery.lease.attemptCharged) {
2319
+ recovery.lease.attemptCharged = false;
2320
+ recovery.attempts -= 1;
2321
+ }
2313
2322
  this.awaitSnapshotLayout(recovery);
2314
2323
  this.retrySnapshot(recovery);
2315
2324
  }
@@ -1,6 +1,6 @@
1
1
  import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
2
2
  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";
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
4
  import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
5
5
  import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
6
6
  import { terminalDeliveryObservationOrdinal } from "../session-runtime/terminal-delivery-observation-identity.js";
@@ -592,6 +592,8 @@ class PreAuthAdmission {
592
592
  socket.off("error", this.#onClose);
593
593
  }
594
594
  }
595
+ const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
596
+ const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
595
597
  export class PaneStreamLiveConnection {
596
598
  #clientId;
597
599
  #socket;
@@ -723,6 +725,9 @@ export class PaneStreamLiveConnection {
723
725
  frozenByWire: false,
724
726
  closed: false,
725
727
  batch: null,
728
+ graphicsAnnounced: false,
729
+ graphicsQueue: [],
730
+ graphicsSending: false,
726
731
  });
727
732
  }
728
733
  }
@@ -1097,6 +1102,7 @@ export class PaneStreamLiveConnection {
1097
1102
  await this.#sendTerminalDelivery(channel.semanticPaneId, message);
1098
1103
  pending.length = 0;
1099
1104
  markReady();
1105
+ this.#startGraphics(channel);
1100
1106
  }
1101
1107
  this.#closeIfAllPanesGone();
1102
1108
  }
@@ -1201,6 +1207,73 @@ export class PaneStreamLiveConnection {
1201
1207
  }
1202
1208
  await this.#awaitSemanticCredit(pane);
1203
1209
  }
1210
+ /** Announce the pane's kitty graphics state; the client pulls bytes it lacks. */
1211
+ #startGraphics(channel) {
1212
+ const graphics = channel.delivery?.graphics;
1213
+ if (!graphics)
1214
+ return;
1215
+ graphics.subscribe((state) => {
1216
+ if (this.#closed || channel.closed)
1217
+ return;
1218
+ channel.graphicsAnnounced = true;
1219
+ this.#sendFrame(null, { type: "terminal-graphics", pane: channel.semanticPaneId, state });
1220
+ });
1221
+ }
1222
+ #requestGraphics(pane, digests) {
1223
+ const channel = this.#panes.get(pane);
1224
+ // Requests are only meaningful after an announcement; ignore stray ones.
1225
+ if (!channel || channel.closed || !channel.graphicsAnnounced)
1226
+ return;
1227
+ for (const digest of digests)
1228
+ if (!channel.graphicsQueue.includes(digest) &&
1229
+ channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS)
1230
+ channel.graphicsQueue.push(digest);
1231
+ if (!channel.graphicsSending)
1232
+ void this.#drainGraphics(channel);
1233
+ }
1234
+ /**
1235
+ * Blob bytes yield to terminal frames: each chunk waits until the socket's
1236
+ * buffered bytes fall below a small window, so images never trip the
1237
+ * socket-wide backpressure ceiling or delay a pane's next seed for long.
1238
+ */
1239
+ async #drainGraphics(channel) {
1240
+ channel.graphicsSending = true;
1241
+ try {
1242
+ while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1243
+ const digest = channel.graphicsQueue.shift();
1244
+ const image = channel.delivery?.graphics?.image(digest);
1245
+ if (!image)
1246
+ continue;
1247
+ const total = image.data.byteLength;
1248
+ for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1249
+ while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1250
+ if (this.#closed || channel.closed)
1251
+ return;
1252
+ await new Promise((resolve) => setTimeout(resolve, 5));
1253
+ }
1254
+ if (this.#closed || channel.closed)
1255
+ return;
1256
+ const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1257
+ this.#sendFrame(null, {
1258
+ type: "terminal-graphics-blob",
1259
+ pane: channel.semanticPaneId,
1260
+ blob: {
1261
+ digest,
1262
+ format: image.format,
1263
+ width: image.width,
1264
+ height: image.height,
1265
+ total,
1266
+ offset,
1267
+ },
1268
+ data: Buffer.from(image.data.subarray(offset, end)).toString("base64"),
1269
+ });
1270
+ }
1271
+ }
1272
+ }
1273
+ finally {
1274
+ channel.graphicsSending = false;
1275
+ }
1276
+ }
1204
1277
  #awaitSemanticCredit(pane) {
1205
1278
  // Backpressure is pane-local. Aggregate pressure is guarded by the hard
1206
1279
  // socket ceiling in #drainNow, but must not let a noisy sibling prevent a
@@ -1774,6 +1847,10 @@ export class PaneStreamLiveConnection {
1774
1847
  this.#acceptSemanticIntent(frame.operationId, frame.intent);
1775
1848
  return;
1776
1849
  }
1850
+ if (frame.type === "terminal-graphics-request") {
1851
+ this.#requestGraphics(frame.pane, frame.digests);
1852
+ return;
1853
+ }
1777
1854
  if (frame.type === "viewport") {
1778
1855
  if (!this.#descriptor.terminalDelivery ||
1779
1856
  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)
@@ -1222,6 +1228,19 @@ class SessionRuntime {
1222
1228
  throw error;
1223
1229
  }
1224
1230
  }
1231
+ historyAnchor(request) {
1232
+ if (this.#disposed)
1233
+ return { status: "unavailable" };
1234
+ const owner = this.#terminalReplicas.get(request.proof.semanticPaneId);
1235
+ if (!owner)
1236
+ return { status: "unavailable" };
1237
+ if (request.operation.kind === "capture") {
1238
+ if (!("revision" in request.proof) || !("stateHash" in request.proof))
1239
+ return { status: "stale" };
1240
+ return owner.captureHistoryAnchor(request.proof, request.operation.historyIndex);
1241
+ }
1242
+ return owner.resolveHistoryAnchor(request.proof, request.operation.token);
1243
+ }
1225
1244
  async captureNativeBacking(semanticPaneId, expected) {
1226
1245
  const owner = this.#terminalReplicas.get(semanticPaneId);
1227
1246
  if (!owner)
@@ -1624,6 +1643,7 @@ class SessionRuntimeConsumerImpl {
1624
1643
  this.#deliveryConnections.delete(connection);
1625
1644
  await upstream.close();
1626
1645
  },
1646
+ ...(upstream.graphics ? { graphics: upstream.graphics } : {}),
1627
1647
  };
1628
1648
  this.#deliveryConnections.add(connection);
1629
1649
  return connection;
@@ -1,6 +1,6 @@
1
1
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
2
2
  import { MAX_RETAINED_NATIVE_BACKING_BYTES, takeNativeSeedBacking, } from "./native-seed-backing.js";
3
- import { WorkspaceIdSchemaZ, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
3
+ import { WorkspaceIdSchemaZ, TERMINAL_GRAPHICS_CAPABILITY_VERSION, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
4
4
  import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
5
5
  import { SYSTEM_SESSION_RUNTIME_SCHEDULER, } from "./runtime-scheduler.js";
6
6
  import { DISABLED_SESSION_RUNTIME_OBSERVABILITY, } from "./runtime-observability.js";
@@ -171,6 +171,9 @@ export class SessionRuntimeTerminalDeliveryHub {
171
171
  this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
172
172
  },
173
173
  close: async () => this.#closeClient(client),
174
+ ...(offer.protocolVersions.includes(TERMINAL_GRAPHICS_CAPABILITY_VERSION)
175
+ ? { graphics: this.#graphicsFor(semanticPaneId, client, closed) }
176
+ : {}),
174
177
  };
175
178
  }
176
179
  catch (error) {
@@ -196,6 +199,22 @@ export class SessionRuntimeTerminalDeliveryHub {
196
199
  this.#pendingClients.delete(key);
197
200
  }
198
201
  }
202
+ #graphicsFor(semanticPaneId, client, closed) {
203
+ const owner = this.#ownerForPane(semanticPaneId);
204
+ return {
205
+ subscribe: (listener) => {
206
+ if (client.closed || !owner.subscribeGraphics)
207
+ return () => { };
208
+ const unsubscribe = owner.subscribeGraphics((state) => {
209
+ if (!client.closed)
210
+ listener(state);
211
+ });
212
+ void closed.then(unsubscribe);
213
+ return unsubscribe;
214
+ },
215
+ image: (digest) => (client.closed ? null : (owner.graphicsImage?.(digest) ?? null)),
216
+ };
217
+ }
199
218
  /** Retire only this pane's delivery owners before its canonical owner is disposed. */
200
219
  failPaneCapture(semanticPaneId, error) {
201
220
  for (const client of this.#clients.values())
@@ -6,6 +6,7 @@ import { SYSTEM_SESSION_RUNTIME_SCHEDULER, } from "./runtime-scheduler.js";
6
6
  import { DISABLED_SESSION_RUNTIME_OBSERVABILITY, } from "./runtime-observability.js";
7
7
  import { createXtermTerminalInterpreterBackend } from "./xterm-terminal-interpreter-backend.js";
8
8
  import { CAUSAL_CELL_OSC, CausalCellLedger, cellsEqual, } from "./causal-cell-ledger.js";
9
+ import { KITTY_PLACEMENT_OSC, KittyApcScanner, KittyImageStore, } from "../graphics/kitty-graphics.js";
9
10
  /** Daemon-owned, sans-I/O VT interpreter with one FIFO for bytes and geometry. */
10
11
  export class TerminalReplicaInterpreter {
11
12
  #nativeSeedBackingCandidate;
@@ -37,6 +38,13 @@ export class TerminalReplicaInterpreter {
37
38
  #pendingWrites = [];
38
39
  #writeFlushScheduled = false;
39
40
  #widgetGate = false;
41
+ // kitty graphics: images outlive reseeds; the scanner owns split APCs.
42
+ #kittyScanner = new KittyApcScanner();
43
+ #kitty;
44
+ #kittyPending = new Map();
45
+ #graphicsListeners = new Set();
46
+ #graphics = null;
47
+ #graphicsKey = "";
40
48
  #markerTail = "";
41
49
  #pendingResize = null;
42
50
  // null: no unpublished writes; undefined: a write requires full projection.
@@ -79,6 +87,8 @@ export class TerminalReplicaInterpreter {
79
87
  rows: options.rows,
80
88
  scrollback: this.#scrollback,
81
89
  });
90
+ this.#kitty = new KittyImageStore(options.kittyImageBudget ? { budget: options.kittyImageBudget } : {});
91
+ this.#attachKitty(this.#backend);
82
92
  this.#snapshot = blankTerminalReplicaSnapshot(options.cols, options.rows);
83
93
  this.#seedReady = new Promise((resolve, reject) => {
84
94
  this.#resolveSeedReady = resolve;
@@ -239,6 +249,9 @@ export class TerminalReplicaInterpreter {
239
249
  }
240
250
  const previous = this.#backend;
241
251
  this.#backend = replacement;
252
+ this.#attachKitty(replacement);
253
+ // tmux never saw cursor-anchored placements; a capture cannot restore them.
254
+ this.#kitty.dropCursorPlacements();
242
255
  this.#nativeReseedRequested = false;
243
256
  this.#scrollback = scrollback;
244
257
  this.#bootstrap = {
@@ -262,6 +275,7 @@ export class TerminalReplicaInterpreter {
262
275
  this.#nativeSeedBackingCandidate = undefined;
263
276
  }
264
277
  previous.dispose();
278
+ this.#publishGraphics();
265
279
  return;
266
280
  }
267
281
  if (operation.type === "cursor") {
@@ -287,6 +301,7 @@ export class TerminalReplicaInterpreter {
287
301
  }
288
302
  this.#backend.resize(operation.cols, operation.rows);
289
303
  this.#commit(false);
304
+ this.#publishGraphics();
290
305
  return;
291
306
  }
292
307
  const baseRevision = this.#revision;
@@ -304,6 +319,7 @@ export class TerminalReplicaInterpreter {
304
319
  };
305
320
  this.#revision = revision;
306
321
  this.#closed = true;
322
+ this.#kitty.dispose();
307
323
  this.#causalCell?.fail("transport-closed");
308
324
  this.#clearSyncRecovery();
309
325
  if (this.#needsSeed)
@@ -321,8 +337,14 @@ export class TerminalReplicaInterpreter {
321
337
  this.#admitRaw(data);
322
338
  this.#stats.parseBatches += 1;
323
339
  this.#observeMarkerBytes(data);
340
+ const scanned = this.#kittyScanner.scan(data);
341
+ for (const command of scanned.commands)
342
+ this.#kittyPending.set(command.marker, command);
324
343
  const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
325
- await this.#writeToBackend(this.#backend, data);
344
+ // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
345
+ // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
346
+ // be all image payload that must never reach the VT parser as text.
347
+ await this.#writeToBackend(this.#backend, scanned.forward);
326
348
  if (this.#observability.enabled)
327
349
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
328
350
  // The parser resets its dirty tracker per write. Synchronized output may
@@ -356,6 +378,7 @@ export class TerminalReplicaInterpreter {
356
378
  else {
357
379
  this.#commit(false, this.#backend.dirtyRange(), trace);
358
380
  }
381
+ this.#publishGraphics();
359
382
  }
360
383
  #scheduleSyncRecovery() {
361
384
  if (this.#syncRecovery || this.#closed)
@@ -541,6 +564,50 @@ export class TerminalReplicaInterpreter {
541
564
  bootstrap: this.#bootstrap,
542
565
  });
543
566
  }
567
+ /** The pane's kitty image store (bytes for the graphics side channel). */
568
+ kittyImages() {
569
+ return this.#kitty;
570
+ }
571
+ /** Latest graphics state, or null while the pane has never held an image. */
572
+ currentGraphics() {
573
+ return this.#graphics;
574
+ }
575
+ onGraphics(listener) {
576
+ this.#graphicsListeners.add(listener);
577
+ return () => this.#graphicsListeners.delete(listener);
578
+ }
579
+ /** Publish graphics state when the store or the viewport-relative placements moved. */
580
+ #publishGraphics() {
581
+ if (this.#graphics === null && this.#kitty.revision === 0)
582
+ return;
583
+ const state = this.#kitty.state(this.#backend.graphicsViewportTop?.() ?? 0, this.#backend.rows, this.#backend.cols, this.#backend.modes().alternateScreen);
584
+ const key = JSON.stringify([state.revision, state.placements]);
585
+ if (key === this.#graphicsKey)
586
+ return;
587
+ this.#graphicsKey = key;
588
+ this.#graphics = state;
589
+ for (const listener of this.#graphicsListeners) {
590
+ try {
591
+ listener(state);
592
+ }
593
+ catch {
594
+ // Renderer callbacks never poison the authoritative parser FIFO.
595
+ }
596
+ }
597
+ }
598
+ #attachKitty(backend) {
599
+ // Minimal test backends omit the OSC seam; they simply never place images.
600
+ if (typeof backend.registerOscHandler !== "function")
601
+ return;
602
+ backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
603
+ const command = this.#kittyPending.get(Number(data));
604
+ if (!command)
605
+ return true;
606
+ this.#kittyPending.delete(Number(data));
607
+ this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
608
+ return true;
609
+ });
610
+ }
544
611
  #currentSnapshotHash() {
545
612
  return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
546
613
  }
@@ -1,3 +1,4 @@
1
+ import { HistoryAnchorOwner, } from "./history-anchor-owner.js";
1
2
  import { layoutContentRows } from "../mirror/layout-content-rows.js";
2
3
  import { terminalReplicaRowsEqual } from "@tmux-ide/core";
3
4
  import { projectNativeGridRow } from "../mirror/native-grid-projection.js";
@@ -20,6 +21,7 @@ export class SessionRuntimeTerminalReplicaOwner {
20
21
  #start;
21
22
  #upstream = null;
22
23
  #disposed = false;
24
+ #historyAnchors = new HistoryAnchorOwner();
23
25
  #cols = 80;
24
26
  #rows = 24;
25
27
  #layoutEpoch = 0;
@@ -56,12 +58,14 @@ export class SessionRuntimeTerminalReplicaOwner {
56
58
  if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
57
59
  return;
58
60
  this.#waitingForGeometryCapture = true;
61
+ this.#historyAnchors.invalidate();
59
62
  this.#supervise(this.#start.then(() => {
60
63
  if (!this.#disposed && this.#waitingForGeometryCapture)
61
64
  this.#upstream?.reseed();
62
65
  }));
63
66
  },
64
67
  onUpdate: (update, trace) => {
68
+ this.#historyAnchors.observe(update);
65
69
  this.#publishedIncarnation = update.incarnation;
66
70
  if (update.type === "terminal.seed") {
67
71
  this.#bootstrapped = true;
@@ -137,6 +141,16 @@ export class SessionRuntimeTerminalReplicaOwner {
137
141
  failCausalCell(reason, traceId) {
138
142
  this.#interpreter.failCausalCell(reason, traceId);
139
143
  }
144
+ captureHistoryAnchor(proof, historyIndex) {
145
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
146
+ return { status: "unavailable" };
147
+ return this.#historyAnchors.capture(proof, historyIndex);
148
+ }
149
+ resolveHistoryAnchor(proof, token) {
150
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
151
+ return { status: "unavailable" };
152
+ return this.#historyAnchors.resolve(proof, token);
153
+ }
140
154
  /** Scalar diagnostic only: no seed construction, hashing, or owner creation. */
141
155
  inputDispatchIncarnation() {
142
156
  return this.#disposed ? null : this.#publishedIncarnation;
@@ -248,6 +262,17 @@ export class SessionRuntimeTerminalReplicaOwner {
248
262
  },
249
263
  };
250
264
  }
265
+ /** Kitty graphics side channel: current state now (if any), then every change. */
266
+ subscribeGraphics(listener) {
267
+ const current = this.#interpreter.currentGraphics();
268
+ if (current)
269
+ listener(current);
270
+ return this.#interpreter.onGraphics(listener);
271
+ }
272
+ /** Image bytes by content digest, while the pane's store still holds them. */
273
+ graphicsImage(digest) {
274
+ return this.#interpreter.kittyImages().imageByDigest(digest) ?? null;
275
+ }
251
276
  async subscribeSource(listener, onRaw) {
252
277
  const canonical = await this.subscribe(listener);
253
278
  this.#rawListeners.add(onRaw);
@@ -268,6 +293,7 @@ export class SessionRuntimeTerminalReplicaOwner {
268
293
  return;
269
294
  this.#noteLifecycle(`dispose-${reason}`);
270
295
  this.#disposed = true;
296
+ this.#historyAnchors.invalidate();
271
297
  this.#historyTimer?.cancel();
272
298
  this.#historyTimer = null;
273
299
  this.#subscriptionEpoch += 1;
@@ -330,6 +356,7 @@ export class SessionRuntimeTerminalReplicaOwner {
330
356
  // Deterministic capture point: reset opens the atomic capture. A probe
331
357
  // armed after reset belongs to the next post-capture delta, never to this
332
358
  // already-started reseed.
359
+ this.#historyAnchors.invalidate();
333
360
  this.#reseed = {
334
361
  nativeCols: event.cols,
335
362
  nativeRows: event.rows,
@@ -447,6 +474,7 @@ export class SessionRuntimeTerminalReplicaOwner {
447
474
  if (!this.#bootstrapped || this.#reseed || this.#waitingForGeometryCapture)
448
475
  return;
449
476
  this.#waitingForGeometryCapture = true;
477
+ this.#historyAnchors.invalidate();
450
478
  void this.#start.then(() => {
451
479
  if (!this.#disposed && this.#waitingForGeometryCapture)
452
480
  this.#upstream?.reseed();
@@ -557,6 +585,7 @@ export class SessionRuntimeTerminalReplicaOwner {
557
585
  this.#noteLifecycle("reseed-retry", diagnostic);
558
586
  this.#reseedRetryCount += 1;
559
587
  this.#waitingForGeometryCapture = true;
588
+ this.#historyAnchors.invalidate();
560
589
  void this.#start.then(() => {
561
590
  if (!this.#disposed)
562
591
  this.#upstream?.reseed();
@@ -564,6 +593,7 @@ export class SessionRuntimeTerminalReplicaOwner {
564
593
  }
565
594
  #supervise(operation) {
566
595
  void operation.catch((error) => {
596
+ this.#historyAnchors.invalidate();
567
597
  this.#noteLifecycle("interpreter-fault");
568
598
  this.#interpreter.abort(error);
569
599
  this.#onFault?.(error);