tmux-ide 2.9.4 → 2.9.5

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 +4354 -3376
  2. package/package.json +7 -6
  3. package/packages/contracts/src/session-runtime.ts +2 -0
  4. package/packages/contracts/src/terminal-delivery.ts +4 -0
  5. package/packages/core/src/terminal-compact-capability.ts +31 -1
  6. package/packages/core/src/terminal-delivery.ts +234 -32
  7. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  8. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  9. package/packages/core/src/terminal-replica.ts +43 -21
  10. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  11. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  12. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  13. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  14. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  15. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  18. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  19. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  20. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  26. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  27. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  28. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  29. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  30. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  31. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  32. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  33. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  34. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  35. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  36. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  37. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  39. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  40. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  41. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  42. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  43. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  44. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  45. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  46. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  47. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  48. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  49. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  50. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  51. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  52. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  53. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  54. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  55. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  56. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  57. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  58. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  59. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  60. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  61. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  62. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  63. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  64. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  65. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  66. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  67. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  68. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  69. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  70. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  71. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  72. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  73. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  74. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  75. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  76. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  77. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  78. package/packages/daemon-client/src/workspace-client.ts +78 -5
  79. package/packages/tmux-bridge/src/index.ts +1 -0
  80. package/packages/tmux-bridge/src/runner.ts +25 -1
@@ -0,0 +1,209 @@
1
+ /** Tiny independent visual oracle for the SSH fixture; no production decoder. */
2
+ export type VisualCell = { text: string; width: number; fg: string; bg: string; bold: boolean };
3
+ export type VisualFrame = {
4
+ cells: VisualCell[][];
5
+ cursor: { x: number; y: number; visible: boolean };
6
+ };
7
+ export type CompletedFrame = {
8
+ cols: number;
9
+ rows: number;
10
+ char: number[];
11
+ fg: number[];
12
+ bg: number[];
13
+ attributes: number[];
14
+ text: string;
15
+ cursor: { x: number; y: number; visible: boolean };
16
+ };
17
+ function check(condition: unknown, message: string): asserts condition {
18
+ if (!condition) throw Error(message);
19
+ }
20
+ const blank = (): VisualCell => ({ text: "", width: 1, fg: "ffffff", bg: "000000", bold: false });
21
+ export function literalStyledFrame(marker: string, cols: 24 | 40 = 40): VisualFrame {
22
+ check(cols === 24 || cols === 40, "literal geometry");
23
+ check(marker.length <= cols, "marker width");
24
+ const cells = Array.from({ length: 8 }, () => Array.from({ length: cols }, blank));
25
+ [...marker].forEach((text, x) => (cells[0]![x] = { ...blank(), text }));
26
+ const initial = marker === "BEFORE_SSH";
27
+ const glyphs = initial ? ["X", "界", "", "é", "Z"] : ["Y", "q", "", "é", "R"];
28
+ glyphs.forEach(
29
+ (text, x) =>
30
+ (cells[1]![x] = {
31
+ text,
32
+ width: initial ? (x === 1 ? 2 : x === 2 ? 0 : 1) : 1,
33
+ fg: text ? "123456" : "ffffff",
34
+ bg: "214365",
35
+ bold: !!text,
36
+ }),
37
+ );
38
+ for (let x = 0; x < cols; x++)
39
+ cells[3]![x] = { ...blank(), text: x < 2 ? String(x + 1) : "", bg: "445566" };
40
+ return { cells, cursor: { x: 4, y: 2, visible: true } };
41
+ }
42
+ export function styledBytes(marker: string): string {
43
+ return `\x1b[0m\x1b[2J\x1b[H${marker}\x1b[2;1H\x1b[1;38;2;18;52;86;48;2;33;67;101m${marker === "BEFORE_SSH" ? "X界éZ" : "Yq éR"}\x1b[0m\x1b[4;1H\x1b[48;2;68;85;102m123456\x1b[4;3H\x1b[K\x1b[0m\x1b[3;5H\x1b]2;${marker}\x07`;
44
+ }
45
+ export function readCompletedFrame(raw: CompletedFrame): VisualFrame {
46
+ check((raw.cols === 24 || raw.cols === 40) && raw.rows === 8, "geometry");
47
+ const size = raw.cols * raw.rows;
48
+ check(
49
+ raw.char.length === size &&
50
+ raw.attributes.length === size &&
51
+ raw.fg.length === size * 4 &&
52
+ raw.bg.length === size * 4,
53
+ "buffer lengths",
54
+ );
55
+ const lines = raw.text.replace(/\n$/u, "").split("\n");
56
+ check(lines.length === 8, "line count");
57
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
58
+ const rgb = (a: number[], i: number) => {
59
+ const c = a.slice(i * 4, i * 4 + 4);
60
+ check(
61
+ c.length === 4 && c.every((v) => Number.isInteger(v) && v >= 0 && v <= 255) && c[3] === 255,
62
+ "RGBA",
63
+ );
64
+ return c
65
+ .slice(0, 3)
66
+ .map((v) => v.toString(16).padStart(2, "0"))
67
+ .join("");
68
+ };
69
+ const continuation = (n: number) => n === 0 || n >>> 30 === 3;
70
+ const cells = lines.map((line, y) => {
71
+ const text = [...segmenter.segment(line)].map((s) => s.segment);
72
+ let pos = 0;
73
+ const row = Array.from({ length: raw.cols }, (_, x) => {
74
+ const i = y * raw.cols + x,
75
+ cp = raw.char[i]!;
76
+ const cont =
77
+ continuation(cp) && x > 0 && !continuation(raw.char[i - 1]!) && raw.char[i - 1] !== 32;
78
+ let glyph = "";
79
+ if (cont) {
80
+ if (cp === 0) {
81
+ check(text[pos] === " ", "direct continuation text");
82
+ pos++;
83
+ }
84
+ } else {
85
+ glyph = text[pos++] ?? "";
86
+ check(/^(?:[\x20-\x7e]|界|é)$/u.test(glyph), "unsupported grapheme");
87
+ }
88
+ const width = cont ? 0 : x < raw.cols - 1 && continuation(raw.char[i + 1]!) ? 2 : 1;
89
+ const kind = cp >>> 30;
90
+ if (cont) {
91
+ const prev = raw.char[i - 1]!;
92
+ check(
93
+ prev === 0x754c || (prev >>> 30 === 2 && ((prev >>> 28) & 3) === 1),
94
+ "continuation owner",
95
+ );
96
+ if (kind === 3)
97
+ check(
98
+ ((cp >>> 26) & 3) === 1 &&
99
+ ((cp >>> 28) & 3) === 0 &&
100
+ (cp & 0x03ffffff) === (prev & 0x03ffffff),
101
+ "interned continuation",
102
+ );
103
+ } else if (kind === 0) {
104
+ check(
105
+ cp !== 0 && glyph.codePointAt(0) === cp && [...glyph].length === 1,
106
+ "direct codepoint mismatch",
107
+ );
108
+ check(width === (glyph === "界" ? 2 : 1), "direct width");
109
+ } else if (kind === 2) {
110
+ check(
111
+ glyph === "é" && ((cp >>> 26) & 3) === 0 && ((cp >>> 28) & 3) === 0 && width === 1,
112
+ "interned grapheme shape",
113
+ );
114
+ } else check(false, "unsupported raw char flags");
115
+ const visible = glyph !== " " && glyph !== "";
116
+ check((raw.attributes[i]! & ~1) === 0, "unsupported attributes");
117
+ return {
118
+ text: visible ? glyph : "",
119
+ width,
120
+ fg: visible ? rgb(raw.fg, i) : "ffffff",
121
+ bg: rgb(raw.bg, i),
122
+ bold: visible && (raw.attributes[i]! & 1) !== 0,
123
+ };
124
+ });
125
+ check(pos === text.length, "unconsumed text");
126
+ return row;
127
+ });
128
+ return {
129
+ cells,
130
+ cursor: { x: raw.cursor.x - 1, y: raw.cursor.y - 1, visible: raw.cursor.visible },
131
+ };
132
+ }
133
+ export function compareVisual(actual: VisualFrame, expected: VisualFrame): void {
134
+ check(JSON.stringify(actual.cursor) === JSON.stringify(expected.cursor), "cursor mismatch");
135
+ check(actual.cells.length === expected.cells.length, "rows mismatch");
136
+ actual.cells.forEach((row, y) => {
137
+ check(row.length === expected.cells[y]!.length, "cols mismatch");
138
+ row.forEach((cell, x) => {
139
+ for (const key of ["text", "width", "fg", "bg", "bold"] as const)
140
+ check(cell[key] === expected.cells[y]![x]![key], `${y}:${x}.${key} mismatch`);
141
+ });
142
+ });
143
+ }
144
+
145
+ export function nativeVisualFrame(frame: {
146
+ cols: number;
147
+ rows: number;
148
+ cursor: number[];
149
+ cells: {
150
+ text: string;
151
+ width: number;
152
+ foreground: string;
153
+ background: string;
154
+ attributes: string[];
155
+ }[][];
156
+ }): VisualFrame {
157
+ check((frame.cols === 24 || frame.cols === 40) && frame.rows === 8, "native geometry");
158
+ const color = (value: string, defaultValue: string) => {
159
+ if (value === "default") return defaultValue;
160
+ check(/^rgb:[0-9a-f]{6}$/u.test(value), "native color vocabulary");
161
+ return value.slice(4);
162
+ };
163
+ return {
164
+ cells: frame.cells.map((row) =>
165
+ row.map((c) => {
166
+ const visible = c.text !== "" && c.text !== " ";
167
+ check(
168
+ c.attributes.every((a) => a === "bold"),
169
+ "native attribute vocabulary",
170
+ );
171
+ return {
172
+ text: visible ? c.text : "",
173
+ width: c.width,
174
+ fg: visible ? color(c.foreground, "ffffff") : "ffffff",
175
+ bg: color(c.background, "000000"),
176
+ bold: visible && c.attributes.includes("bold"),
177
+ };
178
+ }),
179
+ ),
180
+ cursor: { x: frame.cursor[0]!, y: frame.cursor[1]!, visible: true },
181
+ };
182
+ }
183
+
184
+ /** Declared workspace chrome: one tab row plus one title row, then eight native rows. */
185
+ export function cropWorkspaceCompletedFrame(raw: CompletedFrame): CompletedFrame {
186
+ check((raw.cols === 24 || raw.cols === 40) && raw.rows === 10, "workspace geometry");
187
+ const size = raw.cols * raw.rows;
188
+ check(
189
+ raw.char.length === size &&
190
+ raw.attributes.length === size &&
191
+ raw.fg.length === size * 4 &&
192
+ raw.bg.length === size * 4,
193
+ "workspace buffer lengths",
194
+ );
195
+ const lines = raw.text.replace(/\n$/u, "").split("\n");
196
+ check(lines.length === 10, "workspace line count");
197
+ check(raw.cursor.y >= 3 && raw.cursor.y <= 10, "workspace cursor outside content");
198
+ const offset = 2 * raw.cols;
199
+ return {
200
+ cols: raw.cols,
201
+ rows: 8,
202
+ char: raw.char.slice(offset),
203
+ attributes: raw.attributes.slice(offset),
204
+ fg: raw.fg.slice(offset * 4),
205
+ bg: raw.bg.slice(offset * 4),
206
+ text: lines.slice(2).join("\n"),
207
+ cursor: { ...raw.cursor, y: raw.cursor.y - 2 },
208
+ };
209
+ }
@@ -29,6 +29,7 @@ export interface TerminalFastLaneSourcePort {
29
29
  update: CanonicalTerminalReplicaUpdate,
30
30
  metadata?: TerminalReplicaDeliveryMetadata,
31
31
  ) => void,
32
+ onUnavailable?: (message: string) => void,
32
33
  ): () => void;
33
34
  }
34
35
 
@@ -40,6 +41,7 @@ export interface CanonicalTerminalSubscriptionPort {
40
41
  update: CanonicalTerminalReplicaUpdate,
41
42
  metadata?: TerminalReplicaDeliveryMetadata,
42
43
  ) => void,
44
+ onUnavailable?: (message: string) => void,
43
45
  ) => () => void;
44
46
  }
45
47
 
@@ -173,7 +175,9 @@ export interface TerminalFastLane {
173
175
  subscribePane(
174
176
  semanticPaneId: string,
175
177
  listener: (publication: TerminalFastLanePublication) => void,
178
+ onUnavailable?: (message: string) => void,
176
179
  ): () => void;
180
+ paneUnavailable(semanticPaneId: string): string | null;
177
181
  paneState(semanticPaneId: string): TerminalReplicaState | null;
178
182
  paneLastAcceptedUpdateType(semanticPaneId: string): CanonicalTerminalReplicaUpdate["type"] | null;
179
183
  /** Request one coalesced generation repair for a pane whose retained state vanished. */
@@ -193,6 +197,8 @@ interface PaneInterest {
193
197
  readonly semanticPaneId: string;
194
198
  readonly listeners: Set<(publication: TerminalFastLanePublication) => void>;
195
199
  state: TerminalReplicaState | null;
200
+ unavailable: string | null;
201
+ readonly unavailableListeners: Set<(message: string) => void>;
196
202
  lastAcceptedUpdateType: CanonicalTerminalReplicaUpdate["type"] | null;
197
203
  repairPending: boolean;
198
204
  release: (() => void) | null;
@@ -384,7 +390,7 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
384
390
  update: CanonicalTerminalReplicaUpdate,
385
391
  metadata?: TerminalReplicaDeliveryMetadata,
386
392
  ): void => {
387
- if (disposed) return;
393
+ if (disposed || interest.unavailable) return;
388
394
  const traceEnabled = Boolean(metadata?.performanceTraceId && options.onTraceStage);
389
395
  const identity = traceEnabled
390
396
  ? {
@@ -480,6 +486,25 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
480
486
  (update, metadata) => {
481
487
  if (!disposed && epoch === generationEpoch) accept(interest, update, metadata);
482
488
  },
489
+ (message) => {
490
+ if (
491
+ disposed ||
492
+ epoch !== generationEpoch ||
493
+ panes.get(interest.semanticPaneId) !== interest
494
+ )
495
+ return;
496
+ interest.unavailable = message;
497
+ interest.state = null;
498
+ interest.repairPending = false;
499
+ for (const listener of [...interest.unavailableListeners]) {
500
+ try {
501
+ listener(message);
502
+ } catch {
503
+ /* Availability observers cannot interrupt sibling delivery. */
504
+ }
505
+ }
506
+ drainInput();
507
+ },
483
508
  );
484
509
  };
485
510
 
@@ -506,6 +531,8 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
506
531
  semanticPaneId,
507
532
  listeners: new Set(),
508
533
  state: null,
534
+ unavailable: null,
535
+ unavailableListeners: new Set<(message: string) => void>(),
509
536
  lastAcceptedUpdateType: null,
510
537
  repairPending: false,
511
538
  release: null,
@@ -587,6 +614,10 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
587
614
  rejectInput(input, "retired");
588
615
  continue;
589
616
  }
617
+ if (panes.get(input.semanticPaneId)?.unavailable) {
618
+ rejectInput(input, "authority-lost");
619
+ continue;
620
+ }
590
621
  dispatchInput(input, epoch);
591
622
  }
592
623
  return;
@@ -635,6 +666,7 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
635
666
  interest.release?.();
636
667
  interest.release = null;
637
668
  interest.state = null;
669
+ interest.unavailable = null;
638
670
  interest.lastAcceptedUpdateType = null;
639
671
  interest.repairPending = false;
640
672
  open(interest);
@@ -669,7 +701,7 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
669
701
  }
670
702
  };
671
703
  },
672
- subscribePane(semanticPaneId, listener) {
704
+ subscribePane(semanticPaneId, listener, onUnavailable) {
673
705
  if (disposed) return () => undefined;
674
706
  if (retainedPaneIds !== null && !retainedPaneIds.has(semanticPaneId)) return () => undefined;
675
707
  const interest =
@@ -678,18 +710,31 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
678
710
  semanticPaneId,
679
711
  listeners: new Set(),
680
712
  state: null,
713
+ unavailable: null,
714
+ unavailableListeners: new Set<(message: string) => void>(),
681
715
  lastAcceptedUpdateType: null,
682
716
  repairPending: false,
683
717
  release: null,
684
718
  } satisfies PaneInterest);
685
719
  panes.set(semanticPaneId, interest);
686
720
  interest.listeners.add(listener);
721
+ if (onUnavailable) {
722
+ interest.unavailableListeners.add(onUnavailable);
723
+ if (interest.unavailable) {
724
+ try {
725
+ onUnavailable(interest.unavailable);
726
+ } catch {
727
+ /* Availability observers cannot interrupt sibling delivery. */
728
+ }
729
+ }
730
+ }
687
731
  open(interest);
688
732
  let active = true;
689
733
  return () => {
690
734
  if (!active) return;
691
735
  active = false;
692
736
  interest.listeners.delete(listener);
737
+ if (onUnavailable) interest.unavailableListeners.delete(onUnavailable);
693
738
  // Renderer visibility is not terminal-replica lifetime. A pane surface
694
739
  // unmounts when its tmux window is not selected, but output can keep
695
740
  // arriving while hidden. Retain the one canonical pane replica and its
@@ -699,19 +744,22 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
699
744
  // bounded by the runtime inventory.
700
745
  };
701
746
  },
747
+ paneUnavailable: (semanticPaneId) => panes.get(semanticPaneId)?.unavailable ?? null,
702
748
  paneState: (semanticPaneId) => panes.get(semanticPaneId)?.state ?? null,
703
749
  paneLastAcceptedUpdateType: (semanticPaneId) =>
704
750
  panes.get(semanticPaneId)?.lastAcceptedUpdateType ?? null,
705
751
  requestRepair(semanticPaneId, reason) {
706
752
  if (disposed) return false;
707
753
  const interest = panes.get(semanticPaneId);
708
- if (!interest || !isRetainedOrStaged(semanticPaneId)) return false;
754
+ if (!interest || interest.unavailable || !isRetainedOrStaged(semanticPaneId)) return false;
709
755
  const revision = interest.state?.revision ?? 0;
710
756
  queueRepair(interest, reason, revision, revision);
711
757
  return interest.repairPending;
712
758
  },
713
759
  sendInput(semanticPaneId, rawInput, performanceTraceId, causalProbe) {
714
760
  if (disposed) return Promise.resolve({ status: "rejected", reason: "disposed" });
761
+ if (panes.get(semanticPaneId)?.unavailable)
762
+ return Promise.resolve({ status: "rejected", reason: "authority-lost" });
715
763
  const parsed = SessionRuntimeTerminalInputSchemaZ.safeParse(rawInput);
716
764
  if (!parsed.success) {
717
765
  mutableCounters.inputRejected += 1;
@@ -809,13 +857,14 @@ export function createWorkspaceClientTerminalSource(
809
857
  client: CanonicalTerminalSubscriptionPort,
810
858
  ): TerminalFastLaneSourcePort {
811
859
  return {
812
- subscribe(address, listener) {
860
+ subscribe(address, listener, onUnavailable) {
813
861
  return client.subscribeTerminal(
814
862
  {
815
863
  workspaceName: address.workspaceName,
816
864
  semanticPaneId: address.semanticPaneId,
817
865
  },
818
866
  listener,
867
+ onUnavailable,
819
868
  );
820
869
  },
821
870
  };
@@ -263,6 +263,7 @@ export interface WorkspaceClient<
263
263
  update: TerminalReplicaUpdate<TerminalSnapshot, TerminalPatch, TerminalTombstone>,
264
264
  metadata?: TerminalReplicaDeliveryMetadata,
265
265
  ) => void,
266
+ onUnavailable?: (message: string) => void,
266
267
  ): () => void;
267
268
  requestTerminalRepair(
268
269
  target: TerminalReplicaAddress,
@@ -75,6 +75,9 @@ interface TerminalInterest<Snapshot, Patch, Tombstone> {
75
75
  ReturnType<WorkspaceClientRuntimePort<Snapshot, Patch, Tombstone>["subscribeTerminal"]>
76
76
  > | null;
77
77
  unsubscribeUpdate: (() => void) | null;
78
+ unsubscribeUnavailable: (() => void) | null;
79
+ unavailable: string | null;
80
+ readonly unavailableListeners: Set<(message: string) => void>;
78
81
  opening: boolean;
79
82
  }
80
83
 
@@ -89,6 +92,8 @@ interface PreparedTerminalBinding<Snapshot, Patch, Tombstone> {
89
92
  ReturnType<WorkspaceClientRuntimePort<Snapshot, Patch, Tombstone>["subscribeTerminal"]>
90
93
  >;
91
94
  readonly unsubscribeUpdate: () => void;
95
+ readonly unsubscribeUnavailable: () => void;
96
+ unavailable: string | null;
92
97
  readonly pending: Array<{
93
98
  readonly update: TerminalReplicaUpdate<Snapshot, Patch, Tombstone>;
94
99
  readonly metadata?: TerminalReplicaDeliveryMetadata;
@@ -518,6 +523,9 @@ export function createWorkspaceClient<
518
523
  interest: TerminalInterest<TerminalSnapshot, TerminalPatch, TerminalTombstone>,
519
524
  ): Promise<void> => {
520
525
  interest.opening = false;
526
+ interest.unsubscribeUnavailable?.();
527
+ interest.unsubscribeUnavailable = null;
528
+ interest.unavailable = null;
521
529
  interest.unsubscribeUpdate?.();
522
530
  interest.unsubscribeUpdate = null;
523
531
  const subscription = interest.subscription;
@@ -534,6 +542,7 @@ export function createWorkspaceClient<
534
542
  const closing: Promise<void>[] = [];
535
543
  for (const binding of prepared.bindings.values()) {
536
544
  binding.unsubscribeUpdate();
545
+ binding.unsubscribeUnavailable();
537
546
  closing.push(closeSubscription(binding.subscription));
538
547
  }
539
548
  prepared.bindings.clear();
@@ -562,6 +571,19 @@ export function createWorkspaceClient<
562
571
  }
563
572
  }
564
573
  };
574
+ const publishUnavailable = (
575
+ interest: TerminalInterest<TerminalSnapshot, TerminalPatch, TerminalTombstone>,
576
+ message: string,
577
+ ): void => {
578
+ interest.unavailable = message;
579
+ for (const listener of [...interest.unavailableListeners]) {
580
+ try {
581
+ listener(message);
582
+ } catch {
583
+ /* One pane observer cannot interrupt siblings. */
584
+ }
585
+ }
586
+ };
565
587
  const openTerminal = (
566
588
  interest: TerminalInterest<TerminalSnapshot, TerminalPatch, TerminalTombstone>,
567
589
  ): void => {
@@ -591,8 +613,19 @@ export function createWorkspaceClient<
591
613
  return closeSubscription(subscription);
592
614
  }
593
615
  interest.subscription = subscription;
616
+ interest.unsubscribeUnavailable =
617
+ subscription.onUnavailable?.((message) => {
618
+ if (
619
+ disposed ||
620
+ generation !== expectedGeneration ||
621
+ runtime !== expectedRuntime ||
622
+ interest.subscription !== subscription
623
+ )
624
+ return;
625
+ publishUnavailable(interest, message);
626
+ }) ?? null;
594
627
  interest.unsubscribeUpdate = subscription.onUpdate((update, metadata) => {
595
- if (generation !== expectedGeneration) return;
628
+ if (generation !== expectedGeneration || interest.unavailable) return;
596
629
  publishTerminalUpdate(interest, expectedRuntime, update, metadata);
597
630
  });
598
631
  })
@@ -702,12 +735,17 @@ export function createWorkspaceClient<
702
735
  for (const binding of bindings) {
703
736
  binding.interest.subscription = binding.subscription;
704
737
  binding.interest.unsubscribeUpdate = binding.unsubscribeUpdate;
738
+ binding.interest.unsubscribeUnavailable = binding.unsubscribeUnavailable;
705
739
  binding.interest.opening = false;
706
740
  }
707
741
  prepared.bindings.clear();
708
742
  owner.prepared = null;
709
743
  prepared.activated = true;
710
744
  for (const binding of bindings) {
745
+ if (binding.unavailable) {
746
+ publishUnavailable(binding.interest, binding.unavailable);
747
+ binding.pending.length = 0;
748
+ }
711
749
  for (const delivery of binding.pending) {
712
750
  publishTerminalUpdate(
713
751
  binding.interest,
@@ -778,8 +816,9 @@ export function createWorkspaceClient<
778
816
  TerminalPatch,
779
817
  TerminalTombstone
780
818
  >["pending"] = [];
819
+ let subscriptionUnavailable = false;
781
820
  const unsubscribeUpdate = subscription.onUpdate((update, metadata) => {
782
- if (prepared.closed) return;
821
+ if (prepared.closed || subscriptionUnavailable) return;
783
822
  if (prepared.activated) {
784
823
  publishTerminalUpdate(interest, nextRuntime, update, metadata);
785
824
  return;
@@ -821,12 +860,31 @@ export function createWorkspaceClient<
821
860
  await closeSubscription(subscription);
822
861
  return;
823
862
  }
824
- prepared.bindings.set(key, {
863
+ const binding = {
825
864
  interest,
826
865
  subscription,
827
866
  unsubscribeUpdate,
867
+ unsubscribeUnavailable: () => {},
868
+ unavailable: null as string | null,
828
869
  pending,
829
- });
870
+ };
871
+ binding.unsubscribeUnavailable =
872
+ subscription.onUnavailable?.((message) => {
873
+ if (
874
+ prepared.closed ||
875
+ disposed ||
876
+ generation !== owner.clientGeneration ||
877
+ (activeRuntimeOwner !== owner && candidateRuntimeOwner !== owner) ||
878
+ terminals.get(key) !== interest
879
+ )
880
+ return;
881
+ subscriptionUnavailable = true;
882
+ binding.unavailable = message;
883
+ binding.pending.length = 0;
884
+ if (prepared.activated && runtime === nextRuntime)
885
+ publishUnavailable(interest, message);
886
+ }) ?? (() => {});
887
+ prepared.bindings.set(key, binding);
830
888
  }),
831
889
  ];
832
890
  });
@@ -1327,7 +1385,7 @@ export function createWorkspaceClient<
1327
1385
  throw error;
1328
1386
  }
1329
1387
  },
1330
- subscribeTerminal(nextTarget, listener) {
1388
+ subscribeTerminal(nextTarget, listener, onUnavailable) {
1331
1389
  if (disposed) return () => undefined;
1332
1390
  const key = terminalKey(nextTarget);
1333
1391
  const interest =
@@ -1337,19 +1395,34 @@ export function createWorkspaceClient<
1337
1395
  listeners: new Set(),
1338
1396
  subscription: null,
1339
1397
  unsubscribeUpdate: null,
1398
+ unsubscribeUnavailable: null,
1399
+ unavailable: null,
1400
+ unavailableListeners: new Set<(message: string) => void>(),
1340
1401
  opening: false,
1341
1402
  } satisfies TerminalInterest<TerminalSnapshot, TerminalPatch, TerminalTombstone>);
1342
1403
  terminals.set(key, interest);
1343
1404
  interest.listeners.add(listener);
1405
+ if (onUnavailable) {
1406
+ interest.unavailableListeners.add(onUnavailable);
1407
+ if (interest.unavailable) {
1408
+ try {
1409
+ onUnavailable(interest.unavailable);
1410
+ } catch {
1411
+ /* Availability observers cannot interrupt sibling delivery. */
1412
+ }
1413
+ }
1414
+ }
1344
1415
  openTerminal(interest);
1345
1416
  return () => {
1346
1417
  interest.listeners.delete(listener);
1418
+ if (onUnavailable) interest.unavailableListeners.delete(onUnavailable);
1347
1419
  if (interest.listeners.size > 0) return;
1348
1420
  for (const owner of [activeRuntimeOwner, candidateRuntimeOwner]) {
1349
1421
  const prepared = owner?.prepared?.bindings.get(key);
1350
1422
  if (!prepared) continue;
1351
1423
  owner!.prepared!.bindings.delete(key);
1352
1424
  prepared.unsubscribeUpdate();
1425
+ prepared.unsubscribeUnavailable();
1353
1426
  void closeSubscription(prepared.subscription);
1354
1427
  }
1355
1428
  terminals.delete(key);
@@ -1,6 +1,7 @@
1
1
  export { TmuxError } from "./errors.ts";
2
2
  export {
3
3
  runTmux,
4
+ configureTmuxClientResolver,
4
5
  runTmuxBinary,
5
6
  runTmuxBinaryAsync,
6
7
  classifyTmuxError,
@@ -58,11 +58,35 @@ export function _getSpawner(): Spawner {
58
58
  return _spawner;
59
59
  }
60
60
 
61
+ type TmuxClientResolver = (
62
+ environment: NodeJS.ProcessEnv,
63
+ cwd?: string | URL,
64
+ ) => {
65
+ executable: string;
66
+ environment: NodeJS.ProcessEnv;
67
+ };
68
+
69
+ let clientResolver: TmuxClientResolver | undefined;
70
+
71
+ /** Configure ordinary clients at the host composition root; pinned runners are unaffected. */
72
+ export function configureTmuxClientResolver(resolver: TmuxClientResolver): () => void {
73
+ const previous = clientResolver;
74
+ clientResolver = resolver;
75
+ return () => {
76
+ clientResolver = previous;
77
+ };
78
+ }
79
+
61
80
  export function runTmux(args: string[], options: ExecFileSyncOptions = {}): string | Buffer {
62
81
  if ((options.env ?? process.env).TMUX_IDE_RUNTIME_MODE === "development") {
63
82
  throw new Error("Development tmux operations require an explicitly pinned namespace runner");
64
83
  }
65
- return runTmuxBinary("tmux", args, options);
84
+ const resolved = clientResolver?.(options.env ?? process.env, options.cwd);
85
+ return runTmuxBinary(
86
+ resolved?.executable ?? "tmux",
87
+ args,
88
+ resolved ? { ...options, env: resolved.environment } : options,
89
+ );
66
90
  }
67
91
 
68
92
  /**