agent-relay-orchestrator 0.11.1 → 0.11.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 +3 -1
- package/src/api.ts +12 -90
- package/src/spawn.ts +7 -0
- package/src/terminal-stream.ts +91 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
20
|
+
"@xterm/headless": "6.0.0",
|
|
19
21
|
"agent-relay-sdk": "0.2.2"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
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,13 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
599
577
|
}
|
|
600
578
|
}
|
|
601
579
|
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
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 from a server-side headless emulator (see terminal-stream.ts): the
|
|
581
|
+
// snapshot's `content` is a SerializeAddon repaint that restores the exact grid, scrollback,
|
|
582
|
+
// cursor, and SGR state when written to the client's xterm. The client and server run the
|
|
583
|
+
// same emulator over the same byte stream, so the backfill is correct by construction — no
|
|
584
|
+
// stabilization, trailing-newline strip, or cursor-park needed. Live bytes that arrive
|
|
585
|
+
// before the snapshot are already baked into it, so we discard the queue and only forward
|
|
586
|
+
// bytes that arrive after.
|
|
619
587
|
async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
|
|
620
588
|
if (ws.data.syncTimer) {
|
|
621
589
|
clearTimeout(ws.data.syncTimer);
|
|
@@ -628,46 +596,13 @@ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number)
|
|
|
628
596
|
ws.data.ready = true;
|
|
629
597
|
}
|
|
630
598
|
|
|
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.
|
|
599
|
+
// Send a full reset: a control frame with current geometry/status, then the serialized
|
|
600
|
+
// emulator state as a raw byte frame. Used on connect, resume, and refresh.
|
|
664
601
|
async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
|
|
665
602
|
const stream = ws.data.stream;
|
|
666
603
|
if (!stream) return;
|
|
667
|
-
const snapshot = await
|
|
668
|
-
|
|
669
|
-
});
|
|
670
|
-
if (!snapshot) return; // socket torn down mid-stabilize
|
|
604
|
+
const snapshot = await stream.backfill(cols, rows);
|
|
605
|
+
if (ws.data.stream !== stream) return; // socket torn down mid-backfill
|
|
671
606
|
ws.send(JSON.stringify({
|
|
672
607
|
type: "reset",
|
|
673
608
|
session: snapshot.session,
|
|
@@ -675,23 +610,10 @@ async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): P
|
|
|
675
610
|
agentAlive: snapshot.agentAlive,
|
|
676
611
|
cols: snapshot.cols,
|
|
677
612
|
rows: snapshot.rows,
|
|
678
|
-
cursorX: snapshot.cursorX,
|
|
679
|
-
cursorY: snapshot.cursorY,
|
|
680
613
|
capturedAt: snapshot.capturedAt,
|
|
681
614
|
}));
|
|
682
615
|
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));
|
|
616
|
+
ws.send(new TextEncoder().encode(snapshot.content));
|
|
695
617
|
}
|
|
696
618
|
}
|
|
697
619
|
|
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,25 @@
|
|
|
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: a *server-side headless xterm.js* (`@xterm/headless`) per session consumes
|
|
12
|
+
// the same byte stream the browser does, so it holds an authoritative, correctly-rendered
|
|
13
|
+
// grid. On viewer attach we `SerializeAddon.serialize()` that emulator into a byte-perfect
|
|
14
|
+
// repaint. This is the xterm.js-maintainer-blessed pattern for "live attach to a running
|
|
15
|
+
// terminal" — it replaces the fragile capture-pane reconstruction (which had to guess how
|
|
16
|
+
// Claude's purely-relative cursor model maps onto a freshly-seeded grid, and ghosted when
|
|
17
|
+
// it was even one row off). Correctness is now by construction: the server runs the same
|
|
18
|
+
// emulator as the client, parsing the same bytes. tmux control mode does not replay the
|
|
19
|
+
// pre-attach screen, so we seed the headless emulator once from a capture-pane snapshot.
|
|
20
|
+
//
|
|
11
21
|
// Input/resize: written as plain command lines to the control client's stdin
|
|
12
22
|
// (`send-keys -H <hex>`, `resize-window`), so a keystroke costs no process spawn.
|
|
13
23
|
// The relay's own injection path (message delivery, /compact, initial prompts) uses
|
|
14
24
|
// its separate `tmux send-keys` calls and is unaffected — control mode is just
|
|
15
25
|
// another observing client.
|
|
16
26
|
|
|
17
|
-
import {
|
|
27
|
+
import { Terminal } from "@xterm/headless";
|
|
28
|
+
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
29
|
+
import { captureTerminal, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
18
30
|
import type { OrchestratorConfig } from "./config";
|
|
19
31
|
|
|
20
32
|
const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
|
|
@@ -35,12 +47,16 @@ export interface TerminalStreamSubscriber {
|
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export interface TerminalStreamHandle {
|
|
38
|
-
backfill(cols?: number, rows?: number): TerminalSnapshot
|
|
50
|
+
backfill(cols?: number, rows?: number): Promise<TerminalSnapshot>;
|
|
39
51
|
write(bytes: Uint8Array): void;
|
|
40
52
|
resize(cols: number, rows: number): void;
|
|
41
53
|
release(): void;
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
const DEFAULT_COLS = 80;
|
|
57
|
+
const DEFAULT_ROWS = 24;
|
|
58
|
+
const TERMINAL_SCROLLBACK = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_SCROLLBACK) || 1000);
|
|
59
|
+
|
|
44
60
|
// --- Pure protocol helpers (unit-tested) ---
|
|
45
61
|
|
|
46
62
|
// Decode a tmux control-mode `%output` payload: printable ASCII is literal, every
|
|
@@ -114,6 +130,11 @@ class SessionStream {
|
|
|
114
130
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
115
131
|
private lineBuf = "";
|
|
116
132
|
private closed = false;
|
|
133
|
+
// Server-side emulator mirroring the client's view, for byte-perfect serialize() backfill.
|
|
134
|
+
private term: Terminal | null = null;
|
|
135
|
+
private serializer: SerializeAddon | null = null;
|
|
136
|
+
private termCols = DEFAULT_COLS;
|
|
137
|
+
private termRows = DEFAULT_ROWS;
|
|
117
138
|
|
|
118
139
|
constructor(
|
|
119
140
|
private readonly session: string,
|
|
@@ -140,6 +161,10 @@ class SessionStream {
|
|
|
140
161
|
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
141
162
|
);
|
|
142
163
|
}
|
|
164
|
+
// Build the headless emulator at the pane size and seed it with the current screen.
|
|
165
|
+
// tmux control mode only streams output emitted *after* attach, so without this seed
|
|
166
|
+
// the emulator would start blank and miss everything already on screen.
|
|
167
|
+
this.initTerm(snapshot);
|
|
143
168
|
try {
|
|
144
169
|
this.proc = Bun.spawn(tmuxCommand(socket, "-C", "attach-session", "-t", this.session), {
|
|
145
170
|
stdin: "pipe",
|
|
@@ -213,6 +238,9 @@ class SessionStream {
|
|
|
213
238
|
}
|
|
214
239
|
this.pending = [];
|
|
215
240
|
this.pendingBytes = 0;
|
|
241
|
+
// Keep the server-side emulator in lockstep with what subscribers receive, so a
|
|
242
|
+
// serialize() backfill always reflects exactly the bytes already streamed live.
|
|
243
|
+
this.term?.write(merged);
|
|
216
244
|
for (const sub of [...this.subscribers]) {
|
|
217
245
|
if (sub.bufferedAmount && sub.bufferedAmount() > BACKPRESSURE_MAX_BYTES) {
|
|
218
246
|
this.removeSubscriber(sub);
|
|
@@ -235,18 +263,67 @@ class SessionStream {
|
|
|
235
263
|
}
|
|
236
264
|
}
|
|
237
265
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
266
|
+
// Build the headless emulator and seed it with the current screen + cursor. The seed
|
|
267
|
+
// snapshot's (content, cursor) are captured consistently (see captureConsistent), so the
|
|
268
|
+
// emulator's cursor matches where the TUI's next relative delta continues from.
|
|
269
|
+
private initTerm(snapshot: TerminalSnapshot | null): void {
|
|
270
|
+
const cols = snapshot?.cols && snapshot.cols >= 1 ? snapshot.cols : DEFAULT_COLS;
|
|
271
|
+
const rows = snapshot?.rows && snapshot.rows >= 1 ? snapshot.rows : DEFAULT_ROWS;
|
|
272
|
+
this.termCols = cols;
|
|
273
|
+
this.termRows = rows;
|
|
274
|
+
this.term = new Terminal({ cols, rows, scrollback: TERMINAL_SCROLLBACK, allowProposedApi: true });
|
|
275
|
+
this.serializer = new SerializeAddon();
|
|
276
|
+
this.term.loadAddon(this.serializer);
|
|
277
|
+
if (snapshot?.content) {
|
|
278
|
+
// Strip the single trailing newline capture-pane appends; then park the cursor where
|
|
279
|
+
// tmux has it so the seeded emulator state is byte-faithful to the live pane.
|
|
280
|
+
let seed = snapshot.content.replace(/\n$/, "");
|
|
281
|
+
if (snapshot.cursorX != null && snapshot.cursorY != null) {
|
|
282
|
+
seed += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
|
|
283
|
+
}
|
|
284
|
+
this.term.write(seed);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Resolve once the emulator has parsed everything written so far (write() is async),
|
|
289
|
+
// so serialize() can't miss the most recent live chunk.
|
|
290
|
+
private flushTerm(): Promise<void> {
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
if (!this.term) return resolve();
|
|
293
|
+
this.term.write("", () => resolve());
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Serialize the emulator into a byte-perfect repaint. When the viewer's dimensions are
|
|
298
|
+
// given, resize both the tmux pane (so the TUI re-renders at the viewer's width) and the
|
|
299
|
+
// emulator to match. No stabilization needed: the serialize output puts the client in the
|
|
300
|
+
// emulator's exact state, and subsequent live deltas apply identically to both.
|
|
301
|
+
async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
243
302
|
if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
303
|
+
const c = Math.trunc(cols as number);
|
|
304
|
+
const r = Math.trunc(rows as number);
|
|
244
305
|
Bun.spawnSync(
|
|
245
|
-
tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(
|
|
306
|
+
tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
|
|
246
307
|
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
247
308
|
);
|
|
309
|
+
if (this.term && (c !== this.termCols || r !== this.termRows)) {
|
|
310
|
+
this.term.resize(c, r);
|
|
311
|
+
this.termCols = c;
|
|
312
|
+
this.termRows = r;
|
|
313
|
+
}
|
|
248
314
|
}
|
|
249
|
-
|
|
315
|
+
await this.flushTerm();
|
|
316
|
+
const content = this.serializer?.serialize() ?? "";
|
|
317
|
+
const live = sessionLiveness(this.session);
|
|
318
|
+
return {
|
|
319
|
+
session: this.session,
|
|
320
|
+
content,
|
|
321
|
+
running: live.running,
|
|
322
|
+
agentAlive: live.agentAlive,
|
|
323
|
+
cols: this.termCols,
|
|
324
|
+
rows: this.termRows,
|
|
325
|
+
capturedAt: Date.now(),
|
|
326
|
+
};
|
|
250
327
|
}
|
|
251
328
|
|
|
252
329
|
write(bytes: Uint8Array): void {
|
|
@@ -301,6 +378,11 @@ class SessionStream {
|
|
|
301
378
|
this.proc?.kill();
|
|
302
379
|
} catch {}
|
|
303
380
|
this.proc = null;
|
|
381
|
+
try {
|
|
382
|
+
this.term?.dispose();
|
|
383
|
+
} catch {}
|
|
384
|
+
this.term = null;
|
|
385
|
+
this.serializer = null;
|
|
304
386
|
this.onEmpty();
|
|
305
387
|
}
|
|
306
388
|
}
|