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
@@ -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 {
@@ -451,6 +458,11 @@ export class SessionRuntimeTransportBinding {
451
458
  }
452
459
  }
453
460
 
461
+ setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
462
+ this.#assertOpen();
463
+ this.#shared.consumer.setCellPixels?.(width, height, precise);
464
+ }
465
+
454
466
  fitViewport(
455
467
  expectedLease: SessionRuntimeAuthorityLease,
456
468
  cols: number,
@@ -695,20 +707,37 @@ export class SessionRuntimeTransportBinder {
695
707
  }
696
708
 
697
709
  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);
710
+ if (target.lease && holdsCurrentLease(target)) return;
711
+ target.lease = null;
712
+ const controllerClientId = target.consumer.controllerSnapshot().controllerClientId;
713
+ const current =
714
+ controllerClientId === null || controllerClientId === target.consumer.clientId
715
+ ? undefined
716
+ : [...this.#clients.values()].find(
717
+ (candidate) =>
718
+ candidate !== target &&
719
+ candidate.consumer.session === target.consumer.session &&
720
+ candidate.consumer.clientId === controllerClientId,
721
+ );
722
+ if (!current) {
723
+ // Free, already ours, or held by a principal outside this binder (for
724
+ // example the command-center owner route): plain acquisition decides.
725
+ target.lease = target.consumer.acquireController();
709
726
  return;
710
727
  }
711
- target.lease = target.consumer.acquireController();
728
+ // A transport-bound holder whose binding lost its executable lease can
729
+ // never type again, yet it would keep every other viewer locked out. Re-
730
+ // derive its exact current lease so the handoff below reclaims it; a live
731
+ // holder simply hands off as before. Either way only one client can type.
732
+ const previous =
733
+ current.lease && holdsCurrentLease(current)
734
+ ? current.lease
735
+ : current.consumer.acquireController();
736
+ const handedOff = current.consumer.handoffController(previous, target.consumer.clientId);
737
+ // Drop the holder's lease only after the runtime accepted the handoff; a
738
+ // refusal (e.g. during topology verification) must leave it executable.
739
+ current.lease = null;
740
+ target.lease = handedOff;
712
741
  }
713
742
 
714
743
  async release(
@@ -727,18 +756,23 @@ export class SessionRuntimeTransportBinder {
727
756
  }
728
757
  shared.refs -= 1;
729
758
  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);
759
+ try {
760
+ if (shared.lease) {
761
+ // A same-host passive session channel keeps the controller PRINCIPAL
762
+ // continuous across interactive pane retargeting. It contributes no
763
+ // source-pane grant and cannot type; the lease retires with the last
764
+ // host/session ref, while a passive-only host never acquires one.
765
+ const retired = shared.lease;
766
+ shared.lease = null;
767
+ shared.consumer.releaseController(retired);
768
+ }
769
+ } finally {
770
+ // A refused surrender must never strand the controller on a client with
771
+ // no transport: closing the consumer retires whatever it still owns.
772
+ for (const [key, candidate] of this.#clients) {
773
+ if (candidate === shared) this.#clients.delete(key);
774
+ }
775
+ await shared.consumer.close();
741
776
  }
742
- await shared.consumer.close();
743
777
  }
744
778
  }
@@ -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,14 @@ 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;
387
+ /** Renderer cell size in device pixels; only after state carried `cellPixels`. */
388
+ setTerminalCellPixels(
389
+ width: number,
390
+ height: number,
391
+ precise?: { width: number; height: number },
392
+ ): void;
372
393
  close(): void;
373
394
  }
374
395
 
@@ -1590,6 +1611,16 @@ export async function connectIssuedPaneStreamRuntimeClient(
1590
1611
  nack: (nack) => send({ type: "terminal-delivery-nack", nack }),
1591
1612
  setVisibility: (address, visibility) =>
1592
1613
  send({ type: "terminal-delivery-visibility", ...address, visibility }),
1614
+ setTerminalCellPixels: (width, height, precise) =>
1615
+ send({ type: "terminal-cell-pixels", width, height, ...(precise ? { precise } : {}) }),
1616
+ requestTerminalGraphics: (pane, digests) => {
1617
+ for (let start = 0; start < digests.length; start += TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS)
1618
+ send({
1619
+ type: "terminal-graphics-request",
1620
+ pane,
1621
+ digests: digests.slice(start, start + TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS),
1622
+ });
1623
+ },
1593
1624
  submitIntent: (operationId, intent) =>
1594
1625
  new Promise<WorkspaceMultiplexerMutationResult | null>((resolve, reject) => {
1595
1626
  const timer = createRuntimeTimer(() => {
@@ -1651,6 +1682,12 @@ function routeFrame(
1651
1682
  case "layout":
1652
1683
  options.onLayout?.(frame);
1653
1684
  return;
1685
+ case "terminal-graphics":
1686
+ options.onTerminalGraphics?.(frame.pane, frame.state);
1687
+ return;
1688
+ case "terminal-graphics-blob":
1689
+ options.onTerminalGraphicsBlob?.(frame.pane, frame.blob, decodeBase64(frame.data));
1690
+ return;
1654
1691
  case "layout-snapshot":
1655
1692
  options.onLayoutSnapshot?.(frame);
1656
1693
  return;
@@ -1,4 +1,9 @@
1
+ import { readPaneCreationRefusal } from "./pane-creation-refusal.ts";
1
2
  import {
3
+ HistoryAnchorRequestSchemaZ,
4
+ HistoryAnchorResponseSchemaZ,
5
+ type HistoryAnchorRequest,
6
+ TmuxServerDiagnosticsSchemaZ,
2
7
  TmuxAgentOpenRequestSchemaZ,
3
8
  TmuxAgentOpenResponseSchemaZ,
4
9
  type TmuxAgentOpenRequest,
@@ -49,6 +54,7 @@ export class TmuxServerClientError extends Error {
49
54
  constructor(
50
55
  readonly code: "disposed" | "scope-mismatch" | "request-failed",
51
56
  readonly status?: number,
57
+ readonly refusalCode?: "harness_unavailable",
52
58
  ) {
53
59
  super(`tmux server client ${code}`);
54
60
  this.name = "TmuxServerClientError";
@@ -61,7 +67,12 @@ async function request(
61
67
  headers?: Record<string, string>,
62
68
  method?: "DELETE",
63
69
  signal?: AbortSignal,
70
+ responseLimit?: number,
71
+ paneCreationRefusal = false,
64
72
  ) {
73
+ const requestSignal = signal
74
+ ? AbortSignal.any([signal, AbortSignal.timeout(options.timeoutMs ?? 5_000)])
75
+ : AbortSignal.timeout(options.timeoutMs ?? 5_000);
65
76
  const response = await (options.fetch ?? fetch)(new URL(path, options.baseUrl), {
66
77
  method: method ?? (body === undefined ? "GET" : "POST"),
67
78
  headers: {
@@ -72,13 +83,45 @@ async function request(
72
83
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
73
84
  redirect: "error",
74
85
  cache: "no-store",
75
- signal: signal
76
- ? AbortSignal.any([signal, AbortSignal.timeout(options.timeoutMs ?? 5_000)])
77
- : AbortSignal.timeout(options.timeoutMs ?? 5_000),
86
+ signal: requestSignal,
78
87
  });
79
- if (!response.ok) throw new TmuxServerClientError("request-failed", response.status);
80
- const result: unknown = await response.json();
81
- signal?.throwIfAborted();
88
+ if (!response.ok)
89
+ throw new TmuxServerClientError(
90
+ "request-failed",
91
+ response.status,
92
+ paneCreationRefusal ? await readPaneCreationRefusal(response, requestSignal) : undefined,
93
+ );
94
+ let result: unknown;
95
+ if (responseLimit === undefined) result = await response.json();
96
+ else {
97
+ const reader = response.body?.getReader();
98
+ if (!reader) throw new TmuxServerClientError("request-failed");
99
+ const chunks: Uint8Array[] = [];
100
+ let length = 0;
101
+ try {
102
+ for (;;) {
103
+ requestSignal.throwIfAborted();
104
+ const item = await reader.read();
105
+ if (item.done) break;
106
+ length += item.value.byteLength;
107
+ if (length > responseLimit) {
108
+ void reader.cancel().catch(() => undefined);
109
+ throw new TmuxServerClientError("request-failed");
110
+ }
111
+ chunks.push(item.value);
112
+ }
113
+ const bytes = new Uint8Array(length);
114
+ let offset = 0;
115
+ for (const chunk of chunks) {
116
+ bytes.set(chunk, offset);
117
+ offset += chunk.byteLength;
118
+ }
119
+ result = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
120
+ } finally {
121
+ reader.releaseLock();
122
+ }
123
+ }
124
+ requestSignal.throwIfAborted();
82
125
  return result;
83
126
  }
84
127
  export async function listTmuxServers(options: TmuxServerClientOptions) {
@@ -140,6 +183,7 @@ export function createTmuxServerClient(
140
183
  headers?: Record<string, string>,
141
184
  signal?: AbortSignal,
142
185
  defaultTimeoutMs?: number,
186
+ paneCreationRefusal = false,
143
187
  ) {
144
188
  assertCurrent();
145
189
  const result = await request(
@@ -151,6 +195,8 @@ export function createTmuxServerClient(
151
195
  headers,
152
196
  undefined,
153
197
  signal,
198
+ undefined,
199
+ paneCreationRefusal,
154
200
  );
155
201
  assertCurrent();
156
202
  return result;
@@ -352,6 +398,10 @@ export function createTmuxServerClient(
352
398
  await scopedRequest(
353
399
  `panes/create?liveSessionId=${encodeURIComponent(liveSessionId)}`,
354
400
  body,
401
+ undefined,
402
+ undefined,
403
+ undefined,
404
+ true,
355
405
  ),
356
406
  );
357
407
  if (
@@ -426,3 +476,80 @@ export function createTmuxServerClient(
426
476
  };
427
477
  }
428
478
  export type TmuxServerClient = ReturnType<typeof createTmuxServerClient>;
479
+
480
+ /** Optional metadata; old servers have no route. Callers own selected-scope currentness. */
481
+ export async function readTmuxServerDiagnostics(
482
+ options: TmuxServerClientOptions,
483
+ scopeInput: TmuxServerScope,
484
+ signal?: AbortSignal,
485
+ ) {
486
+ const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
487
+ signal?.throwIfAborted();
488
+ try {
489
+ const result = TmuxServerDiagnosticsSchemaZ.parse(
490
+ await request(
491
+ options,
492
+ `${TMUX_SERVERS_API_PATH}/${scope.serverId}/${scope.generation}/diagnostics`,
493
+ undefined,
494
+ undefined,
495
+ undefined,
496
+ signal,
497
+ 4096,
498
+ ),
499
+ );
500
+ if (result.server.serverId !== scope.serverId || result.server.generation !== scope.generation)
501
+ throw new TmuxServerClientError("scope-mismatch");
502
+ return result;
503
+ } catch (error) {
504
+ signal?.throwIfAborted();
505
+ if (error instanceof TmuxServerClientError && error.status === 404) return null;
506
+ throw error;
507
+ }
508
+ }
509
+
510
+ /** Optional old-daemon endpoint. Never retries, creates sessions, or admits input. */
511
+ export async function requestHistoryAnchor(
512
+ options: TmuxServerClientOptions,
513
+ scopeInput: TmuxServerScope,
514
+ input: HistoryAnchorRequest,
515
+ signal?: AbortSignal,
516
+ ) {
517
+ const scope = TmuxServerScopeSchemaZ.parse(scopeInput),
518
+ body = HistoryAnchorRequestSchemaZ.parse(input);
519
+ if (body.proof.generation !== scope.generation) throw new TmuxServerClientError("scope-mismatch");
520
+ signal?.throwIfAborted();
521
+ try {
522
+ const value = HistoryAnchorResponseSchemaZ.parse(
523
+ await request(
524
+ options,
525
+ `${TMUX_SERVERS_API_PATH}/${scope.serverId}/${scope.generation}/history-anchor`,
526
+ body,
527
+ undefined,
528
+ undefined,
529
+ signal,
530
+ 4096,
531
+ ),
532
+ );
533
+ if (value.server.serverId !== scope.serverId || value.server.generation !== scope.generation)
534
+ throw new TmuxServerClientError("scope-mismatch");
535
+ if (
536
+ value.result.status === "resolved" &&
537
+ Object.entries(body.proof).some(
538
+ ([key, v]) =>
539
+ value.result.status === "resolved" &&
540
+ value.result.proof[key as keyof typeof body.proof] !== v,
541
+ )
542
+ )
543
+ throw new TmuxServerClientError("scope-mismatch");
544
+ if (
545
+ (body.operation.kind === "capture" && value.result.status === "resolved") ||
546
+ (body.operation.kind === "resolve" && value.result.status === "captured")
547
+ )
548
+ throw new TmuxServerClientError("scope-mismatch");
549
+ return value;
550
+ } catch (error) {
551
+ signal?.throwIfAborted();
552
+ if (error instanceof TmuxServerClientError && error.status === 404) return null;
553
+ throw error;
554
+ }
555
+ }