agent-relay-orchestrator 0.59.0 → 0.60.0
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 +2 -2
- package/src/terminal-stream.ts +80 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.36"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/terminal-stream.ts
CHANGED
|
@@ -68,6 +68,10 @@ interface PendingCommand {
|
|
|
68
68
|
resolve: (lines: string[]) => void;
|
|
69
69
|
reject: (err: Error) => void;
|
|
70
70
|
timer: ReturnType<typeof setTimeout> | null;
|
|
71
|
+
// Fired SYNCHRONOUSLY when this command's reply block closes — before readLoop hands any
|
|
72
|
+
// later same-chunk %output to enqueue(). The resync screen-capture uses it to snapshot the
|
|
73
|
+
// pre-capture delta boundary (#275 dedup); see snapshotPreCaptureDeltas.
|
|
74
|
+
onFinishSync?: () => void;
|
|
71
75
|
}
|
|
72
76
|
// On attach we include this many lines of tmux scrollback ABOVE the current screen so the
|
|
73
77
|
// viewer can scroll back through pre-attach history (the client lands on the live screen;
|
|
@@ -352,6 +356,12 @@ class SessionStream {
|
|
|
352
356
|
// long the ground gate has been deferring (for the CAN fallback).
|
|
353
357
|
private resyncInFlight = false;
|
|
354
358
|
private groundDeferStart = 0;
|
|
359
|
+
// #275 dedup: deltas that were in `pending` at the resync capture's %end — the PRE-capture
|
|
360
|
+
// %output. doResync drains exactly these before the repaint; %output that arrives after the
|
|
361
|
+
// capture (even later in the same readLoop chunk) accumulates in a fresh `pending` and
|
|
362
|
+
// applies ON TOP of the repaint on the trailing flush, never re-scrolled beneath it.
|
|
363
|
+
private resyncPreCapture: Uint8Array[] | null = null;
|
|
364
|
+
private resyncPreCaptureBytes = 0;
|
|
355
365
|
|
|
356
366
|
constructor(
|
|
357
367
|
private readonly session: string,
|
|
@@ -456,6 +466,11 @@ class SessionStream {
|
|
|
456
466
|
const entry = this.pendingCommands.shift();
|
|
457
467
|
if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
|
|
458
468
|
if (entry.timer) clearTimeout(entry.timer);
|
|
469
|
+
// Snapshot the delta boundary NOW, synchronously, before resolve() and before readLoop
|
|
470
|
+
// processes any further %output in this chunk — so the boundary is exactly this block's %end.
|
|
471
|
+
if (entry.onFinishSync) {
|
|
472
|
+
try { entry.onFinishSync(); } catch {}
|
|
473
|
+
}
|
|
459
474
|
if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
|
|
460
475
|
else entry.resolve(block.lines.map(latin1LineToUtf8));
|
|
461
476
|
}
|
|
@@ -587,16 +602,22 @@ class SessionStream {
|
|
|
587
602
|
this.resyncInFlight = true;
|
|
588
603
|
try {
|
|
589
604
|
const repaint = await this.resyncRepaint();
|
|
590
|
-
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
this.
|
|
605
|
+
// Ordering (#275): the %output snapshotted at the capture's %end (resyncPreCapture) is
|
|
606
|
+
// the PRE-capture stream — drain it to subscribers BEFORE the repaint so scrolled lines
|
|
607
|
+
// can't re-apply on top of it (the duplicate-text race). %output that arrived AFTER the
|
|
608
|
+
// capture stayed in `pending` and applies on top of the repaint via the trailing flush.
|
|
609
|
+
this.drainPreCaptureDeltas();
|
|
610
|
+
if (!repaint || this.closed || this.subscribers.size === 0) {
|
|
611
|
+
this.flush(); // release any post-capture deltas normally (no repaint to inject)
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
596
614
|
const out = forceAbort ? this.prependCan(repaint) : repaint;
|
|
597
615
|
this.broadcast(out);
|
|
616
|
+
this.flush(); // post-capture deltas, on top of the fresh repaint
|
|
598
617
|
tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
|
|
599
618
|
} catch (e) {
|
|
619
|
+
this.drainPreCaptureDeltas();
|
|
620
|
+
this.flush();
|
|
600
621
|
tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
|
|
601
622
|
} finally {
|
|
602
623
|
this.resyncInFlight = false;
|
|
@@ -628,12 +649,58 @@ class SessionStream {
|
|
|
628
649
|
// The grid + cursor are read in-band through the control client (#275), so the capture
|
|
629
650
|
// is serialized with %output deltas and costs no process spawn.
|
|
630
651
|
private async resyncRepaint(): Promise<Uint8Array | null> {
|
|
631
|
-
|
|
632
|
-
|
|
652
|
+
// ORDER IS LOAD-BEARING for the #275 dedup. The screen capture is the serialization
|
|
653
|
+
// point: doResync drains `pending` (pre-capture %output) and emits this repaint right
|
|
654
|
+
// after. So the capture MUST be the LAST in-band read here — any awaited round-trip
|
|
655
|
+
// after it (the old readCursorState/paneDims) lets live %output land in `pending` that
|
|
656
|
+
// doResync then flushes BEFORE this now-stale repaint, re-scrolling lines the repaint
|
|
657
|
+
// still shows on-screen into the client's scrollback → duplicated text under load.
|
|
658
|
+
// Read the cursor first; reuse the cached row count (resize drives its own backfill, so
|
|
659
|
+
// termRows is authoritative without a round-trip); capture the grid last.
|
|
633
660
|
const cursor = await this.readCursorState();
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
661
|
+
// Capture LAST, and tag it so its %end snapshots the pre-capture delta boundary (the
|
|
662
|
+
// capture-pane reply IS the serialization point against the %output stream).
|
|
663
|
+
const lines = await this.command(
|
|
664
|
+
`capture-pane -p -e -t "${this.session}"`,
|
|
665
|
+
true,
|
|
666
|
+
() => this.snapshotPreCaptureDeltas(),
|
|
667
|
+
).catch(() => null);
|
|
668
|
+
const body = lines ? lines.join("\n") : "";
|
|
669
|
+
if (!body) return null;
|
|
670
|
+
return new TextEncoder().encode(buildInPlaceRepaint(body, this.termRows, cursor));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Swap the live `pending` buffer aside at the resync capture's %end: these chunks are the
|
|
674
|
+
// PRE-capture %output (drained before the repaint), and a fresh `pending` collects anything
|
|
675
|
+
// after (applied on top, next flush). Runs synchronously inside finishBlock (#275).
|
|
676
|
+
private snapshotPreCaptureDeltas(): void {
|
|
677
|
+
this.resyncPreCapture = this.pending;
|
|
678
|
+
this.resyncPreCaptureBytes = this.pendingBytes;
|
|
679
|
+
this.pending = [];
|
|
680
|
+
this.pendingBytes = 0;
|
|
681
|
+
if (this.flushTimer !== null) {
|
|
682
|
+
clearTimeout(this.flushTimer);
|
|
683
|
+
this.flushTimer = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Broadcast the snapshotted pre-capture deltas (mirrors flush()'s ANSI-state bookkeeping)
|
|
688
|
+
// ahead of the repaint. No-op when nothing was snapshotted (capture timed out / errored).
|
|
689
|
+
private drainPreCaptureDeltas(): void {
|
|
690
|
+
const chunks = this.resyncPreCapture;
|
|
691
|
+
const total = this.resyncPreCaptureBytes;
|
|
692
|
+
this.resyncPreCapture = null;
|
|
693
|
+
this.resyncPreCaptureBytes = 0;
|
|
694
|
+
if (!chunks || total === 0) return;
|
|
695
|
+
const merged = new Uint8Array(total);
|
|
696
|
+
let offset = 0;
|
|
697
|
+
for (const chunk of chunks) {
|
|
698
|
+
merged.set(chunk, offset);
|
|
699
|
+
offset += chunk.length;
|
|
700
|
+
}
|
|
701
|
+
this.broadcastState = scanAnsiState(merged, this.broadcastState);
|
|
702
|
+
this.broadcast(merged);
|
|
703
|
+
if (this.broadcastState === "ground") this.fireGroundWaiters();
|
|
637
704
|
}
|
|
638
705
|
|
|
639
706
|
// Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
|
|
@@ -793,14 +860,14 @@ class SessionStream {
|
|
|
793
860
|
// Write a command to the control client's stdin and register a FIFO entry for its reply
|
|
794
861
|
// block. `wantReply` commands resolve with the block's lines (or reject on %error /
|
|
795
862
|
// timeout); fire-and-forget commands resolve with [] once their empty block closes.
|
|
796
|
-
private command(line: string, wantReply = false): Promise<string[]> {
|
|
863
|
+
private command(line: string, wantReply = false, onFinishSync?: () => void): Promise<string[]> {
|
|
797
864
|
if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
|
|
798
865
|
const stdin = this.proc?.stdin;
|
|
799
866
|
if (!stdin || typeof stdin === "number") {
|
|
800
867
|
return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
|
|
801
868
|
}
|
|
802
869
|
return new Promise<string[]>((resolve, reject) => {
|
|
803
|
-
const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
|
|
870
|
+
const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null, onFinishSync };
|
|
804
871
|
if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
|
|
805
872
|
this.pendingCommands.push(entry);
|
|
806
873
|
try {
|