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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.59.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.35"
19
+ "agent-relay-sdk": "0.2.36"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
@@ -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
- if (!repaint || this.closed || this.subscribers.size === 0) return;
591
- // Ordering (#275): every %output read before the capture's %end is already in
592
- // `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
593
- // re-apply on top of it (the duplicate-text race). Deltas after %end describe
594
- // post-capture changes and correctly apply on top of the repaint next flush.
595
- this.flush();
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
- const body = await this.readScreen();
632
- if (!body) return null;
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
- const dims = await this.paneDims();
635
- const rows = dims.rows ?? this.termRows;
636
- return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
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 {