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
@@ -44,6 +44,14 @@ import {
44
44
  cellsEqual,
45
45
  type CausalCellLedgerResult,
46
46
  } from "./causal-cell-ledger.ts";
47
+ import {
48
+ KITTY_PLACEMENT_OSC,
49
+ KittyApcScanner,
50
+ KittyImageStore,
51
+ type KittyCommand,
52
+ type KittyImageBudget,
53
+ } from "../graphics/kitty-graphics.ts";
54
+ import type { TerminalGraphicsState } from "@tmux-ide/contracts";
47
55
 
48
56
  type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
49
57
  export type TerminalReplicaInterpreterOperation =
@@ -115,6 +123,8 @@ export interface TerminalReplicaInterpreterOptions {
115
123
  readonly observability?: SessionRuntimeObservability;
116
124
  /** Parser implementation only; SessionRuntime remains the sole state authority. */
117
125
  readonly backendFactory?: TerminalInterpreterBackendFactory;
126
+ /** Shared kitty image byte quotas; defaults to the daemon-wide budget. */
127
+ readonly kittyImageBudget?: KittyImageBudget;
118
128
  }
119
129
 
120
130
  export interface TerminalReplicaInterpreterStats {
@@ -165,6 +175,13 @@ export class TerminalReplicaInterpreter {
165
175
  }> = [];
166
176
  #writeFlushScheduled = false;
167
177
  #widgetGate = false;
178
+ // kitty graphics: images outlive reseeds; the scanner owns split APCs.
179
+ readonly #kittyScanner = new KittyApcScanner();
180
+ readonly #kitty: KittyImageStore;
181
+ readonly #kittyPending = new Map<number, KittyCommand>();
182
+ readonly #graphicsListeners = new Set<(state: TerminalGraphicsState) => void>();
183
+ #graphics: TerminalGraphicsState | null = null;
184
+ #graphicsKey = "";
168
185
  #markerTail = "";
169
186
  #pendingResize: { cols: number; rows: number } | null = null;
170
187
  // null: no unpublished writes; undefined: a write requires full projection.
@@ -208,6 +225,10 @@ export class TerminalReplicaInterpreter {
208
225
  rows: options.rows,
209
226
  scrollback: this.#scrollback,
210
227
  });
228
+ this.#kitty = new KittyImageStore(
229
+ options.kittyImageBudget ? { budget: options.kittyImageBudget } : {},
230
+ );
231
+ this.#attachKitty(this.#backend);
211
232
  this.#snapshot = blankTerminalReplicaSnapshot(options.cols, options.rows);
212
233
  this.#seedReady = new Promise((resolve, reject) => {
213
234
  this.#resolveSeedReady = resolve;
@@ -400,6 +421,9 @@ export class TerminalReplicaInterpreter {
400
421
  }
401
422
  const previous = this.#backend;
402
423
  this.#backend = replacement;
424
+ this.#attachKitty(replacement);
425
+ // tmux never saw cursor-anchored placements; a capture cannot restore them.
426
+ this.#kitty.dropCursorPlacements();
403
427
  this.#nativeReseedRequested = false;
404
428
  this.#scrollback = scrollback;
405
429
  this.#bootstrap = {
@@ -421,6 +445,7 @@ export class TerminalReplicaInterpreter {
421
445
  this.#nativeSeedBackingCandidate = undefined;
422
446
  }
423
447
  previous.dispose();
448
+ this.#publishGraphics();
424
449
  return;
425
450
  }
426
451
  if (operation.type === "cursor") {
@@ -445,6 +470,7 @@ export class TerminalReplicaInterpreter {
445
470
  }
446
471
  this.#backend.resize(operation.cols, operation.rows);
447
472
  this.#commit(false);
473
+ this.#publishGraphics();
448
474
  return;
449
475
  }
450
476
  const baseRevision = this.#revision;
@@ -462,6 +488,7 @@ export class TerminalReplicaInterpreter {
462
488
  };
463
489
  this.#revision = revision;
464
490
  this.#closed = true;
491
+ this.#kitty.dispose();
465
492
  this.#causalCell?.fail("transport-closed");
466
493
  this.#clearSyncRecovery();
467
494
  if (this.#needsSeed)
@@ -479,8 +506,13 @@ export class TerminalReplicaInterpreter {
479
506
  this.#admitRaw(data);
480
507
  this.#stats.parseBatches += 1;
481
508
  this.#observeMarkerBytes(data);
509
+ const scanned = this.#kittyScanner.scan(data);
510
+ for (const command of scanned.commands) this.#kittyPending.set(command.marker, command);
482
511
  const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
483
- await this.#writeToBackend(this.#backend, data);
512
+ // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
513
+ // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
514
+ // be all image payload that must never reach the VT parser as text.
515
+ await this.#writeToBackend(this.#backend, scanned.forward);
484
516
  if (this.#observability.enabled)
485
517
  this.#observability.recordSpan(
486
518
  "parse",
@@ -519,6 +551,7 @@ export class TerminalReplicaInterpreter {
519
551
  } else {
520
552
  this.#commit(false, this.#backend.dirtyRange(), trace);
521
553
  }
554
+ this.#publishGraphics();
522
555
  }
523
556
 
524
557
  #scheduleSyncRecovery(): void {
@@ -719,6 +752,55 @@ export class TerminalReplicaInterpreter {
719
752
  });
720
753
  }
721
754
 
755
+ /** The pane's kitty image store (bytes for the graphics side channel). */
756
+ kittyImages(): KittyImageStore {
757
+ return this.#kitty;
758
+ }
759
+
760
+ /** Latest graphics state, or null while the pane has never held an image. */
761
+ currentGraphics(): TerminalGraphicsState | null {
762
+ return this.#graphics;
763
+ }
764
+
765
+ onGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
766
+ this.#graphicsListeners.add(listener);
767
+ return () => this.#graphicsListeners.delete(listener);
768
+ }
769
+
770
+ /** Publish graphics state when the store or the viewport-relative placements moved. */
771
+ #publishGraphics(): void {
772
+ if (this.#graphics === null && this.#kitty.revision === 0) return;
773
+ const state = this.#kitty.state(
774
+ this.#backend.graphicsViewportTop?.() ?? 0,
775
+ this.#backend.rows,
776
+ this.#backend.cols,
777
+ this.#backend.modes().alternateScreen,
778
+ );
779
+ const key = JSON.stringify([state.revision, state.placements]);
780
+ if (key === this.#graphicsKey) return;
781
+ this.#graphicsKey = key;
782
+ this.#graphics = state;
783
+ for (const listener of this.#graphicsListeners) {
784
+ try {
785
+ listener(state);
786
+ } catch {
787
+ // Renderer callbacks never poison the authoritative parser FIFO.
788
+ }
789
+ }
790
+ }
791
+
792
+ #attachKitty(backend: TerminalInterpreterBackend): void {
793
+ // Minimal test backends omit the OSC seam; they simply never place images.
794
+ if (typeof backend.registerOscHandler !== "function") return;
795
+ backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
796
+ const command = this.#kittyPending.get(Number(data));
797
+ if (!command) return true;
798
+ this.#kittyPending.delete(Number(data));
799
+ this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
800
+ return true;
801
+ });
802
+ }
803
+
722
804
  #currentSnapshotHash(): string {
723
805
  return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
724
806
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ HistoryAnchorOwner,
3
+ type HistoryAnchorIdentity,
4
+ type HistoryAnchorProof,
5
+ } from "./history-anchor-owner.ts";
1
6
  import { layoutContentRows } from "../mirror/layout-content-rows.ts";
2
7
  import type { NativeGridCapture } from "../mirror/native-grid-capture.ts";
3
8
  import type {
@@ -27,6 +32,8 @@ import type {
27
32
  } from "./runtime-observability.ts";
28
33
  import { DISABLED_SESSION_RUNTIME_OBSERVABILITY } from "./runtime-observability.ts";
29
34
  import type { CausalCellLedgerResult } from "./causal-cell-ledger.ts";
35
+ import type { KittyImage } from "../graphics/kitty-graphics.ts";
36
+ import type { TerminalGraphicsState } from "@tmux-ide/contracts";
30
37
 
31
38
  export interface TerminalReplicaSubscription {
32
39
  readonly generation: SessionRuntimeGeneration;
@@ -96,6 +103,7 @@ export class SessionRuntimeTerminalReplicaOwner {
96
103
  readonly #start: Promise<void>;
97
104
  #upstream: MirrorSubscription | null = null;
98
105
  #disposed = false;
106
+ readonly #historyAnchors = new HistoryAnchorOwner();
99
107
  #cols = 80;
100
108
  #rows = 24;
101
109
  #layoutEpoch = 0;
@@ -144,6 +152,7 @@ export class SessionRuntimeTerminalReplicaOwner {
144
152
  onNativeReseedRequired: () => {
145
153
  if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture) return;
146
154
  this.#waitingForGeometryCapture = true;
155
+ this.#historyAnchors.invalidate();
147
156
  this.#supervise(
148
157
  this.#start.then(() => {
149
158
  if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
@@ -151,6 +160,7 @@ export class SessionRuntimeTerminalReplicaOwner {
151
160
  );
152
161
  },
153
162
  onUpdate: (update, trace) => {
163
+ this.#historyAnchors.observe(update);
154
164
  this.#publishedIncarnation = update.incarnation;
155
165
  if (update.type === "terminal.seed") {
156
166
  this.#bootstrapped = true;
@@ -232,6 +242,18 @@ export class SessionRuntimeTerminalReplicaOwner {
232
242
  this.#interpreter.failCausalCell(reason, traceId);
233
243
  }
234
244
 
245
+ captureHistoryAnchor(proof: HistoryAnchorProof, historyIndex: number) {
246
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
247
+ return { status: "unavailable" as const };
248
+ return this.#historyAnchors.capture(proof, historyIndex);
249
+ }
250
+
251
+ resolveHistoryAnchor(proof: HistoryAnchorIdentity, token: string) {
252
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
253
+ return { status: "unavailable" as const };
254
+ return this.#historyAnchors.resolve(proof, token);
255
+ }
256
+
235
257
  /** Scalar diagnostic only: no seed construction, hashing, or owner creation. */
236
258
  inputDispatchIncarnation(): string | null {
237
259
  return this.#disposed ? null : this.#publishedIncarnation;
@@ -350,6 +372,18 @@ export class SessionRuntimeTerminalReplicaOwner {
350
372
  };
351
373
  }
352
374
 
375
+ /** Kitty graphics side channel: current state now (if any), then every change. */
376
+ subscribeGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
377
+ const current = this.#interpreter.currentGraphics();
378
+ if (current) listener(current);
379
+ return this.#interpreter.onGraphics(listener);
380
+ }
381
+
382
+ /** Image bytes by content digest, while the pane's store still holds them. */
383
+ graphicsImage(digest: string): KittyImage | null {
384
+ return this.#interpreter.kittyImages().imageByDigest(digest) ?? null;
385
+ }
386
+
353
387
  async subscribeSource(
354
388
  listener: (
355
389
  update: CanonicalTerminalReplicaUpdate,
@@ -377,6 +411,7 @@ export class SessionRuntimeTerminalReplicaOwner {
377
411
  if (this.#disposed) return;
378
412
  this.#noteLifecycle(`dispose-${reason}`);
379
413
  this.#disposed = true;
414
+ this.#historyAnchors.invalidate();
380
415
  this.#historyTimer?.cancel();
381
416
  this.#historyTimer = null;
382
417
  this.#subscriptionEpoch += 1;
@@ -437,6 +472,7 @@ export class SessionRuntimeTerminalReplicaOwner {
437
472
  // Deterministic capture point: reset opens the atomic capture. A probe
438
473
  // armed after reset belongs to the next post-capture delta, never to this
439
474
  // already-started reseed.
475
+ this.#historyAnchors.invalidate();
440
476
  this.#reseed = {
441
477
  nativeCols: event.cols,
442
478
  nativeRows: event.rows,
@@ -561,6 +597,7 @@ export class SessionRuntimeTerminalReplicaOwner {
561
597
  this.#reseedRetryCount = 0;
562
598
  if (!this.#bootstrapped || this.#reseed || this.#waitingForGeometryCapture) return;
563
599
  this.#waitingForGeometryCapture = true;
600
+ this.#historyAnchors.invalidate();
564
601
  void this.#start.then(() => {
565
602
  if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
566
603
  });
@@ -690,6 +727,7 @@ export class SessionRuntimeTerminalReplicaOwner {
690
727
  this.#noteLifecycle("reseed-retry", diagnostic);
691
728
  this.#reseedRetryCount += 1;
692
729
  this.#waitingForGeometryCapture = true;
730
+ this.#historyAnchors.invalidate();
693
731
  void this.#start.then(() => {
694
732
  if (!this.#disposed) this.#upstream?.reseed();
695
733
  });
@@ -697,6 +735,7 @@ export class SessionRuntimeTerminalReplicaOwner {
697
735
 
698
736
  #supervise(operation: Promise<void>): void {
699
737
  void operation.catch((error) => {
738
+ this.#historyAnchors.invalidate();
700
739
  this.#noteLifecycle("interpreter-fault");
701
740
  this.#interpreter.abort(error);
702
741
  this.#onFault?.(error);
@@ -104,6 +104,13 @@ function assertLiveControllerPrincipal(
104
104
  }
105
105
  }
106
106
 
107
+ function holdsCurrentLease(shared: SharedClient): boolean {
108
+ const lease = shared.lease;
109
+ if (!lease) return false;
110
+ const snapshot = shared.consumer.controllerSnapshot();
111
+ return snapshot.controllerClientId === lease.clientId && snapshot.revision === lease.revision;
112
+ }
113
+
107
114
  const clientsByRegistry = new WeakMap<object, Map<string, SharedClient>>();
108
115
 
109
116
  function authenticatedSurface(transport: SessionRuntimeTransport, hostClientId: string): string {
@@ -695,20 +702,37 @@ export class SessionRuntimeTransportBinder {
695
702
  }
696
703
 
697
704
  activateController(target: SharedClient): void {
698
- if (target.lease) return;
699
- const current = [...this.#clients.values()].find(
700
- (candidate) =>
701
- candidate !== target &&
702
- candidate.consumer.session === target.consumer.session &&
703
- candidate.lease,
704
- );
705
- if (current?.lease) {
706
- const previous = current.lease;
707
- current.lease = null;
708
- target.lease = current.consumer.handoffController(previous, target.consumer.clientId);
705
+ if (target.lease && holdsCurrentLease(target)) return;
706
+ target.lease = null;
707
+ const controllerClientId = target.consumer.controllerSnapshot().controllerClientId;
708
+ const current =
709
+ controllerClientId === null || controllerClientId === target.consumer.clientId
710
+ ? undefined
711
+ : [...this.#clients.values()].find(
712
+ (candidate) =>
713
+ candidate !== target &&
714
+ candidate.consumer.session === target.consumer.session &&
715
+ candidate.consumer.clientId === controllerClientId,
716
+ );
717
+ if (!current) {
718
+ // Free, already ours, or held by a principal outside this binder (for
719
+ // example the command-center owner route): plain acquisition decides.
720
+ target.lease = target.consumer.acquireController();
709
721
  return;
710
722
  }
711
- target.lease = target.consumer.acquireController();
723
+ // A transport-bound holder whose binding lost its executable lease can
724
+ // never type again, yet it would keep every other viewer locked out. Re-
725
+ // derive its exact current lease so the handoff below reclaims it; a live
726
+ // holder simply hands off as before. Either way only one client can type.
727
+ const previous =
728
+ current.lease && holdsCurrentLease(current)
729
+ ? current.lease
730
+ : current.consumer.acquireController();
731
+ const handedOff = current.consumer.handoffController(previous, target.consumer.clientId);
732
+ // Drop the holder's lease only after the runtime accepted the handoff; a
733
+ // refusal (e.g. during topology verification) must leave it executable.
734
+ current.lease = null;
735
+ target.lease = handedOff;
712
736
  }
713
737
 
714
738
  async release(
@@ -727,18 +751,23 @@ export class SessionRuntimeTransportBinder {
727
751
  }
728
752
  shared.refs -= 1;
729
753
  if (shared.refs > 0) return;
730
- if (shared.lease) {
731
- // A same-host passive session channel keeps the controller PRINCIPAL
732
- // continuous across interactive pane retargeting. It contributes no
733
- // source-pane grant and cannot type; the lease retires with the last
734
- // host/session ref, while a passive-only host never acquires one.
735
- const retired = shared.lease;
736
- shared.lease = null;
737
- shared.consumer.releaseController(retired);
738
- }
739
- for (const [key, candidate] of this.#clients) {
740
- if (candidate === shared) this.#clients.delete(key);
754
+ try {
755
+ if (shared.lease) {
756
+ // A same-host passive session channel keeps the controller PRINCIPAL
757
+ // continuous across interactive pane retargeting. It contributes no
758
+ // source-pane grant and cannot type; the lease retires with the last
759
+ // host/session ref, while a passive-only host never acquires one.
760
+ const retired = shared.lease;
761
+ shared.lease = null;
762
+ shared.consumer.releaseController(retired);
763
+ }
764
+ } finally {
765
+ // A refused surrender must never strand the controller on a client with
766
+ // no transport: closing the consumer retires whatever it still owns.
767
+ for (const [key, candidate] of this.#clients) {
768
+ if (candidate === shared) this.#clients.delete(key);
769
+ }
770
+ await shared.consumer.close();
741
771
  }
742
- await shared.consumer.close();
743
772
  }
744
773
  }
@@ -102,6 +102,19 @@ export class XtermTerminalInterpreterBackend implements TerminalInterpreterBacke
102
102
  return this.#terminal.rows;
103
103
  }
104
104
 
105
+ graphicsCursor(): { x: number; line: number; alternate: boolean } {
106
+ const buffer = this.#terminal.buffer.active;
107
+ return {
108
+ x: Math.min(buffer.cursorX, this.#terminal.cols - 1),
109
+ line: buffer.baseY + buffer.cursorY,
110
+ alternate: buffer.type === "alternate",
111
+ };
112
+ }
113
+
114
+ graphicsViewportTop(): number {
115
+ return this.#terminal.buffer.active.baseY;
116
+ }
117
+
105
118
  write(data: Uint8Array | string): Promise<void> {
106
119
  return new Promise((resolve) => this.#terminal.write(data, resolve));
107
120
  }
@@ -326,7 +326,12 @@ export function createApplicationHomeNavigationOwner(options: {
326
326
  command === "switch-session"
327
327
  )
328
328
  return null;
329
- if (options.sessionOwner()?.snapshot()?.status !== "live") return "Open a live session first";
329
+ const status = options.sessionOwner()?.snapshot()?.status;
330
+ if (status !== "live") {
331
+ if (status === "rebinding") return "Terminal is updating. Try again shortly.";
332
+ if (status === "connecting") return "Opening terminal…";
333
+ return "Open a live session first";
334
+ }
330
335
  return command !== "new-window" && command !== "new-agent" && !options.focusedPane()
331
336
  ? "Select a live pane first"
332
337
  : null;
@@ -108,7 +108,9 @@ export function createApplicationTerminalInputIngress(
108
108
  recovery = null;
109
109
  };
110
110
  const recover = (
111
- inputs: ReturnType<typeof terminalInputsForPaste>,
111
+ inputs:
112
+ | ReturnType<typeof terminalInputsForPaste>
113
+ | (() => ReturnType<typeof terminalInputsForPaste> | null),
112
114
  bytes: Uint8Array,
113
115
  kind: "input" | "paste",
114
116
  ): boolean => {
@@ -145,7 +147,15 @@ export function createApplicationTerminalInputIngress(
145
147
  }
146
148
  : undefined;
147
149
  recovery.inputs.push(async () => {
148
- for (const [index, input] of inputs.entries()) {
150
+ if (
151
+ version !== recoveryVersion ||
152
+ !sameScope(target, scope()) ||
153
+ generation()?.status !== "live"
154
+ )
155
+ return false;
156
+ const framed = typeof inputs === "function" ? inputs() : inputs;
157
+ if (framed === null) return false;
158
+ for (const [index, input] of framed.entries()) {
149
159
  if (
150
160
  version !== recoveryVersion ||
151
161
  !sameScope(target, scope()) ||
@@ -197,6 +207,23 @@ export function createApplicationTerminalInputIngress(
197
207
  }
198
208
  })();
199
209
  };
210
+ const pasteMode = (): boolean | null => {
211
+ const active = generation();
212
+ const pane = focusedPane();
213
+ if (active?.status !== "live" || !active.fastLane || !pane) return null;
214
+ const mode = active.fastLane.lane.paneState(pane)?.snapshot?.modes.bracketedPaste;
215
+ return typeof mode === "boolean" ? mode : null;
216
+ };
217
+ const deliverPaste = (bytes: Uint8Array): void => {
218
+ const mode = pasteMode();
219
+ if (mode === null) {
220
+ // Do not let the successful drain of the surrounding FIFO hide refusal.
221
+ ownsNote = false;
222
+ setNote("terminal unavailable · paste was not sent");
223
+ return;
224
+ }
225
+ sendApplicationTerminalPaste(interaction, bytes, captureOrigin(), mode);
226
+ };
200
227
  const captureOrigin = (): boolean =>
201
228
  Boolean(currentTuiPerformanceEventSink()?.terminalInputOrigin);
202
229
  const flush = (): void => {
@@ -291,7 +318,7 @@ export function createApplicationTerminalInputIngress(
291
318
  if (bytes.length === 0) return;
292
319
  const copy = Uint8Array.from(bytes);
293
320
  try {
294
- terminalInputsForPaste(Buffer.from(copy).toString("utf8"));
321
+ terminalInputsForPaste(Buffer.from(copy).toString("utf8"), false);
295
322
  } catch {
296
323
  setNote("terminal unavailable · paste was rejected");
297
324
  return;
@@ -304,16 +331,24 @@ export function createApplicationTerminalInputIngress(
304
331
  active.fastLane &&
305
332
  focusedPane()
306
333
  ) {
307
- sendApplicationTerminalPaste(interaction, copy, captureOrigin());
334
+ deliverPaste(copy);
308
335
  return;
309
336
  }
310
- if (recover(terminalInputsForPaste(Buffer.from(copy).toString("utf8")), copy, "paste"))
337
+ if (
338
+ recover(
339
+ () => {
340
+ const mode = pasteMode();
341
+ return mode === null
342
+ ? null
343
+ : terminalInputsForPaste(Buffer.from(copy).toString("utf8"), mode);
344
+ },
345
+ copy,
346
+ "paste",
347
+ )
348
+ )
311
349
  return;
312
350
  noteAdmission(
313
- pending.enqueue(
314
- () => sendApplicationTerminalPaste(interaction, copy, captureOrigin()),
315
- copy.byteLength,
316
- ),
351
+ pending.enqueue(() => deliverPaste(copy), copy.byteLength),
317
352
  "paste",
318
353
  );
319
354
  },
@@ -20,9 +20,10 @@ export function sendApplicationTerminalPaste(
20
20
  interaction: ApplicationTerminalInteractionController,
21
21
  bytes: Uint8Array,
22
22
  captureOrigin: boolean,
23
+ bracketedPaste: boolean,
23
24
  ): void {
24
25
  const text = Buffer.from(bytes).toString("utf8");
25
- const inputs = terminalInputsForPaste(text);
26
+ const inputs = terminalInputsForPaste(text, bracketedPaste);
26
27
  for (let index = 0; index < inputs.length; index += 1) {
27
28
  const parserOrigin =
28
29
  index === 0 && captureOrigin
@@ -48,10 +48,13 @@ export function terminalInputForOpenTuiKey(
48
48
  }
49
49
 
50
50
  /** Preserve paste ordering and bracketed-paste semantics without an unbounded frame. */
51
- export function terminalInputsForPaste(text: string): readonly SessionRuntimeTerminalInput[] {
51
+ export function terminalInputsForPaste(
52
+ text: string,
53
+ bracketedPaste: boolean,
54
+ ): readonly SessionRuntimeTerminalInput[] {
52
55
  if (text.length === 0) return [];
53
56
  if (text.includes("\0")) throw new TypeError("terminal paste must not contain NUL");
54
- const framed = `\u001b[200~${text}\u001b[201~`;
57
+ const framed = bracketedPaste ? `\u001b[200~${text}\u001b[201~` : text;
55
58
  const inputs: SessionRuntimeTerminalInput[] = [];
56
59
  for (let offset = 0; offset < framed.length; ) {
57
60
  let end = Math.min(offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS, framed.length);
@@ -25,7 +25,7 @@ export function fleetConnectionMessage(status: FleetConnectionStatus): string {
25
25
  case "invalid-target":
26
26
  return "Check the SSH alias in this machine's settings.";
27
27
  case "incompatible":
28
- return "Incompatible daemon protocol. Update the remote daemon, then retry.";
28
+ return "App and remote daemon protocols are incompatible. Check that this app and tmux-ide on the remote machine are up to date, then retry.";
29
29
  case "invalid-descriptor":
30
30
  return "Remote daemon discovery returned an invalid response. Check the installation.";
31
31
  case "daemon-missing":
@@ -0,0 +1,48 @@
1
+ /** Read only a fixed refusal code; never retain server messages or private context. */
2
+ export async function readPaneCreationRefusal(
3
+ response: Response,
4
+ signal: AbortSignal,
5
+ ): Promise<"harness_unavailable" | undefined> {
6
+ if (response.status !== 409 || !response.body) return undefined;
7
+ const reader = response.body.getReader();
8
+ const chunks: Uint8Array[] = [];
9
+ let length = 0;
10
+ try {
11
+ for (;;) {
12
+ signal.throwIfAborted();
13
+ const { done, value } = await reader.read();
14
+ if (done) break;
15
+ length += value.byteLength;
16
+ if (length > 1024) return undefined;
17
+ chunks.push(value);
18
+ }
19
+ signal.throwIfAborted();
20
+ const bytes = new Uint8Array(length);
21
+ let offset = 0;
22
+ for (const chunk of chunks) {
23
+ bytes.set(chunk, offset);
24
+ offset += chunk.byteLength;
25
+ }
26
+ const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
27
+ if (
28
+ !value ||
29
+ typeof value !== "object" ||
30
+ Object.keys(value).length !== 1 ||
31
+ !("error" in value)
32
+ )
33
+ return undefined;
34
+ const error = value.error;
35
+ return error &&
36
+ typeof error === "object" &&
37
+ Object.keys(error).length === 1 &&
38
+ "code" in error &&
39
+ error.code === "harness_unavailable"
40
+ ? "harness_unavailable"
41
+ : undefined;
42
+ } catch {
43
+ return undefined;
44
+ } finally {
45
+ await reader.cancel().catch(() => undefined);
46
+ reader.releaseLock();
47
+ }
48
+ }
@@ -33,6 +33,9 @@ import {
33
33
  type TerminalDeliveryVisibility,
34
34
  type WorkspaceMultiplexerMutationResult,
35
35
  type WindowLinkTopology,
36
+ TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS,
37
+ type TerminalGraphicsBlob,
38
+ type TerminalGraphicsState,
36
39
  } from "@tmux-ide/contracts";
37
40
  import {
38
41
  calibratePaneStreamClocks,
@@ -270,6 +273,14 @@ export interface OpenPaneStreamClientOptions {
270
273
  readonly onClockCalibrationOutcome?: (outcome: PaneStreamClockCalibrationOutcome) => void;
271
274
  readonly diagnosticNextTurn?: (callback: () => void) => () => void;
272
275
  readonly onLayout?: (frame: Extract<PaneStreamServerFrame, { type: "layout" }>) => void;
276
+ /** Kitty graphics state; only sent when the offer carried the graphics capability. */
277
+ readonly onTerminalGraphics?: (pane: string, state: TerminalGraphicsState) => void;
278
+ /** One chunk of requested image bytes (`blob.offset` into `blob.total`). */
279
+ readonly onTerminalGraphicsBlob?: (
280
+ pane: string,
281
+ blob: TerminalGraphicsBlob,
282
+ bytes: Uint8Array,
283
+ ) => void;
273
284
  readonly onLayoutSnapshot?: (
274
285
  frame: Extract<PaneStreamServerFrame, { type: "layout-snapshot" }>,
275
286
  ) => void;
@@ -313,6 +324,8 @@ export type ConnectIssuedPaneStreamRuntimeClientOptions = Pick<
313
324
  | "onClockCalibrationOutcome"
314
325
  | "diagnosticNextTurn"
315
326
  | "onLayout"
327
+ | "onTerminalGraphics"
328
+ | "onTerminalGraphicsBlob"
316
329
  | "onLayoutSnapshot"
317
330
  | "onInputAck"
318
331
  | "onCausalCellProof"
@@ -369,6 +382,8 @@ export interface PaneStreamRuntimeClient {
369
382
  operationId: string,
370
383
  intent: SessionRuntimeSemanticIntent,
371
384
  ): Promise<WorkspaceMultiplexerMutationResult | null>;
385
+ /** Pull image bytes announced in graphics state; the daemon sends each once per request. */
386
+ requestTerminalGraphics(pane: string, digests: readonly string[]): void;
372
387
  close(): void;
373
388
  }
374
389
 
@@ -1590,6 +1605,14 @@ export async function connectIssuedPaneStreamRuntimeClient(
1590
1605
  nack: (nack) => send({ type: "terminal-delivery-nack", nack }),
1591
1606
  setVisibility: (address, visibility) =>
1592
1607
  send({ type: "terminal-delivery-visibility", ...address, visibility }),
1608
+ requestTerminalGraphics: (pane, digests) => {
1609
+ for (let start = 0; start < digests.length; start += TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS)
1610
+ send({
1611
+ type: "terminal-graphics-request",
1612
+ pane,
1613
+ digests: digests.slice(start, start + TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS),
1614
+ });
1615
+ },
1593
1616
  submitIntent: (operationId, intent) =>
1594
1617
  new Promise<WorkspaceMultiplexerMutationResult | null>((resolve, reject) => {
1595
1618
  const timer = createRuntimeTimer(() => {
@@ -1651,6 +1674,12 @@ function routeFrame(
1651
1674
  case "layout":
1652
1675
  options.onLayout?.(frame);
1653
1676
  return;
1677
+ case "terminal-graphics":
1678
+ options.onTerminalGraphics?.(frame.pane, frame.state);
1679
+ return;
1680
+ case "terminal-graphics-blob":
1681
+ options.onTerminalGraphicsBlob?.(frame.pane, frame.blob, decodeBase64(frame.data));
1682
+ return;
1654
1683
  case "layout-snapshot":
1655
1684
  options.onLayoutSnapshot?.(frame);
1656
1685
  return;