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,3 +1,14 @@
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: readonly string[]): boolean {
6
+ return (
7
+ /^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
8
+ /^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? "")
9
+ );
10
+ }
11
+
1
12
  /** Private native capture experiment; this is not the canonical renderer schema. */
2
13
  export interface NativeGridCaptureCell {
3
14
  readonly flags: number;
@@ -1,5 +1,9 @@
1
1
  import type { MirrorChannelIo } from "./control-channel.ts";
2
- import { decodeNativeGridCapture, type NativeGridCapture } from "./native-grid-capture.ts";
2
+ import {
3
+ decodeNativeGridCapture,
4
+ isStockRawGridCapture,
5
+ type NativeGridCapture,
6
+ } from "./native-grid-capture.ts";
3
7
 
4
8
  export type NativeGridReadResult =
5
9
  | {
@@ -83,6 +87,11 @@ export class NativeGridCaptureReader {
83
87
  finish({ status: "changed" });
84
88
  return;
85
89
  }
90
+ if (isStockRawGridCapture(reply.lines)) {
91
+ this.unsupported = true;
92
+ finish({ status: "unsupported" });
93
+ return;
94
+ }
86
95
  const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
87
96
  finish(
88
97
  snapshot
@@ -10,6 +10,7 @@ import {
10
10
  import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.ts";
11
11
  import {
12
12
  decodeNativeGridCapture,
13
+ isStockRawGridCapture,
13
14
  isNativeBootstrapCapture,
14
15
  type NativeGridCapture,
15
16
  } from "./native-grid-capture.ts";
@@ -212,7 +213,10 @@ function nativeBootstrapUnsupported(
212
213
  native: NativeGridCapture | null,
213
214
  ): boolean {
214
215
  if (ok)
215
- return native !== null && (native.version !== 2 || native.currentAttributes === undefined);
216
+ return (
217
+ isStockRawGridCapture(lines) ||
218
+ (native !== null && (native.version !== 2 || native.currentAttributes === undefined))
219
+ );
216
220
  return lines.some((line) =>
217
221
  /^(?:parse error: )?(?:command capture-pane: )?unknown flag -R$/.test(line.trim()),
218
222
  );
@@ -345,6 +349,7 @@ interface SnapshotLease {
345
349
  cancelFence: (() => void) | null;
346
350
  cleanup?: () => void;
347
351
  ownsPause?: boolean;
352
+ attemptCharged?: boolean;
348
353
  }
349
354
 
350
355
  interface RecoveryRecord {
@@ -1959,6 +1964,7 @@ export class SessionChannel {
1959
1964
  return;
1960
1965
  }
1961
1966
  recovery.attempts += 1;
1967
+ lease.attemptCharged = true;
1962
1968
  recovery.stage = "stock-pause";
1963
1969
  recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1964
1970
  // These rows were received before this pause and belong to the new seed,
@@ -2135,6 +2141,7 @@ export class SessionChannel {
2135
2141
  return;
2136
2142
  }
2137
2143
  recovery.attempts += 1;
2144
+ lease.attemptCharged = true;
2138
2145
  const ordinal = ++recovery.reseedOrdinal;
2139
2146
  const participants = [...pane.subs]
2140
2147
  .filter((sub) => !sub.closed && !sub.frozen)
@@ -2924,6 +2931,12 @@ export class SessionChannel {
2924
2931
  if (pane.windowRuntimeId !== change.windowId) continue;
2925
2932
  const recovery = this.recoveries.get(pane.runtimeId);
2926
2933
  if (recovery?.lease && !recovery.lease.retired) {
2934
+ // A newer layout cancels this capture; it is not a failed snapshot.
2935
+ // Refund only this lease, retaining earlier failures and the original deadline.
2936
+ if (recovery.lease.attemptCharged) {
2937
+ recovery.lease.attemptCharged = false;
2938
+ recovery.attempts -= 1;
2939
+ }
2927
2940
  this.awaitSnapshotLayout(recovery);
2928
2941
  this.retrySnapshot(recovery);
2929
2942
  }
@@ -1,4 +1,5 @@
1
1
  import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
2
+ import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
2
3
  import { z } from "zod";
3
4
  import {
4
5
  PANE_STREAM_MAX_HELD_DELTAS,
@@ -8,6 +9,7 @@ import {
8
9
  PANE_STREAM_PROTOCOL_VERSION,
9
10
  PANE_STREAM_REDEEM_PATH,
10
11
  PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
12
+ TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES,
11
13
  PaneStreamClientFrameSchemaZ,
12
14
  PaneStreamLoopbackWebSocketUrlSchemaZ,
13
15
  PaneStreamRedeemFrameSchemaZ,
@@ -314,6 +316,7 @@ export interface SessionRuntimeTerminalDeliveryConnection {
314
316
  nack(nack: TerminalDeliveryNack): void;
315
317
  setVisibility(visibility: TerminalDeliveryVisibility): void;
316
318
  close(): Promise<void>;
319
+ readonly graphics?: TerminalDeliveryGraphics;
317
320
  }
318
321
 
319
322
  interface PendingTicket {
@@ -1039,6 +1042,10 @@ interface PaneChannel {
1039
1042
  frozenByWire: boolean;
1040
1043
  closed: boolean;
1041
1044
  batch: SeedBatch | null;
1045
+ /** Kitty graphics: state announced to this client, and blob digests queued. */
1046
+ graphicsAnnounced: boolean;
1047
+ graphicsQueue: string[];
1048
+ graphicsSending: boolean;
1042
1049
  }
1043
1050
 
1044
1051
  interface QueuedSend {
@@ -1046,6 +1053,9 @@ interface QueuedSend {
1046
1053
  remaining: number;
1047
1054
  }
1048
1055
 
1056
+ const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
1057
+ const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
1058
+
1049
1059
  export class PaneStreamLiveConnection {
1050
1060
  readonly #clientId: string;
1051
1061
  readonly #socket: DirectTerminalSocket;
@@ -1214,6 +1224,9 @@ export class PaneStreamLiveConnection {
1214
1224
  frozenByWire: false,
1215
1225
  closed: false,
1216
1226
  batch: null,
1227
+ graphicsAnnounced: false,
1228
+ graphicsQueue: [],
1229
+ graphicsSending: false,
1217
1230
  });
1218
1231
  }
1219
1232
  }
@@ -1619,6 +1632,7 @@ export class PaneStreamLiveConnection {
1619
1632
  await this.#sendTerminalDelivery(channel.semanticPaneId, message);
1620
1633
  pending.length = 0;
1621
1634
  markReady();
1635
+ this.#startGraphics(channel);
1622
1636
  }
1623
1637
  this.#closeIfAllPanesGone();
1624
1638
  }
@@ -1730,6 +1744,70 @@ export class PaneStreamLiveConnection {
1730
1744
  await this.#awaitSemanticCredit(pane);
1731
1745
  }
1732
1746
 
1747
+ /** Announce the pane's kitty graphics state; the client pulls bytes it lacks. */
1748
+ #startGraphics(channel: PaneChannel): void {
1749
+ const graphics = channel.delivery?.graphics;
1750
+ if (!graphics) return;
1751
+ graphics.subscribe((state) => {
1752
+ if (this.#closed || channel.closed) return;
1753
+ channel.graphicsAnnounced = true;
1754
+ this.#sendFrame(null, { type: "terminal-graphics", pane: channel.semanticPaneId, state });
1755
+ });
1756
+ }
1757
+
1758
+ #requestGraphics(pane: string, digests: readonly string[]): void {
1759
+ const channel = this.#panes.get(pane);
1760
+ // Requests are only meaningful after an announcement; ignore stray ones.
1761
+ if (!channel || channel.closed || !channel.graphicsAnnounced) return;
1762
+ for (const digest of digests)
1763
+ if (
1764
+ !channel.graphicsQueue.includes(digest) &&
1765
+ channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS
1766
+ )
1767
+ channel.graphicsQueue.push(digest);
1768
+ if (!channel.graphicsSending) void this.#drainGraphics(channel);
1769
+ }
1770
+
1771
+ /**
1772
+ * Blob bytes yield to terminal frames: each chunk waits until the socket's
1773
+ * buffered bytes fall below a small window, so images never trip the
1774
+ * socket-wide backpressure ceiling or delay a pane's next seed for long.
1775
+ */
1776
+ async #drainGraphics(channel: PaneChannel): Promise<void> {
1777
+ channel.graphicsSending = true;
1778
+ try {
1779
+ while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1780
+ const digest = channel.graphicsQueue.shift()!;
1781
+ const image = channel.delivery?.graphics?.image(digest);
1782
+ if (!image) continue;
1783
+ const total = image.data.byteLength;
1784
+ for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1785
+ while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1786
+ if (this.#closed || channel.closed) return;
1787
+ await new Promise((resolve) => setTimeout(resolve, 5));
1788
+ }
1789
+ if (this.#closed || channel.closed) return;
1790
+ const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1791
+ this.#sendFrame(null, {
1792
+ type: "terminal-graphics-blob",
1793
+ pane: channel.semanticPaneId,
1794
+ blob: {
1795
+ digest,
1796
+ format: image.format,
1797
+ width: image.width,
1798
+ height: image.height,
1799
+ total,
1800
+ offset,
1801
+ },
1802
+ data: Buffer.from(image.data.subarray(offset, end)).toString("base64"),
1803
+ });
1804
+ }
1805
+ }
1806
+ } finally {
1807
+ channel.graphicsSending = false;
1808
+ }
1809
+ }
1810
+
1733
1811
  #awaitSemanticCredit(pane: string): Promise<void> {
1734
1812
  // Backpressure is pane-local. Aggregate pressure is guarded by the hard
1735
1813
  // socket ceiling in #drainNow, but must not let a noisy sibling prevent a
@@ -2301,6 +2379,10 @@ export class PaneStreamLiveConnection {
2301
2379
  this.#acceptSemanticIntent(frame.operationId, frame.intent);
2302
2380
  return;
2303
2381
  }
2382
+ if (frame.type === "terminal-graphics-request") {
2383
+ this.#requestGraphics(frame.pane, frame.digests);
2384
+ return;
2385
+ }
2304
2386
  if (frame.type === "viewport") {
2305
2387
  if (
2306
2388
  !this.#descriptor.terminalDelivery ||
@@ -183,7 +183,6 @@ export function parseSessionPaneDescriptorReply(
183
183
  !bounded(type, 256) ||
184
184
  !bounded(currentCommand, 512) ||
185
185
  !bounded(cwd, 4_096) ||
186
- !bounded(title, 1_024) ||
187
186
  !bounded(windowName, 1_024) ||
188
187
  !bounded(name, 256) ||
189
188
  !bounded(missionStamp, 256) ||
@@ -203,7 +202,9 @@ export function parseSessionPaneDescriptorReply(
203
202
  type: nonempty(type),
204
203
  currentCommand: nonempty(currentCommand),
205
204
  cwd: nonempty(cwd),
206
- title: nonempty(title),
205
+ // Stock tmux turns any APC, including kitty graphics payloads, into the
206
+ // pane title; an overlong title names nothing and must not drop the pane.
207
+ title: bounded(title, 1_024) ? nonempty(title) : null,
207
208
  windowIndex,
208
209
  windowName: nonempty(windowName),
209
210
  windowId: /^@(?:0|[1-9][0-9]*)$/u.test(windowId) ? windowId : null,
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { CanonicalTerminalReplicaUpdate } from "@tmux-ide/contracts";
3
+
4
+ export interface HistoryAnchorProof {
5
+ readonly workspaceName: string;
6
+ readonly semanticPaneId: string;
7
+ readonly generation: string;
8
+ readonly incarnation: string;
9
+ readonly revision: number;
10
+ readonly stateHash: string;
11
+ }
12
+ export type HistoryAnchorIdentity = Pick<
13
+ HistoryAnchorProof,
14
+ "workspaceName" | "semanticPaneId" | "generation" | "incarnation"
15
+ >;
16
+ type Failure = { readonly status: "unavailable" | "stale" | "lost" };
17
+ const MAX_TOKENS = 32;
18
+ const PROOF_TTL_MS = 5 * 60_000;
19
+
20
+ /** Owner-private row ordinals. Never changes terminal v1 payloads or hashes. */
21
+ export class HistoryAnchorOwner {
22
+ #current: (HistoryAnchorProof & { cols: number; rows: number; alternate: boolean }) | null = null;
23
+ #first = 0;
24
+ #length = 0;
25
+ #proofs = new Map<string, { first: number; length: number; expires: number }>();
26
+ #tokens = new Map<string, { ordinal: number }>();
27
+ constructor(private readonly now: () => number = () => performance.now()) {}
28
+
29
+ invalidate(): void {
30
+ this.#current = null;
31
+ this.#first = 0;
32
+ this.#length = 0;
33
+ this.#tokens.clear();
34
+ this.#proofs.clear();
35
+ }
36
+
37
+ observe(update: CanonicalTerminalReplicaUpdate): void {
38
+ if (update.type === "terminal.tombstone") {
39
+ this.invalidate();
40
+ return;
41
+ }
42
+ if (update.type === "terminal.seed") {
43
+ this.invalidate();
44
+ this.#length = update.snapshot.history.length;
45
+ this.#current = {
46
+ workspaceName: update.workspaceName,
47
+ semanticPaneId: update.semanticPaneId,
48
+ generation: update.generation,
49
+ incarnation: update.incarnation,
50
+ revision: update.revision,
51
+ stateHash: update.stateHash,
52
+ cols: update.cols,
53
+ rows: update.rows,
54
+ alternate: update.snapshot.modes.alternateScreen,
55
+ };
56
+ this.#remember();
57
+ return;
58
+ }
59
+ const old = this.#current;
60
+ if (
61
+ !old ||
62
+ old.workspaceName !== update.workspaceName ||
63
+ old.semanticPaneId !== update.semanticPaneId ||
64
+ old.generation !== update.generation ||
65
+ old.incarnation !== update.incarnation ||
66
+ old.revision !== update.baseRevision
67
+ ) {
68
+ this.invalidate();
69
+ return;
70
+ }
71
+ const delta = update.patch.historyDelta;
72
+ const alternate = update.patch.modes?.alternateScreen ?? old.alternate;
73
+ if (
74
+ delta &&
75
+ (!Number.isSafeInteger(delta.trim) || delta.trim < 0 || delta.trim > this.#length)
76
+ ) {
77
+ this.invalidate();
78
+ return;
79
+ }
80
+ const length =
81
+ update.patch.history?.length ??
82
+ this.#length - (delta?.trim ?? 0) + (delta?.append.length ?? 0);
83
+ const reset =
84
+ update.patch.history !== undefined ||
85
+ update.cols !== old.cols ||
86
+ update.rows !== old.rows ||
87
+ alternate !== old.alternate;
88
+ if (reset) {
89
+ this.#tokens.clear();
90
+ this.#proofs.clear();
91
+ this.#first = 0;
92
+ } else this.#first += delta?.trim ?? 0;
93
+ if (!Number.isSafeInteger(this.#first + length)) {
94
+ this.invalidate();
95
+ return;
96
+ }
97
+ this.#length = length;
98
+ this.#current = {
99
+ workspaceName: update.workspaceName,
100
+ semanticPaneId: update.semanticPaneId,
101
+ generation: update.generation,
102
+ incarnation: update.incarnation,
103
+ revision: update.revision,
104
+ stateHash: update.stateHash,
105
+ cols: update.cols,
106
+ rows: update.rows,
107
+ alternate,
108
+ };
109
+ this.#prune();
110
+ this.#remember();
111
+ }
112
+
113
+ capture(
114
+ proof: HistoryAnchorProof,
115
+ historyIndex: number,
116
+ ): Failure | { status: "captured"; token: string } {
117
+ const failure = this.#admitIdentity(proof);
118
+ if (failure) return failure;
119
+ this.#prune();
120
+ const known =
121
+ this.#current && this.#key(proof) === this.#key(this.#current)
122
+ ? { first: this.#first, length: this.#length }
123
+ : this.#proofs.get(this.#key(proof));
124
+ if (!known) return { status: "stale" };
125
+ if (!Number.isSafeInteger(historyIndex) || historyIndex < 0 || historyIndex >= known.length)
126
+ return { status: "lost" };
127
+ const ordinal = known.first + historyIndex;
128
+ if (ordinal < this.#first || ordinal >= this.#first + this.#length) return { status: "lost" };
129
+ while (this.#tokens.size >= MAX_TOKENS) this.#tokens.delete(this.#tokens.keys().next().value!);
130
+ const token = randomUUID();
131
+ // Saved tabs retain identity until capacity eviction or actual history loss.
132
+ this.#tokens.set(token, { ordinal });
133
+ return { status: "captured", token };
134
+ }
135
+
136
+ resolve(
137
+ proof: HistoryAnchorIdentity,
138
+ token: string,
139
+ ): Failure | { status: "resolved"; proof: HistoryAnchorProof; historyIndex: number } {
140
+ const failure = this.#admitIdentity(proof);
141
+ if (failure) return failure;
142
+ this.#prune();
143
+ const entry = this.#tokens.get(token);
144
+ if (!entry) return { status: "lost" };
145
+ const { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash } =
146
+ this.#current!;
147
+ return {
148
+ status: "resolved",
149
+ proof: { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash },
150
+ historyIndex: entry.ordinal - this.#first,
151
+ };
152
+ }
153
+
154
+ #admitIdentity(proof: HistoryAnchorIdentity): Failure | null {
155
+ const current = this.#current;
156
+ if (!current || current.alternate) return { status: "unavailable" };
157
+ return current.workspaceName === proof.workspaceName &&
158
+ current.semanticPaneId === proof.semanticPaneId &&
159
+ current.generation === proof.generation &&
160
+ current.incarnation === proof.incarnation
161
+ ? null
162
+ : { status: "stale" };
163
+ }
164
+ #key(proof: HistoryAnchorProof): string {
165
+ return JSON.stringify([
166
+ proof.workspaceName,
167
+ proof.semanticPaneId,
168
+ proof.generation,
169
+ proof.incarnation,
170
+ proof.revision,
171
+ proof.stateHash,
172
+ ]);
173
+ }
174
+ #remember(): void {
175
+ if (!this.#current || this.#current.alternate) return;
176
+ while (this.#proofs.size >= 64) this.#proofs.delete(this.#proofs.keys().next().value!);
177
+ this.#proofs.set(this.#key(this.#current), {
178
+ first: this.#first,
179
+ length: this.#length,
180
+ expires: this.now() + PROOF_TTL_MS,
181
+ });
182
+ }
183
+ #prune(): void {
184
+ const now = this.now();
185
+ for (const [key, value] of this.#proofs) if (value.expires <= now) this.#proofs.delete(key);
186
+ for (const [token, entry] of this.#tokens)
187
+ if (entry.ordinal < this.#first || entry.ordinal >= this.#first + this.#length)
188
+ this.#tokens.delete(token);
189
+ }
190
+ }
@@ -1,3 +1,4 @@
1
+ import type { HistoryAnchorRequest, HistoryAnchorResult } from "@tmux-ide/contracts";
1
2
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
2
3
  import type { OwnedNativePlanCompletion } from "../../lib/owned-native-interaction-bindings.ts";
3
4
  import type {
@@ -875,6 +876,12 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
875
876
  return await this.#runtime(session).describe();
876
877
  }
877
878
 
879
+ historyAnchor(session: string, request: HistoryAnchorRequest): HistoryAnchorResult {
880
+ const runtime = this.#sessions.get(session);
881
+ if (this.#disposed || !runtime) return { status: "unavailable" };
882
+ return runtime.historyAnchor(request);
883
+ }
884
+
878
885
  async captureNativeBacking(
879
886
  session: string,
880
887
  semanticPaneId: string,
@@ -1880,6 +1887,18 @@ class SessionRuntime {
1880
1887
  }
1881
1888
  }
1882
1889
 
1890
+ historyAnchor(request: HistoryAnchorRequest): HistoryAnchorResult {
1891
+ if (this.#disposed) return { status: "unavailable" };
1892
+ const owner = this.#terminalReplicas.get(request.proof.semanticPaneId);
1893
+ if (!owner) return { status: "unavailable" };
1894
+ if (request.operation.kind === "capture") {
1895
+ if (!("revision" in request.proof) || !("stateHash" in request.proof))
1896
+ return { status: "stale" };
1897
+ return owner.captureHistoryAnchor(request.proof, request.operation.historyIndex);
1898
+ }
1899
+ return owner.resolveHistoryAnchor(request.proof, request.operation.token);
1900
+ }
1901
+
1883
1902
  async captureNativeBacking(
1884
1903
  semanticPaneId: string,
1885
1904
  expected?: NativeBackingIdentity,
@@ -2424,6 +2443,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2424
2443
  this.#deliveryConnections.delete(connection);
2425
2444
  await upstream.close();
2426
2445
  },
2446
+ ...(upstream.graphics ? { graphics: upstream.graphics } : {}),
2427
2447
  };
2428
2448
  this.#deliveryConnections.add(connection);
2429
2449
  return connection;
@@ -1,3 +1,4 @@
1
+ import type { KittyImage } from "../graphics/kitty-graphics.ts";
1
2
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
2
3
  import {
3
4
  MAX_RETAINED_NATIVE_BACKING_BYTES,
@@ -8,6 +9,8 @@ import {
8
9
  } from "./native-seed-backing.ts";
9
10
  import {
10
11
  WorkspaceIdSchemaZ,
12
+ TERMINAL_GRAPHICS_CAPABILITY_VERSION,
13
+ type TerminalGraphicsState,
11
14
  TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES,
12
15
  TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES,
13
16
  TerminalDeliveryEnvelopeSchemaZ,
@@ -124,6 +127,17 @@ export interface TerminalDeliveryConnection {
124
127
  nack(nack: TerminalDeliveryNack): void;
125
128
  setVisibility(visibility: TerminalDeliveryVisibility): void;
126
129
  close(): Promise<void>;
130
+ /**
131
+ * Kitty graphics side channel, present only when the client offered
132
+ * TERMINAL_GRAPHICS_CAPABILITY_VERSION. Delivery is independent of the
133
+ * canonical FIFO: renderers resolve state against cells they already hold.
134
+ */
135
+ readonly graphics?: TerminalDeliveryGraphics;
136
+ }
137
+
138
+ export interface TerminalDeliveryGraphics {
139
+ subscribe(listener: (state: TerminalGraphicsState) => void): () => void;
140
+ image(digest: string): KittyImage | null;
127
141
  }
128
142
 
129
143
  interface NativeBackingLease {
@@ -173,6 +187,8 @@ export interface TerminalDeliverySourceOwner {
173
187
  ) => void,
174
188
  onRaw: (record: TerminalReplicaCommittedRaw) => void,
175
189
  ): Promise<TerminalReplicaSourceSubscription>;
190
+ subscribeGraphics?(listener: (state: TerminalGraphicsState) => void): () => void;
191
+ graphicsImage?(digest: string): KittyImage | null;
176
192
  }
177
193
 
178
194
  interface ClientState {
@@ -451,6 +467,9 @@ export class SessionRuntimeTerminalDeliveryHub {
451
467
  this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
452
468
  },
453
469
  close: async () => this.#closeClient(client),
470
+ ...(offer.protocolVersions.includes(TERMINAL_GRAPHICS_CAPABILITY_VERSION)
471
+ ? { graphics: this.#graphicsFor(semanticPaneId, client, closed) }
472
+ : {}),
454
473
  };
455
474
  } catch (error) {
456
475
  if (!(error instanceof StockCaptureTabUnavailableError)) throw error;
@@ -474,6 +493,25 @@ export class SessionRuntimeTerminalDeliveryHub {
474
493
  }
475
494
  }
476
495
 
496
+ #graphicsFor(
497
+ semanticPaneId: string,
498
+ client: ClientState,
499
+ closed: Promise<unknown>,
500
+ ): TerminalDeliveryGraphics {
501
+ const owner = this.#ownerForPane(semanticPaneId);
502
+ return {
503
+ subscribe: (listener) => {
504
+ if (client.closed || !owner.subscribeGraphics) return () => {};
505
+ const unsubscribe = owner.subscribeGraphics((state) => {
506
+ if (!client.closed) listener(state);
507
+ });
508
+ void closed.then(unsubscribe);
509
+ return unsubscribe;
510
+ },
511
+ image: (digest) => (client.closed ? null : (owner.graphicsImage?.(digest) ?? null)),
512
+ };
513
+ }
514
+
477
515
  /** Retire only this pane's delivery owners before its canonical owner is disposed. */
478
516
  failPaneCapture(semanticPaneId: string, error: StockCaptureTabUnavailableError): void {
479
517
  for (const client of this.#clients.values())
@@ -25,6 +25,10 @@ export interface TerminalInterpreterBackend {
25
25
  /** Public-parser diagnostic OSC seam; installed only for an active probe. */
26
26
  registerOscHandler(identifier: number, handler: (data: string) => boolean): () => void;
27
27
  write(data: Uint8Array | string): Promise<void>;
28
+ /** SPIKE (kitty graphics): cursor as an absolute buffer line, read mid-parse. */
29
+ graphicsCursor?(): { readonly x: number; readonly line: number; readonly alternate: boolean };
30
+ /** SPIKE (kitty graphics): absolute buffer line of viewport row 0. */
31
+ graphicsViewportTop?(): number;
28
32
  /** Exact native painted state; false means this parser cannot import it. */
29
33
  canImportNativeGrid?(): boolean;
30
34
  importNativeGrid?(snapshot: NativeGridCapture): boolean;