agent-relay-orchestrator 0.10.22 → 0.10.24

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.10.22",
3
+ "version": "0.10.24",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -7,6 +7,7 @@ import type { OrchestratorConfig } from "./config";
7
7
  import type { ProviderProbeCache } from "./provider-probe";
8
8
  import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
9
9
  import type { TerminalSnapshot } from "./spawn";
10
+ import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
10
11
  import { VERSION, runtimeMetadata } from "./version";
11
12
  import { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
12
13
 
@@ -58,16 +59,19 @@ export interface FileStatResult {
58
59
  }
59
60
 
60
61
  const MAX_FILE_PREVIEW_BYTES = 1024 * 1024;
61
- const DEFAULT_TERMINAL_STREAM_HEARTBEAT_MS = 5_000;
62
62
 
63
63
  interface TerminalSocketData {
64
64
  kind: "terminal";
65
65
  config: OrchestratorConfig;
66
66
  session: string;
67
- timer?: ReturnType<typeof setInterval>;
68
- lastSnapshotSignature?: string;
69
- lastHeartbeatAt?: number;
67
+ stream?: TerminalStreamHandle;
70
68
  paused?: boolean;
69
+ // Backfill is deferred until the client reports its size, so the captured pane
70
+ // wraps identically to the viewer's xterm. Until then, live bytes queue.
71
+ synced?: boolean;
72
+ ready?: boolean;
73
+ queue?: Uint8Array[];
74
+ syncTimer?: ReturnType<typeof setTimeout>;
71
75
  }
72
76
 
73
77
  type TerminalSocket = ServerWebSocket<TerminalSocketData>;
@@ -555,11 +559,91 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
555
559
  }
556
560
 
557
561
  function startTerminalSocket(ws: TerminalSocket): void {
558
- sendTerminalSnapshot(ws, true);
559
- const intervalMs = Number(process.env.AGENT_RELAY_TERMINAL_STREAM_INTERVAL_MS) || 125;
560
- ws.data.timer = setInterval(() => {
561
- if (!ws.data.paused) sendTerminalSnapshot(ws, false);
562
- }, Math.max(50, intervalMs));
562
+ ws.data.queue = [];
563
+ const subscriber: TerminalStreamSubscriber = {
564
+ onData: (bytes) => {
565
+ if (ws.data.paused) return;
566
+ // Queue live bytes until the client has reported its size and the backfill is
567
+ // flushed, so the viewer never sees live output before (or mis-sized against) history.
568
+ if (!ws.data.ready) {
569
+ ws.data.queue?.push(bytes);
570
+ return;
571
+ }
572
+ try {
573
+ ws.send(bytes);
574
+ } catch {}
575
+ },
576
+ onClose: (reason) => {
577
+ try {
578
+ ws.send(JSON.stringify({ type: "closed", reason: reason ?? null }));
579
+ } catch {}
580
+ },
581
+ bufferedAmount: () => {
582
+ try {
583
+ return ws.getBufferedAmount();
584
+ } catch {
585
+ return 0;
586
+ }
587
+ },
588
+ };
589
+ try {
590
+ ws.data.stream = acquireTerminalStream(ws.data.session, ws.data.config, subscriber);
591
+ // Wait for the client's resize to size the pane before backfilling. Fall back to
592
+ // the pane's current size if no resize arrives (e.g. a non-fitting client).
593
+ ws.data.syncTimer = setTimeout(() => syncAndBackfill(ws), 700);
594
+ } catch (e) {
595
+ ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
596
+ ws.close();
597
+ }
598
+ }
599
+
600
+ // Resize the pane to the viewer's dimensions, capture the matching backfill, and
601
+ // release the queued live bytes. Idempotent-safe: only the first call backfills.
602
+ function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): void {
603
+ if (ws.data.syncTimer) {
604
+ clearTimeout(ws.data.syncTimer);
605
+ ws.data.syncTimer = undefined;
606
+ }
607
+ ws.data.synced = true;
608
+ sendBackfill(ws, cols, rows);
609
+ ws.data.ready = true;
610
+ const queued = ws.data.queue ?? [];
611
+ ws.data.queue = [];
612
+ for (const bytes of queued) {
613
+ try {
614
+ ws.send(bytes);
615
+ } catch {}
616
+ }
617
+ }
618
+
619
+ // Send a full reset: a control frame with current geometry/status, then the
620
+ // captured scrollback as a raw byte frame. Used on connect, resume, and refresh.
621
+ function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): void {
622
+ const stream = ws.data.stream;
623
+ if (!stream) return;
624
+ const snapshot = stream.backfill(cols, rows);
625
+ ws.send(JSON.stringify({
626
+ type: "reset",
627
+ session: snapshot.session,
628
+ running: snapshot.running,
629
+ agentAlive: snapshot.agentAlive,
630
+ cols: snapshot.cols,
631
+ rows: snapshot.rows,
632
+ cursorX: snapshot.cursorX,
633
+ cursorY: snapshot.cursorY,
634
+ capturedAt: snapshot.capturedAt,
635
+ }));
636
+ if (snapshot.content) {
637
+ // Park the cursor where tmux actually has it (screen-relative). Without this the
638
+ // cursor sits at the end of the captured text, so the TUI's first relative redraw
639
+ // (cursor-up + rewrite of its prompt/statusline) lands at the wrong row and stacks
640
+ // a ghost copy into scrollback.
641
+ let content = snapshot.content;
642
+ if (snapshot.cursorX != null && snapshot.cursorY != null) {
643
+ content += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
644
+ }
645
+ ws.send(new TextEncoder().encode(content));
646
+ }
563
647
  }
564
648
 
565
649
  function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer): void {
@@ -575,63 +659,37 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
575
659
  const frame = payload as Record<string, unknown>;
576
660
  try {
577
661
  if (frame.type === "input") {
578
- sendTerminalInput(ws.data.session, ws.data.config, { data: typeof frame.data === "string" ? frame.data : "" });
579
- sendTerminalSnapshot(ws, true);
662
+ const text = typeof frame.data === "string" ? frame.data : "";
663
+ if (text) ws.data.stream?.write(new TextEncoder().encode(text));
580
664
  } else if (frame.type === "resize") {
581
- resizeTerminal(ws.data.session, ws.data.config, { cols: frame.cols, rows: frame.rows });
582
- sendTerminalSnapshot(ws, true);
665
+ const cols = Number(frame.cols);
666
+ const rows = Number(frame.rows);
667
+ // First resize sizes the pane and triggers the (size-matched) backfill;
668
+ // later ones just reflow the live stream.
669
+ if (!ws.data.synced) {
670
+ syncAndBackfill(ws, cols, rows);
671
+ } else {
672
+ ws.data.stream?.resize(cols, rows);
673
+ }
583
674
  } else if (frame.type === "pause") {
584
675
  ws.data.paused = frame.paused === true;
585
- if (!ws.data.paused) sendTerminalSnapshot(ws, true);
676
+ // We drop (not buffer) bytes while paused, so resync with a fresh backfill.
677
+ if (!ws.data.paused && ws.data.synced) sendBackfill(ws);
586
678
  } else if (frame.type === "refresh") {
587
- sendTerminalSnapshot(ws, true);
588
- }
589
- } catch (e) {
590
- ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
591
- }
592
- }
593
-
594
- function sendTerminalSnapshot(ws: TerminalSocket, force: boolean): void {
595
- try {
596
- const snapshot = captureTerminal(ws.data.session, ws.data.config);
597
- const signature = terminalSnapshotSignature(snapshot);
598
- const changed = force || signature !== ws.data.lastSnapshotSignature;
599
- if (!changed) {
600
- sendTerminalHeartbeat(ws, snapshot, signature);
601
- return;
679
+ if (ws.data.synced) sendBackfill(ws);
602
680
  }
603
- ws.data.lastSnapshotSignature = signature;
604
- ws.data.lastHeartbeatAt = snapshot.capturedAt;
605
- ws.send(JSON.stringify({ type: "snapshot", ...snapshot }));
606
681
  } catch (e) {
607
682
  ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
608
- ws.close();
609
683
  }
610
684
  }
611
685
 
612
- function sendTerminalHeartbeat(ws: TerminalSocket, snapshot: TerminalSnapshot, signature: string): void {
613
- const heartbeatMs = Math.max(
614
- 1_000,
615
- Number(process.env.AGENT_RELAY_TERMINAL_STREAM_HEARTBEAT_MS) || DEFAULT_TERMINAL_STREAM_HEARTBEAT_MS,
616
- );
617
- if (ws.data.lastHeartbeatAt && snapshot.capturedAt - ws.data.lastHeartbeatAt < heartbeatMs) return;
618
- ws.data.lastHeartbeatAt = snapshot.capturedAt;
619
- ws.send(JSON.stringify({
620
- type: "heartbeat",
621
- session: snapshot.session,
622
- running: snapshot.running,
623
- cols: snapshot.cols,
624
- rows: snapshot.rows,
625
- cursorX: snapshot.cursorX,
626
- cursorY: snapshot.cursorY,
627
- capturedAt: snapshot.capturedAt,
628
- signature,
629
- }));
630
- }
631
-
632
686
  function stopTerminalSocket(ws: TerminalSocket): void {
633
- if (ws.data.timer) clearInterval(ws.data.timer);
634
- ws.data.timer = undefined;
687
+ if (ws.data.syncTimer) {
688
+ clearTimeout(ws.data.syncTimer);
689
+ ws.data.syncTimer = undefined;
690
+ }
691
+ ws.data.stream?.release();
692
+ ws.data.stream = undefined;
635
693
  }
636
694
 
637
695
  function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName?: string; spawnRequestId?: string; tmuxSession?: string } {
package/src/spawn.ts CHANGED
@@ -1096,12 +1096,12 @@ export function resizeTerminal(name: string, config: OrchestratorConfig, input:
1096
1096
  return { session: name, ...clamped };
1097
1097
  }
1098
1098
 
1099
- function tmuxSocketForSession(name: string): string | undefined {
1099
+ export function tmuxSocketForSession(name: string): string | undefined {
1100
1100
  const record = loadState().find((item) => item.name === name);
1101
1101
  return record ? readRunnerInfo(record)?.tmuxSocket : undefined;
1102
1102
  }
1103
1103
 
1104
- function tmuxCommand(socketName: string | undefined, ...args: string[]): string[] {
1104
+ export function tmuxCommand(socketName: string | undefined, ...args: string[]): string[] {
1105
1105
  return socketName ? ["tmux", "-L", socketName, ...args] : ["tmux", ...args];
1106
1106
  }
1107
1107
 
@@ -0,0 +1,335 @@
1
+ // Live terminal streaming via tmux control mode (`tmux -C`).
2
+ //
3
+ // One control-mode client is attached per tmux *session* and shared (ref-counted)
4
+ // across every connected viewer, so the cost is flat in viewer count: the control
5
+ // protocol is parsed once per session, then raw bytes fan out to all subscribers.
6
+ //
7
+ // Output: tmux emits `%output %<pane> <octal-escaped-bytes>` lines; we decode them
8
+ // to raw bytes, coalesce on a short window, and broadcast. No screen-scraping, no
9
+ // full-buffer repaint — the client just writes the byte stream to xterm.
10
+ //
11
+ // Input/resize: written as plain command lines to the control client's stdin
12
+ // (`send-keys -H <hex>`, `resize-window`), so a keystroke costs no process spawn.
13
+ // The relay's own injection path (message delivery, /compact, initial prompts) uses
14
+ // its separate `tmux send-keys` calls and is unaffected — control mode is just
15
+ // another observing client.
16
+
17
+ import { captureTerminal, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
18
+ import type { OrchestratorConfig } from "./config";
19
+
20
+ const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
21
+ const FLUSH_MAX_BYTES = Math.max(4096, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES) || 65536);
22
+ const BACKPRESSURE_MAX_BYTES = Math.max(
23
+ 1 << 20,
24
+ Number(process.env.AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES) || 8 << 20,
25
+ );
26
+
27
+ export interface TerminalStreamSubscriber {
28
+ onData(bytes: Uint8Array): void;
29
+ onClose(reason?: string): void;
30
+ // Bytes currently buffered on the subscriber's transport (e.g. WS bufferedAmount).
31
+ // When it exceeds the backpressure cap the subscriber is dropped and expected to
32
+ // reconnect and re-backfill — append streams can't drop bytes, so we shed the slow
33
+ // client instead of ballooning orchestrator memory.
34
+ bufferedAmount?(): number;
35
+ }
36
+
37
+ export interface TerminalStreamHandle {
38
+ backfill(cols?: number, rows?: number): TerminalSnapshot;
39
+ write(bytes: Uint8Array): void;
40
+ resize(cols: number, rows: number): void;
41
+ release(): void;
42
+ }
43
+
44
+ // --- Pure protocol helpers (unit-tested) ---
45
+
46
+ // Decode a tmux control-mode `%output` payload: printable ASCII is literal, every
47
+ // other byte is `\ooo` octal, and backslash is `\\`.
48
+ export function decodeControlOutput(data: string): Uint8Array {
49
+ const out: number[] = [];
50
+ for (let i = 0; i < data.length; ) {
51
+ const ch = data[i]!;
52
+ if (ch === "\\") {
53
+ const next = data[i + 1];
54
+ if (next === "\\") {
55
+ out.push(0x5c);
56
+ i += 2;
57
+ continue;
58
+ }
59
+ let oct = "";
60
+ let j = i + 1;
61
+ while (j < data.length && oct.length < 3 && data[j]! >= "0" && data[j]! <= "7") {
62
+ oct += data[j];
63
+ j += 1;
64
+ }
65
+ if (oct.length > 0) {
66
+ out.push(parseInt(oct, 8) & 0xff);
67
+ i = j;
68
+ continue;
69
+ }
70
+ out.push(0x5c);
71
+ i += 1;
72
+ continue;
73
+ }
74
+ out.push(ch.charCodeAt(0) & 0xff);
75
+ i += 1;
76
+ }
77
+ return Uint8Array.from(out);
78
+ }
79
+
80
+ export type ControlLine =
81
+ | { type: "output"; pane: string; bytes: Uint8Array }
82
+ | { type: "exit"; reason?: string }
83
+ | { type: "other" };
84
+
85
+ export function parseControlLine(line: string): ControlLine {
86
+ if (line.startsWith("%output ")) {
87
+ const rest = line.slice(8);
88
+ const sp = rest.indexOf(" ");
89
+ const pane = sp === -1 ? rest : rest.slice(0, sp);
90
+ const data = sp === -1 ? "" : rest.slice(sp + 1);
91
+ return { type: "output", pane, bytes: decodeControlOutput(data) };
92
+ }
93
+ if (line === "%exit" || line.startsWith("%exit ")) {
94
+ const reason = line.slice(5).trim();
95
+ return { type: "exit", ...(reason ? { reason } : {}) };
96
+ }
97
+ return { type: "other" };
98
+ }
99
+
100
+ // Encode raw input bytes for `send-keys -H` (space-separated hex octets).
101
+ export function encodeSendKeysHex(bytes: Uint8Array): string {
102
+ return Array.from(bytes)
103
+ .map((b) => b.toString(16).padStart(2, "0"))
104
+ .join(" ");
105
+ }
106
+
107
+ // --- Shared session stream ---
108
+
109
+ class SessionStream {
110
+ private readonly subscribers = new Set<TerminalStreamSubscriber>();
111
+ private proc: ReturnType<typeof Bun.spawn> | null = null;
112
+ private pending: Uint8Array[] = [];
113
+ private pendingBytes = 0;
114
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
115
+ private lineBuf = "";
116
+ private closed = false;
117
+
118
+ constructor(
119
+ private readonly session: string,
120
+ private readonly config: OrchestratorConfig,
121
+ private readonly onEmpty: () => void,
122
+ ) {}
123
+
124
+ private socket: string | undefined;
125
+
126
+ start(): void {
127
+ const socket = tmuxSocketForSession(this.session);
128
+ this.socket = socket;
129
+ // Pin the window size before attaching so the control client can't reflow the
130
+ // pane (default window-size would shrink it to the new client's 80x24).
131
+ const snapshot = this.safeBackfill();
132
+ Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
133
+ stdin: "ignore",
134
+ stdout: "ignore",
135
+ stderr: "ignore",
136
+ });
137
+ if (snapshot?.cols && snapshot?.rows) {
138
+ Bun.spawnSync(
139
+ tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(snapshot.cols), "-y", String(snapshot.rows)),
140
+ { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
141
+ );
142
+ }
143
+ try {
144
+ this.proc = Bun.spawn(tmuxCommand(socket, "-C", "attach-session", "-t", this.session), {
145
+ stdin: "pipe",
146
+ stdout: "pipe",
147
+ stderr: "ignore",
148
+ });
149
+ } catch (e) {
150
+ this.fail(e instanceof Error ? e.message : String(e));
151
+ return;
152
+ }
153
+ void this.readLoop();
154
+ void this.proc.exited.then(() => this.fail("terminal session ended"));
155
+ }
156
+
157
+ private async readLoop(): Promise<void> {
158
+ const proc = this.proc;
159
+ if (!proc?.stdout || typeof proc.stdout === "number") return;
160
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
161
+ const decoder = new TextDecoder("latin1");
162
+ try {
163
+ for (;;) {
164
+ const { done, value } = await reader.read();
165
+ if (done) break;
166
+ this.lineBuf += decoder.decode(value, { stream: true });
167
+ let nl: number;
168
+ while ((nl = this.lineBuf.indexOf("\n")) !== -1) {
169
+ const line = this.lineBuf.slice(0, nl).replace(/\r$/, "");
170
+ this.lineBuf = this.lineBuf.slice(nl + 1);
171
+ this.handleLine(line);
172
+ }
173
+ }
174
+ } catch {
175
+ // reader aborted on teardown
176
+ }
177
+ this.fail("terminal stream closed");
178
+ }
179
+
180
+ private handleLine(line: string): void {
181
+ const parsed = parseControlLine(line);
182
+ if (parsed.type === "output") {
183
+ this.enqueue(parsed.bytes);
184
+ } else if (parsed.type === "exit") {
185
+ this.fail(parsed.reason ? `tmux exit: ${parsed.reason}` : "tmux exit");
186
+ }
187
+ }
188
+
189
+ private enqueue(bytes: Uint8Array): void {
190
+ if (bytes.length === 0) return;
191
+ this.pending.push(bytes);
192
+ this.pendingBytes += bytes.length;
193
+ if (this.pendingBytes >= FLUSH_MAX_BYTES) {
194
+ this.flush();
195
+ return;
196
+ }
197
+ if (this.flushTimer === null) {
198
+ this.flushTimer = setTimeout(() => this.flush(), FLUSH_MS);
199
+ }
200
+ }
201
+
202
+ private flush(): void {
203
+ if (this.flushTimer !== null) {
204
+ clearTimeout(this.flushTimer);
205
+ this.flushTimer = null;
206
+ }
207
+ if (this.pendingBytes === 0) return;
208
+ const merged = new Uint8Array(this.pendingBytes);
209
+ let offset = 0;
210
+ for (const chunk of this.pending) {
211
+ merged.set(chunk, offset);
212
+ offset += chunk.length;
213
+ }
214
+ this.pending = [];
215
+ this.pendingBytes = 0;
216
+ for (const sub of [...this.subscribers]) {
217
+ if (sub.bufferedAmount && sub.bufferedAmount() > BACKPRESSURE_MAX_BYTES) {
218
+ this.removeSubscriber(sub);
219
+ try {
220
+ sub.onClose("backpressure");
221
+ } catch {}
222
+ continue;
223
+ }
224
+ try {
225
+ sub.onData(merged);
226
+ } catch {}
227
+ }
228
+ }
229
+
230
+ private safeBackfill(): TerminalSnapshot | null {
231
+ try {
232
+ return captureTerminal(this.session, this.config);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ // Capture the priming snapshot. When the viewer's dimensions are given, resize the
239
+ // tmux window to exactly that size *first* (synchronously) so the captured content
240
+ // wraps identically to the client's xterm — a width mismatch desyncs every
241
+ // absolute-positioned redraw the TUI emits and stacks ghost frames into scrollback.
242
+ backfill(cols?: number, rows?: number): TerminalSnapshot {
243
+ if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
244
+ Bun.spawnSync(
245
+ tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(Math.trunc(cols as number)), "-y", String(Math.trunc(rows as number))),
246
+ { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
247
+ );
248
+ }
249
+ return captureTerminal(this.session, this.config);
250
+ }
251
+
252
+ write(bytes: Uint8Array): void {
253
+ if (this.closed || !this.proc || bytes.length === 0) return;
254
+ this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
255
+ }
256
+
257
+ resize(cols: number, rows: number): void {
258
+ if (this.closed || !this.proc) return;
259
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
260
+ this.command(`resize-window -t "${this.session}" -x ${Math.trunc(cols)} -y ${Math.trunc(rows)}`);
261
+ }
262
+
263
+ private command(line: string): void {
264
+ const stdin = this.proc?.stdin;
265
+ if (!stdin || typeof stdin === "number") return;
266
+ try {
267
+ (stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
268
+ (stdin as { flush?(): void }).flush?.();
269
+ } catch {}
270
+ }
271
+
272
+ addSubscriber(sub: TerminalStreamSubscriber): void {
273
+ this.subscribers.add(sub);
274
+ }
275
+
276
+ removeSubscriber(sub: TerminalStreamSubscriber): void {
277
+ if (!this.subscribers.delete(sub)) return;
278
+ if (this.subscribers.size === 0) this.destroy();
279
+ }
280
+
281
+ private fail(reason: string): void {
282
+ if (this.closed) return;
283
+ const subs = [...this.subscribers];
284
+ this.subscribers.clear();
285
+ this.destroy();
286
+ for (const sub of subs) {
287
+ try {
288
+ sub.onClose(reason);
289
+ } catch {}
290
+ }
291
+ }
292
+
293
+ private destroy(): void {
294
+ if (this.closed) return;
295
+ this.closed = true;
296
+ if (this.flushTimer !== null) {
297
+ clearTimeout(this.flushTimer);
298
+ this.flushTimer = null;
299
+ }
300
+ try {
301
+ this.proc?.kill();
302
+ } catch {}
303
+ this.proc = null;
304
+ this.onEmpty();
305
+ }
306
+ }
307
+
308
+ const streams = new Map<string, SessionStream>();
309
+
310
+ export function acquireTerminalStream(
311
+ session: string,
312
+ config: OrchestratorConfig,
313
+ subscriber: TerminalStreamSubscriber,
314
+ ): TerminalStreamHandle {
315
+ let stream = streams.get(session);
316
+ if (!stream) {
317
+ stream = new SessionStream(session, config, () => {
318
+ if (streams.get(session) === stream) streams.delete(session);
319
+ });
320
+ streams.set(session, stream);
321
+ stream.start();
322
+ }
323
+ const active = stream;
324
+ active.addSubscriber(subscriber);
325
+ return {
326
+ backfill: (cols, rows) => active.backfill(cols, rows),
327
+ write: (bytes) => active.write(bytes),
328
+ resize: (cols, rows) => active.resize(cols, rows),
329
+ release: () => active.removeSubscriber(subscriber),
330
+ };
331
+ }
332
+
333
+ export function activeTerminalStreamCount(): number {
334
+ return streams.size;
335
+ }