@yolo-labs/yolobridge 0.9.0 → 0.11.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.
@@ -191,16 +191,61 @@ export async function postHeartbeat(cfg, workspaceId, attachmentId) {
191
191
  const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
192
192
  return Boolean(body?.recorded);
193
193
  }
194
- export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
194
+ export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy, extra) {
195
195
  const body = await postEvent(cfg, workspaceId, {
196
196
  attachmentId,
197
197
  type: 'read-output-reply',
198
198
  requestId,
199
199
  output,
200
200
  busy,
201
+ ...(extra?.cols && extra?.rows ? { cols: extra.cols, rows: extra.rows } : {}),
202
+ ...(extra?.raw
203
+ ? {
204
+ raw: extra.raw.data,
205
+ epoch: extra.raw.epoch,
206
+ baseOffset: extra.raw.baseOffset,
207
+ endOffset: extra.raw.endOffset,
208
+ truncated: extra.raw.truncated,
209
+ }
210
+ : {}),
201
211
  });
202
212
  return Boolean(body?.resolved);
203
213
  }
214
+ /**
215
+ * `POST .../yolobridge/events {type:'output-chunk'}` — one batch of live PTY
216
+ * output (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming").
217
+ *
218
+ * Sent ONLY while the server has an unexpired `output-stream-start` lease
219
+ * outstanding, i.e. only while a browser is actually watching the tile. A
220
+ * daemon with no viewer sends none of these at all — see attach-cmd.ts's
221
+ * stream controller.
222
+ *
223
+ * Authenticated with the WORKSPACE-SCOPED credential like every other
224
+ * post-attach call (Boundary B refuses an account token here), and the bytes
225
+ * are relayed in memory by the server onto the workspace's own event stream —
226
+ * never logged, never written to Mongo, never appended to the resumable event
227
+ * buffer. This is the operator's live screen; it can contain anything they
228
+ * typed.
229
+ *
230
+ * `droppedBytes` is how many bytes were SKIPPED immediately before `data`
231
+ * (see output-stream.ts). Non-zero means the viewer must treat its screen as
232
+ * unreliable and re-seed — it is not a diagnostic counter, it is part of the
233
+ * protocol.
234
+ */
235
+ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
236
+ const body = await postEvent(cfg, workspaceId, {
237
+ attachmentId,
238
+ type: 'output-chunk',
239
+ streamId: chunk.streamId,
240
+ seq: chunk.seq,
241
+ data: chunk.data,
242
+ droppedBytes: chunk.droppedBytes,
243
+ ...(chunk.epoch ? { epoch: chunk.epoch } : {}),
244
+ ...(typeof chunk.startOffset === 'number' ? { startOffset: chunk.startOffset } : {}),
245
+ ...(chunk.cols && chunk.rows ? { cols: chunk.cols, rows: chunk.rows } : {}),
246
+ });
247
+ return Boolean(body?.relayed);
248
+ }
204
249
  async function postEvent(cfg, workspaceId, payload) {
205
250
  const fetchImpl = cfg.fetchImpl ?? fetch;
206
251
  const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/events`, {
@@ -22,7 +22,8 @@ import { SseFrameParser } from './sse-frame-parser.js';
22
22
  import { actionForFrame } from './frame-actions.js';
23
23
  import { startHeartbeat, defaultTimers } from './heartbeat.js';
24
24
  import { nextBackoffMs } from './reconnect.js';
25
- import { deliverPromptToLocalAgent, captureLocalAgentOutput } from './local-agent.js';
25
+ import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, } from './local-agent.js';
26
+ import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, } from './output-stream.js';
26
27
  import * as apiClient from './api-client.js';
27
28
  import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
28
29
  import { loadAuth, saveAuth, loadAttachment, saveAttachment, clearAttachment, } from './config-store.js';
@@ -83,6 +84,11 @@ export async function runAttachDaemon(deps) {
83
84
  const refreshBufferMs = deps.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
84
85
  const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
85
86
  const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
87
+ const subscribeAgentOutput = deps.subscribeAgentOutput ?? onLocalAgentData;
88
+ const takeRawSeed = deps.takeRawSeed ?? takeRawSeedFromAgent;
89
+ const primeRawStream = deps.primeRawStream ?? primeRawStreamFromAgent;
90
+ const getGeometry = deps.getAgentGeometry ?? getLocalAgentGeometry;
91
+ const flushIntervalMs = deps.outputFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
86
92
  /**
87
93
  * THE ACCOUNT identity. Full-account bearer from `yolo-bridge login`, and the
88
94
  * ONLY thing `ensureFreshToken` may write to. Used for exactly one call —
@@ -594,6 +600,133 @@ export async function runAttachDaemon(deps) {
594
600
  clearAttachment(env, io);
595
601
  return { ok: true, reason: 'stopped' };
596
602
  }
603
+ let outputStream;
604
+ function stopOutputStream() {
605
+ const session = outputStream;
606
+ if (!session)
607
+ return;
608
+ outputStream = undefined;
609
+ try {
610
+ session.unsubscribe();
611
+ }
612
+ catch { /* best effort */ }
613
+ timers.clearInterval(session.flushHandle);
614
+ // Anything still queued belongs to a streamId that no longer exists.
615
+ session.buffer.reset();
616
+ }
617
+ async function flushOutputStream(session) {
618
+ if (outputStream !== session)
619
+ return;
620
+ // Lease check FIRST, before any work: this tick is the only thing that
621
+ // ever notices a lapsed grant, and it must notice it even when the buffer
622
+ // is empty.
623
+ if (now() >= session.leaseUntilMs) {
624
+ stopOutputStream();
625
+ return;
626
+ }
627
+ if (session.posting)
628
+ return;
629
+ const batch = session.buffer.drain();
630
+ if (!batch)
631
+ return;
632
+ const bytes = Buffer.byteLength(batch.data, 'utf-8');
633
+ session.posting = true;
634
+ try {
635
+ const geometry = getGeometry();
636
+ await apiClient.postOutputChunk(scopedCfg(), workspaceId, attachmentId, {
637
+ streamId: session.streamId,
638
+ seq: session.seq,
639
+ data: batch.data,
640
+ droppedBytes: batch.droppedBytes,
641
+ epoch: session.epoch,
642
+ startOffset: batch.startOffset,
643
+ cols: geometry?.cols,
644
+ rows: geometry?.rows,
645
+ });
646
+ if (outputStream === session)
647
+ session.seq += 1;
648
+ }
649
+ catch (err) {
650
+ // These bytes are GONE — re-queueing them would reorder the stream
651
+ // behind whatever the PTY produced while the POST was in flight, and an
652
+ // out-of-order terminal frame is worse than an acknowledged hole. Fold
653
+ // them (plus any gap this batch was already carrying) into the next
654
+ // batch's `droppedBytes` so the viewer re-seeds instead of trusting a
655
+ // screen that silently lost a frame.
656
+ if (outputStream === session)
657
+ session.buffer.noteDropped(bytes + batch.droppedBytes);
658
+ // Out-of-band, never `log`: this fires mid-session while the local
659
+ // agent's TUI owns stdout (connection-state.ts's module header). The
660
+ // detail carries the transport error only — NEVER the PTY bytes.
661
+ noteConnection('degraded', {
662
+ detail: `output chunk relay failed: ${err instanceof Error ? err.message : String(err)}`,
663
+ });
664
+ }
665
+ finally {
666
+ if (outputStream === session)
667
+ session.posting = false;
668
+ }
669
+ }
670
+ /**
671
+ * Honour an `output-stream-start` frame.
672
+ *
673
+ * Same `streamId` → this is a LEASE RENEWAL (the server re-sends while
674
+ * demand persists); keep the tap, the buffer and the sequence exactly as
675
+ * they are, so a renewal is invisible to the viewer.
676
+ *
677
+ * Different `streamId` → a NEW episode (the previous one was stopped, or
678
+ * this daemon reconnected and the server re-granted). Tear the old one down
679
+ * first: its queued bytes describe a screen the viewer is no longer showing.
680
+ */
681
+ function startOrRenewOutputStream(streamId, leaseMs) {
682
+ if (!streamId)
683
+ return;
684
+ const existing = outputStream;
685
+ if (existing && existing.streamId === streamId) {
686
+ existing.leaseUntilMs = now() + leaseMs;
687
+ return;
688
+ }
689
+ stopOutputStream();
690
+ // Prime from the raw ring, atomically with taking the tap below. The seed
691
+ // the viewer fetches is a SEPARATE round trip that can land either side of
692
+ // this moment, so the stream deliberately starts BEHIND it: overlapping
693
+ // bytes are trimmed by the viewer using their absolute offsets, whereas a
694
+ // hole would cost a re-seed. Overlap is free; a gap is not.
695
+ const prime = primeRawStream();
696
+ const buffer = new OutputStreamBuffer(deps.outputStreamOptions);
697
+ buffer.setBaseOffset(prime.startOffset);
698
+ if (prime.data)
699
+ buffer.push(prime.data);
700
+ const session = {
701
+ streamId,
702
+ seq: 0,
703
+ buffer,
704
+ unsubscribe: () => { },
705
+ flushHandle: undefined,
706
+ leaseUntilMs: now() + leaseMs,
707
+ posting: false,
708
+ epoch: prime.epoch,
709
+ };
710
+ outputStream = session;
711
+ // Subscribe AFTER the session exists so a synchronous first chunk can't
712
+ // land on a half-built one.
713
+ session.unsubscribe = subscribeAgentOutput((data, meta) => {
714
+ if (outputStream !== session)
715
+ return;
716
+ if (meta && meta.epoch !== session.epoch) {
717
+ // The local agent was respawned under us. Everything queued describes a
718
+ // screen that no longer exists; rebase onto the new session's offsets
719
+ // and let the viewer notice the epoch change and re-seed.
720
+ buffer.reset();
721
+ buffer.setBaseOffset(meta.startOffset);
722
+ session.epoch = meta.epoch;
723
+ }
724
+ buffer.push(data);
725
+ });
726
+ session.flushHandle = timers.setInterval(() => {
727
+ void flushOutputStream(session);
728
+ }, flushIntervalMs);
729
+ }
597
730
  let heartbeat;
598
731
  let attempt = 0;
599
732
  /** Set when a proactive refresh (see ensureFreshToken) fails while a
@@ -730,13 +863,39 @@ export async function runAttachDaemon(deps) {
730
863
  break;
731
864
  case 'read-output': {
732
865
  const captured = await captureOutput();
866
+ // Taken AFTER the (async) capture and synchronously, so
867
+ // `endOffset` names the exact byte position this reply leaves
868
+ // the viewer at. Anything the PTY produces from here on
869
+ // carries a higher offset and is spliced on by the viewer;
870
+ // anything already inside `raw.data` is trimmed by the same
871
+ // arithmetic. That is what makes seed and tap non-atomic in
872
+ // WALL CLOCK yet exactly-once in effect.
873
+ const raw = action.mode === 'raw' ? takeRawSeed() : undefined;
733
874
  await apiClient
734
- .postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
875
+ .postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy, {
876
+ cols: raw?.cols ?? captured.cols,
877
+ rows: raw?.rows ?? captured.rows,
878
+ raw: raw
879
+ ? {
880
+ epoch: raw.epoch,
881
+ baseOffset: raw.baseOffset,
882
+ endOffset: raw.endOffset,
883
+ data: raw.data,
884
+ truncated: raw.truncated,
885
+ }
886
+ : undefined,
887
+ })
735
888
  .catch((err) => noteConnection('degraded', {
736
889
  detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
737
890
  }));
738
891
  break;
739
892
  }
893
+ case 'output-stream-start':
894
+ startOrRenewOutputStream(action.streamId, action.leaseMs);
895
+ break;
896
+ case 'output-stream-stop':
897
+ stopOutputStream();
898
+ break;
740
899
  case 'detached':
741
900
  noteConnection('detached');
742
901
  sawDetached = true;
@@ -765,6 +924,11 @@ export async function runAttachDaemon(deps) {
765
924
  }
766
925
  heartbeat?.stop();
767
926
  heartbeat = undefined;
927
+ // The grant died with the connection that carried it. A reconnect gets a
928
+ // FRESH `output-stream-start` (new streamId) if demand still exists —
929
+ // never a silent resumption of the old one, which is what keeps a
930
+ // reconnect from splicing two episodes' bytes into one screen.
931
+ stopOutputStream();
768
932
  if (sawDetached || sawGone) {
769
933
  clearAttachment(env, io);
770
934
  return { ok: true, reason: 'detached-by-server' };
@@ -781,6 +945,11 @@ export async function runAttachDaemon(deps) {
781
945
  }
782
946
  finally {
783
947
  heartbeat?.stop();
948
+ // Belt and braces for every path out of the loop (a break, a throw, a
949
+ // stop signal): no code path may leave a live PTY tap or flush timer
950
+ // behind, or the daemon would keep streaming with nobody watching — the
951
+ // exact failure this whole mechanism exists to prevent.
952
+ stopOutputStream();
784
953
  }
785
954
  if (scopedRefreshFailed) {
786
955
  // OUT-OF-BAND ONLY — no `log()` here, unlike the account-token path below.
@@ -7,6 +7,13 @@
7
7
  * reconnecting" — is unit-testable without a live stream or a real
8
8
  * daemon loop.
9
9
  */
10
+ /** Lease to assume when a server sends `output-stream-start` without one (or
11
+ * with a nonsense value). Deliberately short — an unknown lease is a reason to
12
+ * be conservative, not generous. */
13
+ export const FALLBACK_OUTPUT_STREAM_LEASE_MS = 30_000;
14
+ /** Upper bound on a server-supplied lease. A buggy or hostile server cannot
15
+ * talk this daemon into streaming for an hour on one frame. */
16
+ export const MAX_OUTPUT_STREAM_LEASE_MS = 5 * 60_000;
10
17
  export function actionForFrame(frame) {
11
18
  const data = (frame.data ?? {});
12
19
  switch (frame.event) {
@@ -25,7 +32,24 @@ export function actionForFrame(frame) {
25
32
  kind: 'read-output',
26
33
  attachmentId: String(data.attachmentId ?? ''),
27
34
  requestId: String(data.requestId ?? ''),
35
+ // Anything unrecognized (including an absent field, which is what a
36
+ // server predating raw seeding sends) means the old screen dump.
37
+ mode: data.mode === 'raw' ? 'raw' : 'screen',
28
38
  };
39
+ case 'output-stream-start': {
40
+ const raw = typeof data.leaseMs === 'number' ? data.leaseMs : NaN;
41
+ const leaseMs = Number.isFinite(raw) && raw > 0
42
+ ? Math.min(raw, MAX_OUTPUT_STREAM_LEASE_MS)
43
+ : FALLBACK_OUTPUT_STREAM_LEASE_MS;
44
+ return {
45
+ kind: 'output-stream-start',
46
+ attachmentId: String(data.attachmentId ?? ''),
47
+ streamId: String(data.streamId ?? ''),
48
+ leaseMs,
49
+ };
50
+ }
51
+ case 'output-stream-stop':
52
+ return { kind: 'output-stream-stop', attachmentId: String(data.attachmentId ?? '') };
29
53
  case 'detached':
30
54
  return { kind: 'detached', attachmentId: String(data.attachmentId ?? '') };
31
55
  default:
@@ -32,7 +32,9 @@
32
32
  * module at all, so nothing there needed to change.
33
33
  */
34
34
  import { createRequire } from 'node:module';
35
+ import { randomUUID } from 'node:crypto';
35
36
  import * as pty from 'node-pty';
37
+ import { splitByUtf8Bytes } from './output-stream.js';
36
38
  // `@xterm/headless`'s published CJS bundle is a heavily minified/webpacked
37
39
  // single file — `cjs-module-lexer` (Node ESM's static CJS-named-export
38
40
  // detector) can't find `Terminal` on it, so a plain
@@ -45,8 +47,8 @@ const require = createRequire(import.meta.url);
45
47
  const { Terminal } = require('@xterm/headless');
46
48
  /** Default agent binary: overridable via `--agent` (cli.ts) or this env var. */
47
49
  export const DEFAULT_AGENT_BIN = process.env.YOLOBRIDGE_AGENT_BIN || 'claude';
48
- const DEFAULT_COLS = 120;
49
- const DEFAULT_ROWS = 40;
50
+ export const DEFAULT_COLS = 120;
51
+ export const DEFAULT_ROWS = 40;
50
52
  /** How recently the PTY must have produced output to be considered "busy". */
51
53
  const DEFAULT_BUSY_WINDOW_MS = 2_000;
52
54
  /**
@@ -95,6 +97,191 @@ function sleep(ms) {
95
97
  return new Promise((resolve) => setTimeout(resolve, ms));
96
98
  }
97
99
  let current;
100
+ const rawDataListeners = new Set();
101
+ /**
102
+ * Register a raw-output tap. Returns an unsubscribe that is safe to call more
103
+ * than once.
104
+ */
105
+ export function onLocalAgentData(listener) {
106
+ rawDataListeners.add(listener);
107
+ return () => {
108
+ rawDataListeners.delete(listener);
109
+ };
110
+ }
111
+ /** Fan a PTY chunk out to every tap. A throwing tap must never break the
112
+ * human's own view of the session, which is the very next thing that would
113
+ * happen if this propagated out of the `onData` handler. */
114
+ function fanOutRawData(data, meta) {
115
+ if (rawDataListeners.size === 0)
116
+ return;
117
+ for (const listener of rawDataListeners) {
118
+ try {
119
+ listener(data, meta);
120
+ }
121
+ catch {
122
+ // A broken tap degrades the remote view, never the local session.
123
+ }
124
+ }
125
+ }
126
+ // ─── The raw replay ring (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming")
127
+ //
128
+ // ⚠️ WHY THIS EXISTS AT ALL, given `serializeTerminalBuffer` already produces a
129
+ // perfectly good screen dump.
130
+ //
131
+ // A line dump is the right primitive for a STATIC snapshot (`read_tile_output`
132
+ // still uses it). It is the WRONG primitive for resuming a LIVE stream, and
133
+ // the difference is not cosmetic. A dump carries the glyphs and their colours
134
+ // and nothing else: not the cursor position, not the scroll region (DECSTBM),
135
+ // not whether the alternate screen is active, not the saved cursor, not
136
+ // autowrap mode. A real TUI — Claude Code, codex, anything with a spinner or a
137
+ // redrawn prompt box — repaints using RELATIVE moves (`\x1b[A`, `\x1b[K`, a
138
+ // bare `\r`). Seed a viewer from a dump and every one of those moves is applied
139
+ // from the wrong origin, so redraws land on the wrong rows: text drawn on top
140
+ // of other text, prompt boxes marching down the screen. That is a state
141
+ // reconstruction bug, and the only fix that does not have a next instance is
142
+ // to stop reconstructing state.
143
+ //
144
+ // So: keep the last `RAW_RING_MAX_BYTES` of the RAW PTY byte stream and replay
145
+ // those bytes into the viewer's terminal. The viewer's xterm then parses
146
+ // exactly what the daemon's terminal parsed and arrives at exactly the same
147
+ // state — no cursor inference, no mode inference, no seam to get wrong.
148
+ //
149
+ // Every byte is stamped with an absolute offset, which is what makes the seed
150
+ // and the tap safe to be two independent, racing operations: overlap is
151
+ // deduplicated by offset and a hole is detected by offset. Neither is possible
152
+ // with `seq`, which only orders what was actually sent.
153
+ /**
154
+ * Raw PTY bytes retained for seeding a viewer. 256 KiB.
155
+ *
156
+ * The number is chosen for what it has to CONTAIN, not for memory: a viewer
157
+ * seeded mid-session needs the bytes back to (at least) the TUI's most recent
158
+ * full repaint, and a repainting agent redraws its whole screen many times per
159
+ * minute — a 120×40 full repaint with colour is on the order of 10-30 KiB, so
160
+ * this holds many of them. It is also comfortably under the server's 5 MB JSON
161
+ * body limit, since the whole ring can travel in one seed reply.
162
+ */
163
+ export const RAW_RING_MAX_BYTES = 256 * 1024;
164
+ /**
165
+ * How much of the ring is replayed into a NEW streaming episode's queue.
166
+ *
167
+ * The tap and the seed are separate round trips, so a viewer's seed can end at
168
+ * an offset slightly before (or after) the point the tap starts at. Priming
169
+ * the episode with a recent tail guarantees the stream covers the seam from
170
+ * below; the viewer trims the duplicate prefix by offset. 64 KiB is ~1000×
171
+ * more than one HTTP round trip's worth of agent output, and a miss is not
172
+ * fatal anyway — it reads as a gap and costs one extra re-seed.
173
+ */
174
+ export const RAW_STREAM_PRIME_BYTES = 64 * 1024;
175
+ function newRawRing() {
176
+ return { epoch: randomUUID(), chunks: [], bytes: 0, baseOffset: 0, endOffset: 0 };
177
+ }
178
+ let rawRing = newRawRing();
179
+ /** Append to the ring and return where the chunk landed. Synchronous and
180
+ * called from `onData`, so a tap and a seed taken in the same tick can never
181
+ * disagree about the offset. */
182
+ function pushRaw(data) {
183
+ const startOffset = rawRing.endOffset;
184
+ const bytes = Buffer.byteLength(data, 'utf-8');
185
+ rawRing.chunks.push(data);
186
+ rawRing.bytes += bytes;
187
+ rawRing.endOffset += bytes;
188
+ trimRawRing();
189
+ return { epoch: rawRing.epoch, startOffset };
190
+ }
191
+ /** Drop from the FRONT until the ring is within its cap, advancing
192
+ * `baseOffset` by exactly what was dropped. Cuts only at code-point
193
+ * boundaries (`splitByUtf8Bytes`) so a replayed tail is never a lone
194
+ * surrogate. */
195
+ function trimRawRing() {
196
+ while (rawRing.bytes > RAW_RING_MAX_BYTES && rawRing.chunks.length > 0) {
197
+ const overflow = rawRing.bytes - RAW_RING_MAX_BYTES;
198
+ const oldest = rawRing.chunks[0];
199
+ const oldestBytes = Buffer.byteLength(oldest, 'utf-8');
200
+ if (oldestBytes <= overflow) {
201
+ rawRing.chunks.shift();
202
+ rawRing.bytes -= oldestBytes;
203
+ rawRing.baseOffset += oldestBytes;
204
+ continue;
205
+ }
206
+ const { head, tail } = splitByUtf8Bytes(oldest, overflow);
207
+ const droppedBytes = Buffer.byteLength(head, 'utf-8');
208
+ if (droppedBytes === 0) {
209
+ // A single code point wider than the overflow — drop the chunk rather
210
+ // than spin.
211
+ rawRing.chunks.shift();
212
+ rawRing.bytes -= oldestBytes;
213
+ rawRing.baseOffset += oldestBytes;
214
+ continue;
215
+ }
216
+ rawRing.chunks[0] = tail;
217
+ rawRing.bytes -= droppedBytes;
218
+ rawRing.baseOffset += droppedBytes;
219
+ }
220
+ }
221
+ /**
222
+ * Take the current replay tail plus the PTY's grid size, as ONE synchronous
223
+ * observation.
224
+ *
225
+ * Atomic by construction: nothing can interleave between reading the ring and
226
+ * reading the offsets, so `endOffset` is exactly the position a viewer reaches
227
+ * by writing `data`. Bytes the PTY produces after this call carry offsets
228
+ * ≥ `endOffset` and are therefore either spliced on cleanly or (if they were
229
+ * already in `data`) trimmed away by the viewer — never lost, never applied
230
+ * twice.
231
+ */
232
+ export function takeRawSeed() {
233
+ if (!current)
234
+ return undefined;
235
+ return {
236
+ epoch: rawRing.epoch,
237
+ baseOffset: rawRing.baseOffset,
238
+ endOffset: rawRing.endOffset,
239
+ data: rawRing.chunks.join(''),
240
+ cols: current.cols,
241
+ rows: current.rows,
242
+ truncated: rawRing.baseOffset > 0,
243
+ };
244
+ }
245
+ export function primeRawStream(maxBytes = RAW_STREAM_PRIME_BYTES) {
246
+ const all = rawRing.chunks.join('');
247
+ const total = Buffer.byteLength(all, 'utf-8');
248
+ if (total <= maxBytes) {
249
+ return { epoch: rawRing.epoch, startOffset: rawRing.baseOffset, data: all };
250
+ }
251
+ const { head, tail } = splitByUtf8Bytes(all, total - maxBytes);
252
+ return {
253
+ epoch: rawRing.epoch,
254
+ startOffset: rawRing.baseOffset + Buffer.byteLength(head, 'utf-8'),
255
+ data: tail,
256
+ };
257
+ }
258
+ /** The PTY's grid, which the tile must render at EXACTLY (it cannot be
259
+ * resized — the human at the keyboard is watching the same PTY). */
260
+ export function getLocalAgentGeometry() {
261
+ if (!current)
262
+ return undefined;
263
+ return { cols: current.cols, rows: current.rows };
264
+ }
265
+ /** The same recent-activity heuristic `captureLocalAgentOutput` reports, without
266
+ * paying for a buffer serialization. */
267
+ export function isLocalAgentBusy() {
268
+ if (!current)
269
+ return false;
270
+ return Date.now() - current.lastOutputAt < current.busyWindowMs;
271
+ }
272
+ /** Test seam: forget the ring and start a fresh epoch. */
273
+ export function __resetRawRing() {
274
+ rawRing = newRawRing();
275
+ }
276
+ /**
277
+ * Test seam: the daemon's OWN headless terminal — the thing a remote viewer
278
+ * must end up cell-for-cell identical to. Exposed only so a test can assert
279
+ * that equality directly (glyphs, attributes AND cursor position) instead of
280
+ * eyeballing a serialized dump, which cannot see cursor drift at all.
281
+ */
282
+ export function __getLocalAgentTerminal() {
283
+ return current?.term;
284
+ }
98
285
  function sanitizeEnv(env) {
99
286
  const out = {};
100
287
  for (const [k, v] of Object.entries(env)) {
@@ -336,9 +523,16 @@ export function startLocalAgent(opts = {}) {
336
523
  env: sanitizeEnv(opts.env ?? process.env),
337
524
  });
338
525
  const term = new Terminal({ cols, rows, allowProposedApi: true });
526
+ // A NEW PTY is a new byte stream: everything retained belongs to a screen
527
+ // that no longer exists, and offsets restart. The fresh `epoch` is what a
528
+ // viewer keyed to the old one sees, and it re-seeds rather than splicing two
529
+ // sessions' bytes together.
530
+ rawRing = newRawRing();
339
531
  const state = {
340
532
  ptyProcess,
341
533
  term,
534
+ cols,
535
+ rows,
342
536
  lastOutputAt: Date.now(),
343
537
  busyWindowMs,
344
538
  readinessQuietMs,
@@ -354,6 +548,11 @@ export function startLocalAgent(opts = {}) {
354
548
  state.lastOutputAt = Date.now();
355
549
  outStream.write(data);
356
550
  state.writeChain = state.writeChain.then(() => new Promise((resolve) => term.write(data, () => resolve())));
551
+ // Ring FIRST, then the taps, with the offset the ring assigned — a tap and
552
+ // a `takeRawSeed()` taken in the same tick must agree on where this chunk
553
+ // sits, or the viewer's dedupe/gap logic is reasoning about two different
554
+ // coordinate systems.
555
+ fanOutRawData(data, pushRaw(data));
357
556
  });
358
557
  if (inStream && typeof inStream.on === 'function') {
359
558
  const stdinListener = (data) => {
@@ -374,7 +573,7 @@ export function startLocalAgent(opts = {}) {
374
573
  current = undefined;
375
574
  opts.onExit?.({ exitCode, signal });
376
575
  });
377
- return { stop: stopLocalAgent };
576
+ return { stop: stopLocalAgent, cols, rows };
378
577
  }
379
578
  /**
380
579
  * Real bug (found 2026-08-23 chasing a report that `attach` never fully
@@ -588,5 +787,8 @@ export async function captureLocalAgentOutput() {
588
787
  await current.writeChain;
589
788
  const output = serializeTerminalBuffer(current.term);
590
789
  const busy = Date.now() - current.lastOutputAt < current.busyWindowMs;
591
- return { output, busy };
790
+ // The grid the screen above was laid out FOR. A viewer that renders it at
791
+ // any other width re-wraps every long line — the dump has no width of its
792
+ // own, only the one the PTY composed it at.
793
+ return { output, busy, cols: current.cols, rows: current.rows };
592
794
  }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Volume control for the daemon→server live output stream
3
+ * (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming").
4
+ *
5
+ * PURE — no timers, no network, no PTY. `attach-cmd.ts` owns the flush tick
6
+ * and the POST; this file owns the only question that is genuinely hard:
7
+ * **what do we send, and what do we refuse to send, when the PTY produces
8
+ * more than the link (or the operator's bandwidth budget) can carry?**
9
+ *
10
+ * PTY output is bursty and can be enormous — a build log, `yes`, a `cat` of a
11
+ * multi-megabyte file. Relaying it verbatim would turn one careless command on
12
+ * the operator's laptop into an unbounded upload and an unbounded fan-out onto
13
+ * the workspace event bus. Three independent bounds, each doing a different
14
+ * job:
15
+ *
16
+ * 1. **`maxQueueBytes`** — the standing backlog. When the producer outruns
17
+ * the drain the queue is trimmed from the FRONT (oldest first), because
18
+ * on a terminal the newest bytes are the ones the viewer needs: they are
19
+ * what is on screen right now. The trimmed byte count is REMEMBERED, not
20
+ * forgotten (see `droppedBytes`).
21
+ * 2. **`maxBatchBytes`** — the size of any single POST, so one flush can
22
+ * never produce a request the server has to buffer megabytes for.
23
+ * 3. **`maxBytesPerSecond`** — a token bucket over the whole stream. This is
24
+ * the one that actually caps sustained cost; the other two bound a single
25
+ * moment. A full bucket is allowed as a one-second burst, so ordinary
26
+ * agent output (which is bursty but small) is never throttled at all.
27
+ *
28
+ * **A drop is always MARKED, never silent.** `drain()` returns the number of
29
+ * bytes skipped alongside the data that follows them, and the viewer renders
30
+ * an explicit gap marker and re-seeds from the poll route. That matters
31
+ * specifically because this is a TERMINAL: dropping bytes out of a stream of
32
+ * cursor-addressing escapes does not degrade gracefully into "slightly less
33
+ * text", it produces a screen that is confidently wrong. Telling the viewer
34
+ * "there is a hole here" is what lets it recover instead of lying.
35
+ *
36
+ * Byte lengths are UTF-8 (`Buffer.byteLength`), because that is what actually
37
+ * crosses the wire — not `String.length`, which under-counts every non-ASCII
38
+ * character an agent's box-drawing/emoji output is full of.
39
+ */
40
+ /** Default flush cadence, owned by the caller — exported here so the tuning
41
+ * constants that describe one mechanism live together. Short enough that
42
+ * output reads as live, long enough that a chatty PTY costs ~12 POSTs/second
43
+ * rather than one per `onData`. */
44
+ export const DEFAULT_FLUSH_INTERVAL_MS = 80;
45
+ /** ~192 KiB/s sustained. Comfortably above a fast agent's real output rate
46
+ * (a streaming LLM response is a few KiB/s), far below what `cat`ting a
47
+ * large file would produce. */
48
+ export const DEFAULT_MAX_BYTES_PER_SECOND = 192 * 1024;
49
+ /** One POST never carries more than this. At the default flush interval this
50
+ * is also well above the per-tick share of the rate cap, so it only ever
51
+ * binds on the very first tick after an idle period (when the token bucket
52
+ * is full). */
53
+ export const DEFAULT_MAX_BATCH_BYTES = 32 * 1024;
54
+ /** Standing backlog cap. Two seconds of rate-capped output — enough to ride
55
+ * out one slow POST without dropping anything, small enough that a runaway
56
+ * producer can never grow the daemon's heap. */
57
+ export const DEFAULT_MAX_QUEUE_BYTES = 384 * 1024;
58
+ /**
59
+ * Longest prefix of `s` whose UTF-8 encoding is at most `maxBytes`, plus the
60
+ * remainder.
61
+ *
62
+ * Never splits a surrogate pair: a JS string is UTF-16, and cutting between
63
+ * the halves of an astral character (an emoji in an agent's output) produces a
64
+ * lone surrogate, which `JSON.stringify` escapes into a value the server
65
+ * decodes back as U+FFFD — a permanently corrupted character rather than one
66
+ * that reassembles on the far side. Splitting only at code-point boundaries
67
+ * makes the two halves concatenate back into exactly the original text.
68
+ *
69
+ * Fast path for pure ASCII (`byteLength === length`), which is the
70
+ * overwhelming majority of terminal output; the walk is only paid for when a
71
+ * chunk actually contains multibyte characters AND is large enough to need
72
+ * splitting.
73
+ */
74
+ export function splitByUtf8Bytes(s, maxBytes) {
75
+ if (maxBytes <= 0)
76
+ return { head: '', tail: s };
77
+ const total = Buffer.byteLength(s, 'utf-8');
78
+ if (total <= maxBytes)
79
+ return { head: s, tail: '' };
80
+ if (total === s.length) {
81
+ // Pure ASCII — one byte per UTF-16 code unit, so the cut is exact.
82
+ return { head: s.slice(0, maxBytes), tail: s.slice(maxBytes) };
83
+ }
84
+ let bytes = 0;
85
+ let i = 0;
86
+ while (i < s.length) {
87
+ const code = s.codePointAt(i);
88
+ const units = code > 0xffff ? 2 : 1;
89
+ const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
90
+ if (bytes + size > maxBytes)
91
+ break;
92
+ bytes += size;
93
+ i += units;
94
+ }
95
+ return { head: s.slice(0, i), tail: s.slice(i) };
96
+ }
97
+ /**
98
+ * Bounded, rate-limited FIFO of PTY output awaiting relay.
99
+ *
100
+ * Holds JS strings rather than Buffers because that is what `node-pty` hands
101
+ * us and what the JSON body carries — converting to bytes and back would add
102
+ * two encodes per chunk for no gain, and the only place byte counts matter
103
+ * (the caps) can measure them on demand.
104
+ */
105
+ export class OutputStreamBuffer {
106
+ maxQueueBytes;
107
+ maxBatchBytes;
108
+ maxBytesPerSecond;
109
+ now;
110
+ chunks = [];
111
+ queuedBytesValue = 0;
112
+ droppedBytes = 0;
113
+ /** Absolute offset of the first byte still queued (== `writeOffset` when the
114
+ * queue is empty). Advanced by everything that LEAVES the queue, whether it
115
+ * was drained or trimmed, so it always names the next byte a viewer has not
116
+ * been offered. */
117
+ headOffset = 0;
118
+ /** Absolute offset one past the last byte ever pushed. */
119
+ writeOffset = 0;
120
+ /** Token bucket, in bytes. Starts FULL so the first burst after an idle
121
+ * period (the common case — a viewer opens the tile and the agent starts
122
+ * talking) is never throttled. */
123
+ tokens;
124
+ lastRefillAt;
125
+ constructor(opts = {}) {
126
+ this.maxQueueBytes = opts.maxQueueBytes ?? DEFAULT_MAX_QUEUE_BYTES;
127
+ this.maxBatchBytes = opts.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
128
+ this.maxBytesPerSecond = opts.maxBytesPerSecond ?? DEFAULT_MAX_BYTES_PER_SECOND;
129
+ this.now = opts.now ?? Date.now;
130
+ this.tokens = this.maxBytesPerSecond;
131
+ this.lastRefillAt = this.now();
132
+ this.headOffset = opts.startOffset ?? 0;
133
+ this.writeOffset = this.headOffset;
134
+ }
135
+ /**
136
+ * Rebase this buffer onto a new absolute byte position. Only meaningful on
137
+ * an EMPTY buffer — it is called when a streaming episode starts (priming
138
+ * from local-agent.ts's raw ring) and when the PTY session underneath is
139
+ * replaced, both of which discard whatever was queued first.
140
+ */
141
+ setBaseOffset(offset) {
142
+ this.headOffset = offset;
143
+ this.writeOffset = offset;
144
+ }
145
+ /** Absolute offset of the next byte a viewer has not been offered yet. */
146
+ get nextOffset() {
147
+ return this.headOffset;
148
+ }
149
+ get queuedBytes() {
150
+ return this.queuedBytesValue;
151
+ }
152
+ /** Bytes skipped so far and not yet reported to the viewer. */
153
+ get pendingDroppedBytes() {
154
+ return this.droppedBytes;
155
+ }
156
+ /**
157
+ * Enqueue raw PTY output, trimming the OLDEST bytes if that pushes the
158
+ * backlog past `maxQueueBytes`.
159
+ *
160
+ * A single chunk larger than the whole cap is itself trimmed from its front,
161
+ * so `push` can never leave the queue over the bound no matter what one
162
+ * `onData` delivers.
163
+ */
164
+ push(chunk) {
165
+ if (!chunk)
166
+ return;
167
+ this.chunks.push(chunk);
168
+ const pushed = Buffer.byteLength(chunk, 'utf-8');
169
+ this.queuedBytesValue += pushed;
170
+ this.writeOffset += pushed;
171
+ this.trim();
172
+ }
173
+ /**
174
+ * Record bytes that were LOST rather than queued — today, a batch whose POST
175
+ * failed after `drain()` had already handed the bytes over. Reporting them
176
+ * as a gap is the honest outcome: they are gone, and the viewer's screen is
177
+ * missing them either way. Re-queueing them instead would reorder the stream
178
+ * behind whatever arrived while the POST was in flight, which on a terminal
179
+ * is worse than an acknowledged hole.
180
+ */
181
+ noteDropped(bytes) {
182
+ if (bytes > 0)
183
+ this.droppedBytes += bytes;
184
+ }
185
+ trim() {
186
+ while (this.queuedBytesValue > this.maxQueueBytes && this.chunks.length > 0) {
187
+ const overflow = this.queuedBytesValue - this.maxQueueBytes;
188
+ const oldest = this.chunks[0];
189
+ const oldestBytes = Buffer.byteLength(oldest, 'utf-8');
190
+ if (oldestBytes <= overflow) {
191
+ this.chunks.shift();
192
+ this.queuedBytesValue -= oldestBytes;
193
+ this.droppedBytes += oldestBytes;
194
+ this.headOffset += oldestBytes;
195
+ continue;
196
+ }
197
+ // Partially trim the oldest chunk: drop exactly the overflow off its
198
+ // front (at a code-point boundary) and keep the rest.
199
+ const { head } = splitByUtf8Bytes(oldest, overflow);
200
+ const dropped = Buffer.byteLength(head, 'utf-8');
201
+ const kept = oldest.slice(head.length);
202
+ this.chunks[0] = kept;
203
+ this.queuedBytesValue -= dropped;
204
+ this.droppedBytes += dropped;
205
+ this.headOffset += dropped;
206
+ // `splitByUtf8Bytes` can stop just SHORT of `overflow` when the next
207
+ // code point straddles the boundary; loop again rather than assuming one
208
+ // pass is enough.
209
+ if (dropped === 0) {
210
+ // Cannot make progress (a single code point wider than the overflow):
211
+ // drop the whole chunk rather than spin.
212
+ this.chunks.shift();
213
+ this.queuedBytesValue -= oldestBytes;
214
+ this.droppedBytes += oldestBytes;
215
+ this.headOffset += oldestBytes;
216
+ }
217
+ }
218
+ }
219
+ refill() {
220
+ const at = this.now();
221
+ const elapsedMs = at - this.lastRefillAt;
222
+ if (elapsedMs <= 0)
223
+ return;
224
+ this.lastRefillAt = at;
225
+ this.tokens = Math.min(this.maxBytesPerSecond, this.tokens + (this.maxBytesPerSecond * elapsedMs) / 1000);
226
+ }
227
+ /**
228
+ * Take the next batch to relay, or `null` when there is nothing to send
229
+ * right now (empty queue with no gap to report, or the rate cap is spent).
230
+ *
231
+ * Spending the rate cap is deliberately NOT an error and does not itself
232
+ * drop anything: the bytes stay queued and go out on a later tick. They are
233
+ * only dropped if the producer keeps running long enough to overflow
234
+ * `maxQueueBytes` — which is exactly the "genuinely more output than we will
235
+ * ever relay" case, and is reported as a gap when it happens.
236
+ */
237
+ drain() {
238
+ this.refill();
239
+ const allowance = Math.min(this.maxBatchBytes, Math.floor(this.tokens));
240
+ if (allowance <= 0)
241
+ return null;
242
+ const startOffset = this.headOffset;
243
+ let taken = '';
244
+ let takenBytes = 0;
245
+ while (this.chunks.length > 0 && takenBytes < allowance) {
246
+ const chunk = this.chunks[0];
247
+ const chunkBytes = Buffer.byteLength(chunk, 'utf-8');
248
+ if (takenBytes + chunkBytes <= allowance) {
249
+ taken += chunk;
250
+ takenBytes += chunkBytes;
251
+ this.chunks.shift();
252
+ this.queuedBytesValue -= chunkBytes;
253
+ continue;
254
+ }
255
+ const { head, tail } = splitByUtf8Bytes(chunk, allowance - takenBytes);
256
+ if (!head)
257
+ break; // next code point doesn't fit — leave it for the next tick
258
+ const headBytes = Buffer.byteLength(head, 'utf-8');
259
+ taken += head;
260
+ takenBytes += headBytes;
261
+ this.chunks[0] = tail;
262
+ this.queuedBytesValue -= headBytes;
263
+ break;
264
+ }
265
+ const droppedBytes = this.droppedBytes;
266
+ if (takenBytes === 0 && droppedBytes === 0)
267
+ return null;
268
+ this.droppedBytes = 0;
269
+ this.tokens -= takenBytes;
270
+ this.headOffset += takenBytes;
271
+ return { data: taken, droppedBytes, startOffset };
272
+ }
273
+ /** Forget everything queued. Used when a stream ends — those bytes belong to
274
+ * a `streamId` that no longer exists, and carrying them into the next
275
+ * stream would splice one session's screen into another's. */
276
+ reset() {
277
+ this.chunks = [];
278
+ this.queuedBytesValue = 0;
279
+ this.droppedBytes = 0;
280
+ this.headOffset = this.writeOffset;
281
+ }
282
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",