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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.11.2",
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 a server-side headless emulator (see terminal-stream.ts): the
581
- // snapshot's `content` is a SerializeAddon repaint that restores the exact grid, scrollback,
582
- // cursor, and SGR state when written to the client's xterm. The client and server run the
583
- // same emulator over the same byte stream, so the backfill is correct by construction no
584
- // stabilization, trailing-newline strip, or cursor-park needed. Live bytes that arrive
585
- // before the snapshot are already baked into it, so we discard the queue and only forward
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);
@@ -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: a *server-side headless xterm.js* (`@xterm/headless`) per session consumes
12
- // the same byte stream the browser does, so it holds an authoritative, correctly-rendered
13
- // grid. On viewer attach we `SerializeAddon.serialize()` that emulator into a byte-perfect
14
- // repaint. This is the xterm.js-maintainer-blessed pattern for "live attach to a running
15
- // terminal" it replaces the fragile capture-pane reconstruction (which had to guess how
16
- // Claude's purely-relative cursor model maps onto a freshly-seeded grid, and ghosted when
17
- // it was even one row off). Correctness is now by construction: the server runs the same
18
- // emulator as the client, parsing the same bytes. tmux control mode does not replay the
19
- // pre-attach screen, so we seed the headless emulator once from a capture-pane snapshot.
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 { Terminal } from "@xterm/headless";
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 TERMINAL_SCROLLBACK = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_SCROLLBACK) || 1000);
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
- // Server-side emulator mirroring the client's view, for byte-perfect serialize() backfill.
134
- private term: Terminal | null = null;
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 snapshot = this.safeBackfill();
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 (snapshot?.cols && snapshot?.rows) {
220
+ if (dims.cols && dims.rows) {
159
221
  Bun.spawnSync(
160
- tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(snapshot.cols), "-y", String(snapshot.rows)),
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
- // Keep the server-side emulator in lockstep with what subscribers receive, so a
242
- // serialize() backfill always reflects exactly the bytes already streamed live.
243
- this.term?.write(merged);
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(merged);
314
+ sub.onData(bytes);
254
315
  } catch {}
255
316
  }
256
317
  }
257
318
 
258
- private safeBackfill(): TerminalSnapshot | null {
259
- try {
260
- return captureTerminal(this.session, this.config);
261
- } catch {
262
- return null;
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
- // Build the headless emulator and seed it with the current screen + cursor. The seed
267
- // snapshot's (content, cursor) are captured consistently (see captureConsistent), so the
268
- // emulator's cursor matches where the TUI's next relative delta continues from.
269
- private initTerm(snapshot: TerminalSnapshot | null): void {
270
- const cols = snapshot?.cols && snapshot.cols >= 1 ? snapshot.cols : DEFAULT_COLS;
271
- const rows = snapshot?.rows && snapshot.rows >= 1 ? snapshot.rows : DEFAULT_ROWS;
272
- this.termCols = cols;
273
- this.termRows = rows;
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
- // Resolve once the emulator has parsed everything written so far (write() is async),
289
- // so serialize() can't miss the most recent live chunk.
290
- private flushTerm(): Promise<void> {
291
- return new Promise((resolve) => {
292
- if (!this.term) return resolve();
293
- this.term.write("", () => resolve());
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
- // Serialize the emulator into a byte-perfect repaint. When the viewer's dimensions are
298
- // given, resize both the tmux pane (so the TUI re-renders at the viewer's width) and the
299
- // emulator to match. No stabilization needed: the serialize output puts the client in the
300
- // emulator's exact state, and subsequent live deltas apply identically to both.
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
- Bun.spawnSync(
306
- tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
307
- { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
308
- );
309
- if (this.term && (c !== this.termCols || r !== this.termRows)) {
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 this.flushTerm();
316
- const content = this.serializer?.serialize() ?? "";
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
- this.command(`resize-window -t "${this.session}" -x ${Math.trunc(cols)} -y ${Math.trunc(rows)}`);
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
  }