agent-relay-orchestrator 0.10.24 → 0.10.26

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/api.ts +66 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.10.24",
3
+ "version": "0.10.26",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -590,38 +590,84 @@ function startTerminalSocket(ws: TerminalSocket): void {
590
590
  ws.data.stream = acquireTerminalStream(ws.data.session, ws.data.config, subscriber);
591
591
  // Wait for the client's resize to size the pane before backfilling. Fall back to
592
592
  // the pane's current size if no resize arrives (e.g. a non-fitting client).
593
- ws.data.syncTimer = setTimeout(() => syncAndBackfill(ws), 700);
593
+ ws.data.syncTimer = setTimeout(() => {
594
+ if (!ws.data.synced) void syncAndBackfill(ws);
595
+ }, 700);
594
596
  } catch (e) {
595
597
  ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
596
598
  ws.close();
597
599
  }
598
600
  }
599
601
 
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 {
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.
619
+ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
603
620
  if (ws.data.syncTimer) {
604
621
  clearTimeout(ws.data.syncTimer);
605
622
  ws.data.syncTimer = undefined;
606
623
  }
624
+ if (ws.data.synced) return;
607
625
  ws.data.synced = true;
608
- sendBackfill(ws, cols, rows);
609
- ws.data.ready = true;
610
- const queued = ws.data.queue ?? [];
626
+ await sendBackfill(ws, cols, rows);
611
627
  ws.data.queue = [];
612
- for (const bytes of queued) {
613
- try {
614
- ws.send(bytes);
615
- } catch {}
628
+ ws.data.ready = true;
629
+ }
630
+
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;
616
658
  }
659
+ return snapshot;
617
660
  }
618
661
 
619
662
  // Send a full reset: a control frame with current geometry/status, then the
620
663
  // captured scrollback as a raw byte frame. Used on connect, resume, and refresh.
621
- function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): void {
664
+ async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): Promise<void> {
622
665
  const stream = ws.data.stream;
623
666
  if (!stream) return;
624
- const snapshot = stream.backfill(cols, rows);
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
625
671
  ws.send(JSON.stringify({
626
672
  type: "reset",
627
673
  session: snapshot.session,
@@ -634,11 +680,14 @@ function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number): void {
634
680
  capturedAt: snapshot.capturedAt,
635
681
  }));
636
682
  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$/, "");
637
687
  // Park the cursor where tmux actually has it (screen-relative). Without this the
638
688
  // cursor sits at the end of the captured text, so the TUI's first relative redraw
639
689
  // (cursor-up + rewrite of its prompt/statusline) lands at the wrong row and stacks
640
690
  // a ghost copy into scrollback.
641
- let content = snapshot.content;
642
691
  if (snapshot.cursorX != null && snapshot.cursorY != null) {
643
692
  content += `\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`;
644
693
  }
@@ -667,16 +716,16 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
667
716
  // First resize sizes the pane and triggers the (size-matched) backfill;
668
717
  // later ones just reflow the live stream.
669
718
  if (!ws.data.synced) {
670
- syncAndBackfill(ws, cols, rows);
719
+ void syncAndBackfill(ws, cols, rows);
671
720
  } else {
672
721
  ws.data.stream?.resize(cols, rows);
673
722
  }
674
723
  } else if (frame.type === "pause") {
675
724
  ws.data.paused = frame.paused === true;
676
725
  // We drop (not buffer) bytes while paused, so resync with a fresh backfill.
677
- if (!ws.data.paused && ws.data.synced) sendBackfill(ws);
726
+ if (!ws.data.paused && ws.data.synced) void sendBackfill(ws);
678
727
  } else if (frame.type === "refresh") {
679
- if (ws.data.synced) sendBackfill(ws);
728
+ if (ws.data.synced) void sendBackfill(ws);
680
729
  }
681
730
  } catch (e) {
682
731
  ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));