@yolo-labs/yolobridge 0.10.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,13 +191,23 @@ 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
  }
@@ -230,6 +240,9 @@ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
230
240
  seq: chunk.seq,
231
241
  data: chunk.data,
232
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 } : {}),
233
246
  });
234
247
  return Boolean(body?.relayed);
235
248
  }
@@ -22,7 +22,7 @@ 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, onLocalAgentData } from './local-agent.js';
25
+ import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, } from './local-agent.js';
26
26
  import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, } from './output-stream.js';
27
27
  import * as apiClient from './api-client.js';
28
28
  import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
@@ -85,6 +85,9 @@ export async function runAttachDaemon(deps) {
85
85
  const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
86
86
  const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
87
87
  const subscribeAgentOutput = deps.subscribeAgentOutput ?? onLocalAgentData;
88
+ const takeRawSeed = deps.takeRawSeed ?? takeRawSeedFromAgent;
89
+ const primeRawStream = deps.primeRawStream ?? primeRawStreamFromAgent;
90
+ const getGeometry = deps.getAgentGeometry ?? getLocalAgentGeometry;
88
91
  const flushIntervalMs = deps.outputFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
89
92
  /**
90
93
  * THE ACCOUNT identity. Full-account bearer from `yolo-bridge login`, and the
@@ -629,11 +632,16 @@ export async function runAttachDaemon(deps) {
629
632
  const bytes = Buffer.byteLength(batch.data, 'utf-8');
630
633
  session.posting = true;
631
634
  try {
635
+ const geometry = getGeometry();
632
636
  await apiClient.postOutputChunk(scopedCfg(), workspaceId, attachmentId, {
633
637
  streamId: session.streamId,
634
638
  seq: session.seq,
635
639
  data: batch.data,
636
640
  droppedBytes: batch.droppedBytes,
641
+ epoch: session.epoch,
642
+ startOffset: batch.startOffset,
643
+ cols: geometry?.cols,
644
+ rows: geometry?.rows,
637
645
  });
638
646
  if (outputStream === session)
639
647
  session.seq += 1;
@@ -679,7 +687,16 @@ export async function runAttachDaemon(deps) {
679
687
  return;
680
688
  }
681
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();
682
696
  const buffer = new OutputStreamBuffer(deps.outputStreamOptions);
697
+ buffer.setBaseOffset(prime.startOffset);
698
+ if (prime.data)
699
+ buffer.push(prime.data);
683
700
  const session = {
684
701
  streamId,
685
702
  seq: 0,
@@ -688,13 +705,22 @@ export async function runAttachDaemon(deps) {
688
705
  flushHandle: undefined,
689
706
  leaseUntilMs: now() + leaseMs,
690
707
  posting: false,
708
+ epoch: prime.epoch,
691
709
  };
692
710
  outputStream = session;
693
711
  // Subscribe AFTER the session exists so a synchronous first chunk can't
694
712
  // land on a half-built one.
695
- session.unsubscribe = subscribeAgentOutput((data) => {
713
+ session.unsubscribe = subscribeAgentOutput((data, meta) => {
696
714
  if (outputStream !== session)
697
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
+ }
698
724
  buffer.push(data);
699
725
  });
700
726
  session.flushHandle = timers.setInterval(() => {
@@ -837,8 +863,28 @@ export async function runAttachDaemon(deps) {
837
863
  break;
838
864
  case 'read-output': {
839
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;
840
874
  await apiClient
841
- .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
+ })
842
888
  .catch((err) => noteConnection('degraded', {
843
889
  detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
844
890
  }));
@@ -32,6 +32,9 @@ export function actionForFrame(frame) {
32
32
  kind: 'read-output',
33
33
  attachmentId: String(data.attachmentId ?? ''),
34
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',
35
38
  };
36
39
  case 'output-stream-start': {
37
40
  const raw = typeof data.leaseMs === 'number' ? data.leaseMs : NaN;
@@ -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
  /**
@@ -109,18 +111,177 @@ export function onLocalAgentData(listener) {
109
111
  /** Fan a PTY chunk out to every tap. A throwing tap must never break the
110
112
  * human's own view of the session, which is the very next thing that would
111
113
  * happen if this propagated out of the `onData` handler. */
112
- function fanOutRawData(data) {
114
+ function fanOutRawData(data, meta) {
113
115
  if (rawDataListeners.size === 0)
114
116
  return;
115
117
  for (const listener of rawDataListeners) {
116
118
  try {
117
- listener(data);
119
+ listener(data, meta);
118
120
  }
119
121
  catch {
120
122
  // A broken tap degrades the remote view, never the local session.
121
123
  }
122
124
  }
123
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
+ }
124
285
  function sanitizeEnv(env) {
125
286
  const out = {};
126
287
  for (const [k, v] of Object.entries(env)) {
@@ -362,9 +523,16 @@ export function startLocalAgent(opts = {}) {
362
523
  env: sanitizeEnv(opts.env ?? process.env),
363
524
  });
364
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();
365
531
  const state = {
366
532
  ptyProcess,
367
533
  term,
534
+ cols,
535
+ rows,
368
536
  lastOutputAt: Date.now(),
369
537
  busyWindowMs,
370
538
  readinessQuietMs,
@@ -380,7 +548,11 @@ export function startLocalAgent(opts = {}) {
380
548
  state.lastOutputAt = Date.now();
381
549
  outStream.write(data);
382
550
  state.writeChain = state.writeChain.then(() => new Promise((resolve) => term.write(data, () => resolve())));
383
- fanOutRawData(data);
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));
384
556
  });
385
557
  if (inStream && typeof inStream.on === 'function') {
386
558
  const stdinListener = (data) => {
@@ -401,7 +573,7 @@ export function startLocalAgent(opts = {}) {
401
573
  current = undefined;
402
574
  opts.onExit?.({ exitCode, signal });
403
575
  });
404
- return { stop: stopLocalAgent };
576
+ return { stop: stopLocalAgent, cols, rows };
405
577
  }
406
578
  /**
407
579
  * Real bug (found 2026-08-23 chasing a report that `attach` never fully
@@ -615,5 +787,8 @@ export async function captureLocalAgentOutput() {
615
787
  await current.writeChain;
616
788
  const output = serializeTerminalBuffer(current.term);
617
789
  const busy = Date.now() - current.lastOutputAt < current.busyWindowMs;
618
- 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 };
619
794
  }
@@ -110,6 +110,13 @@ export class OutputStreamBuffer {
110
110
  chunks = [];
111
111
  queuedBytesValue = 0;
112
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;
113
120
  /** Token bucket, in bytes. Starts FULL so the first burst after an idle
114
121
  * period (the common case — a viewer opens the tile and the agent starts
115
122
  * talking) is never throttled. */
@@ -122,6 +129,22 @@ export class OutputStreamBuffer {
122
129
  this.now = opts.now ?? Date.now;
123
130
  this.tokens = this.maxBytesPerSecond;
124
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;
125
148
  }
126
149
  get queuedBytes() {
127
150
  return this.queuedBytesValue;
@@ -142,7 +165,9 @@ export class OutputStreamBuffer {
142
165
  if (!chunk)
143
166
  return;
144
167
  this.chunks.push(chunk);
145
- this.queuedBytesValue += Buffer.byteLength(chunk, 'utf-8');
168
+ const pushed = Buffer.byteLength(chunk, 'utf-8');
169
+ this.queuedBytesValue += pushed;
170
+ this.writeOffset += pushed;
146
171
  this.trim();
147
172
  }
148
173
  /**
@@ -166,6 +191,7 @@ export class OutputStreamBuffer {
166
191
  this.chunks.shift();
167
192
  this.queuedBytesValue -= oldestBytes;
168
193
  this.droppedBytes += oldestBytes;
194
+ this.headOffset += oldestBytes;
169
195
  continue;
170
196
  }
171
197
  // Partially trim the oldest chunk: drop exactly the overflow off its
@@ -176,6 +202,7 @@ export class OutputStreamBuffer {
176
202
  this.chunks[0] = kept;
177
203
  this.queuedBytesValue -= dropped;
178
204
  this.droppedBytes += dropped;
205
+ this.headOffset += dropped;
179
206
  // `splitByUtf8Bytes` can stop just SHORT of `overflow` when the next
180
207
  // code point straddles the boundary; loop again rather than assuming one
181
208
  // pass is enough.
@@ -185,6 +212,7 @@ export class OutputStreamBuffer {
185
212
  this.chunks.shift();
186
213
  this.queuedBytesValue -= oldestBytes;
187
214
  this.droppedBytes += oldestBytes;
215
+ this.headOffset += oldestBytes;
188
216
  }
189
217
  }
190
218
  }
@@ -211,6 +239,7 @@ export class OutputStreamBuffer {
211
239
  const allowance = Math.min(this.maxBatchBytes, Math.floor(this.tokens));
212
240
  if (allowance <= 0)
213
241
  return null;
242
+ const startOffset = this.headOffset;
214
243
  let taken = '';
215
244
  let takenBytes = 0;
216
245
  while (this.chunks.length > 0 && takenBytes < allowance) {
@@ -238,7 +267,8 @@ export class OutputStreamBuffer {
238
267
  return null;
239
268
  this.droppedBytes = 0;
240
269
  this.tokens -= takenBytes;
241
- return { data: taken, droppedBytes };
270
+ this.headOffset += takenBytes;
271
+ return { data: taken, droppedBytes, startOffset };
242
272
  }
243
273
  /** Forget everything queued. Used when a stream ends — those bytes belong to
244
274
  * a `streamId` that no longer exists, and carrying them into the next
@@ -247,5 +277,6 @@ export class OutputStreamBuffer {
247
277
  this.chunks = [];
248
278
  this.queuedBytesValue = 0;
249
279
  this.droppedBytes = 0;
280
+ this.headOffset = this.writeOffset;
250
281
  }
251
282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.10.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",