agent-relay-orchestrator 0.10.25 → 0.10.27
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 +60 -14
- package/src/spawn.ts +41 -11
package/package.json
CHANGED
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(() =>
|
|
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
|
|
|
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
|
+
|
|
600
610
|
// Resize the pane to the viewer's dimensions and capture the matching backfill.
|
|
601
611
|
//
|
|
602
|
-
// Claude's TUI streams pure *relative* cursor deltas (no absolute positioning), so
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
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> {
|
|
609
620
|
if (ws.data.syncTimer) {
|
|
610
621
|
clearTimeout(ws.data.syncTimer);
|
|
611
622
|
ws.data.syncTimer = undefined;
|
|
612
623
|
}
|
|
624
|
+
if (ws.data.synced) return;
|
|
613
625
|
ws.data.synced = true;
|
|
614
|
-
sendBackfill(ws, cols, rows);
|
|
626
|
+
await sendBackfill(ws, cols, rows);
|
|
615
627
|
ws.data.queue = [];
|
|
616
628
|
ws.data.ready = true;
|
|
617
629
|
}
|
|
618
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;
|
|
658
|
+
}
|
|
659
|
+
return snapshot;
|
|
660
|
+
}
|
|
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,
|
|
@@ -670,16 +716,16 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
|
|
|
670
716
|
// First resize sizes the pane and triggers the (size-matched) backfill;
|
|
671
717
|
// later ones just reflow the live stream.
|
|
672
718
|
if (!ws.data.synced) {
|
|
673
|
-
syncAndBackfill(ws, cols, rows);
|
|
719
|
+
void syncAndBackfill(ws, cols, rows);
|
|
674
720
|
} else {
|
|
675
721
|
ws.data.stream?.resize(cols, rows);
|
|
676
722
|
}
|
|
677
723
|
} else if (frame.type === "pause") {
|
|
678
724
|
ws.data.paused = frame.paused === true;
|
|
679
725
|
// We drop (not buffer) bytes while paused, so resync with a fresh backfill.
|
|
680
|
-
if (!ws.data.paused && ws.data.synced) sendBackfill(ws);
|
|
726
|
+
if (!ws.data.paused && ws.data.synced) void sendBackfill(ws);
|
|
681
727
|
} else if (frame.type === "refresh") {
|
|
682
|
-
if (ws.data.synced) sendBackfill(ws);
|
|
728
|
+
if (ws.data.synced) void sendBackfill(ws);
|
|
683
729
|
}
|
|
684
730
|
} catch (e) {
|
|
685
731
|
ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
|
package/src/spawn.ts
CHANGED
|
@@ -962,20 +962,14 @@ export function captureTerminal(name: string, config: OrchestratorConfig): Termi
|
|
|
962
962
|
}
|
|
963
963
|
|
|
964
964
|
const size = tmuxPaneSize(name, socketName);
|
|
965
|
-
const cursor =
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
stderr: "pipe",
|
|
970
|
-
});
|
|
971
|
-
if (result.exitCode !== 0) {
|
|
972
|
-
const stderr = result.stderr.toString().trim();
|
|
973
|
-
throw new Error(stderr || `tmux capture-pane failed with exit code ${result.exitCode}`);
|
|
974
|
-
}
|
|
965
|
+
const { content, cursor } = captureConsistent(
|
|
966
|
+
() => captureContent(name, socketName),
|
|
967
|
+
() => tmuxCursorPos(name, socketName),
|
|
968
|
+
);
|
|
975
969
|
|
|
976
970
|
return {
|
|
977
971
|
session: name,
|
|
978
|
-
content
|
|
972
|
+
content,
|
|
979
973
|
running: true,
|
|
980
974
|
agentAlive,
|
|
981
975
|
...size,
|
|
@@ -984,6 +978,42 @@ export function captureTerminal(name: string, config: OrchestratorConfig): Termi
|
|
|
984
978
|
};
|
|
985
979
|
}
|
|
986
980
|
|
|
981
|
+
// Capture cursor and content *consistently*. They come from separate tmux invocations,
|
|
982
|
+
// so if the pane scrolls between them (e.g. tool output streaming while the agent
|
|
983
|
+
// "thinks"), cursorY ends up off-by-one against the captured grid — the parked cursor
|
|
984
|
+
// then lands a row off and the TUI's next relative redraw stacks a stale statusline row
|
|
985
|
+
// (bottom-box ghost). Read content, then cursor, then content again; accept only when the
|
|
986
|
+
// two content reads bracket the cursor read unchanged, which proves the cursor reflects
|
|
987
|
+
// that exact grid. Fall through with the latest capture if the pane never holds still.
|
|
988
|
+
export function captureConsistent(
|
|
989
|
+
readContent: () => string,
|
|
990
|
+
readCursor: () => { cursorX?: number; cursorY?: number },
|
|
991
|
+
maxAttempts = 4,
|
|
992
|
+
): { content: string; cursor: { cursorX?: number; cursorY?: number } } {
|
|
993
|
+
let content = readContent();
|
|
994
|
+
let cursor = readCursor();
|
|
995
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
996
|
+
const recheck = readContent();
|
|
997
|
+
if (recheck === content) break;
|
|
998
|
+
content = recheck;
|
|
999
|
+
cursor = readCursor();
|
|
1000
|
+
}
|
|
1001
|
+
return { content, cursor };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function captureContent(name: string, socketName?: string): string {
|
|
1005
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-e", "-S", "-1000", "-t", name), {
|
|
1006
|
+
stdin: "ignore",
|
|
1007
|
+
stdout: "pipe",
|
|
1008
|
+
stderr: "pipe",
|
|
1009
|
+
});
|
|
1010
|
+
if (result.exitCode !== 0) {
|
|
1011
|
+
const stderr = result.stderr.toString().trim();
|
|
1012
|
+
throw new Error(stderr || `tmux capture-pane failed with exit code ${result.exitCode}`);
|
|
1013
|
+
}
|
|
1014
|
+
return result.stdout.toString();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
987
1017
|
export function terminalInputTokens(data: string): TerminalInputToken[] {
|
|
988
1018
|
const tokens: TerminalInputToken[] = [];
|
|
989
1019
|
let literal = "";
|