agent-relay-orchestrator 0.11.2 → 0.11.3
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/package.json +4 -4
- package/src/api.ts +6 -7
- package/src/terminal-stream.ts +233 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@xterm/addon-serialize": "0.14.0",
|
|
20
|
-
"@xterm/headless": "6.0.0",
|
|
21
19
|
"agent-relay-sdk": "0.2.2"
|
|
22
20
|
},
|
|
23
21
|
"devDependencies": {
|
|
24
|
-
"@types/bun": "latest"
|
|
22
|
+
"@types/bun": "latest",
|
|
23
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
24
|
+
"@xterm/headless": "6.0.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"typescript": "^5"
|
package/src/api.ts
CHANGED
|
@@ -577,13 +577,12 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
// Backfill comes from
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
// bytes that arrive after.
|
|
580
|
+
// Backfill comes straight from tmux's own grid (see terminal-stream.ts): the snapshot's
|
|
581
|
+
// `content` is a `capture-pane -e` repaint of the current screen (styled, cursor parked).
|
|
582
|
+
// tmux is the real emulator and never drifts, so the repaint is byte-faithful and live
|
|
583
|
+
// relative deltas then apply identically on the client. Live bytes that arrive before the
|
|
584
|
+
// snapshot are already on tmux's grid (and thus in the capture), so we discard the queue
|
|
585
|
+
// and only forward bytes that arrive after.
|
|
587
586
|
async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
|
|
588
587
|
if (ws.data.syncTimer) {
|
|
589
588
|
clearTimeout(ws.data.syncTimer);
|
package/src/terminal-stream.ts
CHANGED
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
// to raw bytes, coalesce on a short window, and broadcast. No screen-scraping, no
|
|
9
9
|
// full-buffer repaint — the client just writes the byte stream to xterm.
|
|
10
10
|
//
|
|
11
|
-
// Backfill:
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
11
|
+
// Backfill: tmux control mode does not replay the pre-attach screen, so on viewer
|
|
12
|
+
// attach we repaint from tmux's *own* grid via `capture-pane -e` (styled, current
|
|
13
|
+
// screen only — no scrollback, exactly what `tmux attach` shows) and park the cursor
|
|
14
|
+
// where tmux has it. tmux is the real terminal emulator: its grid never drifts, so the
|
|
15
|
+
// repaint is byte-faithful by definition, and subsequent live relative-cursor deltas
|
|
16
|
+
// apply identically on the client (which now mirrors tmux exactly). We deliberately do
|
|
17
|
+
// NOT keep a server-side @xterm/headless mirror: seeding such an emulator mid-stream
|
|
18
|
+
// from a snapshot and then feeding it Claude's purely-relative deltas accumulates error
|
|
19
|
+
// (the seed is never byte-perfect), which ghosted the screen into a staircase. tmux's
|
|
20
|
+
// grid is the only authoritative source, so we read it directly.
|
|
20
21
|
//
|
|
21
22
|
// Input/resize: written as plain command lines to the control client's stdin
|
|
22
23
|
// (`send-keys -H <hex>`, `resize-window`), so a keystroke costs no process spawn.
|
|
@@ -24,9 +25,7 @@
|
|
|
24
25
|
// its separate `tmux send-keys` calls and is unaffected — control mode is just
|
|
25
26
|
// another observing client.
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
-
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
29
|
-
import { captureTerminal, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
28
|
+
import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
30
29
|
import type { OrchestratorConfig } from "./config";
|
|
31
30
|
|
|
32
31
|
const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
|
|
@@ -35,6 +34,23 @@ const BACKPRESSURE_MAX_BYTES = Math.max(
|
|
|
35
34
|
1 << 20,
|
|
36
35
|
Number(process.env.AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES) || 8 << 20,
|
|
37
36
|
);
|
|
37
|
+
// After a resize the TUI repaints asynchronously; let tmux's grid settle before we
|
|
38
|
+
// capture, so the backfill is the post-resize frame, not a half-reflowed one.
|
|
39
|
+
const RESIZE_SETTLE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESIZE_SETTLE_MS) || 90);
|
|
40
|
+
// Live deltas are relative-cursor moves; replaying them onto a client seeded mid-stream
|
|
41
|
+
// from a capture-pane snapshot can drift (lost scroll-region/SGR/wrap state → doubled
|
|
42
|
+
// statusline, faded suggestion rendered solid, cursor off by a row). We can't transfer
|
|
43
|
+
// full emulator state, so we periodically re-stamp tmux's authoritative grid in place to
|
|
44
|
+
// snap the client back. Debounce after output settles; cap so continuous "thinking"
|
|
45
|
+
// streams still correct. 0 disables the corrector.
|
|
46
|
+
const RESYNC_DEBOUNCE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS) || 120);
|
|
47
|
+
const RESYNC_MAX_INTERVAL_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS) || 350);
|
|
48
|
+
// On attach we include this many lines of tmux scrollback ABOVE the current screen so the
|
|
49
|
+
// viewer can scroll back through pre-attach history (the client lands on the live screen;
|
|
50
|
+
// the history sits in its scroll buffer). This is a one-time per-attach paint, so it's
|
|
51
|
+
// fine to be large; the live resync corrector only ever repaints the current screen, so it
|
|
52
|
+
// never disturbs this history. 0 = current screen only.
|
|
53
|
+
const BACKFILL_SCROLLBACK_LINES = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_BACKFILL_SCROLLBACK) || 1000);
|
|
38
54
|
|
|
39
55
|
export interface TerminalStreamSubscriber {
|
|
40
56
|
onData(bytes: Uint8Array): void;
|
|
@@ -55,7 +71,10 @@ export interface TerminalStreamHandle {
|
|
|
55
71
|
|
|
56
72
|
const DEFAULT_COLS = 80;
|
|
57
73
|
const DEFAULT_ROWS = 24;
|
|
58
|
-
const
|
|
74
|
+
const TERMINAL_DEBUG = process.env.AGENT_RELAY_TERMINAL_DEBUG === "1";
|
|
75
|
+
function tdbg(...args: unknown[]): void {
|
|
76
|
+
if (TERMINAL_DEBUG) console.error("[term-debug]", ...args);
|
|
77
|
+
}
|
|
59
78
|
|
|
60
79
|
// --- Pure protocol helpers (unit-tested) ---
|
|
61
80
|
|
|
@@ -120,6 +139,44 @@ export function encodeSendKeysHex(bytes: Uint8Array): string {
|
|
|
120
139
|
.join(" ");
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
// Build a client repaint from tmux's current-screen grid: lay the rows out top-down
|
|
143
|
+
// (explicit CRLF so it's independent of the client's convertEol), then park the cursor
|
|
144
|
+
// where tmux has it so the next live relative delta continues from the right cell.
|
|
145
|
+
export function buildScreenRepaint(content: string, cursorX?: number, cursorY?: number): string {
|
|
146
|
+
let out = content.replace(/\n$/, "").replace(/\n/g, "\r\n");
|
|
147
|
+
if (cursorX != null && cursorY != null && Number.isFinite(cursorX) && Number.isFinite(cursorY)) {
|
|
148
|
+
out += `\x1b[${cursorY + 1};${cursorX + 1}H`;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Build an in-place authoritative repaint that overwrites the client grid with tmux's
|
|
154
|
+
// current screen WITHOUT a full-screen clear (so it can run mid-stream as a drift
|
|
155
|
+
// corrector without flicker). Each row is absolute-positioned, SGR-reset, and erased
|
|
156
|
+
// (`\x1b[2K`) before its styled cells are written, so any drift — a doubled statusline, a
|
|
157
|
+
// suggestion stuck solid instead of faded, leftover cells — is stamped out. The cursor is
|
|
158
|
+
// re-parked at tmux's true position and visibility restored. Wrapped in cursor-hide/show
|
|
159
|
+
// so the multi-row paint doesn't visibly skitter the cursor across the screen.
|
|
160
|
+
export function buildInPlaceRepaint(
|
|
161
|
+
content: string,
|
|
162
|
+
rows: number,
|
|
163
|
+
cursor: { cursorX?: number; cursorY?: number; visible?: boolean } = {},
|
|
164
|
+
): string {
|
|
165
|
+
const lines = content.replace(/\n$/, "").split("\n");
|
|
166
|
+
const height = Number.isFinite(rows) && rows > 0 ? Math.trunc(rows) : lines.length;
|
|
167
|
+
let out = "\x1b[?25l\x1b[m";
|
|
168
|
+
for (let i = 0; i < height; i++) {
|
|
169
|
+
out += `\x1b[${i + 1};1H\x1b[m\x1b[2K`;
|
|
170
|
+
if (lines[i]) out += lines[i];
|
|
171
|
+
}
|
|
172
|
+
out += "\x1b[m";
|
|
173
|
+
if (cursor.cursorX != null && cursor.cursorY != null && Number.isFinite(cursor.cursorX) && Number.isFinite(cursor.cursorY)) {
|
|
174
|
+
out += `\x1b[${cursor.cursorY + 1};${cursor.cursorX + 1}H`;
|
|
175
|
+
}
|
|
176
|
+
out += cursor.visible === false ? "\x1b[?25l" : "\x1b[?25h";
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
123
180
|
// --- Shared session stream ---
|
|
124
181
|
|
|
125
182
|
class SessionStream {
|
|
@@ -130,11 +187,14 @@ class SessionStream {
|
|
|
130
187
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
131
188
|
private lineBuf = "";
|
|
132
189
|
private closed = false;
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
private serializer: SerializeAddon | null = null;
|
|
190
|
+
// Last size we resized the pane to / reported to viewers (tmux is the source of truth;
|
|
191
|
+
// these are just for redundant-resize avoidance and debug logging).
|
|
136
192
|
private termCols = DEFAULT_COLS;
|
|
137
193
|
private termRows = DEFAULT_ROWS;
|
|
194
|
+
// Drift corrector: re-stamp tmux's authoritative grid after live deltas settle.
|
|
195
|
+
private resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
196
|
+
private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
197
|
+
private resyncDirty = false;
|
|
138
198
|
|
|
139
199
|
constructor(
|
|
140
200
|
private readonly session: string,
|
|
@@ -149,22 +209,20 @@ class SessionStream {
|
|
|
149
209
|
this.socket = socket;
|
|
150
210
|
// Pin the window size before attaching so the control client can't reflow the
|
|
151
211
|
// pane (default window-size would shrink it to the new client's 80x24).
|
|
152
|
-
const
|
|
212
|
+
const dims = this.paneDims();
|
|
213
|
+
if (dims.cols) this.termCols = dims.cols;
|
|
214
|
+
if (dims.rows) this.termRows = dims.rows;
|
|
153
215
|
Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
|
|
154
216
|
stdin: "ignore",
|
|
155
217
|
stdout: "ignore",
|
|
156
218
|
stderr: "ignore",
|
|
157
219
|
});
|
|
158
|
-
if (
|
|
220
|
+
if (dims.cols && dims.rows) {
|
|
159
221
|
Bun.spawnSync(
|
|
160
|
-
tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(
|
|
222
|
+
tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(dims.cols), "-y", String(dims.rows)),
|
|
161
223
|
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
162
224
|
);
|
|
163
225
|
}
|
|
164
|
-
// Build the headless emulator at the pane size and seed it with the current screen.
|
|
165
|
-
// tmux control mode only streams output emitted *after* attach, so without this seed
|
|
166
|
-
// the emulator would start blank and miss everything already on screen.
|
|
167
|
-
this.initTerm(snapshot);
|
|
168
226
|
try {
|
|
169
227
|
this.proc = Bun.spawn(tmuxCommand(socket, "-C", "attach-session", "-t", this.session), {
|
|
170
228
|
stdin: "pipe",
|
|
@@ -238,9 +296,12 @@ class SessionStream {
|
|
|
238
296
|
}
|
|
239
297
|
this.pending = [];
|
|
240
298
|
this.pendingBytes = 0;
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
this.
|
|
299
|
+
this.broadcast(merged);
|
|
300
|
+
// Live deltas just went out; schedule an authoritative resync to correct any drift.
|
|
301
|
+
this.scheduleResync();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private broadcast(bytes: Uint8Array): void {
|
|
244
305
|
for (const sub of [...this.subscribers]) {
|
|
245
306
|
if (sub.bufferedAmount && sub.bufferedAmount() > BACKPRESSURE_MAX_BYTES) {
|
|
246
307
|
this.removeSubscriber(sub);
|
|
@@ -250,71 +311,141 @@ class SessionStream {
|
|
|
250
311
|
continue;
|
|
251
312
|
}
|
|
252
313
|
try {
|
|
253
|
-
sub.onData(
|
|
314
|
+
sub.onData(bytes);
|
|
254
315
|
} catch {}
|
|
255
316
|
}
|
|
256
317
|
}
|
|
257
318
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
319
|
+
// Schedule a drift-correcting resync: a trailing debounce fires once output settles,
|
|
320
|
+
// and a capped interval guarantees correction during continuous output.
|
|
321
|
+
private scheduleResync(): void {
|
|
322
|
+
if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
|
|
323
|
+
this.resyncDirty = true;
|
|
324
|
+
if (this.resyncTimer) clearTimeout(this.resyncTimer);
|
|
325
|
+
this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
326
|
+
if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
|
|
327
|
+
this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
263
328
|
}
|
|
264
329
|
}
|
|
265
330
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
this.term = new Terminal({ cols, rows, scrollback: TERMINAL_SCROLLBACK, allowProposedApi: true });
|
|
275
|
-
this.serializer = new SerializeAddon();
|
|
276
|
-
this.term.loadAddon(this.serializer);
|
|
277
|
-
if (snapshot?.content) {
|
|
278
|
-
// Strip the single trailing newline capture-pane appends; then park the cursor where
|
|
279
|
-
// tmux has it so the seeded emulator state is byte-faithful to the live pane.
|
|
280
|
-
let seed = snapshot.content.replace(/\n$/, "");
|
|
281
|
-
if (snapshot.cursorX != null && snapshot.cursorY != null) {
|
|
282
|
-
seed += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
|
|
283
|
-
}
|
|
284
|
-
this.term.write(seed);
|
|
331
|
+
private doResync(): void {
|
|
332
|
+
if (this.resyncTimer) {
|
|
333
|
+
clearTimeout(this.resyncTimer);
|
|
334
|
+
this.resyncTimer = null;
|
|
335
|
+
}
|
|
336
|
+
if (this.resyncCapTimer) {
|
|
337
|
+
clearTimeout(this.resyncCapTimer);
|
|
338
|
+
this.resyncCapTimer = null;
|
|
285
339
|
}
|
|
340
|
+
if (!this.resyncDirty || this.closed) return;
|
|
341
|
+
this.resyncDirty = false;
|
|
342
|
+
if (this.subscribers.size === 0) return;
|
|
343
|
+
const repaint = this.resyncRepaint();
|
|
344
|
+
if (!repaint) return;
|
|
345
|
+
this.broadcast(repaint);
|
|
346
|
+
tdbg(`resync ${this.session} bytes=${repaint.length} viewers=${this.subscribers.size}`);
|
|
286
347
|
}
|
|
287
348
|
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
349
|
+
// Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
|
|
350
|
+
// reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
|
|
351
|
+
// doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
|
|
352
|
+
// full-screen clear flash, and re-parks the cursor at tmux's true position/visibility.
|
|
353
|
+
private resyncRepaint(): Uint8Array | null {
|
|
354
|
+
const body = this.readScreen();
|
|
355
|
+
if (!body) return null;
|
|
356
|
+
const cursor = this.readCursorState();
|
|
357
|
+
const rows = this.paneDims().rows ?? this.termRows;
|
|
358
|
+
return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Read tmux's authoritative current-screen grid (styled, no scrollback) plus a
|
|
362
|
+
// consistent cursor position, and turn it into a client repaint. capture-pane reads
|
|
363
|
+
// tmux's real emulator grid, so it's always internally coherent; captureConsistent
|
|
364
|
+
// guards the (content, cursor) read against a mid-render cursor move.
|
|
365
|
+
private captureScreen(): { content: string; cursorX?: number; cursorY?: number } {
|
|
366
|
+
const { content, cursor } = captureConsistent(
|
|
367
|
+
() => this.readBackfill(),
|
|
368
|
+
() => this.readCursor(),
|
|
369
|
+
);
|
|
370
|
+
return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Backfill capture: current screen plus scrollback history above it (cursor is
|
|
374
|
+
// viewport-relative, so it still parks on the live screen). Falls back to screen-only.
|
|
375
|
+
private readBackfill(): string {
|
|
376
|
+
if (BACKFILL_SCROLLBACK_LINES <= 0) return this.readScreen();
|
|
377
|
+
const r = Bun.spawnSync(
|
|
378
|
+
tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-S", `-${BACKFILL_SCROLLBACK_LINES}`, "-t", this.session),
|
|
379
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
380
|
+
);
|
|
381
|
+
return r.exitCode === 0 ? r.stdout.toString("utf8") : this.readScreen();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Current-screen-only capture (no scrollback) — used by the live resync corrector so it
|
|
385
|
+
// overwrites just the visible grid and leaves the client's scroll-back history intact.
|
|
386
|
+
private readScreen(): string {
|
|
387
|
+
const r = Bun.spawnSync(
|
|
388
|
+
tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-t", this.session),
|
|
389
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
390
|
+
);
|
|
391
|
+
return r.exitCode === 0 ? r.stdout.toString("utf8") : "";
|
|
295
392
|
}
|
|
296
393
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
394
|
+
private readCursor(): { cursorX?: number; cursorY?: number } {
|
|
395
|
+
const r = Bun.spawnSync(
|
|
396
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y}"),
|
|
397
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
398
|
+
);
|
|
399
|
+
const [x, y] = r.stdout.toString().trim().split(/\s+/).map(Number);
|
|
400
|
+
return {
|
|
401
|
+
...(Number.isFinite(x) ? { cursorX: x } : {}),
|
|
402
|
+
...(Number.isFinite(y) ? { cursorY: y } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
|
|
407
|
+
// the TUI had the cursor shown or hidden rather than guessing.
|
|
408
|
+
private readCursorState(): { cursorX?: number; cursorY?: number; visible?: boolean } {
|
|
409
|
+
const r = Bun.spawnSync(
|
|
410
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y} #{cursor_flag}"),
|
|
411
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
412
|
+
);
|
|
413
|
+
const [x, y, flag] = r.stdout.toString().trim().split(/\s+/);
|
|
414
|
+
const cx = Number(x);
|
|
415
|
+
const cy = Number(y);
|
|
416
|
+
return {
|
|
417
|
+
...(Number.isFinite(cx) ? { cursorX: cx } : {}),
|
|
418
|
+
...(Number.isFinite(cy) ? { cursorY: cy } : {}),
|
|
419
|
+
...(flag === "0" || flag === "1" ? { visible: flag === "1" } : {}),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
|
|
424
|
+
// When the viewer's dimensions are given we resize the tmux pane first so the TUI
|
|
425
|
+
// re-renders at the viewer's width, let it settle, then capture the post-resize frame.
|
|
301
426
|
async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
427
|
+
let resized = false;
|
|
302
428
|
if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
303
429
|
const c = Math.trunc(cols as number);
|
|
304
430
|
const r = Math.trunc(rows as number);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
this.term.resize(c, r);
|
|
431
|
+
if (c !== this.termCols || r !== this.termRows) {
|
|
432
|
+
Bun.spawnSync(
|
|
433
|
+
tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
|
|
434
|
+
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
435
|
+
);
|
|
311
436
|
this.termCols = c;
|
|
312
437
|
this.termRows = r;
|
|
438
|
+
resized = true;
|
|
313
439
|
}
|
|
314
440
|
}
|
|
315
|
-
await
|
|
316
|
-
const content = this.
|
|
441
|
+
if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
|
|
442
|
+
const { content } = this.captureScreen();
|
|
443
|
+
// Report tmux's actual pane size (authoritative) back to the viewer.
|
|
444
|
+
const dims = this.paneDims();
|
|
445
|
+
if (dims.cols) this.termCols = dims.cols;
|
|
446
|
+
if (dims.rows) this.termRows = dims.rows;
|
|
317
447
|
const live = sessionLiveness(this.session);
|
|
448
|
+
tdbg(`backfill ${this.session} req=${cols}x${rows} term=${this.termCols}x${this.termRows} contentLen=${content.length} viewers=${this.subscribers.size}`);
|
|
318
449
|
return {
|
|
319
450
|
session: this.session,
|
|
320
451
|
content,
|
|
@@ -334,7 +465,28 @@ class SessionStream {
|
|
|
334
465
|
resize(cols: number, rows: number): void {
|
|
335
466
|
if (this.closed || !this.proc) return;
|
|
336
467
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
|
|
337
|
-
|
|
468
|
+
const c = Math.trunc(cols);
|
|
469
|
+
const r = Math.trunc(rows);
|
|
470
|
+
this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
471
|
+
this.termCols = c;
|
|
472
|
+
this.termRows = r;
|
|
473
|
+
tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private paneDims(): { cols?: number; rows?: number } {
|
|
477
|
+
try {
|
|
478
|
+
const out = Bun.spawnSync(
|
|
479
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
|
|
480
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
481
|
+
).stdout.toString().trim();
|
|
482
|
+
const [w, h] = out.split(/\s+/).map(Number);
|
|
483
|
+
return {
|
|
484
|
+
...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
|
|
485
|
+
...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
|
|
486
|
+
};
|
|
487
|
+
} catch {
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
338
490
|
}
|
|
339
491
|
|
|
340
492
|
private command(line: string): void {
|
|
@@ -348,10 +500,12 @@ class SessionStream {
|
|
|
348
500
|
|
|
349
501
|
addSubscriber(sub: TerminalStreamSubscriber): void {
|
|
350
502
|
this.subscribers.add(sub);
|
|
503
|
+
tdbg(`attach ${this.session} viewers=${this.subscribers.size} term=${this.termCols}x${this.termRows}`);
|
|
351
504
|
}
|
|
352
505
|
|
|
353
506
|
removeSubscriber(sub: TerminalStreamSubscriber): void {
|
|
354
507
|
if (!this.subscribers.delete(sub)) return;
|
|
508
|
+
tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
|
|
355
509
|
if (this.subscribers.size === 0) this.destroy();
|
|
356
510
|
}
|
|
357
511
|
|
|
@@ -374,15 +528,18 @@ class SessionStream {
|
|
|
374
528
|
clearTimeout(this.flushTimer);
|
|
375
529
|
this.flushTimer = null;
|
|
376
530
|
}
|
|
531
|
+
if (this.resyncTimer !== null) {
|
|
532
|
+
clearTimeout(this.resyncTimer);
|
|
533
|
+
this.resyncTimer = null;
|
|
534
|
+
}
|
|
535
|
+
if (this.resyncCapTimer !== null) {
|
|
536
|
+
clearTimeout(this.resyncCapTimer);
|
|
537
|
+
this.resyncCapTimer = null;
|
|
538
|
+
}
|
|
377
539
|
try {
|
|
378
540
|
this.proc?.kill();
|
|
379
541
|
} catch {}
|
|
380
542
|
this.proc = null;
|
|
381
|
-
try {
|
|
382
|
-
this.term?.dispose();
|
|
383
|
-
} catch {}
|
|
384
|
-
this.term = null;
|
|
385
|
-
this.serializer = null;
|
|
386
543
|
this.onEmpty();
|
|
387
544
|
}
|
|
388
545
|
}
|