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 +1 -1
- package/src/api.ts +113 -55
- package/src/spawn.ts +2 -2
- package/src/terminal-stream.ts +335 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
634
|
-
|
|
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
|
+
}
|