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
@@ -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,18 @@ export class TerminalReplicaInterpreter {
239
249
  }
240
250
  const previous = this.#backend;
241
251
  this.#backend = replacement;
252
+ this.#attachKitty(replacement);
253
+ // A capture replay does not erase graphics already observed by this owner.
254
+ // Keep anchors only when both backends agree on their coordinate basis;
255
+ // reflow, history replacement and authoritative stream resets are ambiguous.
256
+ const sameGraphicsBasis = operation.bootstrap === "painted-capture" &&
257
+ previous.cols === replacement.cols &&
258
+ previous.rows === replacement.rows &&
259
+ previous.graphicsViewportTop?.() !== undefined &&
260
+ previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
261
+ previous.modes().alternateScreen === replacement.modes().alternateScreen;
262
+ if (!sameGraphicsBasis)
263
+ this.#kitty.dropCursorPlacements();
242
264
  this.#nativeReseedRequested = false;
243
265
  this.#scrollback = scrollback;
244
266
  this.#bootstrap = {
@@ -262,6 +284,7 @@ export class TerminalReplicaInterpreter {
262
284
  this.#nativeSeedBackingCandidate = undefined;
263
285
  }
264
286
  previous.dispose();
287
+ this.#publishGraphics();
265
288
  return;
266
289
  }
267
290
  if (operation.type === "cursor") {
@@ -276,6 +299,8 @@ export class TerminalReplicaInterpreter {
276
299
  return;
277
300
  }
278
301
  if (operation.type === "resize") {
302
+ if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
303
+ this.#kitty.dropCursorPlacements();
279
304
  this.#causalCell?.fail("geometry-drift");
280
305
  if (this.#backend.modes().synchronizedOutput) {
281
306
  // Geometry is part of the admitted FIFO even while publication is
@@ -287,6 +312,7 @@ export class TerminalReplicaInterpreter {
287
312
  }
288
313
  this.#backend.resize(operation.cols, operation.rows);
289
314
  this.#commit(false);
315
+ this.#publishGraphics();
290
316
  return;
291
317
  }
292
318
  const baseRevision = this.#revision;
@@ -304,6 +330,7 @@ export class TerminalReplicaInterpreter {
304
330
  };
305
331
  this.#revision = revision;
306
332
  this.#closed = true;
333
+ this.#kitty.dispose();
307
334
  this.#causalCell?.fail("transport-closed");
308
335
  this.#clearSyncRecovery();
309
336
  if (this.#needsSeed)
@@ -321,8 +348,20 @@ export class TerminalReplicaInterpreter {
321
348
  this.#admitRaw(data);
322
349
  this.#stats.parseBatches += 1;
323
350
  this.#observeMarkerBytes(data);
351
+ const scanned = this.#kittyScanner.scan(data);
352
+ for (const command of scanned.commands)
353
+ this.#kittyPending.set(command.marker, command);
324
354
  const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
325
- await this.#writeToBackend(this.#backend, data);
355
+ // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
356
+ // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
357
+ // be all image payload that must never reach the VT parser as text.
358
+ // A chunk entirely inside image payload changes nothing the parser can
359
+ // see. Skipping it matters: every parser write settles on a later timer
360
+ // turn, so a frame split into dozens of %output chunks would otherwise
361
+ // serialize dozens of turns and fall behind a 30 fps image stream.
362
+ if (scanned.forward.byteLength === 0)
363
+ return;
364
+ await this.#writeToBackend(this.#backend, scanned.forward);
326
365
  if (this.#observability.enabled)
327
366
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
328
367
  // The parser resets its dirty tracker per write. Synchronized output may
@@ -356,6 +395,7 @@ export class TerminalReplicaInterpreter {
356
395
  else {
357
396
  this.#commit(false, this.#backend.dirtyRange(), trace);
358
397
  }
398
+ this.#publishGraphics();
359
399
  }
360
400
  #scheduleSyncRecovery() {
361
401
  if (this.#syncRecovery || this.#closed)
@@ -541,6 +581,50 @@ export class TerminalReplicaInterpreter {
541
581
  bootstrap: this.#bootstrap,
542
582
  });
543
583
  }
584
+ /** The pane's kitty image store (bytes for the graphics side channel). */
585
+ kittyImages() {
586
+ return this.#kitty;
587
+ }
588
+ /** Latest graphics state, or null while the pane has never held an image. */
589
+ currentGraphics() {
590
+ return this.#graphics;
591
+ }
592
+ onGraphics(listener) {
593
+ this.#graphicsListeners.add(listener);
594
+ return () => this.#graphicsListeners.delete(listener);
595
+ }
596
+ /** Publish graphics state when the store or the viewport-relative placements moved. */
597
+ #publishGraphics() {
598
+ if (this.#graphics === null && this.#kitty.revision === 0)
599
+ return;
600
+ const state = this.#kitty.state(this.#backend.graphicsViewportTop?.() ?? 0, this.#backend.rows, this.#backend.cols, this.#backend.modes().alternateScreen);
601
+ const key = JSON.stringify([state.revision, state.placements]);
602
+ if (key === this.#graphicsKey)
603
+ return;
604
+ this.#graphicsKey = key;
605
+ this.#graphics = state;
606
+ for (const listener of this.#graphicsListeners) {
607
+ try {
608
+ listener(state);
609
+ }
610
+ catch {
611
+ // Renderer callbacks never poison the authoritative parser FIFO.
612
+ }
613
+ }
614
+ }
615
+ #attachKitty(backend) {
616
+ // Minimal test backends omit the OSC seam; they simply never place images.
617
+ if (typeof backend.registerOscHandler !== "function")
618
+ return;
619
+ backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
620
+ const command = this.#kittyPending.get(Number(data));
621
+ if (!command)
622
+ return true;
623
+ this.#kittyPending.delete(Number(data));
624
+ this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
625
+ return true;
626
+ });
627
+ }
544
628
  #currentSnapshotHash() {
545
629
  return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
546
630
  }
@@ -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);
@@ -29,6 +29,13 @@ function assertLiveControllerPrincipal(shared, lease) {
29
29
  throw new SessionRuntimeControllerLeaseError("stale-controller-lease", "The host no longer owns session controller authority.");
30
30
  }
31
31
  }
32
+ function holdsCurrentLease(shared) {
33
+ const lease = shared.lease;
34
+ if (!lease)
35
+ return false;
36
+ const snapshot = shared.consumer.controllerSnapshot();
37
+ return snapshot.controllerClientId === lease.clientId && snapshot.revision === lease.revision;
38
+ }
32
39
  const clientsByRegistry = new WeakMap();
33
40
  function authenticatedSurface(transport, hostClientId) {
34
41
  // The production host and development gateway mint these principals
@@ -290,6 +297,10 @@ export class SessionRuntimeTransportBinding {
290
297
  throw error;
291
298
  }
292
299
  }
300
+ setCellPixels(width, height, precise) {
301
+ this.#assertOpen();
302
+ this.#shared.consumer.setCellPixels?.(width, height, precise);
303
+ }
293
304
  fitViewport(expectedLease, cols, rows, semanticWindowId) {
294
305
  this.#assertOpen();
295
306
  if (this.#shared.geometryTransportLeaseIds.at(-1) !== this.#transportLeaseId) {
@@ -471,18 +482,33 @@ export class SessionRuntimeTransportBinder {
471
482
  : this.registry.bindExecutionSource(base, sourceSemanticPaneId);
472
483
  }
473
484
  activateController(target) {
474
- if (target.lease)
485
+ if (target.lease && holdsCurrentLease(target))
475
486
  return;
476
- const current = [...this.#clients.values()].find((candidate) => candidate !== target &&
477
- candidate.consumer.session === target.consumer.session &&
478
- candidate.lease);
479
- if (current?.lease) {
480
- const previous = current.lease;
481
- current.lease = null;
482
- target.lease = current.consumer.handoffController(previous, target.consumer.clientId);
487
+ target.lease = null;
488
+ const controllerClientId = target.consumer.controllerSnapshot().controllerClientId;
489
+ const current = controllerClientId === null || controllerClientId === target.consumer.clientId
490
+ ? undefined
491
+ : [...this.#clients.values()].find((candidate) => candidate !== target &&
492
+ candidate.consumer.session === target.consumer.session &&
493
+ candidate.consumer.clientId === controllerClientId);
494
+ if (!current) {
495
+ // Free, already ours, or held by a principal outside this binder (for
496
+ // example the command-center owner route): plain acquisition decides.
497
+ target.lease = target.consumer.acquireController();
483
498
  return;
484
499
  }
485
- target.lease = target.consumer.acquireController();
500
+ // A transport-bound holder whose binding lost its executable lease can
501
+ // never type again, yet it would keep every other viewer locked out. Re-
502
+ // derive its exact current lease so the handoff below reclaims it; a live
503
+ // holder simply hands off as before. Either way only one client can type.
504
+ const previous = current.lease && holdsCurrentLease(current)
505
+ ? current.lease
506
+ : current.consumer.acquireController();
507
+ const handedOff = current.consumer.handoffController(previous, target.consumer.clientId);
508
+ // Drop the holder's lease only after the runtime accepted the handoff; a
509
+ // refusal (e.g. during topology verification) must leave it executable.
510
+ current.lease = null;
511
+ target.lease = handedOff;
486
512
  }
487
513
  async release(shared, grants, interactive) {
488
514
  if (shared.refs <= 0)
@@ -500,19 +526,25 @@ export class SessionRuntimeTransportBinder {
500
526
  shared.refs -= 1;
501
527
  if (shared.refs > 0)
502
528
  return;
503
- if (shared.lease) {
504
- // A same-host passive session channel keeps the controller PRINCIPAL
505
- // continuous across interactive pane retargeting. It contributes no
506
- // source-pane grant and cannot type; the lease retires with the last
507
- // host/session ref, while a passive-only host never acquires one.
508
- const retired = shared.lease;
509
- shared.lease = null;
510
- shared.consumer.releaseController(retired);
529
+ try {
530
+ if (shared.lease) {
531
+ // A same-host passive session channel keeps the controller PRINCIPAL
532
+ // continuous across interactive pane retargeting. It contributes no
533
+ // source-pane grant and cannot type; the lease retires with the last
534
+ // host/session ref, while a passive-only host never acquires one.
535
+ const retired = shared.lease;
536
+ shared.lease = null;
537
+ shared.consumer.releaseController(retired);
538
+ }
511
539
  }
512
- for (const [key, candidate] of this.#clients) {
513
- if (candidate === shared)
514
- this.#clients.delete(key);
540
+ finally {
541
+ // A refused surrender must never strand the controller on a client with
542
+ // no transport: closing the consumer retires whatever it still owns.
543
+ for (const [key, candidate] of this.#clients) {
544
+ if (candidate === shared)
545
+ this.#clients.delete(key);
546
+ }
547
+ await shared.consumer.close();
515
548
  }
516
- await shared.consumer.close();
517
549
  }
518
550
  }
@@ -82,6 +82,17 @@ export class XtermTerminalInterpreterBackend {
82
82
  get rows() {
83
83
  return this.#terminal.rows;
84
84
  }
85
+ graphicsCursor() {
86
+ const buffer = this.#terminal.buffer.active;
87
+ return {
88
+ x: Math.min(buffer.cursorX, this.#terminal.cols - 1),
89
+ line: buffer.baseY + buffer.cursorY,
90
+ alternate: buffer.type === "alternate",
91
+ };
92
+ }
93
+ graphicsViewportTop() {
94
+ return this.#terminal.buffer.active.baseY;
95
+ }
85
96
  write(data) {
86
97
  return new Promise((resolve) => this.#terminal.write(data, resolve));
87
98
  }
@@ -232,8 +232,14 @@ export function createApplicationHomeNavigationOwner(options) {
232
232
  command === "whats-new" ||
233
233
  command === "switch-session")
234
234
  return null;
235
- if (options.sessionOwner()?.snapshot()?.status !== "live")
235
+ const status = options.sessionOwner()?.snapshot()?.status;
236
+ if (status !== "live") {
237
+ if (status === "rebinding")
238
+ return "Terminal is updating. Try again shortly.";
239
+ if (status === "connecting")
240
+ return "Opening terminal…";
236
241
  return "Open a live session first";
242
+ }
237
243
  return command !== "new-window" && command !== "new-agent" && !options.focusedPane()
238
244
  ? "Select a live pane first"
239
245
  : null;
@@ -103,7 +103,14 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
103
103
  }
104
104
  : undefined;
105
105
  recovery.inputs.push(async () => {
106
- for (const [index, input] of inputs.entries()) {
106
+ if (version !== recoveryVersion ||
107
+ !sameScope(target, scope()) ||
108
+ generation()?.status !== "live")
109
+ return false;
110
+ const framed = typeof inputs === "function" ? inputs() : inputs;
111
+ if (framed === null)
112
+ return false;
113
+ for (const [index, input] of framed.entries()) {
107
114
  if (version !== recoveryVersion ||
108
115
  !sameScope(target, scope()) ||
109
116
  generation()?.status !== "live" ||
@@ -155,6 +162,24 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
155
162
  }
156
163
  })();
157
164
  };
165
+ const pasteMode = () => {
166
+ const active = generation();
167
+ const pane = focusedPane();
168
+ if (active?.status !== "live" || !active.fastLane || !pane)
169
+ return null;
170
+ const mode = active.fastLane.lane.paneState(pane)?.snapshot?.modes.bracketedPaste;
171
+ return typeof mode === "boolean" ? mode : null;
172
+ };
173
+ const deliverPaste = (bytes) => {
174
+ const mode = pasteMode();
175
+ if (mode === null) {
176
+ // Do not let the successful drain of the surrounding FIFO hide refusal.
177
+ ownsNote = false;
178
+ setNote("terminal unavailable · paste was not sent");
179
+ return;
180
+ }
181
+ sendApplicationTerminalPaste(interaction, bytes, captureOrigin(), mode);
182
+ };
158
183
  const captureOrigin = () => Boolean(currentTuiPerformanceEventSink()?.terminalInputOrigin);
159
184
  const flush = () => {
160
185
  const owner = sessionOwner();
@@ -243,7 +268,7 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
243
268
  return;
244
269
  const copy = Uint8Array.from(bytes);
245
270
  try {
246
- terminalInputsForPaste(Buffer.from(copy).toString("utf8"));
271
+ terminalInputsForPaste(Buffer.from(copy).toString("utf8"), false);
247
272
  }
248
273
  catch {
249
274
  setNote("terminal unavailable · paste was rejected");
@@ -255,12 +280,17 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
255
280
  active?.status === "live" &&
256
281
  active.fastLane &&
257
282
  focusedPane()) {
258
- sendApplicationTerminalPaste(interaction, copy, captureOrigin());
283
+ deliverPaste(copy);
259
284
  return;
260
285
  }
261
- if (recover(terminalInputsForPaste(Buffer.from(copy).toString("utf8")), copy, "paste"))
286
+ if (recover(() => {
287
+ const mode = pasteMode();
288
+ return mode === null
289
+ ? null
290
+ : terminalInputsForPaste(Buffer.from(copy).toString("utf8"), mode);
291
+ }, copy, "paste"))
262
292
  return;
263
- noteAdmission(pending.enqueue(() => sendApplicationTerminalPaste(interaction, copy, captureOrigin()), copy.byteLength), "paste");
293
+ noteAdmission(pending.enqueue(() => deliverPaste(copy), copy.byteLength), "paste");
264
294
  },
265
295
  dispose() {
266
296
  cancelInteractionInput();
@@ -10,9 +10,9 @@ export function sendApplicationTerminalKey(interaction, event, captureOrigin) {
10
10
  void interaction.sendInput(input, parserOrigin);
11
11
  return true;
12
12
  }
13
- export function sendApplicationTerminalPaste(interaction, bytes, captureOrigin) {
13
+ export function sendApplicationTerminalPaste(interaction, bytes, captureOrigin, bracketedPaste) {
14
14
  const text = Buffer.from(bytes).toString("utf8");
15
- const inputs = terminalInputsForPaste(text);
15
+ const inputs = terminalInputsForPaste(text, bracketedPaste);
16
16
  for (let index = 0; index < inputs.length; index += 1) {
17
17
  const parserOrigin = index === 0 && captureOrigin
18
18
  ? { origin: "bracketed-paste", payload: Buffer.from(bytes) }
@@ -33,12 +33,12 @@ export function terminalInputForOpenTuiKey(event) {
33
33
  return SessionRuntimeTerminalInputSchemaZ.parse({ kind: "text", data });
34
34
  }
35
35
  /** Preserve paste ordering and bracketed-paste semantics without an unbounded frame. */
36
- export function terminalInputsForPaste(text) {
36
+ export function terminalInputsForPaste(text, bracketedPaste) {
37
37
  if (text.length === 0)
38
38
  return [];
39
39
  if (text.includes("\0"))
40
40
  throw new TypeError("terminal paste must not contain NUL");
41
- const framed = `\u001b[200~${text}\u001b[201~`;
41
+ const framed = bracketedPaste ? `\u001b[200~${text}\u001b[201~` : text;
42
42
  const inputs = [];
43
43
  for (let offset = 0; offset < framed.length;) {
44
44
  let end = Math.min(offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS, framed.length);
@@ -0,0 +1,38 @@
1
+ import type { Hono } from "hono";
2
+ import { TmuxServerScopeSchemaZ, TmuxServerDiagnosticsSchemaZ } from "@tmux-ide/contracts";
3
+ import { TmuxServerScopeError, type TmuxServerOwners } from "../lib/tmux-server-owners.ts";
4
+ import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
5
+
6
+ /** Mount beneath the existing owner-authenticated, no-store server router. */
7
+ export function mountTmuxServerDiagnosticsRoute(
8
+ app: Hono,
9
+ options: {
10
+ scopedPath: string;
11
+ owners: TmuxServerOwners<NativeTmuxServerOwner>;
12
+ },
13
+ ) {
14
+ app.get(`${options.scopedPath}/diagnostics`, async (c) => {
15
+ const scope = TmuxServerScopeSchemaZ.safeParse({
16
+ serverId: c.req.param("serverId"),
17
+ generation: c.req.param("generation"),
18
+ });
19
+ if (!scope.success) return c.json({ error: { code: "invalid-request" } }, 400);
20
+ try {
21
+ c.req.raw.signal.throwIfAborted();
22
+ const tmuxVersion = await options.owners.withOwner(scope.data, (owner) =>
23
+ owner.readServerVersion(c.req.raw.signal),
24
+ );
25
+ c.req.raw.signal.throwIfAborted();
26
+ return c.json(
27
+ TmuxServerDiagnosticsSchemaZ.parse({ version: 1, server: scope.data, tmuxVersion }),
28
+ );
29
+ } catch (error) {
30
+ if (error instanceof TmuxServerScopeError)
31
+ return c.json(
32
+ { error: { code: "scope-unavailable" } },
33
+ error.code === "not-found" ? 404 : 409,
34
+ );
35
+ return c.json({ error: { code: "diagnostics-unavailable" } }, 503);
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,69 @@
1
+ import type { Hono } from "hono";
2
+ import {
3
+ HistoryAnchorRequestSchemaZ,
4
+ HistoryAnchorResponseSchemaZ,
5
+ TmuxServerScopeSchemaZ,
6
+ } from "@tmux-ide/contracts";
7
+ import { TmuxServerScopeError, type TmuxServerOwners } from "../lib/tmux-server-owners.ts";
8
+ import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
9
+ /** Mounted below owner-authenticated no-store middleware. Never opens a runtime. */
10
+ export function mountTmuxServerHistoryAnchorRoute(
11
+ app: Hono,
12
+ options: {
13
+ scopedPath: string;
14
+ owners: TmuxServerOwners<NativeTmuxServerOwner>;
15
+ readBody: (request: Request) => Promise<unknown>;
16
+ },
17
+ ) {
18
+ app.post(`${options.scopedPath}/history-anchor`, async (c) => {
19
+ const scope = TmuxServerScopeSchemaZ.safeParse({
20
+ serverId: c.req.param("serverId"),
21
+ generation: c.req.param("generation"),
22
+ });
23
+ if (!scope.success) return c.json({ error: { code: "invalid-request" } }, 400);
24
+ let body;
25
+ try {
26
+ body = HistoryAnchorRequestSchemaZ.parse(await options.readBody(c.req.raw));
27
+ } catch {
28
+ return c.json({ error: { code: "invalid-request" } }, 400);
29
+ }
30
+ if (body.proof.generation !== scope.data.generation)
31
+ return c.json({ error: { code: "stale-generation" } }, 409);
32
+ try {
33
+ c.req.raw.signal.throwIfAborted();
34
+ const result = await options.owners.withOwner(scope.data, async (owner) => {
35
+ const sessions = await owner.catalog();
36
+ c.req.raw.signal.throwIfAborted();
37
+ const workspace = owner.workspaceRegistry.get(body.proof.workspaceName);
38
+ if (
39
+ !workspace ||
40
+ !sessions.some(
41
+ (row) =>
42
+ row.sessionName === workspace.sessionName && row.liveSessionId === body.liveSessionId,
43
+ )
44
+ )
45
+ return { status: "stale" as const };
46
+ const result = owner.sessionRuntimeRegistry.historyAnchor(
47
+ workspace.sessionName,
48
+ HistoryAnchorRequestSchemaZ.parse({
49
+ ...body,
50
+ proof: { ...body.proof, workspaceName: workspace.sessionName },
51
+ }),
52
+ );
53
+ // Delivery aliases are transport addresses, not canonical session names.
54
+ return result.status === "resolved"
55
+ ? { ...result, proof: { ...result.proof, workspaceName: body.proof.workspaceName } }
56
+ : result;
57
+ });
58
+ c.req.raw.signal.throwIfAborted();
59
+ return c.json(HistoryAnchorResponseSchemaZ.parse({ version: 1, server: scope.data, result }));
60
+ } catch (error) {
61
+ if (error instanceof TmuxServerScopeError)
62
+ return c.json(
63
+ { error: { code: "scope-unavailable" } },
64
+ error.code === "not-found" ? 404 : 409,
65
+ );
66
+ return c.json({ error: { code: "history-unavailable" } }, 503);
67
+ }
68
+ });
69
+ }