agent-relay-orchestrator 0.11.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.11.0",
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
- // How long to wait between stabilization captures, and how many to try. The viewer's
603
- // resize fires SIGWINCH at the TUI, whose repaint is *asynchronous* capturing before
604
- // it lands snapshots a mid-repaint screen with a transient cursor, and the post-capture
605
- // relative deltas then land offset and stack a ghost frame. We poll until the capture
606
- // stops changing (or the budget runs out) so we only ever backfill a quiescent screen.
607
- const BACKFILL_STABILIZE_STEP_MS = Math.max(20, Number(process.env.AGENT_RELAY_TERMINAL_STABILIZE_STEP_MS) || 60);
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 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
- // Re-capture until the screen stops changing (or the budget runs out). The first
632
- // capture resizes the pane to the viewer geometry, which fires SIGWINCH at the TUI
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 captureStableSnapshot((resize) => (resize ? stream.backfill(cols, rows) : stream.backfill()), {
668
- shouldAbort: () => ws.data.stream !== stream,
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
- // Strip the single trailing newline capture-pane appends. Left in, it scrolls xterm
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/relay.ts CHANGED
@@ -25,7 +25,7 @@ export interface ManagedAgentReport {
25
25
  workspaceMode?: WorkspaceMode;
26
26
  workspace?: WorkspaceMetadata;
27
27
  sessionName?: string;
28
- supervisor?: "process" | "systemd" | "unknown";
28
+ supervisor?: "process" | "systemd" | "launchd" | "unknown";
29
29
  systemdUnit?: string;
30
30
  terminalSession?: string;
31
31
  terminalAvailable?: boolean;
@@ -52,7 +52,7 @@ export interface ManagedSessionExitDiagnostics {
52
52
  policyName?: string;
53
53
  spawnRequestId?: string;
54
54
  automationRunId?: string;
55
- supervisor: "process" | "systemd" | "unknown";
55
+ supervisor: "process" | "systemd" | "launchd" | "unknown";
56
56
  systemdUnit?: string;
57
57
  terminalSession?: string;
58
58
  terminalAvailable?: boolean;
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",
@@ -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 { captureTerminal, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
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
- // 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 {
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(Math.trunc(cols as number)), "-y", String(Math.trunc(rows as number))),
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
- return captureTerminal(this.session, this.config);
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
  }