agent-relay-orchestrator 0.11.1 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/api.ts +11 -90
- package/src/spawn.ts +7 -0
- package/src/terminal-stream.ts +261 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"agent-relay-sdk": "0.2.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@types/bun": "latest"
|
|
22
|
+
"@types/bun": "latest",
|
|
23
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
24
|
+
"@xterm/headless": "6.0.0"
|
|
23
25
|
},
|
|
24
26
|
"peerDependencies": {
|
|
25
27
|
"typescript": "^5"
|
package/src/api.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
3
|
import type { ServerWebSocket } from "bun";
|
|
5
4
|
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
6
5
|
import type { OrchestratorConfig } from "./config";
|
|
7
6
|
import type { ProviderProbeCache } from "./provider-probe";
|
|
8
7
|
import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
|
|
9
|
-
import type { TerminalSnapshot } from "./spawn";
|
|
10
8
|
import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
|
|
11
9
|
import { VERSION, runtimeMetadata } from "./version";
|
|
12
10
|
import { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
|
|
@@ -76,26 +74,6 @@ interface TerminalSocketData {
|
|
|
76
74
|
|
|
77
75
|
type TerminalSocket = ServerWebSocket<TerminalSocketData>;
|
|
78
76
|
|
|
79
|
-
export function terminalSnapshotSignature(snapshot: TerminalSnapshot): string {
|
|
80
|
-
const hash = createHash("sha1");
|
|
81
|
-
hash.update(snapshot.session);
|
|
82
|
-
hash.update("\0");
|
|
83
|
-
hash.update(snapshot.running ? "1" : "0");
|
|
84
|
-
hash.update("\0");
|
|
85
|
-
hash.update(snapshot.agentAlive ? "1" : "0");
|
|
86
|
-
hash.update("\0");
|
|
87
|
-
hash.update(String(snapshot.cols ?? ""));
|
|
88
|
-
hash.update("\0");
|
|
89
|
-
hash.update(String(snapshot.rows ?? ""));
|
|
90
|
-
hash.update("\0");
|
|
91
|
-
hash.update(String(snapshot.cursorX ?? ""));
|
|
92
|
-
hash.update("\0");
|
|
93
|
-
hash.update(String(snapshot.cursorY ?? ""));
|
|
94
|
-
hash.update("\0");
|
|
95
|
-
hash.update(snapshot.content);
|
|
96
|
-
return hash.digest("hex");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
77
|
function listDirectories(requestedPath: string | undefined, baseDir: string): DirectoryListing {
|
|
100
78
|
const base = resolve(baseDir);
|
|
101
79
|
const target = resolve(requestedPath || base);
|
|
@@ -599,23 +577,12 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
599
577
|
}
|
|
600
578
|
}
|
|
601
579
|
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
// relative deltas then
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
const BACKFILL_STABILIZE_MAX_STEPS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_STABILIZE_MAX_STEPS ?? 8));
|
|
609
|
-
|
|
610
|
-
// Resize the pane to the viewer's dimensions and capture the matching backfill.
|
|
611
|
-
//
|
|
612
|
-
// Claude's TUI streams pure *relative* cursor deltas (no absolute positioning), so the
|
|
613
|
-
// backfill snapshot must reflect a *settled* screen: the cursor we park has to match
|
|
614
|
-
// where the next delta continues from. We stabilize the capture first (see sendBackfill),
|
|
615
|
-
// so by the time we flush, every byte up to the capture is baked into the snapshot. The
|
|
616
|
-
// capture is synchronous (spawnSync), so the queued bytes are all PRE-capture and already
|
|
617
|
-
// in the snapshot — replaying them would double-apply deltas and stack a garbled ghost
|
|
618
|
-
// frame, so we discard the queue here and only forward bytes that arrive after capture.
|
|
580
|
+
// Backfill comes straight from tmux's own grid (see terminal-stream.ts): the snapshot's
|
|
581
|
+
// `content` is a `capture-pane -e` repaint of the current screen (styled, cursor parked).
|
|
582
|
+
// tmux is the real emulator and never drifts, so the repaint is byte-faithful and live
|
|
583
|
+
// relative deltas then apply identically on the client. Live bytes that arrive before the
|
|
584
|
+
// snapshot are already on tmux's grid (and thus in the capture), so we discard the queue
|
|
585
|
+
// and only forward bytes that arrive after.
|
|
619
586
|
async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
|
|
620
587
|
if (ws.data.syncTimer) {
|
|
621
588
|
clearTimeout(ws.data.syncTimer);
|
|
@@ -628,46 +595,13 @@ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number)
|
|
|
628
595
|
ws.data.ready = true;
|
|
629
596
|
}
|
|
630
597
|
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
// whose repaint is asynchronous; capturing before it lands snapshots a mid-repaint
|
|
634
|
-
// screen with a transient cursor, and the post-capture relative deltas then stack a
|
|
635
|
-
// ghost frame. `shouldAbort` lets the caller bail if the socket is torn down mid-poll.
|
|
636
|
-
export async function captureStableSnapshot(
|
|
637
|
-
capture: (resize: boolean) => TerminalSnapshot,
|
|
638
|
-
options?: {
|
|
639
|
-
stepMs?: number;
|
|
640
|
-
maxSteps?: number;
|
|
641
|
-
sleep?: (ms: number) => Promise<void>;
|
|
642
|
-
shouldAbort?: () => boolean;
|
|
643
|
-
},
|
|
644
|
-
): Promise<TerminalSnapshot | null> {
|
|
645
|
-
const stepMs = options?.stepMs ?? BACKFILL_STABILIZE_STEP_MS;
|
|
646
|
-
const maxSteps = options?.maxSteps ?? BACKFILL_STABILIZE_MAX_STEPS;
|
|
647
|
-
const sleep = options?.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
648
|
-
let snapshot = capture(true);
|
|
649
|
-
let signature = terminalSnapshotSignature(snapshot);
|
|
650
|
-
for (let i = 0; i < maxSteps; i++) {
|
|
651
|
-
await sleep(stepMs);
|
|
652
|
-
if (options?.shouldAbort?.()) return null;
|
|
653
|
-
const next = capture(false);
|
|
654
|
-
const nextSignature = terminalSnapshotSignature(next);
|
|
655
|
-
snapshot = next;
|
|
656
|
-
if (nextSignature === signature) break;
|
|
657
|
-
signature = nextSignature;
|
|
658
|
-
}
|
|
659
|
-
return snapshot;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Send a full reset: a control frame with current geometry/status, then the
|
|
663
|
-
// captured scrollback as a raw byte frame. Used on connect, resume, and refresh.
|
|
598
|
+
// Send a full reset: a control frame with current geometry/status, then the serialized
|
|
599
|
+
// emulator state as a raw byte frame. Used on connect, resume, and refresh.
|
|
664
600
|
async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
|
|
665
601
|
const stream = ws.data.stream;
|
|
666
602
|
if (!stream) return;
|
|
667
|
-
const snapshot = await
|
|
668
|
-
|
|
669
|
-
});
|
|
670
|
-
if (!snapshot) return; // socket torn down mid-stabilize
|
|
603
|
+
const snapshot = await stream.backfill(cols, rows);
|
|
604
|
+
if (ws.data.stream !== stream) return; // socket torn down mid-backfill
|
|
671
605
|
ws.send(JSON.stringify({
|
|
672
606
|
type: "reset",
|
|
673
607
|
session: snapshot.session,
|
|
@@ -675,23 +609,10 @@ async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): P
|
|
|
675
609
|
agentAlive: snapshot.agentAlive,
|
|
676
610
|
cols: snapshot.cols,
|
|
677
611
|
rows: snapshot.rows,
|
|
678
|
-
cursorX: snapshot.cursorX,
|
|
679
|
-
cursorY: snapshot.cursorY,
|
|
680
612
|
capturedAt: snapshot.capturedAt,
|
|
681
613
|
}));
|
|
682
614
|
if (snapshot.content) {
|
|
683
|
-
|
|
684
|
-
// one extra row so the whole screen sits one row too high — and the cursor-park below
|
|
685
|
-
// then lands a row off, offsetting every relative statusline redraw (bottom-box ghost).
|
|
686
|
-
let content = snapshot.content.replace(/\n$/, "");
|
|
687
|
-
// Park the cursor where tmux actually has it (screen-relative). Without this the
|
|
688
|
-
// cursor sits at the end of the captured text, so the TUI's first relative redraw
|
|
689
|
-
// (cursor-up + rewrite of its prompt/statusline) lands at the wrong row and stacks
|
|
690
|
-
// a ghost copy into scrollback.
|
|
691
|
-
if (snapshot.cursorX != null && snapshot.cursorY != null) {
|
|
692
|
-
content += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
|
|
693
|
-
}
|
|
694
|
-
ws.send(new TextEncoder().encode(content));
|
|
615
|
+
ws.send(new TextEncoder().encode(snapshot.content));
|
|
695
616
|
}
|
|
696
617
|
}
|
|
697
618
|
|
package/src/spawn.ts
CHANGED
|
@@ -1144,6 +1144,13 @@ function tmuxHasSession(name: string, socketName?: string): boolean {
|
|
|
1144
1144
|
return result.exitCode === 0;
|
|
1145
1145
|
}
|
|
1146
1146
|
|
|
1147
|
+
// Lightweight liveness for the live terminal stream's backfill metadata — avoids a full
|
|
1148
|
+
// capture-pane just to learn whether the pane/agent are still up.
|
|
1149
|
+
export function sessionLiveness(name: string): { running: boolean; agentAlive: boolean } {
|
|
1150
|
+
const socketName = tmuxSocketForSession(name);
|
|
1151
|
+
return { running: tmuxHasSession(name, socketName), agentAlive: isSessionAlive(name) };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1147
1154
|
function tmuxPaneSize(name: string, socketName?: string): { cols?: number; rows?: number } {
|
|
1148
1155
|
const result = Bun.spawnSync(tmuxCommand(socketName, "display-message", "-p", "-t", name, "#{pane_width} #{pane_height}"), {
|
|
1149
1156
|
stdin: "ignore",
|
package/src/terminal-stream.ts
CHANGED
|
@@ -8,13 +8,24 @@
|
|
|
8
8
|
// to raw bytes, coalesce on a short window, and broadcast. No screen-scraping, no
|
|
9
9
|
// full-buffer repaint — the client just writes the byte stream to xterm.
|
|
10
10
|
//
|
|
11
|
+
// Backfill: tmux control mode does not replay the pre-attach screen, so on viewer
|
|
12
|
+
// attach we repaint from tmux's *own* grid via `capture-pane -e` (styled, current
|
|
13
|
+
// screen only — no scrollback, exactly what `tmux attach` shows) and park the cursor
|
|
14
|
+
// where tmux has it. tmux is the real terminal emulator: its grid never drifts, so the
|
|
15
|
+
// repaint is byte-faithful by definition, and subsequent live relative-cursor deltas
|
|
16
|
+
// apply identically on the client (which now mirrors tmux exactly). We deliberately do
|
|
17
|
+
// NOT keep a server-side @xterm/headless mirror: seeding such an emulator mid-stream
|
|
18
|
+
// from a snapshot and then feeding it Claude's purely-relative deltas accumulates error
|
|
19
|
+
// (the seed is never byte-perfect), which ghosted the screen into a staircase. tmux's
|
|
20
|
+
// grid is the only authoritative source, so we read it directly.
|
|
21
|
+
//
|
|
11
22
|
// Input/resize: written as plain command lines to the control client's stdin
|
|
12
23
|
// (`send-keys -H <hex>`, `resize-window`), so a keystroke costs no process spawn.
|
|
13
24
|
// The relay's own injection path (message delivery, /compact, initial prompts) uses
|
|
14
25
|
// its separate `tmux send-keys` calls and is unaffected — control mode is just
|
|
15
26
|
// another observing client.
|
|
16
27
|
|
|
17
|
-
import {
|
|
28
|
+
import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
18
29
|
import type { OrchestratorConfig } from "./config";
|
|
19
30
|
|
|
20
31
|
const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
|
|
@@ -23,6 +34,23 @@ const BACKPRESSURE_MAX_BYTES = Math.max(
|
|
|
23
34
|
1 << 20,
|
|
24
35
|
Number(process.env.AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES) || 8 << 20,
|
|
25
36
|
);
|
|
37
|
+
// After a resize the TUI repaints asynchronously; let tmux's grid settle before we
|
|
38
|
+
// capture, so the backfill is the post-resize frame, not a half-reflowed one.
|
|
39
|
+
const RESIZE_SETTLE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESIZE_SETTLE_MS) || 90);
|
|
40
|
+
// Live deltas are relative-cursor moves; replaying them onto a client seeded mid-stream
|
|
41
|
+
// from a capture-pane snapshot can drift (lost scroll-region/SGR/wrap state → doubled
|
|
42
|
+
// statusline, faded suggestion rendered solid, cursor off by a row). We can't transfer
|
|
43
|
+
// full emulator state, so we periodically re-stamp tmux's authoritative grid in place to
|
|
44
|
+
// snap the client back. Debounce after output settles; cap so continuous "thinking"
|
|
45
|
+
// streams still correct. 0 disables the corrector.
|
|
46
|
+
const RESYNC_DEBOUNCE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS) || 120);
|
|
47
|
+
const RESYNC_MAX_INTERVAL_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS) || 350);
|
|
48
|
+
// On attach we include this many lines of tmux scrollback ABOVE the current screen so the
|
|
49
|
+
// viewer can scroll back through pre-attach history (the client lands on the live screen;
|
|
50
|
+
// the history sits in its scroll buffer). This is a one-time per-attach paint, so it's
|
|
51
|
+
// fine to be large; the live resync corrector only ever repaints the current screen, so it
|
|
52
|
+
// never disturbs this history. 0 = current screen only.
|
|
53
|
+
const BACKFILL_SCROLLBACK_LINES = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_BACKFILL_SCROLLBACK) || 1000);
|
|
26
54
|
|
|
27
55
|
export interface TerminalStreamSubscriber {
|
|
28
56
|
onData(bytes: Uint8Array): void;
|
|
@@ -35,12 +63,19 @@ export interface TerminalStreamSubscriber {
|
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
export interface TerminalStreamHandle {
|
|
38
|
-
backfill(cols?: number, rows?: number): TerminalSnapshot
|
|
66
|
+
backfill(cols?: number, rows?: number): Promise<TerminalSnapshot>;
|
|
39
67
|
write(bytes: Uint8Array): void;
|
|
40
68
|
resize(cols: number, rows: number): void;
|
|
41
69
|
release(): void;
|
|
42
70
|
}
|
|
43
71
|
|
|
72
|
+
const DEFAULT_COLS = 80;
|
|
73
|
+
const DEFAULT_ROWS = 24;
|
|
74
|
+
const TERMINAL_DEBUG = process.env.AGENT_RELAY_TERMINAL_DEBUG === "1";
|
|
75
|
+
function tdbg(...args: unknown[]): void {
|
|
76
|
+
if (TERMINAL_DEBUG) console.error("[term-debug]", ...args);
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
// --- Pure protocol helpers (unit-tested) ---
|
|
45
80
|
|
|
46
81
|
// Decode a tmux control-mode `%output` payload: printable ASCII is literal, every
|
|
@@ -104,6 +139,44 @@ export function encodeSendKeysHex(bytes: Uint8Array): string {
|
|
|
104
139
|
.join(" ");
|
|
105
140
|
}
|
|
106
141
|
|
|
142
|
+
// Build a client repaint from tmux's current-screen grid: lay the rows out top-down
|
|
143
|
+
// (explicit CRLF so it's independent of the client's convertEol), then park the cursor
|
|
144
|
+
// where tmux has it so the next live relative delta continues from the right cell.
|
|
145
|
+
export function buildScreenRepaint(content: string, cursorX?: number, cursorY?: number): string {
|
|
146
|
+
let out = content.replace(/\n$/, "").replace(/\n/g, "\r\n");
|
|
147
|
+
if (cursorX != null && cursorY != null && Number.isFinite(cursorX) && Number.isFinite(cursorY)) {
|
|
148
|
+
out += `\x1b[${cursorY + 1};${cursorX + 1}H`;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Build an in-place authoritative repaint that overwrites the client grid with tmux's
|
|
154
|
+
// current screen WITHOUT a full-screen clear (so it can run mid-stream as a drift
|
|
155
|
+
// corrector without flicker). Each row is absolute-positioned, SGR-reset, and erased
|
|
156
|
+
// (`\x1b[2K`) before its styled cells are written, so any drift — a doubled statusline, a
|
|
157
|
+
// suggestion stuck solid instead of faded, leftover cells — is stamped out. The cursor is
|
|
158
|
+
// re-parked at tmux's true position and visibility restored. Wrapped in cursor-hide/show
|
|
159
|
+
// so the multi-row paint doesn't visibly skitter the cursor across the screen.
|
|
160
|
+
export function buildInPlaceRepaint(
|
|
161
|
+
content: string,
|
|
162
|
+
rows: number,
|
|
163
|
+
cursor: { cursorX?: number; cursorY?: number; visible?: boolean } = {},
|
|
164
|
+
): string {
|
|
165
|
+
const lines = content.replace(/\n$/, "").split("\n");
|
|
166
|
+
const height = Number.isFinite(rows) && rows > 0 ? Math.trunc(rows) : lines.length;
|
|
167
|
+
let out = "\x1b[?25l\x1b[m";
|
|
168
|
+
for (let i = 0; i < height; i++) {
|
|
169
|
+
out += `\x1b[${i + 1};1H\x1b[m\x1b[2K`;
|
|
170
|
+
if (lines[i]) out += lines[i];
|
|
171
|
+
}
|
|
172
|
+
out += "\x1b[m";
|
|
173
|
+
if (cursor.cursorX != null && cursor.cursorY != null && Number.isFinite(cursor.cursorX) && Number.isFinite(cursor.cursorY)) {
|
|
174
|
+
out += `\x1b[${cursor.cursorY + 1};${cursor.cursorX + 1}H`;
|
|
175
|
+
}
|
|
176
|
+
out += cursor.visible === false ? "\x1b[?25l" : "\x1b[?25h";
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
107
180
|
// --- Shared session stream ---
|
|
108
181
|
|
|
109
182
|
class SessionStream {
|
|
@@ -114,6 +187,14 @@ class SessionStream {
|
|
|
114
187
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
115
188
|
private lineBuf = "";
|
|
116
189
|
private closed = false;
|
|
190
|
+
// Last size we resized the pane to / reported to viewers (tmux is the source of truth;
|
|
191
|
+
// these are just for redundant-resize avoidance and debug logging).
|
|
192
|
+
private termCols = DEFAULT_COLS;
|
|
193
|
+
private termRows = DEFAULT_ROWS;
|
|
194
|
+
// Drift corrector: re-stamp tmux's authoritative grid after live deltas settle.
|
|
195
|
+
private resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
196
|
+
private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
197
|
+
private resyncDirty = false;
|
|
117
198
|
|
|
118
199
|
constructor(
|
|
119
200
|
private readonly session: string,
|
|
@@ -128,15 +209,17 @@ class SessionStream {
|
|
|
128
209
|
this.socket = socket;
|
|
129
210
|
// Pin the window size before attaching so the control client can't reflow the
|
|
130
211
|
// pane (default window-size would shrink it to the new client's 80x24).
|
|
131
|
-
const
|
|
212
|
+
const dims = this.paneDims();
|
|
213
|
+
if (dims.cols) this.termCols = dims.cols;
|
|
214
|
+
if (dims.rows) this.termRows = dims.rows;
|
|
132
215
|
Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
|
|
133
216
|
stdin: "ignore",
|
|
134
217
|
stdout: "ignore",
|
|
135
218
|
stderr: "ignore",
|
|
136
219
|
});
|
|
137
|
-
if (
|
|
220
|
+
if (dims.cols && dims.rows) {
|
|
138
221
|
Bun.spawnSync(
|
|
139
|
-
tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(
|
|
222
|
+
tmuxCommand(socket, "resize-window", "-t", this.session, "-x", String(dims.cols), "-y", String(dims.rows)),
|
|
140
223
|
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
141
224
|
);
|
|
142
225
|
}
|
|
@@ -213,6 +296,12 @@ class SessionStream {
|
|
|
213
296
|
}
|
|
214
297
|
this.pending = [];
|
|
215
298
|
this.pendingBytes = 0;
|
|
299
|
+
this.broadcast(merged);
|
|
300
|
+
// Live deltas just went out; schedule an authoritative resync to correct any drift.
|
|
301
|
+
this.scheduleResync();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private broadcast(bytes: Uint8Array): void {
|
|
216
305
|
for (const sub of [...this.subscribers]) {
|
|
217
306
|
if (sub.bufferedAmount && sub.bufferedAmount() > BACKPRESSURE_MAX_BYTES) {
|
|
218
307
|
this.removeSubscriber(sub);
|
|
@@ -222,31 +311,150 @@ class SessionStream {
|
|
|
222
311
|
continue;
|
|
223
312
|
}
|
|
224
313
|
try {
|
|
225
|
-
sub.onData(
|
|
314
|
+
sub.onData(bytes);
|
|
226
315
|
} catch {}
|
|
227
316
|
}
|
|
228
317
|
}
|
|
229
318
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
319
|
+
// Schedule a drift-correcting resync: a trailing debounce fires once output settles,
|
|
320
|
+
// and a capped interval guarantees correction during continuous output.
|
|
321
|
+
private scheduleResync(): void {
|
|
322
|
+
if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
|
|
323
|
+
this.resyncDirty = true;
|
|
324
|
+
if (this.resyncTimer) clearTimeout(this.resyncTimer);
|
|
325
|
+
this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
326
|
+
if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
|
|
327
|
+
this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private doResync(): void {
|
|
332
|
+
if (this.resyncTimer) {
|
|
333
|
+
clearTimeout(this.resyncTimer);
|
|
334
|
+
this.resyncTimer = null;
|
|
335
|
+
}
|
|
336
|
+
if (this.resyncCapTimer) {
|
|
337
|
+
clearTimeout(this.resyncCapTimer);
|
|
338
|
+
this.resyncCapTimer = null;
|
|
235
339
|
}
|
|
340
|
+
if (!this.resyncDirty || this.closed) return;
|
|
341
|
+
this.resyncDirty = false;
|
|
342
|
+
if (this.subscribers.size === 0) return;
|
|
343
|
+
const repaint = this.resyncRepaint();
|
|
344
|
+
if (!repaint) return;
|
|
345
|
+
this.broadcast(repaint);
|
|
346
|
+
tdbg(`resync ${this.session} bytes=${repaint.length} viewers=${this.subscribers.size}`);
|
|
236
347
|
}
|
|
237
348
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
349
|
+
// Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
|
|
350
|
+
// reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
|
|
351
|
+
// doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
|
|
352
|
+
// full-screen clear flash, and re-parks the cursor at tmux's true position/visibility.
|
|
353
|
+
private resyncRepaint(): Uint8Array | null {
|
|
354
|
+
const body = this.readScreen();
|
|
355
|
+
if (!body) return null;
|
|
356
|
+
const cursor = this.readCursorState();
|
|
357
|
+
const rows = this.paneDims().rows ?? this.termRows;
|
|
358
|
+
return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Read tmux's authoritative current-screen grid (styled, no scrollback) plus a
|
|
362
|
+
// consistent cursor position, and turn it into a client repaint. capture-pane reads
|
|
363
|
+
// tmux's real emulator grid, so it's always internally coherent; captureConsistent
|
|
364
|
+
// guards the (content, cursor) read against a mid-render cursor move.
|
|
365
|
+
private captureScreen(): { content: string; cursorX?: number; cursorY?: number } {
|
|
366
|
+
const { content, cursor } = captureConsistent(
|
|
367
|
+
() => this.readBackfill(),
|
|
368
|
+
() => this.readCursor(),
|
|
369
|
+
);
|
|
370
|
+
return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Backfill capture: current screen plus scrollback history above it (cursor is
|
|
374
|
+
// viewport-relative, so it still parks on the live screen). Falls back to screen-only.
|
|
375
|
+
private readBackfill(): string {
|
|
376
|
+
if (BACKFILL_SCROLLBACK_LINES <= 0) return this.readScreen();
|
|
377
|
+
const r = Bun.spawnSync(
|
|
378
|
+
tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-S", `-${BACKFILL_SCROLLBACK_LINES}`, "-t", this.session),
|
|
379
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
380
|
+
);
|
|
381
|
+
return r.exitCode === 0 ? r.stdout.toString("utf8") : this.readScreen();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Current-screen-only capture (no scrollback) — used by the live resync corrector so it
|
|
385
|
+
// overwrites just the visible grid and leaves the client's scroll-back history intact.
|
|
386
|
+
private readScreen(): string {
|
|
387
|
+
const r = Bun.spawnSync(
|
|
388
|
+
tmuxCommand(this.socket, "capture-pane", "-p", "-e", "-t", this.session),
|
|
389
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
390
|
+
);
|
|
391
|
+
return r.exitCode === 0 ? r.stdout.toString("utf8") : "";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private readCursor(): { cursorX?: number; cursorY?: number } {
|
|
395
|
+
const r = Bun.spawnSync(
|
|
396
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y}"),
|
|
397
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
398
|
+
);
|
|
399
|
+
const [x, y] = r.stdout.toString().trim().split(/\s+/).map(Number);
|
|
400
|
+
return {
|
|
401
|
+
...(Number.isFinite(x) ? { cursorX: x } : {}),
|
|
402
|
+
...(Number.isFinite(y) ? { cursorY: y } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
|
|
407
|
+
// the TUI had the cursor shown or hidden rather than guessing.
|
|
408
|
+
private readCursorState(): { cursorX?: number; cursorY?: number; visible?: boolean } {
|
|
409
|
+
const r = Bun.spawnSync(
|
|
410
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y} #{cursor_flag}"),
|
|
411
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
412
|
+
);
|
|
413
|
+
const [x, y, flag] = r.stdout.toString().trim().split(/\s+/);
|
|
414
|
+
const cx = Number(x);
|
|
415
|
+
const cy = Number(y);
|
|
416
|
+
return {
|
|
417
|
+
...(Number.isFinite(cx) ? { cursorX: cx } : {}),
|
|
418
|
+
...(Number.isFinite(cy) ? { cursorY: cy } : {}),
|
|
419
|
+
...(flag === "0" || flag === "1" ? { visible: flag === "1" } : {}),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
|
|
424
|
+
// When the viewer's dimensions are given we resize the tmux pane first so the TUI
|
|
425
|
+
// re-renders at the viewer's width, let it settle, then capture the post-resize frame.
|
|
426
|
+
async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
427
|
+
let resized = false;
|
|
243
428
|
if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
429
|
+
const c = Math.trunc(cols as number);
|
|
430
|
+
const r = Math.trunc(rows as number);
|
|
431
|
+
if (c !== this.termCols || r !== this.termRows) {
|
|
432
|
+
Bun.spawnSync(
|
|
433
|
+
tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
|
|
434
|
+
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
435
|
+
);
|
|
436
|
+
this.termCols = c;
|
|
437
|
+
this.termRows = r;
|
|
438
|
+
resized = true;
|
|
439
|
+
}
|
|
248
440
|
}
|
|
249
|
-
|
|
441
|
+
if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
|
|
442
|
+
const { content } = this.captureScreen();
|
|
443
|
+
// Report tmux's actual pane size (authoritative) back to the viewer.
|
|
444
|
+
const dims = this.paneDims();
|
|
445
|
+
if (dims.cols) this.termCols = dims.cols;
|
|
446
|
+
if (dims.rows) this.termRows = dims.rows;
|
|
447
|
+
const live = sessionLiveness(this.session);
|
|
448
|
+
tdbg(`backfill ${this.session} req=${cols}x${rows} term=${this.termCols}x${this.termRows} contentLen=${content.length} viewers=${this.subscribers.size}`);
|
|
449
|
+
return {
|
|
450
|
+
session: this.session,
|
|
451
|
+
content,
|
|
452
|
+
running: live.running,
|
|
453
|
+
agentAlive: live.agentAlive,
|
|
454
|
+
cols: this.termCols,
|
|
455
|
+
rows: this.termRows,
|
|
456
|
+
capturedAt: Date.now(),
|
|
457
|
+
};
|
|
250
458
|
}
|
|
251
459
|
|
|
252
460
|
write(bytes: Uint8Array): void {
|
|
@@ -257,7 +465,28 @@ class SessionStream {
|
|
|
257
465
|
resize(cols: number, rows: number): void {
|
|
258
466
|
if (this.closed || !this.proc) return;
|
|
259
467
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
|
|
260
|
-
|
|
468
|
+
const c = Math.trunc(cols);
|
|
469
|
+
const r = Math.trunc(rows);
|
|
470
|
+
this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
471
|
+
this.termCols = c;
|
|
472
|
+
this.termRows = r;
|
|
473
|
+
tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private paneDims(): { cols?: number; rows?: number } {
|
|
477
|
+
try {
|
|
478
|
+
const out = Bun.spawnSync(
|
|
479
|
+
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
|
|
480
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
481
|
+
).stdout.toString().trim();
|
|
482
|
+
const [w, h] = out.split(/\s+/).map(Number);
|
|
483
|
+
return {
|
|
484
|
+
...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
|
|
485
|
+
...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
|
|
486
|
+
};
|
|
487
|
+
} catch {
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
261
490
|
}
|
|
262
491
|
|
|
263
492
|
private command(line: string): void {
|
|
@@ -271,10 +500,12 @@ class SessionStream {
|
|
|
271
500
|
|
|
272
501
|
addSubscriber(sub: TerminalStreamSubscriber): void {
|
|
273
502
|
this.subscribers.add(sub);
|
|
503
|
+
tdbg(`attach ${this.session} viewers=${this.subscribers.size} term=${this.termCols}x${this.termRows}`);
|
|
274
504
|
}
|
|
275
505
|
|
|
276
506
|
removeSubscriber(sub: TerminalStreamSubscriber): void {
|
|
277
507
|
if (!this.subscribers.delete(sub)) return;
|
|
508
|
+
tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
|
|
278
509
|
if (this.subscribers.size === 0) this.destroy();
|
|
279
510
|
}
|
|
280
511
|
|
|
@@ -297,6 +528,14 @@ class SessionStream {
|
|
|
297
528
|
clearTimeout(this.flushTimer);
|
|
298
529
|
this.flushTimer = null;
|
|
299
530
|
}
|
|
531
|
+
if (this.resyncTimer !== null) {
|
|
532
|
+
clearTimeout(this.resyncTimer);
|
|
533
|
+
this.resyncTimer = null;
|
|
534
|
+
}
|
|
535
|
+
if (this.resyncCapTimer !== null) {
|
|
536
|
+
clearTimeout(this.resyncCapTimer);
|
|
537
|
+
this.resyncCapTimer = null;
|
|
538
|
+
}
|
|
300
539
|
try {
|
|
301
540
|
this.proc?.kill();
|
|
302
541
|
} catch {}
|