agent-relay-orchestrator 0.27.0 → 0.27.2

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.0",
3
+ "version": "0.27.2",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
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
 
@@ -116,8 +147,22 @@ export function decodeControlOutput(data: string): Uint8Array {
116
147
  export type ControlLine =
117
148
  | { type: "output"; pane: string; bytes: Uint8Array }
118
149
  | { type: "exit"; reason?: string }
150
+ // Command-reply block framing. Every command written to the control client's stdin is
151
+ // answered, in order, with a `%begin … %end` (or `%error`) block carrying the same
152
+ // command number on both ends. The content lines between them are the command's output
153
+ // (e.g. capture-pane grid rows). Correlating these in-band serializes captures with
154
+ // `%output` deltas — the ordering guarantee that kills the duplicate-text race (#275).
155
+ | { type: "begin"; num: number }
156
+ | { type: "end"; num: number }
157
+ | { type: "error"; num: number }
119
158
  | { type: "other" };
120
159
 
160
+ // `%begin <timestamp> <cmd-number> <flags>` → the command number is the 2nd field.
161
+ function parseBlockNum(line: string, prefixLen: number): number {
162
+ const n = Number(line.slice(prefixLen).trim().split(/\s+/)[1]);
163
+ return Number.isFinite(n) ? n : -1;
164
+ }
165
+
121
166
  export function parseControlLine(line: string): ControlLine {
122
167
  if (line.startsWith("%output ")) {
123
168
  const rest = line.slice(8);
@@ -126,6 +171,9 @@ export function parseControlLine(line: string): ControlLine {
126
171
  const data = sp === -1 ? "" : rest.slice(sp + 1);
127
172
  return { type: "output", pane, bytes: decodeControlOutput(data) };
128
173
  }
174
+ if (line.startsWith("%begin ")) return { type: "begin", num: parseBlockNum(line, 7) };
175
+ if (line.startsWith("%end ")) return { type: "end", num: parseBlockNum(line, 5) };
176
+ if (line.startsWith("%error ")) return { type: "error", num: parseBlockNum(line, 7) };
129
177
  if (line === "%exit" || line.startsWith("%exit ")) {
130
178
  const reason = line.slice(5).trim();
131
179
  return { type: "exit", ...(reason ? { reason } : {}) };
@@ -133,6 +181,67 @@ export function parseControlLine(line: string): ControlLine {
133
181
  return { type: "other" };
134
182
  }
135
183
 
184
+ // --- ANSI ground-state tracker (unit-tested) ---
185
+ //
186
+ // tmux chunks `%output` at pty-read / flush boundaries that can land mid-escape-sequence.
187
+ // That's fine for xterm (its parser is stateful across writes), but if we splice an
188
+ // out-of-band repaint (resync) BETWEEN the two halves of a split sequence, the injected
189
+ // ESC aborts the half-parsed CSI and the sequence's tail bytes then arrive in ground state
190
+ // and print as literal text — the stray `S` (final byte of `CSI Ps S`, scroll-up, which
191
+ // scroll-region TUIs like Claude Code emit constantly). So we track whether the outbound
192
+ // stream is at a sequence boundary (ground) and only inject there. This is a deliberately
193
+ // minimal VT state machine: enough to know "are we mid-sequence?", not a full parser.
194
+ export type AnsiState = "ground" | "esc" | "esc-charset" | "csi" | "string" | "string-esc";
195
+
196
+ export function advanceAnsiState(state: AnsiState, byte: number): AnsiState {
197
+ // CAN (0x18) / SUB (0x1a) abort any in-progress sequence from any state → ground.
198
+ if (byte === 0x18 || byte === 0x1a) return "ground";
199
+ switch (state) {
200
+ case "ground":
201
+ if (byte === 0x1b) return "esc";
202
+ if (byte === 0x9b) return "csi"; // C1 CSI
203
+ if (byte === 0x9d || byte === 0x90) return "string"; // C1 OSC / DCS
204
+ return "ground"; // text, UTF-8 continuation bytes, lone C1 ST, etc.
205
+ case "esc":
206
+ if (byte === 0x5b) return "csi"; // '[' → CSI
207
+ if (byte === 0x5d || byte === 0x50 || byte === 0x58 || byte === 0x5e || byte === 0x5f)
208
+ return "string"; // ']' OSC, 'P' DCS, 'X' SOS, '^' PM, '_' APC → string until ST/BEL
209
+ if (byte >= 0x28 && byte <= 0x2b) return "esc-charset"; // ( ) * + → next byte designates a charset
210
+ if (byte >= 0x20 && byte <= 0x2f) return "esc"; // other intermediate, keep collecting
211
+ return "ground"; // final byte of a 2-byte escape (ESC M, ESC 7, ESC =, …)
212
+ case "esc-charset":
213
+ return "ground"; // the single charset-designator byte
214
+ case "csi":
215
+ if (byte === 0x1b) return "esc"; // ESC cancels and restarts a sequence
216
+ if (byte >= 0x40 && byte <= 0x7e) return "ground"; // final byte ends the CSI
217
+ return "csi"; // parameter (0x30–0x3f) / intermediate (0x20–0x2f) / executed C0
218
+ case "string":
219
+ if (byte === 0x07 || byte === 0x9c) return "ground"; // BEL or C1 ST terminates
220
+ if (byte === 0x1b) return "string-esc"; // possible 7-bit ST (ESC \)
221
+ return "string";
222
+ case "string-esc":
223
+ if (byte === 0x5c) return "ground"; // ST: ESC \
224
+ if (byte === 0x1b) return "string-esc";
225
+ return "string"; // stray ESC inside the string — stay in string mode
226
+ }
227
+ }
228
+
229
+ // Fold a chunk of outbound bytes into the running ANSI state. Returns the state AFTER the
230
+ // chunk, so the caller knows whether the stream currently sits at a sequence boundary.
231
+ export function scanAnsiState(bytes: Uint8Array, state: AnsiState = "ground"): AnsiState {
232
+ for (let i = 0; i < bytes.length; i++) state = advanceAnsiState(state, bytes[i]!);
233
+ return state;
234
+ }
235
+
236
+ // Parse a `#{pane_width} #{pane_height}` reply line into positive dims (omits non-finite).
237
+ export function parsePaneDims(line: string): { cols?: number; rows?: number } {
238
+ const [w, h] = line.trim().split(/\s+/).map(Number);
239
+ return {
240
+ ...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
241
+ ...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
242
+ };
243
+ }
244
+
136
245
  // Encode raw input bytes for `send-keys -H` (space-separated hex octets).
137
246
  export function encodeSendKeysHex(bytes: Uint8Array): string {
138
247
  return Array.from(bytes)
@@ -196,6 +305,24 @@ class SessionStream {
196
305
  private resyncTimer: ReturnType<typeof setTimeout> | null = null;
197
306
  private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
198
307
  private resyncDirty = false;
308
+ // Running ANSI parse state of the outbound (live) stream. A resync repaint or backfill
309
+ // reset is only injected when this is "ground" (a sequence boundary), so it can't split
310
+ // an escape sequence. Tracked over live flush bytes only — injected repaints are balanced.
311
+ private broadcastState: AnsiState = "ground";
312
+ private groundWaiters: Array<() => void> = [];
313
+ // The viewer that owns window sizing (the interactive typist). While set, only it may
314
+ // resize the shared tmux window; read-only watchers render at the current pane size.
315
+ private sizingOwner: TerminalStreamSubscriber | null = null;
316
+ // In-band command-reply correlation (#275): one FIFO entry per command written to the
317
+ // control client's stdin; tmux answers each with a %begin…%end (or %error) block, in
318
+ // order. The first block on attach is unsolicited (discarded via attachBlockSeen).
319
+ private pendingCommands: PendingCommand[] = [];
320
+ private currentBlock: { num: number; lines: string[] } | null = null;
321
+ private attachBlockSeen = false;
322
+ // Resync runs an async in-band capture; guard against overlapping captures and track how
323
+ // long the ground gate has been deferring (for the CAN fallback).
324
+ private resyncInFlight = false;
325
+ private groundDeferStart = 0;
199
326
 
200
327
  constructor(
201
328
  private readonly session: string,
@@ -209,8 +336,9 @@ class SessionStream {
209
336
  const socket = tmuxSocketForSession(this.session);
210
337
  this.socket = socket;
211
338
  // 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();
339
+ // pane (default window-size would shrink it to the new client's 80x24). These are
340
+ // one-time, pre-attach spawnSyncs (the control client isn't up yet) — fine to block.
341
+ const dims = this.paneDimsSync();
214
342
  if (dims.cols) this.termCols = dims.cols;
215
343
  if (dims.rows) this.termRows = dims.rows;
216
344
  Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
@@ -262,12 +390,45 @@ class SessionStream {
262
390
  }
263
391
 
264
392
  private handleLine(line: string): void {
393
+ // Inside a reply block, every line is the command's output until its OWN %end/%error
394
+ // (matched by command number — a captured grid row could otherwise masquerade as one).
395
+ // `%output` notifications never appear inside a block, so this can't swallow live deltas.
396
+ if (this.currentBlock) {
397
+ const parsed = parseControlLine(line);
398
+ if ((parsed.type === "end" || parsed.type === "error") && parsed.num === this.currentBlock.num) {
399
+ this.finishBlock(parsed.type === "error");
400
+ } else {
401
+ this.currentBlock.lines.push(line);
402
+ }
403
+ return;
404
+ }
265
405
  const parsed = parseControlLine(line);
266
- if (parsed.type === "output") {
406
+ if (parsed.type === "begin") {
407
+ this.currentBlock = { num: parsed.num, lines: [] };
408
+ } else if (parsed.type === "output") {
267
409
  this.enqueue(parsed.bytes);
268
410
  } else if (parsed.type === "exit") {
269
411
  this.fail(parsed.reason ? `tmux exit: ${parsed.reason}` : "tmux exit");
270
412
  }
413
+ // A stray %end/%error with no open block, or any %other notification — ignore.
414
+ }
415
+
416
+ // Resolve the just-closed reply block against the FIFO head. The very first block on
417
+ // control-mode attach is unsolicited (tmux's empty initial command) — discard it so the
418
+ // correlation never drifts off by one.
419
+ private finishBlock(isError: boolean): void {
420
+ const block = this.currentBlock;
421
+ this.currentBlock = null;
422
+ if (!block) return;
423
+ if (!this.attachBlockSeen) {
424
+ this.attachBlockSeen = true;
425
+ return;
426
+ }
427
+ const entry = this.pendingCommands.shift();
428
+ if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
429
+ if (entry.timer) clearTimeout(entry.timer);
430
+ if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
431
+ else entry.resolve(block.lines);
271
432
  }
272
433
 
273
434
  private enqueue(bytes: Uint8Array): void {
@@ -297,7 +458,11 @@ class SessionStream {
297
458
  }
298
459
  this.pending = [];
299
460
  this.pendingBytes = 0;
461
+ // Track ANSI ground state over LIVE bytes only (injected repaints are balanced by
462
+ // construction). The resync gate and whenAtGround read this to know it's safe to inject.
463
+ this.broadcastState = scanAnsiState(merged, this.broadcastState);
300
464
  this.broadcast(merged);
465
+ if (this.broadcastState === "ground") this.fireGroundWaiters();
301
466
  // Live deltas just went out; schedule an authoritative resync to correct any drift.
302
467
  this.scheduleResync();
303
468
  }
@@ -317,19 +482,106 @@ class SessionStream {
317
482
  }
318
483
  }
319
484
 
485
+ // Run `cb` at the next ANSI sequence boundary on the live stream (or now, if already at
486
+ // one), so an injected reset/backfill can't splice between the halves of a live escape
487
+ // sequence (#276). Falls back to firing anyway after GROUND_WAIT_MAX_MS if the stream
488
+ // stalls mid-sequence (rare — a dead pane); the reset path does a full clear regardless.
489
+ whenAtGround(cb: () => void): void {
490
+ if (this.closed || this.broadcastState === "ground") {
491
+ cb();
492
+ return;
493
+ }
494
+ let fired = false;
495
+ const fire = () => {
496
+ if (fired) return;
497
+ fired = true;
498
+ clearTimeout(timer);
499
+ const i = this.groundWaiters.indexOf(waiter);
500
+ if (i !== -1) this.groundWaiters.splice(i, 1);
501
+ try {
502
+ cb();
503
+ } catch {}
504
+ };
505
+ const waiter = fire;
506
+ const timer = setTimeout(fire, GROUND_WAIT_MAX_MS);
507
+ this.groundWaiters.push(waiter);
508
+ }
509
+
510
+ private fireGroundWaiters(): void {
511
+ if (this.groundWaiters.length === 0) return;
512
+ const waiters = this.groundWaiters;
513
+ this.groundWaiters = [];
514
+ for (const w of waiters) {
515
+ try {
516
+ w();
517
+ } catch {}
518
+ }
519
+ }
520
+
320
521
  // Schedule a drift-correcting resync: a trailing debounce fires once output settles,
321
522
  // and a capped interval guarantees correction during continuous output.
322
523
  private scheduleResync(): void {
323
524
  if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
324
525
  this.resyncDirty = true;
325
526
  if (this.resyncTimer) clearTimeout(this.resyncTimer);
326
- this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
527
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_DEBOUNCE_MS);
327
528
  if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
328
- this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
529
+ this.resyncCapTimer = setTimeout(() => void this.doResync(), RESYNC_MAX_INTERVAL_MS);
530
+ }
531
+ }
532
+
533
+ private async doResync(): Promise<void> {
534
+ this.clearResyncTimers();
535
+ if (!this.resyncDirty || this.closed || this.subscribers.size === 0) return;
536
+ // One in-band capture at a time; the next flush re-arms us.
537
+ if (this.resyncInFlight) {
538
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
539
+ return;
329
540
  }
541
+ // Ground-state gate (#276): never splice a repaint between the two halves of a split
542
+ // escape sequence (the injected ESC aborts the half-parsed CSI and its tail prints as
543
+ // literal text — the stray "S"). If we're mid-sequence, keep the work pending and
544
+ // re-check shortly. If the stream stays mid-sequence past the hard cap (stalled pane),
545
+ // force the repaint with a CAN prefix to abort the client's half-sequence.
546
+ let forceAbort = false;
547
+ if (this.broadcastState !== "ground") {
548
+ const now = Date.now();
549
+ if (this.groundDeferStart === 0) this.groundDeferStart = now;
550
+ if (now - this.groundDeferStart < RESYNC_GROUND_DEFER_MAX_MS) {
551
+ this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
552
+ return;
553
+ }
554
+ forceAbort = true;
555
+ }
556
+ this.groundDeferStart = 0;
557
+ this.resyncDirty = false;
558
+ this.resyncInFlight = true;
559
+ try {
560
+ const repaint = await this.resyncRepaint();
561
+ if (!repaint || this.closed || this.subscribers.size === 0) return;
562
+ // Ordering (#275): every %output read before the capture's %end is already in
563
+ // `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
564
+ // re-apply on top of it (the duplicate-text race). Deltas after %end describe
565
+ // post-capture changes and correctly apply on top of the repaint next flush.
566
+ this.flush();
567
+ const out = forceAbort ? this.prependCan(repaint) : repaint;
568
+ this.broadcast(out);
569
+ tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
570
+ } catch (e) {
571
+ tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
572
+ } finally {
573
+ this.resyncInFlight = false;
574
+ }
575
+ }
576
+
577
+ private prependCan(bytes: Uint8Array): Uint8Array {
578
+ const out = new Uint8Array(bytes.length + 1);
579
+ out[0] = CAN_BYTE;
580
+ out.set(bytes, 1);
581
+ return out;
330
582
  }
331
583
 
332
- private doResync(): void {
584
+ private clearResyncTimers(): void {
333
585
  if (this.resyncTimer) {
334
586
  clearTimeout(this.resyncTimer);
335
587
  this.resyncTimer = null;
@@ -338,66 +590,61 @@ class SessionStream {
338
590
  clearTimeout(this.resyncCapTimer);
339
591
  this.resyncCapTimer = null;
340
592
  }
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
593
  }
349
594
 
350
595
  // Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
351
596
  // reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
352
597
  // doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
353
598
  // 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();
599
+ // The grid + cursor are read in-band through the control client (#275), so the capture
600
+ // is serialized with %output deltas and costs no process spawn.
601
+ private async resyncRepaint(): Promise<Uint8Array | null> {
602
+ const body = await this.readScreen();
356
603
  if (!body) return null;
357
- const cursor = this.readCursorState();
358
- const rows = this.paneDims().rows ?? this.termRows;
604
+ const cursor = await this.readCursorState();
605
+ const dims = await this.paneDims();
606
+ const rows = dims.rows ?? this.termRows;
359
607
  return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
360
608
  }
361
609
 
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
- );
610
+ // Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
611
+ // client repaint. capture-pane reads tmux's real emulator grid, so it's internally
612
+ // coherent; we bracket content/cursor/content (in-band, cheap now) to guard the read
613
+ // against a mid-render change.
614
+ private async captureScreen(): Promise<{ content: string; cursorX?: number; cursorY?: number }> {
615
+ let content = await this.readBackfill();
616
+ let cursor = await this.readCursor();
617
+ for (let attempt = 0; attempt < 4; attempt++) {
618
+ const recheck = await this.readBackfill();
619
+ if (recheck === content) break;
620
+ content = recheck;
621
+ cursor = await this.readCursor();
622
+ }
371
623
  return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
372
624
  }
373
625
 
374
626
  // Backfill capture: current screen plus scrollback history above it (cursor is
375
627
  // viewport-relative, so it still parks on the live screen). Falls back to screen-only.
376
- private readBackfill(): string {
628
+ private async readBackfill(): Promise<string> {
377
629
  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();
630
+ const lines = await this.runCommand(
631
+ `capture-pane -p -e -S -${BACKFILL_SCROLLBACK_LINES} -t "${this.session}"`,
632
+ ).catch(() => null);
633
+ return lines ? lines.join("\n") : this.readScreen();
383
634
  }
384
635
 
385
636
  // Current-screen-only capture (no scrollback) — used by the live resync corrector so it
386
637
  // 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);
638
+ private async readScreen(): Promise<string> {
639
+ const lines = await this.runCommand(`capture-pane -p -e -t "${this.session}"`).catch(() => null);
640
+ return lines ? lines.join("\n") : "";
641
+ }
642
+
643
+ private async readCursor(): Promise<{ cursorX?: number; cursorY?: number }> {
644
+ const lines = await this.runCommand(
645
+ `display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y}"`,
646
+ ).catch(() => [] as string[]);
647
+ const [x, y] = (lines[0] ?? "").trim().split(/\s+/).map(Number);
401
648
  return {
402
649
  ...(Number.isFinite(x) ? { cursorX: x } : {}),
403
650
  ...(Number.isFinite(y) ? { cursorY: y } : {}),
@@ -406,12 +653,11 @@ class SessionStream {
406
653
 
407
654
  // Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
408
655
  // 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+/);
656
+ private async readCursorState(): Promise<{ cursorX?: number; cursorY?: number; visible?: boolean }> {
657
+ const lines = await this.runCommand(
658
+ `display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y} #{cursor_flag}"`,
659
+ ).catch(() => [] as string[]);
660
+ const [x, y, flag] = (lines[0] ?? "").trim().split(/\s+/);
415
661
  const cx = Number(x);
416
662
  const cy = Number(y);
417
663
  return {
@@ -424,25 +670,22 @@ class SessionStream {
424
670
  // Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
425
671
  // When the viewer's dimensions are given we resize the tmux pane first so the TUI
426
672
  // 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> {
673
+ async backfill(sub: TerminalStreamSubscriber, cols?: number, rows?: number): Promise<TerminalSnapshot> {
428
674
  let resized = false;
429
- if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
675
+ if (this.maySize(sub) && Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
430
676
  const c = Math.trunc(cols as number);
431
677
  const r = Math.trunc(rows as number);
432
678
  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
- );
679
+ void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
437
680
  this.termCols = c;
438
681
  this.termRows = r;
439
682
  resized = true;
440
683
  }
441
684
  }
442
685
  if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
443
- const { content } = this.captureScreen();
686
+ const { content } = await this.captureScreen();
444
687
  // Report tmux's actual pane size (authoritative) back to the viewer.
445
- const dims = this.paneDims();
688
+ const dims = await this.paneDims();
446
689
  if (dims.cols) this.termCols = dims.cols;
447
690
  if (dims.rows) this.termRows = dims.rows;
448
691
  const live = sessionLiveness(this.session);
@@ -458,45 +701,106 @@ class SessionStream {
458
701
  };
459
702
  }
460
703
 
461
- write(bytes: Uint8Array): void {
704
+ write(sub: TerminalStreamSubscriber, bytes: Uint8Array): void {
462
705
  if (this.closed || !this.proc || bytes.length === 0) return;
463
- this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
706
+ // Typing is the strongest interactivity signal — the typist owns window sizing.
707
+ this.sizingOwner = sub;
708
+ void this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
709
+ }
710
+
711
+ setInteractive(sub: TerminalStreamSubscriber, interactive: boolean): void {
712
+ if (interactive) {
713
+ this.sizingOwner = sub;
714
+ } else if (this.sizingOwner === sub) {
715
+ this.sizingOwner = null;
716
+ }
717
+ }
718
+
719
+ // A viewer may size the shared window only if it's the sizing owner, or nobody owns
720
+ // sizing yet (first viewer, before anyone has declared interactivity — it still needs
721
+ // a sensible size). Once an interactive viewer exists, read-only watchers never resize.
722
+ private maySize(sub: TerminalStreamSubscriber): boolean {
723
+ return this.sizingOwner === null || this.sizingOwner === sub;
464
724
  }
465
725
 
466
- resize(cols: number, rows: number): void {
726
+ resize(sub: TerminalStreamSubscriber, cols: number, rows: number): void {
467
727
  if (this.closed || !this.proc) return;
728
+ if (!this.maySize(sub)) return; // read-only watcher: don't reflow the shared window
468
729
  if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
469
730
  const c = Math.trunc(cols);
470
731
  const r = Math.trunc(rows);
471
- this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
732
+ void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
472
733
  this.termCols = c;
473
734
  this.termRows = r;
474
735
  tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
475
736
  }
476
737
 
477
- private paneDims(): { cols?: number; rows?: number } {
738
+ // Pane dimensions, in-band through the control client (no spawn).
739
+ private async paneDims(): Promise<{ cols?: number; rows?: number }> {
740
+ const lines = await this.runCommand(
741
+ `display-message -p -t "${this.session}" "#{pane_width} #{pane_height}"`,
742
+ ).catch(() => [] as string[]);
743
+ return parsePaneDims(lines[0] ?? "");
744
+ }
745
+
746
+ // Synchronous pane-dims read for start(), which runs before the control client attaches.
747
+ private paneDimsSync(): { cols?: number; rows?: number } {
478
748
  try {
479
749
  const out = Bun.spawnSync(
480
750
  tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
481
751
  { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
482
752
  ).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
- };
753
+ return parsePaneDims(out);
488
754
  } catch {
489
755
  return {};
490
756
  }
491
757
  }
492
758
 
493
- private command(line: string): void {
759
+ // Run a control command expecting its reply lines (the FIFO correlates them in order).
760
+ private runCommand(line: string): Promise<string[]> {
761
+ return this.command(line, true);
762
+ }
763
+
764
+ // Write a command to the control client's stdin and register a FIFO entry for its reply
765
+ // block. `wantReply` commands resolve with the block's lines (or reject on %error /
766
+ // timeout); fire-and-forget commands resolve with [] once their empty block closes.
767
+ private command(line: string, wantReply = false): Promise<string[]> {
768
+ if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
494
769
  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 {}
770
+ if (!stdin || typeof stdin === "number") {
771
+ return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
772
+ }
773
+ return new Promise<string[]>((resolve, reject) => {
774
+ const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
775
+ if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
776
+ this.pendingCommands.push(entry);
777
+ try {
778
+ (stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
779
+ (stdin as { flush?(): void }).flush?.();
780
+ } catch (e) {
781
+ const idx = this.pendingCommands.indexOf(entry);
782
+ if (idx !== -1) this.pendingCommands.splice(idx, 1);
783
+ if (entry.timer) clearTimeout(entry.timer);
784
+ if (wantReply) reject(e instanceof Error ? e : new Error(String(e)));
785
+ else resolve([]);
786
+ }
787
+ });
788
+ }
789
+
790
+ // A reply that never arrived means a protocol desync — reject the awaited command and
791
+ // reset the whole reply queue + block state so the next command starts clean. The byte
792
+ // stream itself keeps flowing; only in-flight captures fail (the resync just skips a beat).
793
+ private commandTimeout(entry: PendingCommand): void {
794
+ if (!this.pendingCommands.includes(entry)) return;
795
+ tdbg(`command timeout ${this.session}; resetting reply queue (${this.pendingCommands.length} pending)`);
796
+ const pend = this.pendingCommands;
797
+ this.pendingCommands = [];
798
+ this.currentBlock = null;
799
+ for (const e of pend) {
800
+ if (e.timer) clearTimeout(e.timer);
801
+ if (e.wantReply) e.reject(new Error("control command timed out"));
802
+ else e.resolve([]);
803
+ }
500
804
  }
501
805
 
502
806
  addSubscriber(sub: TerminalStreamSubscriber): void {
@@ -506,6 +810,7 @@ class SessionStream {
506
810
 
507
811
  removeSubscriber(sub: TerminalStreamSubscriber): void {
508
812
  if (!this.subscribers.delete(sub)) return;
813
+ if (this.sizingOwner === sub) this.sizingOwner = null;
509
814
  tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
510
815
  if (this.subscribers.size === 0) this.destroy();
511
816
  }
@@ -529,14 +834,17 @@ class SessionStream {
529
834
  clearTimeout(this.flushTimer);
530
835
  this.flushTimer = null;
531
836
  }
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;
837
+ this.clearResyncTimers();
838
+ // Reject any in-flight in-band commands so awaiters (captures) unwind instead of hanging.
839
+ const pend = this.pendingCommands;
840
+ this.pendingCommands = [];
841
+ this.currentBlock = null;
842
+ for (const e of pend) {
843
+ if (e.timer) clearTimeout(e.timer);
844
+ if (e.wantReply) e.reject(new Error("terminal stream closed"));
845
+ else e.resolve([]);
539
846
  }
847
+ this.fireGroundWaiters();
540
848
  try {
541
849
  this.proc?.kill();
542
850
  } catch {}
@@ -563,9 +871,11 @@ export function acquireTerminalStream(
563
871
  const active = stream;
564
872
  active.addSubscriber(subscriber);
565
873
  return {
566
- backfill: (cols, rows) => active.backfill(cols, rows),
567
- write: (bytes) => active.write(bytes),
568
- resize: (cols, rows) => active.resize(cols, rows),
874
+ backfill: (cols, rows) => active.backfill(subscriber, cols, rows),
875
+ write: (bytes) => active.write(subscriber, bytes),
876
+ resize: (cols, rows) => active.resize(subscriber, cols, rows),
877
+ setInteractive: (interactive) => active.setInteractive(subscriber, interactive),
878
+ whenAtGround: (cb) => active.whenAtGround(cb),
569
879
  release: () => active.removeSubscriber(subscriber),
570
880
  };
571
881
  }
@@ -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
  /**
@@ -1069,6 +1100,10 @@ function nextBranchName(repoRoot: string, branch: string): string {
1069
1100
  return `${stem}-${Date.now()}`;
1070
1101
  }
1071
1102
 
1103
+ export function workspacesRoot(baseDir: string): string {
1104
+ return join(resolve(baseDir), ".agent-relay", "workspaces");
1105
+ }
1106
+
1072
1107
  function repoSlug(repoRoot: string): string {
1073
1108
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
1074
1109
  return `${safeSegment(basename(repoRoot), 60)}-${hash}`;