agent-relay-orchestrator 0.11.1 → 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.1",
3
+ "version": "0.11.3",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,9 @@
19
19
  "agent-relay-sdk": "0.2.2"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/bun": "latest"
22
+ "@types/bun": "latest",
23
+ "@xterm/addon-serialize": "0.14.0",
24
+ "@xterm/headless": "6.0.0"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "typescript": "^5"
package/src/api.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
2
- import { createHash } from "node:crypto";
3
2
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
4
3
  import type { ServerWebSocket } from "bun";
5
4
  import { proxyArtifactRequest } from "./artifact-proxy";
6
5
  import type { OrchestratorConfig } from "./config";
7
6
  import type { ProviderProbeCache } from "./provider-probe";
8
7
  import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
9
- import type { TerminalSnapshot } from "./spawn";
10
8
  import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
11
9
  import { VERSION, runtimeMetadata } from "./version";
12
10
  import { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
@@ -76,26 +74,6 @@ interface TerminalSocketData {
76
74
 
77
75
  type TerminalSocket = ServerWebSocket<TerminalSocketData>;
78
76
 
79
- export function terminalSnapshotSignature(snapshot: TerminalSnapshot): string {
80
- const hash = createHash("sha1");
81
- hash.update(snapshot.session);
82
- hash.update("\0");
83
- hash.update(snapshot.running ? "1" : "0");
84
- hash.update("\0");
85
- hash.update(snapshot.agentAlive ? "1" : "0");
86
- hash.update("\0");
87
- hash.update(String(snapshot.cols ?? ""));
88
- hash.update("\0");
89
- hash.update(String(snapshot.rows ?? ""));
90
- hash.update("\0");
91
- hash.update(String(snapshot.cursorX ?? ""));
92
- hash.update("\0");
93
- hash.update(String(snapshot.cursorY ?? ""));
94
- hash.update("\0");
95
- hash.update(snapshot.content);
96
- return hash.digest("hex");
97
- }
98
-
99
77
  function listDirectories(requestedPath: string | undefined, baseDir: string): DirectoryListing {
100
78
  const base = resolve(baseDir);
101
79
  const target = resolve(requestedPath || base);
@@ -599,23 +577,12 @@ function startTerminalSocket(ws: TerminalSocket): void {
599
577
  }
600
578
  }
601
579
 
602
- // How long to wait between stabilization captures, and how many to try. The viewer's
603
- // resize fires SIGWINCH at the TUI, whose repaint is *asynchronous* capturing before
604
- // it lands snapshots a mid-repaint screen with a transient cursor, and the post-capture
605
- // relative deltas then land offset and stack a ghost frame. We poll until the capture
606
- // stops changing (or the budget runs out) so we only ever backfill a quiescent screen.
607
- const BACKFILL_STABILIZE_STEP_MS = Math.max(20, Number(process.env.AGENT_RELAY_TERMINAL_STABILIZE_STEP_MS) || 60);
608
- const BACKFILL_STABILIZE_MAX_STEPS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_STABILIZE_MAX_STEPS ?? 8));
609
-
610
- // Resize the pane to the viewer's dimensions and capture the matching backfill.
611
- //
612
- // Claude's TUI streams pure *relative* cursor deltas (no absolute positioning), so the
613
- // backfill snapshot must reflect a *settled* screen: the cursor we park has to match
614
- // where the next delta continues from. We stabilize the capture first (see sendBackfill),
615
- // so by the time we flush, every byte up to the capture is baked into the snapshot. The
616
- // capture is synchronous (spawnSync), so the queued bytes are all PRE-capture and already
617
- // in the snapshot — replaying them would double-apply deltas and stack a garbled ghost
618
- // frame, so we discard the queue here and only forward bytes that arrive after capture.
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.
619
586
  async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
620
587
  if (ws.data.syncTimer) {
621
588
  clearTimeout(ws.data.syncTimer);
@@ -628,46 +595,13 @@ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number)
628
595
  ws.data.ready = true;
629
596
  }
630
597
 
631
- // Re-capture until the screen stops changing (or the budget runs out). The first
632
- // capture resizes the pane to the viewer geometry, which fires SIGWINCH at the TUI
633
- // whose repaint is asynchronous; capturing before it lands snapshots a mid-repaint
634
- // screen with a transient cursor, and the post-capture relative deltas then stack a
635
- // ghost frame. `shouldAbort` lets the caller bail if the socket is torn down mid-poll.
636
- export async function captureStableSnapshot(
637
- capture: (resize: boolean) => TerminalSnapshot,
638
- options?: {
639
- stepMs?: number;
640
- maxSteps?: number;
641
- sleep?: (ms: number) => Promise<void>;
642
- shouldAbort?: () => boolean;
643
- },
644
- ): Promise<TerminalSnapshot | null> {
645
- const stepMs = options?.stepMs ?? BACKFILL_STABILIZE_STEP_MS;
646
- const maxSteps = options?.maxSteps ?? BACKFILL_STABILIZE_MAX_STEPS;
647
- const sleep = options?.sleep ?? ((ms: number) => Bun.sleep(ms));
648
- let snapshot = capture(true);
649
- let signature = terminalSnapshotSignature(snapshot);
650
- for (let i = 0; i < maxSteps; i++) {
651
- await sleep(stepMs);
652
- if (options?.shouldAbort?.()) return null;
653
- const next = capture(false);
654
- const nextSignature = terminalSnapshotSignature(next);
655
- snapshot = next;
656
- if (nextSignature === signature) break;
657
- signature = nextSignature;
658
- }
659
- return snapshot;
660
- }
661
-
662
- // Send a full reset: a control frame with current geometry/status, then the
663
- // captured scrollback as a raw byte frame. Used on connect, resume, and refresh.
598
+ // Send a full reset: a control frame with current geometry/status, then the serialized
599
+ // emulator state as a raw byte frame. Used on connect, resume, and refresh.
664
600
  async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
665
601
  const stream = ws.data.stream;
666
602
  if (!stream) return;
667
- const snapshot = await captureStableSnapshot((resize) => (resize ? stream.backfill(cols, rows) : stream.backfill()), {
668
- shouldAbort: () => ws.data.stream !== stream,
669
- });
670
- if (!snapshot) return; // socket torn down mid-stabilize
603
+ const snapshot = await stream.backfill(cols, rows);
604
+ if (ws.data.stream !== stream) return; // socket torn down mid-backfill
671
605
  ws.send(JSON.stringify({
672
606
  type: "reset",
673
607
  session: snapshot.session,
@@ -675,23 +609,10 @@ async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): P
675
609
  agentAlive: snapshot.agentAlive,
676
610
  cols: snapshot.cols,
677
611
  rows: snapshot.rows,
678
- cursorX: snapshot.cursorX,
679
- cursorY: snapshot.cursorY,
680
612
  capturedAt: snapshot.capturedAt,
681
613
  }));
682
614
  if (snapshot.content) {
683
- // Strip the single trailing newline capture-pane appends. Left in, it scrolls xterm
684
- // one extra row so the whole screen sits one row too high — and the cursor-park below
685
- // then lands a row off, offsetting every relative statusline redraw (bottom-box ghost).
686
- let content = snapshot.content.replace(/\n$/, "");
687
- // Park the cursor where tmux actually has it (screen-relative). Without this the
688
- // cursor sits at the end of the captured text, so the TUI's first relative redraw
689
- // (cursor-up + rewrite of its prompt/statusline) lands at the wrong row and stacks
690
- // a ghost copy into scrollback.
691
- if (snapshot.cursorX != null && snapshot.cursorY != null) {
692
- content += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
693
- }
694
- ws.send(new TextEncoder().encode(content));
615
+ ws.send(new TextEncoder().encode(snapshot.content));
695
616
  }
696
617
  }
697
618
 
package/src/spawn.ts CHANGED
@@ -1144,6 +1144,13 @@ function tmuxHasSession(name: string, socketName?: string): boolean {
1144
1144
  return result.exitCode === 0;
1145
1145
  }
1146
1146
 
1147
+ // Lightweight liveness for the live terminal stream's backfill metadata — avoids a full
1148
+ // capture-pane just to learn whether the pane/agent are still up.
1149
+ export function sessionLiveness(name: string): { running: boolean; agentAlive: boolean } {
1150
+ const socketName = tmuxSocketForSession(name);
1151
+ return { running: tmuxHasSession(name, socketName), agentAlive: isSessionAlive(name) };
1152
+ }
1153
+
1147
1154
  function tmuxPaneSize(name: string, socketName?: string): { cols?: number; rows?: number } {
1148
1155
  const result = Bun.spawnSync(tmuxCommand(socketName, "display-message", "-p", "-t", name, "#{pane_width} #{pane_height}"), {
1149
1156
  stdin: "ignore",
@@ -8,13 +8,24 @@
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: 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.
21
+ //
11
22
  // Input/resize: written as plain command lines to the control client's stdin
12
23
  // (`send-keys -H <hex>`, `resize-window`), so a keystroke costs no process spawn.
13
24
  // The relay's own injection path (message delivery, /compact, initial prompts) uses
14
25
  // its separate `tmux send-keys` calls and is unaffected — control mode is just
15
26
  // another observing client.
16
27
 
17
- import { captureTerminal, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
28
+ import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
18
29
  import type { OrchestratorConfig } from "./config";
19
30
 
20
31
  const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
@@ -23,6 +34,23 @@ const BACKPRESSURE_MAX_BYTES = Math.max(
23
34
  1 << 20,
24
35
  Number(process.env.AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES) || 8 << 20,
25
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);
26
54
 
27
55
  export interface TerminalStreamSubscriber {
28
56
  onData(bytes: Uint8Array): void;
@@ -35,12 +63,19 @@ export interface TerminalStreamSubscriber {
35
63
  }
36
64
 
37
65
  export interface TerminalStreamHandle {
38
- backfill(cols?: number, rows?: number): TerminalSnapshot;
66
+ backfill(cols?: number, rows?: number): Promise<TerminalSnapshot>;
39
67
  write(bytes: Uint8Array): void;
40
68
  resize(cols: number, rows: number): void;
41
69
  release(): void;
42
70
  }
43
71
 
72
+ const DEFAULT_COLS = 80;
73
+ const DEFAULT_ROWS = 24;
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
+ }
78
+
44
79
  // --- Pure protocol helpers (unit-tested) ---
45
80
 
46
81
  // Decode a tmux control-mode `%output` payload: printable ASCII is literal, every
@@ -104,6 +139,44 @@ export function encodeSendKeysHex(bytes: Uint8Array): string {
104
139
  .join(" ");
105
140
  }
106
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
+
107
180
  // --- Shared session stream ---
108
181
 
109
182
  class SessionStream {
@@ -114,6 +187,14 @@ class SessionStream {
114
187
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
115
188
  private lineBuf = "";
116
189
  private closed = false;
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).
192
+ private termCols = DEFAULT_COLS;
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;
117
198
 
118
199
  constructor(
119
200
  private readonly session: string,
@@ -128,15 +209,17 @@ class SessionStream {
128
209
  this.socket = socket;
129
210
  // Pin the window size before attaching so the control client can't reflow the
130
211
  // pane (default window-size would shrink it to the new client's 80x24).
131
- 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;
132
215
  Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
133
216
  stdin: "ignore",
134
217
  stdout: "ignore",
135
218
  stderr: "ignore",
136
219
  });
137
- if (snapshot?.cols && snapshot?.rows) {
220
+ if (dims.cols && dims.rows) {
138
221
  Bun.spawnSync(
139
- 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)),
140
223
  { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
141
224
  );
142
225
  }
@@ -213,6 +296,12 @@ class SessionStream {
213
296
  }
214
297
  this.pending = [];
215
298
  this.pendingBytes = 0;
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 {
216
305
  for (const sub of [...this.subscribers]) {
217
306
  if (sub.bufferedAmount && sub.bufferedAmount() > BACKPRESSURE_MAX_BYTES) {
218
307
  this.removeSubscriber(sub);
@@ -222,31 +311,150 @@ class SessionStream {
222
311
  continue;
223
312
  }
224
313
  try {
225
- sub.onData(merged);
314
+ sub.onData(bytes);
226
315
  } catch {}
227
316
  }
228
317
  }
229
318
 
230
- private safeBackfill(): TerminalSnapshot | null {
231
- try {
232
- return captureTerminal(this.session, this.config);
233
- } catch {
234
- 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);
328
+ }
329
+ }
330
+
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;
235
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}`);
236
347
  }
237
348
 
238
- // Capture the priming snapshot. When the viewer's dimensions are given, resize the
239
- // tmux window to exactly that size *first* (synchronously) so the captured content
240
- // wraps identically to the client's xterm a width mismatch desyncs every
241
- // absolute-positioned redraw the TUI emits and stacks ghost frames into scrollback.
242
- backfill(cols?: number, rows?: number): TerminalSnapshot {
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") : "";
392
+ }
393
+
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.
426
+ async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
427
+ let resized = false;
243
428
  if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
244
- Bun.spawnSync(
245
- tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(Math.trunc(cols as number)), "-y", String(Math.trunc(rows as number))),
246
- { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
247
- );
429
+ const c = Math.trunc(cols as number);
430
+ const r = Math.trunc(rows as number);
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
+ );
436
+ this.termCols = c;
437
+ this.termRows = r;
438
+ resized = true;
439
+ }
248
440
  }
249
- return captureTerminal(this.session, this.config);
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;
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}`);
449
+ return {
450
+ session: this.session,
451
+ content,
452
+ running: live.running,
453
+ agentAlive: live.agentAlive,
454
+ cols: this.termCols,
455
+ rows: this.termRows,
456
+ capturedAt: Date.now(),
457
+ };
250
458
  }
251
459
 
252
460
  write(bytes: Uint8Array): void {
@@ -257,7 +465,28 @@ class SessionStream {
257
465
  resize(cols: number, rows: number): void {
258
466
  if (this.closed || !this.proc) return;
259
467
  if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
260
- 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
+ }
261
490
  }
262
491
 
263
492
  private command(line: string): void {
@@ -271,10 +500,12 @@ class SessionStream {
271
500
 
272
501
  addSubscriber(sub: TerminalStreamSubscriber): void {
273
502
  this.subscribers.add(sub);
503
+ tdbg(`attach ${this.session} viewers=${this.subscribers.size} term=${this.termCols}x${this.termRows}`);
274
504
  }
275
505
 
276
506
  removeSubscriber(sub: TerminalStreamSubscriber): void {
277
507
  if (!this.subscribers.delete(sub)) return;
508
+ tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
278
509
  if (this.subscribers.size === 0) this.destroy();
279
510
  }
280
511
 
@@ -297,6 +528,14 @@ class SessionStream {
297
528
  clearTimeout(this.flushTimer);
298
529
  this.flushTimer = null;
299
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
+ }
300
539
  try {
301
540
  this.proc?.kill();
302
541
  } catch {}