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.
- package/bin/cli.js +2881 -1336
- package/package.json +2 -2
- package/packages/contracts/src/index.ts +5 -0
- package/packages/contracts/src/pane-stream.ts +54 -0
- package/packages/contracts/src/terminal-graphics.ts +118 -0
- package/packages/contracts/src/terminal-history-anchor.ts +61 -0
- package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/kitty-placeholders.ts +208 -0
- package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
- package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
- package/packages/daemon/dist/lib/daemon-embed.js +21 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +12 -4
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +9 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +9 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
- package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +699 -0
- package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +7 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
- package/packages/daemon/dist/terminal/mirror/session-channel.js +56 -4
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +180 -1
- package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
- package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +29 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +85 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +53 -21
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
- package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
- package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
- package/packages/daemon/src/lib/daemon-embed.ts +26 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
- package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
- package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
- package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +756 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +14 -0
- package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
- package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
- package/packages/daemon/src/terminal/mirror/session-channel.ts +75 -3
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +199 -0
- package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
- package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +38 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +99 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +58 -24
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
- package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
- package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
- package/packages/daemon-client/src/pane-stream-client.ts +37 -0
- package/packages/daemon-client/src/tmux-server-client.ts +133 -6
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
- /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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
deliverPaste(copy);
|
|
308
335
|
return;
|
|
309
336
|
}
|
|
310
|
-
if (
|
|
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(
|
|
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 "
|
|
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:
|
|
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)
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
Binary file
|