agent-relay-orchestrator 0.27.1 → 0.28.0

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.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.16"
19
+ "agent-relay-sdk": "0.2.17"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -79,6 +79,10 @@ interface TerminalSocketData {
79
79
  ready?: boolean;
80
80
  queue?: Uint8Array[];
81
81
  syncTimer?: ReturnType<typeof setTimeout>;
82
+ // Bytes dropped (not sent) while this viewer was paused. On resume, 0 means the
83
+ // screen is unchanged → resume the live stream without a reset+backfill (which
84
+ // otherwise wipes scrollback/scroll position on every focus flick — #272).
85
+ droppedWhilePaused?: number;
82
86
  }
83
87
 
84
88
  type TerminalSocket = ServerWebSocket<TerminalSocketData>;
@@ -682,7 +686,11 @@ function startTerminalSocket(ws: TerminalSocket): void {
682
686
  ws.data.queue = [];
683
687
  const subscriber: TerminalStreamSubscriber = {
684
688
  onData: (bytes) => {
685
- if (ws.data.paused) return;
689
+ if (ws.data.paused) {
690
+ // Count what we drop so resume can tell whether a re-backfill is needed.
691
+ ws.data.droppedWhilePaused = (ws.data.droppedWhilePaused ?? 0) + bytes.length;
692
+ return;
693
+ }
686
694
  // Queue live bytes until the client has reported its size and the backfill is
687
695
  // flushed, so the viewer never sees live output before (or mis-sized against) history.
688
696
  if (!ws.data.ready) {
@@ -694,9 +702,16 @@ function startTerminalSocket(ws: TerminalSocket): void {
694
702
  } catch {}
695
703
  },
696
704
  onClose: (reason) => {
705
+ // Tell the client why, THEN actually close the socket. Sending only the frame
706
+ // (the old behaviour) left a live-looking socket the client never reconnected
707
+ // on — a backpressure drop made the terminal silently dead to input and output
708
+ // (#271). The client reconnects + re-backfills on a transient reason.
697
709
  try {
698
710
  ws.send(JSON.stringify({ type: "closed", reason: reason ?? null }));
699
711
  } catch {}
712
+ try {
713
+ ws.close();
714
+ } catch {}
700
715
  },
701
716
  bufferedAmount: () => {
702
717
  try {
@@ -732,30 +747,47 @@ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number)
732
747
  }
733
748
  if (ws.data.synced) return;
734
749
  ws.data.synced = true;
750
+ // Initial connect: live bytes are still queued (ready=false), so the reset+content can't
751
+ // split a live sequence — emit immediately (no ground gate, no first-paint latency).
735
752
  await sendBackfill(ws, cols, rows);
736
- ws.data.queue = [];
737
- ws.data.ready = true;
753
+ // But only START forwarding live output at a sequence boundary, so the first forwarded
754
+ // flush doesn't begin mid-escape-sequence (an orphan tail on the client — #276).
755
+ const stream = ws.data.stream;
756
+ const flip = () => {
757
+ ws.data.queue = [];
758
+ ws.data.ready = true;
759
+ };
760
+ if (stream) stream.whenAtGround(flip);
761
+ else flip();
738
762
  }
739
763
 
740
764
  // Send a full reset: a control frame with current geometry/status, then the serialized
741
- // emulator state as a raw byte frame. Used on connect, resume, and refresh.
742
- async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
765
+ // emulator state as a raw byte frame. Used on connect, resume, and refresh. On resume /
766
+ // refresh the live stream is flowing to this socket, so `gateGround` defers the
767
+ // reset+content until a sequence boundary — otherwise the reset would splice between the
768
+ // halves of a live escape sequence and orphan its tail on the client (#276).
769
+ async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number, gateGround = false): Promise<void> {
743
770
  const stream = ws.data.stream;
744
771
  if (!stream) return;
745
772
  const snapshot = await stream.backfill(cols, rows);
746
773
  if (ws.data.stream !== stream) return; // socket torn down mid-backfill
747
- ws.send(JSON.stringify({
748
- type: "reset",
749
- session: snapshot.session,
750
- running: snapshot.running,
751
- agentAlive: snapshot.agentAlive,
752
- cols: snapshot.cols,
753
- rows: snapshot.rows,
754
- capturedAt: snapshot.capturedAt,
755
- }));
756
- if (snapshot.content) {
757
- ws.send(new TextEncoder().encode(snapshot.content));
758
- }
774
+ const emit = () => {
775
+ if (ws.data.stream !== stream) return;
776
+ ws.send(JSON.stringify({
777
+ type: "reset",
778
+ session: snapshot.session,
779
+ running: snapshot.running,
780
+ agentAlive: snapshot.agentAlive,
781
+ cols: snapshot.cols,
782
+ rows: snapshot.rows,
783
+ capturedAt: snapshot.capturedAt,
784
+ }));
785
+ if (snapshot.content) {
786
+ ws.send(new TextEncoder().encode(snapshot.content));
787
+ }
788
+ };
789
+ if (gateGround) await new Promise<void>((resolve) => stream.whenAtGround(() => { emit(); resolve(); }));
790
+ else emit();
759
791
  }
760
792
 
761
793
  function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer): void {
@@ -786,11 +818,23 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
786
818
  ws.data.stream?.resize(cols, rows);
787
819
  }
788
820
  } else if (frame.type === "pause") {
821
+ const wasPaused = ws.data.paused === true;
789
822
  ws.data.paused = frame.paused === true;
790
- // We drop (not buffer) bytes while paused, so resync with a fresh backfill.
791
- if (!ws.data.paused && ws.data.synced) void sendBackfill(ws);
823
+ // On resume: only re-backfill if bytes were actually dropped while paused. A
824
+ // focus flick (alt-tab) pauses+resumes with zero output in between — re-backfill
825
+ // there needlessly wipes scrollback and scroll position (#272). When nothing was
826
+ // dropped the client's grid already matches tmux, so just resume the live stream.
827
+ if (wasPaused && !ws.data.paused && ws.data.synced) {
828
+ const dropped = ws.data.droppedWhilePaused ?? 0;
829
+ ws.data.droppedWhilePaused = 0;
830
+ if (dropped > 0) void sendBackfill(ws, undefined, undefined, true);
831
+ }
792
832
  } else if (frame.type === "refresh") {
793
- if (ws.data.synced) void sendBackfill(ws);
833
+ if (ws.data.synced) void sendBackfill(ws, undefined, undefined, true);
834
+ } else if (frame.type === "interactive") {
835
+ // Read-only watchers must not reflow the shared tmux window (#273); the typist owns
836
+ // sizing. The client reports its interactivity so the stream knows who may resize.
837
+ ws.data.stream?.setInteractive(frame.interactive === true);
794
838
  }
795
839
  } catch (e) {
796
840
  ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
package/src/control.ts CHANGED
@@ -3,7 +3,7 @@ import type { OrchestratorConfig } from "./config";
3
3
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
4
4
  import { handleSelfUpgrade } from "./self-upgrade";
5
5
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
6
- import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps } from "./workspace-probe";
6
+ import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
7
7
 
8
8
  interface ControlHandler {
9
9
  handleCommand(command: RelayCommand): Promise<boolean>;
@@ -94,6 +94,7 @@ export function createControlHandler(
94
94
  worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
95
95
  branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
96
96
  deleteBranch: command.params.deleteBranch !== false,
97
+ workspacesRoot: workspacesRoot(config.baseDir),
97
98
  });
98
99
  await relay.updateCommand(command.id, "succeeded", result);
99
100
  } else if (command.type === "workspace.reconcile") {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { diagnoseSessionExit, hydrateTerminalGuests, isSessionAlive, reapTermina
7
7
  import { startApiServer } from "./api";
8
8
  import { recoverManagedAgents } from "./recovery";
9
9
  import { ProviderProbeCache } from "./provider-probe";
10
+ import { sweepEmptyWorkspaceContainers, workspacesRoot } from "./workspace-probe";
10
11
 
11
12
  const args = process.argv.slice(2);
12
13
 
@@ -77,6 +78,10 @@ async function startup(): Promise<void> {
77
78
  // Recover existing tmux sessions
78
79
  await recoverManagedAgents(config, control, relay);
79
80
 
81
+ // Sweep empty workspace container dirs left behind by prior cleanups (#280).
82
+ const swept = sweepEmptyWorkspaceContainers(workspacesRoot(config.baseDir));
83
+ if (swept.length > 0) console.error(`[orchestrator] Swept ${swept.length} empty workspace container(s)`);
84
+
80
85
  // Restore guest-terminal TTLs persisted before the last restart, then reap any
81
86
  // that expired (or were orphaned) while the orchestrator was down (#144).
82
87
  hydrateTerminalGuests();
package/src/spawn.ts CHANGED
@@ -4,7 +4,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { artifactProxyBaseUrl } from "./artifact-proxy";
5
5
  import type { OrchestratorConfig } from "./config";
6
6
  import type { ManagedAgentReport, ManagedSessionExitDiagnostics } from "./relay";
7
- import { resolveSpawnWorkspace } from "./workspace-probe";
7
+ import { resolveSpawnWorkspace, workspacesRoot } from "./workspace-probe";
8
8
  import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
9
9
  import { errMessage } from "agent-relay-sdk";
10
10
  import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
@@ -350,7 +350,7 @@ export async function spawnAgent(
350
350
  ...opts,
351
351
  label,
352
352
  workspaceSymlinks: opts.workspaceSymlinks,
353
- workspaceRoot: join(resolve(config.baseDir), ".agent-relay", "workspaces"),
353
+ workspaceRoot: workspacesRoot(config.baseDir),
354
354
  });
355
355
  const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace };
356
356
 
@@ -25,7 +25,7 @@
25
25
  // its separate `tmux send-keys` calls and is unaffected — control mode is just
26
26
  // another observing client.
27
27
 
28
- import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
28
+ import { sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
29
29
  import type { OrchestratorConfig } from "./config";
30
30
  import { errMessage } from "agent-relay-sdk";
31
31
 
@@ -46,6 +46,29 @@ const RESIZE_SETTLE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RES
46
46
  // streams still correct. 0 disables the corrector.
47
47
  const RESYNC_DEBOUNCE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS) || 120);
48
48
  const RESYNC_MAX_INTERVAL_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS) || 350);
49
+ // When a resync falls due mid-escape-sequence we defer it (ground-state gate, #276) and
50
+ // re-check after this short interval until the stream reaches a sequence boundary.
51
+ const RESYNC_GROUND_RETRY_MS = Math.max(1, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_RETRY_MS) || 16);
52
+ // Hard cap on ground-state deferral: if the stream sits mid-sequence this long (a stalled
53
+ // or dead pane — rare), inject the repaint anyway, CAN-prefixed to abort the client's
54
+ // half-parsed sequence. The orphan-tail risk is accepted in that degenerate case (#276).
55
+ const RESYNC_GROUND_DEFER_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_DEFER_MAX_MS) || 500);
56
+ // Per-command reply timeout for the in-band control protocol. A reply that never lands
57
+ // means a desync; we reject and reset the reply queue so the next command starts clean.
58
+ const COMMAND_TIMEOUT_MS = Math.max(100, Number(process.env.AGENT_RELAY_TERMINAL_COMMAND_TIMEOUT_MS) || 2000);
59
+ // Upper bound a backfill/ready-flip waits for the live stream to reach a sequence boundary
60
+ // before proceeding anyway (whenAtGround fallback for a stalled stream).
61
+ const GROUND_WAIT_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_GROUND_WAIT_MAX_MS) || 500);
62
+ // CAN (cancel) aborts a half-parsed sequence on the client.
63
+ const CAN_BYTE = 0x18;
64
+
65
+ interface PendingCommand {
66
+ wantReply: boolean;
67
+ lines: string[];
68
+ resolve: (lines: string[]) => void;
69
+ reject: (err: Error) => void;
70
+ timer: ReturnType<typeof setTimeout> | null;
71
+ }
49
72
  // On attach we include this many lines of tmux scrollback ABOVE the current screen so the
50
73
  // viewer can scroll back through pre-attach history (the client lands on the live screen;
51
74
  // the history sits in its scroll buffer). This is a one-time per-attach paint, so it's
@@ -67,6 +90,14 @@ export interface TerminalStreamHandle {
67
90
  backfill(cols?: number, rows?: number): Promise<TerminalSnapshot>;
68
91
  write(bytes: Uint8Array): void;
69
92
  resize(cols: number, rows: number): void;
93
+ // Declare whether this viewer is interactive (typing). Only the interactive viewer
94
+ // sizes the shared tmux window, so read-only watchers can't reflow a working
95
+ // terminal by attaching/refreshing at their own width (#273).
96
+ setInteractive(interactive: boolean): void;
97
+ // Run `cb` when the outbound stream is at an ANSI sequence boundary (or after a short
98
+ // fallback). Used to gate reset/backfill injection so it can't split a live escape
99
+ // sequence on the client (#276).
100
+ whenAtGround(cb: () => void): void;
70
101
  release(): void;
71
102
  }
72
103
 
@@ -113,11 +144,38 @@ export function decodeControlOutput(data: string): Uint8Array {
113
144
  return Uint8Array.from(out);
114
145
  }
115
146
 
147
+ // The control stream is read as latin1 so the `%output` octal path above sees faithful
148
+ // bytes. But command-reply blocks (capture-pane grid rows) arrive as raw UTF-8, so their
149
+ // lines reach us as the byte-faithful latin1 representation. Re-decode them to real UTF-8
150
+ // here, or multi-byte glyphs (box-drawing, powerline) double-encode on the next repaint
151
+ // and render as mojibake (#270). Safe to do per line: no UTF-8 continuation byte is
152
+ // 0x0A/0x0D, so the newline split never lands mid-sequence.
153
+ const REPLY_UTF8_DECODER = new TextDecoder("utf-8");
154
+ export function latin1LineToUtf8(line: string): string {
155
+ const bytes = new Uint8Array(line.length);
156
+ for (let i = 0; i < line.length; i++) bytes[i] = line.charCodeAt(i) & 0xff;
157
+ return REPLY_UTF8_DECODER.decode(bytes);
158
+ }
159
+
116
160
  export type ControlLine =
117
161
  | { type: "output"; pane: string; bytes: Uint8Array }
118
162
  | { type: "exit"; reason?: string }
163
+ // Command-reply block framing. Every command written to the control client's stdin is
164
+ // answered, in order, with a `%begin … %end` (or `%error`) block carrying the same
165
+ // command number on both ends. The content lines between them are the command's output
166
+ // (e.g. capture-pane grid rows). Correlating these in-band serializes captures with
167
+ // `%output` deltas — the ordering guarantee that kills the duplicate-text race (#275).
168
+ | { type: "begin"; num: number }
169
+ | { type: "end"; num: number }
170
+ | { type: "error"; num: number }
119
171
  | { type: "other" };
120
172
 
173
+ // `%begin <timestamp> <cmd-number> <flags>` → the command number is the 2nd field.
174
+ function parseBlockNum(line: string, prefixLen: number): number {
175
+ const n = Number(line.slice(prefixLen).trim().split(/\s+/)[1]);
176
+ return Number.isFinite(n) ? n : -1;
177
+ }
178
+
121
179
  export function parseControlLine(line: string): ControlLine {
122
180
  if (line.startsWith("%output ")) {
123
181
  const rest = line.slice(8);
@@ -126,6 +184,9 @@ export function parseControlLine(line: string): ControlLine {
126
184
  const data = sp === -1 ? "" : rest.slice(sp + 1);
127
185
  return { type: "output", pane, bytes: decodeControlOutput(data) };
128
186
  }
187
+ if (line.startsWith("%begin ")) return { type: "begin", num: parseBlockNum(line, 7) };
188
+ if (line.startsWith("%end ")) return { type: "end", num: parseBlockNum(line, 5) };
189
+ if (line.startsWith("%error ")) return { type: "error", num: parseBlockNum(line, 7) };
129
190
  if (line === "%exit" || line.startsWith("%exit ")) {
130
191
  const reason = line.slice(5).trim();
131
192
  return { type: "exit", ...(reason ? { reason } : {}) };
@@ -133,6 +194,67 @@ export function parseControlLine(line: string): ControlLine {
133
194
  return { type: "other" };
134
195
  }
135
196
 
197
+ // --- ANSI ground-state tracker (unit-tested) ---
198
+ //
199
+ // tmux chunks `%output` at pty-read / flush boundaries that can land mid-escape-sequence.
200
+ // That's fine for xterm (its parser is stateful across writes), but if we splice an
201
+ // out-of-band repaint (resync) BETWEEN the two halves of a split sequence, the injected
202
+ // ESC aborts the half-parsed CSI and the sequence's tail bytes then arrive in ground state
203
+ // and print as literal text — the stray `S` (final byte of `CSI Ps S`, scroll-up, which
204
+ // scroll-region TUIs like Claude Code emit constantly). So we track whether the outbound
205
+ // stream is at a sequence boundary (ground) and only inject there. This is a deliberately
206
+ // minimal VT state machine: enough to know "are we mid-sequence?", not a full parser.
207
+ export type AnsiState = "ground" | "esc" | "esc-charset" | "csi" | "string" | "string-esc";
208
+
209
+ export function advanceAnsiState(state: AnsiState, byte: number): AnsiState {
210
+ // CAN (0x18) / SUB (0x1a) abort any in-progress sequence from any state → ground.
211
+ if (byte === 0x18 || byte === 0x1a) return "ground";
212
+ switch (state) {
213
+ case "ground":
214
+ if (byte === 0x1b) return "esc";
215
+ if (byte === 0x9b) return "csi"; // C1 CSI
216
+ if (byte === 0x9d || byte === 0x90) return "string"; // C1 OSC / DCS
217
+ return "ground"; // text, UTF-8 continuation bytes, lone C1 ST, etc.
218
+ case "esc":
219
+ if (byte === 0x5b) return "csi"; // '[' → CSI
220
+ if (byte === 0x5d || byte === 0x50 || byte === 0x58 || byte === 0x5e || byte === 0x5f)
221
+ return "string"; // ']' OSC, 'P' DCS, 'X' SOS, '^' PM, '_' APC → string until ST/BEL
222
+ if (byte >= 0x28 && byte <= 0x2b) return "esc-charset"; // ( ) * + → next byte designates a charset
223
+ if (byte >= 0x20 && byte <= 0x2f) return "esc"; // other intermediate, keep collecting
224
+ return "ground"; // final byte of a 2-byte escape (ESC M, ESC 7, ESC =, …)
225
+ case "esc-charset":
226
+ return "ground"; // the single charset-designator byte
227
+ case "csi":
228
+ if (byte === 0x1b) return "esc"; // ESC cancels and restarts a sequence
229
+ if (byte >= 0x40 && byte <= 0x7e) return "ground"; // final byte ends the CSI
230
+ return "csi"; // parameter (0x30–0x3f) / intermediate (0x20–0x2f) / executed C0
231
+ case "string":
232
+ if (byte === 0x07 || byte === 0x9c) return "ground"; // BEL or C1 ST terminates
233
+ if (byte === 0x1b) return "string-esc"; // possible 7-bit ST (ESC \)
234
+ return "string";
235
+ case "string-esc":
236
+ if (byte === 0x5c) return "ground"; // ST: ESC \
237
+ if (byte === 0x1b) return "string-esc";
238
+ return "string"; // stray ESC inside the string — stay in string mode
239
+ }
240
+ }
241
+
242
+ // Fold a chunk of outbound bytes into the running ANSI state. Returns the state AFTER the
243
+ // chunk, so the caller knows whether the stream currently sits at a sequence boundary.
244
+ export function scanAnsiState(bytes: Uint8Array, state: AnsiState = "ground"): AnsiState {
245
+ for (let i = 0; i < bytes.length; i++) state = advanceAnsiState(state, bytes[i]!);
246
+ return state;
247
+ }
248
+
249
+ // Parse a `#{pane_width} #{pane_height}` reply line into positive dims (omits non-finite).
250
+ export function parsePaneDims(line: string): { cols?: number; rows?: number } {
251
+ const [w, h] = line.trim().split(/\s+/).map(Number);
252
+ return {
253
+ ...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
254
+ ...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
255
+ };
256
+ }
257
+
136
258
  // Encode raw input bytes for `send-keys -H` (space-separated hex octets).
137
259
  export function encodeSendKeysHex(bytes: Uint8Array): string {
138
260
  return Array.from(bytes)
@@ -196,6 +318,24 @@ class SessionStream {
196
318
  private resyncTimer: ReturnType<typeof setTimeout> | null = null;
197
319
  private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
198
320
  private resyncDirty = false;
321
+ // Running ANSI parse state of the outbound (live) stream. A resync repaint or backfill
322
+ // reset is only injected when this is "ground" (a sequence boundary), so it can't split
323
+ // an escape sequence. Tracked over live flush bytes only — injected repaints are balanced.
324
+ private broadcastState: AnsiState = "ground";
325
+ private groundWaiters: Array<() => void> = [];
326
+ // The viewer that owns window sizing (the interactive typist). While set, only it may
327
+ // resize the shared tmux window; read-only watchers render at the current pane size.
328
+ private sizingOwner: TerminalStreamSubscriber | null = null;
329
+ // In-band command-reply correlation (#275): one FIFO entry per command written to the
330
+ // control client's stdin; tmux answers each with a %begin…%end (or %error) block, in
331
+ // order. The first block on attach is unsolicited (discarded via attachBlockSeen).
332
+ private pendingCommands: PendingCommand[] = [];
333
+ private currentBlock: { num: number; lines: string[] } | null = null;
334
+ private attachBlockSeen = false;
335
+ // Resync runs an async in-band capture; guard against overlapping captures and track how
336
+ // long the ground gate has been deferring (for the CAN fallback).
337
+ private resyncInFlight = false;
338
+ private groundDeferStart = 0;
199
339
 
200
340
  constructor(
201
341
  private readonly session: string,
@@ -209,8 +349,9 @@ class SessionStream {
209
349
  const socket = tmuxSocketForSession(this.session);
210
350
  this.socket = socket;
211
351
  // Pin the window size before attaching so the control client can't reflow the
212
- // pane (default window-size would shrink it to the new client's 80x24).
213
- const dims = this.paneDims();
352
+ // pane (default window-size would shrink it to the new client's 80x24). These are
353
+ // one-time, pre-attach spawnSyncs (the control client isn't up yet) — fine to block.
354
+ const dims = this.paneDimsSync();
214
355
  if (dims.cols) this.termCols = dims.cols;
215
356
  if (dims.rows) this.termRows = dims.rows;
216
357
  Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
@@ -262,12 +403,45 @@ class SessionStream {
262
403
  }
263
404
 
264
405
  private handleLine(line: string): void {
406
+ // Inside a reply block, every line is the command's output until its OWN %end/%error
407
+ // (matched by command number — a captured grid row could otherwise masquerade as one).
408
+ // `%output` notifications never appear inside a block, so this can't swallow live deltas.
409
+ if (this.currentBlock) {
410
+ const parsed = parseControlLine(line);
411
+ if ((parsed.type === "end" || parsed.type === "error") && parsed.num === this.currentBlock.num) {
412
+ this.finishBlock(parsed.type === "error");
413
+ } else {
414
+ this.currentBlock.lines.push(line);
415
+ }
416
+ return;
417
+ }
265
418
  const parsed = parseControlLine(line);
266
- if (parsed.type === "output") {
419
+ if (parsed.type === "begin") {
420
+ this.currentBlock = { num: parsed.num, lines: [] };
421
+ } else if (parsed.type === "output") {
267
422
  this.enqueue(parsed.bytes);
268
423
  } else if (parsed.type === "exit") {
269
424
  this.fail(parsed.reason ? `tmux exit: ${parsed.reason}` : "tmux exit");
270
425
  }
426
+ // A stray %end/%error with no open block, or any %other notification — ignore.
427
+ }
428
+
429
+ // Resolve the just-closed reply block against the FIFO head. The very first block on
430
+ // control-mode attach is unsolicited (tmux's empty initial command) — discard it so the
431
+ // correlation never drifts off by one.
432
+ private finishBlock(isError: boolean): void {
433
+ const block = this.currentBlock;
434
+ this.currentBlock = null;
435
+ if (!block) return;
436
+ if (!this.attachBlockSeen) {
437
+ this.attachBlockSeen = true;
438
+ return;
439
+ }
440
+ const entry = this.pendingCommands.shift();
441
+ if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
442
+ if (entry.timer) clearTimeout(entry.timer);
443
+ if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
444
+ else entry.resolve(block.lines.map(latin1LineToUtf8));
271
445
  }
272
446
 
273
447
  private enqueue(bytes: Uint8Array): void {
@@ -297,7 +471,11 @@ class SessionStream {
297
471
  }
298
472
  this.pending = [];
299
473
  this.pendingBytes = 0;
474
+ // Track ANSI ground state over LIVE bytes only (injected repaints are balanced by
475
+ // construction). The resync gate and whenAtGround read this to know it's safe to inject.
476
+ this.broadcastState = scanAnsiState(merged, this.broadcastState);
300
477
  this.broadcast(merged);
478
+ if (this.broadcastState === "ground") this.fireGroundWaiters();
301
479
  // Live deltas just went out; schedule an authoritative resync to correct any drift.
302
480
  this.scheduleResync();
303
481
  }
@@ -317,19 +495,106 @@ class SessionStream {
317
495
  }
318
496
  }
319
497
 
498
+ // Run `cb` at the next ANSI sequence boundary on the live stream (or now, if already at
499
+ // one), so an injected reset/backfill can't splice between the halves of a live escape
500
+ // sequence (#276). Falls back to firing anyway after GROUND_WAIT_MAX_MS if the stream
501
+ // stalls mid-sequence (rare — a dead pane); the reset path does a full clear regardless.
502
+ whenAtGround(cb: () => void): void {
503
+ if (this.closed || this.broadcastState === "ground") {
504
+ cb();
505
+ return;
506
+ }
507
+ let fired = false;
508
+ const fire = () => {
509
+ if (fired) return;
510
+ fired = true;
511
+ clearTimeout(timer);
512
+ const i = this.groundWaiters.indexOf(waiter);
513
+ if (i !== -1) this.groundWaiters.splice(i, 1);
514
+ try {
515
+ cb();
516
+ } catch {}
517
+ };
518
+ const waiter = fire;
519
+ const timer = setTimeout(fire, GROUND_WAIT_MAX_MS);
520
+ this.groundWaiters.push(waiter);
521
+ }
522
+
523
+ private fireGroundWaiters(): void {
524
+ if (this.groundWaiters.length === 0) return;
525
+ const waiters = this.groundWaiters;
526
+ this.groundWaiters = [];
527
+ for (const w of waiters) {
528
+ try {
529
+ w();
530
+ } catch {}
531
+ }
532
+ }
533
+
320
534
  // Schedule a drift-correcting resync: a trailing debounce fires once output settles,
321
535
  // and a capped interval guarantees correction during continuous output.
322
536
  private scheduleResync(): void {
323
537
  if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
324
538
  this.resyncDirty = true;
325
539
  if (this.resyncTimer) clearTimeout(this.resyncTimer);
326
- this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
540
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_DEBOUNCE_MS);
327
541
  if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
328
- this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
542
+ this.resyncCapTimer = setTimeout(() => void this.doResync(), RESYNC_MAX_INTERVAL_MS);
543
+ }
544
+ }
545
+
546
+ private async doResync(): Promise<void> {
547
+ this.clearResyncTimers();
548
+ if (!this.resyncDirty || this.closed || this.subscribers.size === 0) return;
549
+ // One in-band capture at a time; the next flush re-arms us.
550
+ if (this.resyncInFlight) {
551
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
552
+ return;
553
+ }
554
+ // Ground-state gate (#276): never splice a repaint between the two halves of a split
555
+ // escape sequence (the injected ESC aborts the half-parsed CSI and its tail prints as
556
+ // literal text — the stray "S"). If we're mid-sequence, keep the work pending and
557
+ // re-check shortly. If the stream stays mid-sequence past the hard cap (stalled pane),
558
+ // force the repaint with a CAN prefix to abort the client's half-sequence.
559
+ let forceAbort = false;
560
+ if (this.broadcastState !== "ground") {
561
+ const now = Date.now();
562
+ if (this.groundDeferStart === 0) this.groundDeferStart = now;
563
+ if (now - this.groundDeferStart < RESYNC_GROUND_DEFER_MAX_MS) {
564
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
565
+ return;
566
+ }
567
+ forceAbort = true;
568
+ }
569
+ this.groundDeferStart = 0;
570
+ this.resyncDirty = false;
571
+ this.resyncInFlight = true;
572
+ try {
573
+ const repaint = await this.resyncRepaint();
574
+ if (!repaint || this.closed || this.subscribers.size === 0) return;
575
+ // Ordering (#275): every %output read before the capture's %end is already in
576
+ // `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
577
+ // re-apply on top of it (the duplicate-text race). Deltas after %end describe
578
+ // post-capture changes and correctly apply on top of the repaint next flush.
579
+ this.flush();
580
+ const out = forceAbort ? this.prependCan(repaint) : repaint;
581
+ this.broadcast(out);
582
+ tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
583
+ } catch (e) {
584
+ tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
585
+ } finally {
586
+ this.resyncInFlight = false;
329
587
  }
330
588
  }
331
589
 
332
- private doResync(): void {
590
+ private prependCan(bytes: Uint8Array): Uint8Array {
591
+ const out = new Uint8Array(bytes.length + 1);
592
+ out[0] = CAN_BYTE;
593
+ out.set(bytes, 1);
594
+ return out;
595
+ }
596
+
597
+ private clearResyncTimers(): void {
333
598
  if (this.resyncTimer) {
334
599
  clearTimeout(this.resyncTimer);
335
600
  this.resyncTimer = null;
@@ -338,66 +603,61 @@ class SessionStream {
338
603
  clearTimeout(this.resyncCapTimer);
339
604
  this.resyncCapTimer = null;
340
605
  }
341
- if (!this.resyncDirty || this.closed) return;
342
- this.resyncDirty = false;
343
- if (this.subscribers.size === 0) return;
344
- const repaint = this.resyncRepaint();
345
- if (!repaint) return;
346
- this.broadcast(repaint);
347
- tdbg(`resync ${this.session} bytes=${repaint.length} viewers=${this.subscribers.size}`);
348
606
  }
349
607
 
350
608
  // Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
351
609
  // reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
352
610
  // doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
353
611
  // full-screen clear flash, and re-parks the cursor at tmux's true position/visibility.
354
- private resyncRepaint(): Uint8Array | null {
355
- const body = this.readScreen();
612
+ // The grid + cursor are read in-band through the control client (#275), so the capture
613
+ // is serialized with %output deltas and costs no process spawn.
614
+ private async resyncRepaint(): Promise<Uint8Array | null> {
615
+ const body = await this.readScreen();
356
616
  if (!body) return null;
357
- const cursor = this.readCursorState();
358
- const rows = this.paneDims().rows ?? this.termRows;
617
+ const cursor = await this.readCursorState();
618
+ const dims = await this.paneDims();
619
+ const rows = dims.rows ?? this.termRows;
359
620
  return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
360
621
  }
361
622
 
362
- // Read tmux's authoritative current-screen grid (styled, no scrollback) plus a
363
- // consistent cursor position, and turn it into a client repaint. capture-pane reads
364
- // tmux's real emulator grid, so it's always internally coherent; captureConsistent
365
- // guards the (content, cursor) read against a mid-render cursor move.
366
- private captureScreen(): { content: string; cursorX?: number; cursorY?: number } {
367
- const { content, cursor } = captureConsistent(
368
- () => this.readBackfill(),
369
- () => this.readCursor(),
370
- );
623
+ // Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
624
+ // client repaint. capture-pane reads tmux's real emulator grid, so it's internally
625
+ // coherent; we bracket content/cursor/content (in-band, cheap now) to guard the read
626
+ // against a mid-render change.
627
+ private async captureScreen(): Promise<{ content: string; cursorX?: number; cursorY?: number }> {
628
+ let content = await this.readBackfill();
629
+ let cursor = await this.readCursor();
630
+ for (let attempt = 0; attempt < 4; attempt++) {
631
+ const recheck = await this.readBackfill();
632
+ if (recheck === content) break;
633
+ content = recheck;
634
+ cursor = await this.readCursor();
635
+ }
371
636
  return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
372
637
  }
373
638
 
374
639
  // Backfill capture: current screen plus scrollback history above it (cursor is
375
640
  // viewport-relative, so it still parks on the live screen). Falls back to screen-only.
376
- private readBackfill(): string {
641
+ private async readBackfill(): Promise<string> {
377
642
  if (BACKFILL_SCROLLBACK_LINES <= 0) return this.readScreen();
378
- const r = Bun.spawnSync(
379
- tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-S", `-${BACKFILL_SCROLLBACK_LINES}`, "-t", this.session),
380
- { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
381
- );
382
- return r.exitCode === 0 ? r.stdout.toString("utf8") : this.readScreen();
643
+ const lines = await this.runCommand(
644
+ `capture-pane -p -e -S -${BACKFILL_SCROLLBACK_LINES} -t "${this.session}"`,
645
+ ).catch(() => null);
646
+ return lines ? lines.join("\n") : this.readScreen();
383
647
  }
384
648
 
385
649
  // Current-screen-only capture (no scrollback) — used by the live resync corrector so it
386
650
  // overwrites just the visible grid and leaves the client's scroll-back history intact.
387
- private readScreen(): string {
388
- const r = Bun.spawnSync(
389
- tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-t", this.session),
390
- { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
391
- );
392
- return r.exitCode === 0 ? r.stdout.toString("utf8") : "";
393
- }
394
-
395
- private readCursor(): { cursorX?: number; cursorY?: number } {
396
- const r = Bun.spawnSync(
397
- tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y}"),
398
- { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
399
- );
400
- const [x, y] = r.stdout.toString().trim().split(/\s+/).map(Number);
651
+ private async readScreen(): Promise<string> {
652
+ const lines = await this.runCommand(`capture-pane -p -e -t "${this.session}"`).catch(() => null);
653
+ return lines ? lines.join("\n") : "";
654
+ }
655
+
656
+ private async readCursor(): Promise<{ cursorX?: number; cursorY?: number }> {
657
+ const lines = await this.runCommand(
658
+ `display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y}"`,
659
+ ).catch(() => [] as string[]);
660
+ const [x, y] = (lines[0] ?? "").trim().split(/\s+/).map(Number);
401
661
  return {
402
662
  ...(Number.isFinite(x) ? { cursorX: x } : {}),
403
663
  ...(Number.isFinite(y) ? { cursorY: y } : {}),
@@ -406,12 +666,11 @@ class SessionStream {
406
666
 
407
667
  // Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
408
668
  // the TUI had the cursor shown or hidden rather than guessing.
409
- private readCursorState(): { cursorX?: number; cursorY?: number; visible?: boolean } {
410
- const r = Bun.spawnSync(
411
- tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y} #{cursor_flag}"),
412
- { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
413
- );
414
- const [x, y, flag] = r.stdout.toString().trim().split(/\s+/);
669
+ private async readCursorState(): Promise<{ cursorX?: number; cursorY?: number; visible?: boolean }> {
670
+ const lines = await this.runCommand(
671
+ `display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y} #{cursor_flag}"`,
672
+ ).catch(() => [] as string[]);
673
+ const [x, y, flag] = (lines[0] ?? "").trim().split(/\s+/);
415
674
  const cx = Number(x);
416
675
  const cy = Number(y);
417
676
  return {
@@ -424,25 +683,22 @@ class SessionStream {
424
683
  // Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
425
684
  // When the viewer's dimensions are given we resize the tmux pane first so the TUI
426
685
  // re-renders at the viewer's width, let it settle, then capture the post-resize frame.
427
- async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
686
+ async backfill(sub: TerminalStreamSubscriber, cols?: number, rows?: number): Promise<TerminalSnapshot> {
428
687
  let resized = false;
429
- if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
688
+ if (this.maySize(sub) && Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
430
689
  const c = Math.trunc(cols as number);
431
690
  const r = Math.trunc(rows as number);
432
691
  if (c !== this.termCols || r !== this.termRows) {
433
- Bun.spawnSync(
434
- tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
435
- { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
436
- );
692
+ void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
437
693
  this.termCols = c;
438
694
  this.termRows = r;
439
695
  resized = true;
440
696
  }
441
697
  }
442
698
  if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
443
- const { content } = this.captureScreen();
699
+ const { content } = await this.captureScreen();
444
700
  // Report tmux's actual pane size (authoritative) back to the viewer.
445
- const dims = this.paneDims();
701
+ const dims = await this.paneDims();
446
702
  if (dims.cols) this.termCols = dims.cols;
447
703
  if (dims.rows) this.termRows = dims.rows;
448
704
  const live = sessionLiveness(this.session);
@@ -458,45 +714,106 @@ class SessionStream {
458
714
  };
459
715
  }
460
716
 
461
- write(bytes: Uint8Array): void {
717
+ write(sub: TerminalStreamSubscriber, bytes: Uint8Array): void {
462
718
  if (this.closed || !this.proc || bytes.length === 0) return;
463
- this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
719
+ // Typing is the strongest interactivity signal — the typist owns window sizing.
720
+ this.sizingOwner = sub;
721
+ void this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
722
+ }
723
+
724
+ setInteractive(sub: TerminalStreamSubscriber, interactive: boolean): void {
725
+ if (interactive) {
726
+ this.sizingOwner = sub;
727
+ } else if (this.sizingOwner === sub) {
728
+ this.sizingOwner = null;
729
+ }
730
+ }
731
+
732
+ // A viewer may size the shared window only if it's the sizing owner, or nobody owns
733
+ // sizing yet (first viewer, before anyone has declared interactivity — it still needs
734
+ // a sensible size). Once an interactive viewer exists, read-only watchers never resize.
735
+ private maySize(sub: TerminalStreamSubscriber): boolean {
736
+ return this.sizingOwner === null || this.sizingOwner === sub;
464
737
  }
465
738
 
466
- resize(cols: number, rows: number): void {
739
+ resize(sub: TerminalStreamSubscriber, cols: number, rows: number): void {
467
740
  if (this.closed || !this.proc) return;
741
+ if (!this.maySize(sub)) return; // read-only watcher: don't reflow the shared window
468
742
  if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
469
743
  const c = Math.trunc(cols);
470
744
  const r = Math.trunc(rows);
471
- this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
745
+ void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
472
746
  this.termCols = c;
473
747
  this.termRows = r;
474
748
  tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
475
749
  }
476
750
 
477
- private paneDims(): { cols?: number; rows?: number } {
751
+ // Pane dimensions, in-band through the control client (no spawn).
752
+ private async paneDims(): Promise<{ cols?: number; rows?: number }> {
753
+ const lines = await this.runCommand(
754
+ `display-message -p -t "${this.session}" "#{pane_width} #{pane_height}"`,
755
+ ).catch(() => [] as string[]);
756
+ return parsePaneDims(lines[0] ?? "");
757
+ }
758
+
759
+ // Synchronous pane-dims read for start(), which runs before the control client attaches.
760
+ private paneDimsSync(): { cols?: number; rows?: number } {
478
761
  try {
479
762
  const out = Bun.spawnSync(
480
763
  tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
481
764
  { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
482
765
  ).stdout.toString().trim();
483
- const [w, h] = out.split(/\s+/).map(Number);
484
- return {
485
- ...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
486
- ...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
487
- };
766
+ return parsePaneDims(out);
488
767
  } catch {
489
768
  return {};
490
769
  }
491
770
  }
492
771
 
493
- private command(line: string): void {
772
+ // Run a control command expecting its reply lines (the FIFO correlates them in order).
773
+ private runCommand(line: string): Promise<string[]> {
774
+ return this.command(line, true);
775
+ }
776
+
777
+ // Write a command to the control client's stdin and register a FIFO entry for its reply
778
+ // block. `wantReply` commands resolve with the block's lines (or reject on %error /
779
+ // timeout); fire-and-forget commands resolve with [] once their empty block closes.
780
+ private command(line: string, wantReply = false): Promise<string[]> {
781
+ if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
494
782
  const stdin = this.proc?.stdin;
495
- if (!stdin || typeof stdin === "number") return;
496
- try {
497
- (stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
498
- (stdin as { flush?(): void }).flush?.();
499
- } catch {}
783
+ if (!stdin || typeof stdin === "number") {
784
+ return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
785
+ }
786
+ return new Promise<string[]>((resolve, reject) => {
787
+ const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
788
+ if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
789
+ this.pendingCommands.push(entry);
790
+ try {
791
+ (stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
792
+ (stdin as { flush?(): void }).flush?.();
793
+ } catch (e) {
794
+ const idx = this.pendingCommands.indexOf(entry);
795
+ if (idx !== -1) this.pendingCommands.splice(idx, 1);
796
+ if (entry.timer) clearTimeout(entry.timer);
797
+ if (wantReply) reject(e instanceof Error ? e : new Error(String(e)));
798
+ else resolve([]);
799
+ }
800
+ });
801
+ }
802
+
803
+ // A reply that never arrived means a protocol desync — reject the awaited command and
804
+ // reset the whole reply queue + block state so the next command starts clean. The byte
805
+ // stream itself keeps flowing; only in-flight captures fail (the resync just skips a beat).
806
+ private commandTimeout(entry: PendingCommand): void {
807
+ if (!this.pendingCommands.includes(entry)) return;
808
+ tdbg(`command timeout ${this.session}; resetting reply queue (${this.pendingCommands.length} pending)`);
809
+ const pend = this.pendingCommands;
810
+ this.pendingCommands = [];
811
+ this.currentBlock = null;
812
+ for (const e of pend) {
813
+ if (e.timer) clearTimeout(e.timer);
814
+ if (e.wantReply) e.reject(new Error("control command timed out"));
815
+ else e.resolve([]);
816
+ }
500
817
  }
501
818
 
502
819
  addSubscriber(sub: TerminalStreamSubscriber): void {
@@ -506,6 +823,7 @@ class SessionStream {
506
823
 
507
824
  removeSubscriber(sub: TerminalStreamSubscriber): void {
508
825
  if (!this.subscribers.delete(sub)) return;
826
+ if (this.sizingOwner === sub) this.sizingOwner = null;
509
827
  tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
510
828
  if (this.subscribers.size === 0) this.destroy();
511
829
  }
@@ -529,14 +847,17 @@ class SessionStream {
529
847
  clearTimeout(this.flushTimer);
530
848
  this.flushTimer = null;
531
849
  }
532
- if (this.resyncTimer !== null) {
533
- clearTimeout(this.resyncTimer);
534
- this.resyncTimer = null;
535
- }
536
- if (this.resyncCapTimer !== null) {
537
- clearTimeout(this.resyncCapTimer);
538
- this.resyncCapTimer = null;
850
+ this.clearResyncTimers();
851
+ // Reject any in-flight in-band commands so awaiters (captures) unwind instead of hanging.
852
+ const pend = this.pendingCommands;
853
+ this.pendingCommands = [];
854
+ this.currentBlock = null;
855
+ for (const e of pend) {
856
+ if (e.timer) clearTimeout(e.timer);
857
+ if (e.wantReply) e.reject(new Error("terminal stream closed"));
858
+ else e.resolve([]);
539
859
  }
860
+ this.fireGroundWaiters();
540
861
  try {
541
862
  this.proc?.kill();
542
863
  } catch {}
@@ -563,9 +884,11 @@ export function acquireTerminalStream(
563
884
  const active = stream;
564
885
  active.addSubscriber(subscriber);
565
886
  return {
566
- backfill: (cols, rows) => active.backfill(cols, rows),
567
- write: (bytes) => active.write(bytes),
568
- resize: (cols, rows) => active.resize(cols, rows),
887
+ backfill: (cols, rows) => active.backfill(subscriber, cols, rows),
888
+ write: (bytes) => active.write(subscriber, bytes),
889
+ resize: (cols, rows) => active.resize(subscriber, cols, rows),
890
+ setInteractive: (interactive) => active.setInteractive(subscriber, interactive),
891
+ whenAtGround: (cb) => active.whenAtGround(cb),
569
892
  release: () => active.removeSubscriber(subscriber),
570
893
  };
571
894
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -127,7 +127,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
127
127
  const repoRoot = probe.repoRoot;
128
128
  const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
129
129
  const branch = await availableBranch(repoRoot, branchName(input, id));
130
- const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : join(homedir(), ".agent-relay", "workspaces");
130
+ const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
131
131
  const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
132
132
  mkdirSync(join(worktreePath, ".."), { recursive: true });
133
133
  requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
@@ -471,7 +471,7 @@ export function pruneWorktrees(input: { repoRoot?: string }): { repoRoot: string
471
471
  return { repoRoot: repo, pruned: true, output: result.stdout.trim() || undefined };
472
472
  }
473
473
 
474
- export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean } {
474
+ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; containerRemoved?: boolean } {
475
475
  if (!workspace.worktreePath) throw new Error("worktreePath required");
476
476
  const path = resolve(workspace.worktreePath);
477
477
  const repo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
@@ -484,7 +484,38 @@ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?:
484
484
  if (workspace.branch && workspace.deleteBranch !== false) {
485
485
  branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
486
486
  }
487
- return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted };
487
+ const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
488
+ return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, containerRemoved };
489
+ }
490
+
491
+ export function sweepEmptyWorkspaceContainers(wsRoot: string): string[] {
492
+ const root = resolve(wsRoot);
493
+ if (!existsSync(root)) return [];
494
+ const removed: string[] = [];
495
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
496
+ if (!entry.isDirectory()) continue;
497
+ const dir = join(root, entry.name);
498
+ if (readdirSync(dir).length === 0) {
499
+ rmdirSync(dir);
500
+ removed.push(dir);
501
+ }
502
+ }
503
+ return removed;
504
+ }
505
+
506
+ function removeEmptyContainer(container: string, wsRoot: string): boolean {
507
+ try {
508
+ if (!existsSync(container)) return false;
509
+ if (readdirSync(container).length !== 0) return false;
510
+ if (!isDirectChildOf(container, wsRoot)) return false;
511
+ rmdirSync(container);
512
+ return true;
513
+ } catch { return false; }
514
+ }
515
+
516
+ function isDirectChildOf(child: string, parent: string): boolean {
517
+ const rel = relative(resolve(parent), resolve(child));
518
+ return !!rel && !rel.includes("/") && !rel.startsWith("..") && !isAbsolute(rel);
488
519
  }
489
520
 
490
521
  /**
@@ -1055,7 +1086,9 @@ function workspaceId(input: WorkspaceResolutionInput): string {
1055
1086
 
1056
1087
  function branchName(input: WorkspaceResolutionInput, id: string): string {
1057
1088
  const owner = input.policyName || input.label || input.automationId || "manual";
1058
- return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
1089
+ // 40 fits a full UUID (36) plus the `sp_`-stripped slack. A tighter cap (was 24)
1090
+ // sliced the session UUID mid-string, leaving an unaddressable, dangling ref (#282).
1091
+ return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 40)}`;
1059
1092
  }
1060
1093
 
1061
1094
  /** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
@@ -1069,6 +1102,10 @@ function nextBranchName(repoRoot: string, branch: string): string {
1069
1102
  return `${stem}-${Date.now()}`;
1070
1103
  }
1071
1104
 
1105
+ export function workspacesRoot(baseDir: string): string {
1106
+ return join(resolve(baseDir), ".agent-relay", "workspaces");
1107
+ }
1108
+
1072
1109
  function repoSlug(repoRoot: string): string {
1073
1110
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
1074
1111
  return `${safeSegment(basename(repoRoot), 60)}-${hash}`;