@yolo-labs/yolobridge 0.9.0 → 0.10.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.
@@ -201,6 +201,38 @@ export async function postReadOutputReply(cfg, workspaceId, attachmentId, reques
201
201
  });
202
202
  return Boolean(body?.resolved);
203
203
  }
204
+ /**
205
+ * `POST .../yolobridge/events {type:'output-chunk'}` — one batch of live PTY
206
+ * output (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming").
207
+ *
208
+ * Sent ONLY while the server has an unexpired `output-stream-start` lease
209
+ * outstanding, i.e. only while a browser is actually watching the tile. A
210
+ * daemon with no viewer sends none of these at all — see attach-cmd.ts's
211
+ * stream controller.
212
+ *
213
+ * Authenticated with the WORKSPACE-SCOPED credential like every other
214
+ * post-attach call (Boundary B refuses an account token here), and the bytes
215
+ * are relayed in memory by the server onto the workspace's own event stream —
216
+ * never logged, never written to Mongo, never appended to the resumable event
217
+ * buffer. This is the operator's live screen; it can contain anything they
218
+ * typed.
219
+ *
220
+ * `droppedBytes` is how many bytes were SKIPPED immediately before `data`
221
+ * (see output-stream.ts). Non-zero means the viewer must treat its screen as
222
+ * unreliable and re-seed — it is not a diagnostic counter, it is part of the
223
+ * protocol.
224
+ */
225
+ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
226
+ const body = await postEvent(cfg, workspaceId, {
227
+ attachmentId,
228
+ type: 'output-chunk',
229
+ streamId: chunk.streamId,
230
+ seq: chunk.seq,
231
+ data: chunk.data,
232
+ droppedBytes: chunk.droppedBytes,
233
+ });
234
+ return Boolean(body?.relayed);
235
+ }
204
236
  async function postEvent(cfg, workspaceId, payload) {
205
237
  const fetchImpl = cfg.fetchImpl ?? fetch;
206
238
  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 } 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,8 @@ 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 flushIntervalMs = deps.outputFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
86
89
  /**
87
90
  * THE ACCOUNT identity. Full-account bearer from `yolo-bridge login`, and the
88
91
  * ONLY thing `ensureFreshToken` may write to. Used for exactly one call —
@@ -594,6 +597,110 @@ export async function runAttachDaemon(deps) {
594
597
  clearAttachment(env, io);
595
598
  return { ok: true, reason: 'stopped' };
596
599
  }
600
+ let outputStream;
601
+ function stopOutputStream() {
602
+ const session = outputStream;
603
+ if (!session)
604
+ return;
605
+ outputStream = undefined;
606
+ try {
607
+ session.unsubscribe();
608
+ }
609
+ catch { /* best effort */ }
610
+ timers.clearInterval(session.flushHandle);
611
+ // Anything still queued belongs to a streamId that no longer exists.
612
+ session.buffer.reset();
613
+ }
614
+ async function flushOutputStream(session) {
615
+ if (outputStream !== session)
616
+ return;
617
+ // Lease check FIRST, before any work: this tick is the only thing that
618
+ // ever notices a lapsed grant, and it must notice it even when the buffer
619
+ // is empty.
620
+ if (now() >= session.leaseUntilMs) {
621
+ stopOutputStream();
622
+ return;
623
+ }
624
+ if (session.posting)
625
+ return;
626
+ const batch = session.buffer.drain();
627
+ if (!batch)
628
+ return;
629
+ const bytes = Buffer.byteLength(batch.data, 'utf-8');
630
+ session.posting = true;
631
+ try {
632
+ await apiClient.postOutputChunk(scopedCfg(), workspaceId, attachmentId, {
633
+ streamId: session.streamId,
634
+ seq: session.seq,
635
+ data: batch.data,
636
+ droppedBytes: batch.droppedBytes,
637
+ });
638
+ if (outputStream === session)
639
+ session.seq += 1;
640
+ }
641
+ catch (err) {
642
+ // These bytes are GONE — re-queueing them would reorder the stream
643
+ // behind whatever the PTY produced while the POST was in flight, and an
644
+ // out-of-order terminal frame is worse than an acknowledged hole. Fold
645
+ // them (plus any gap this batch was already carrying) into the next
646
+ // batch's `droppedBytes` so the viewer re-seeds instead of trusting a
647
+ // screen that silently lost a frame.
648
+ if (outputStream === session)
649
+ session.buffer.noteDropped(bytes + batch.droppedBytes);
650
+ // Out-of-band, never `log`: this fires mid-session while the local
651
+ // agent's TUI owns stdout (connection-state.ts's module header). The
652
+ // detail carries the transport error only — NEVER the PTY bytes.
653
+ noteConnection('degraded', {
654
+ detail: `output chunk relay failed: ${err instanceof Error ? err.message : String(err)}`,
655
+ });
656
+ }
657
+ finally {
658
+ if (outputStream === session)
659
+ session.posting = false;
660
+ }
661
+ }
662
+ /**
663
+ * Honour an `output-stream-start` frame.
664
+ *
665
+ * Same `streamId` → this is a LEASE RENEWAL (the server re-sends while
666
+ * demand persists); keep the tap, the buffer and the sequence exactly as
667
+ * they are, so a renewal is invisible to the viewer.
668
+ *
669
+ * Different `streamId` → a NEW episode (the previous one was stopped, or
670
+ * this daemon reconnected and the server re-granted). Tear the old one down
671
+ * first: its queued bytes describe a screen the viewer is no longer showing.
672
+ */
673
+ function startOrRenewOutputStream(streamId, leaseMs) {
674
+ if (!streamId)
675
+ return;
676
+ const existing = outputStream;
677
+ if (existing && existing.streamId === streamId) {
678
+ existing.leaseUntilMs = now() + leaseMs;
679
+ return;
680
+ }
681
+ stopOutputStream();
682
+ const buffer = new OutputStreamBuffer(deps.outputStreamOptions);
683
+ const session = {
684
+ streamId,
685
+ seq: 0,
686
+ buffer,
687
+ unsubscribe: () => { },
688
+ flushHandle: undefined,
689
+ leaseUntilMs: now() + leaseMs,
690
+ posting: false,
691
+ };
692
+ outputStream = session;
693
+ // Subscribe AFTER the session exists so a synchronous first chunk can't
694
+ // land on a half-built one.
695
+ session.unsubscribe = subscribeAgentOutput((data) => {
696
+ if (outputStream !== session)
697
+ return;
698
+ buffer.push(data);
699
+ });
700
+ session.flushHandle = timers.setInterval(() => {
701
+ void flushOutputStream(session);
702
+ }, flushIntervalMs);
703
+ }
597
704
  let heartbeat;
598
705
  let attempt = 0;
599
706
  /** Set when a proactive refresh (see ensureFreshToken) fails while a
@@ -737,6 +844,12 @@ export async function runAttachDaemon(deps) {
737
844
  }));
738
845
  break;
739
846
  }
847
+ case 'output-stream-start':
848
+ startOrRenewOutputStream(action.streamId, action.leaseMs);
849
+ break;
850
+ case 'output-stream-stop':
851
+ stopOutputStream();
852
+ break;
740
853
  case 'detached':
741
854
  noteConnection('detached');
742
855
  sawDetached = true;
@@ -765,6 +878,11 @@ export async function runAttachDaemon(deps) {
765
878
  }
766
879
  heartbeat?.stop();
767
880
  heartbeat = undefined;
881
+ // The grant died with the connection that carried it. A reconnect gets a
882
+ // FRESH `output-stream-start` (new streamId) if demand still exists —
883
+ // never a silent resumption of the old one, which is what keeps a
884
+ // reconnect from splicing two episodes' bytes into one screen.
885
+ stopOutputStream();
768
886
  if (sawDetached || sawGone) {
769
887
  clearAttachment(env, io);
770
888
  return { ok: true, reason: 'detached-by-server' };
@@ -781,6 +899,11 @@ export async function runAttachDaemon(deps) {
781
899
  }
782
900
  finally {
783
901
  heartbeat?.stop();
902
+ // Belt and braces for every path out of the loop (a break, a throw, a
903
+ // stop signal): no code path may leave a live PTY tap or flush timer
904
+ // behind, or the daemon would keep streaming with nobody watching — the
905
+ // exact failure this whole mechanism exists to prevent.
906
+ stopOutputStream();
784
907
  }
785
908
  if (scopedRefreshFailed) {
786
909
  // 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) {
@@ -26,6 +33,20 @@ export function actionForFrame(frame) {
26
33
  attachmentId: String(data.attachmentId ?? ''),
27
34
  requestId: String(data.requestId ?? ''),
28
35
  };
36
+ case 'output-stream-start': {
37
+ const raw = typeof data.leaseMs === 'number' ? data.leaseMs : NaN;
38
+ const leaseMs = Number.isFinite(raw) && raw > 0
39
+ ? Math.min(raw, MAX_OUTPUT_STREAM_LEASE_MS)
40
+ : FALLBACK_OUTPUT_STREAM_LEASE_MS;
41
+ return {
42
+ kind: 'output-stream-start',
43
+ attachmentId: String(data.attachmentId ?? ''),
44
+ streamId: String(data.streamId ?? ''),
45
+ leaseMs,
46
+ };
47
+ }
48
+ case 'output-stream-stop':
49
+ return { kind: 'output-stream-stop', attachmentId: String(data.attachmentId ?? '') };
29
50
  case 'detached':
30
51
  return { kind: 'detached', attachmentId: String(data.attachmentId ?? '') };
31
52
  default:
@@ -95,6 +95,32 @@ function sleep(ms) {
95
95
  return new Promise((resolve) => setTimeout(resolve, ms));
96
96
  }
97
97
  let current;
98
+ const rawDataListeners = new Set();
99
+ /**
100
+ * Register a raw-output tap. Returns an unsubscribe that is safe to call more
101
+ * than once.
102
+ */
103
+ export function onLocalAgentData(listener) {
104
+ rawDataListeners.add(listener);
105
+ return () => {
106
+ rawDataListeners.delete(listener);
107
+ };
108
+ }
109
+ /** Fan a PTY chunk out to every tap. A throwing tap must never break the
110
+ * human's own view of the session, which is the very next thing that would
111
+ * happen if this propagated out of the `onData` handler. */
112
+ function fanOutRawData(data) {
113
+ if (rawDataListeners.size === 0)
114
+ return;
115
+ for (const listener of rawDataListeners) {
116
+ try {
117
+ listener(data);
118
+ }
119
+ catch {
120
+ // A broken tap degrades the remote view, never the local session.
121
+ }
122
+ }
123
+ }
98
124
  function sanitizeEnv(env) {
99
125
  const out = {};
100
126
  for (const [k, v] of Object.entries(env)) {
@@ -354,6 +380,7 @@ export function startLocalAgent(opts = {}) {
354
380
  state.lastOutputAt = Date.now();
355
381
  outStream.write(data);
356
382
  state.writeChain = state.writeChain.then(() => new Promise((resolve) => term.write(data, () => resolve())));
383
+ fanOutRawData(data);
357
384
  });
358
385
  if (inStream && typeof inStream.on === 'function') {
359
386
  const stdinListener = (data) => {
@@ -0,0 +1,251 @@
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
+ /** Token bucket, in bytes. Starts FULL so the first burst after an idle
114
+ * period (the common case — a viewer opens the tile and the agent starts
115
+ * talking) is never throttled. */
116
+ tokens;
117
+ lastRefillAt;
118
+ constructor(opts = {}) {
119
+ this.maxQueueBytes = opts.maxQueueBytes ?? DEFAULT_MAX_QUEUE_BYTES;
120
+ this.maxBatchBytes = opts.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
121
+ this.maxBytesPerSecond = opts.maxBytesPerSecond ?? DEFAULT_MAX_BYTES_PER_SECOND;
122
+ this.now = opts.now ?? Date.now;
123
+ this.tokens = this.maxBytesPerSecond;
124
+ this.lastRefillAt = this.now();
125
+ }
126
+ get queuedBytes() {
127
+ return this.queuedBytesValue;
128
+ }
129
+ /** Bytes skipped so far and not yet reported to the viewer. */
130
+ get pendingDroppedBytes() {
131
+ return this.droppedBytes;
132
+ }
133
+ /**
134
+ * Enqueue raw PTY output, trimming the OLDEST bytes if that pushes the
135
+ * backlog past `maxQueueBytes`.
136
+ *
137
+ * A single chunk larger than the whole cap is itself trimmed from its front,
138
+ * so `push` can never leave the queue over the bound no matter what one
139
+ * `onData` delivers.
140
+ */
141
+ push(chunk) {
142
+ if (!chunk)
143
+ return;
144
+ this.chunks.push(chunk);
145
+ this.queuedBytesValue += Buffer.byteLength(chunk, 'utf-8');
146
+ this.trim();
147
+ }
148
+ /**
149
+ * Record bytes that were LOST rather than queued — today, a batch whose POST
150
+ * failed after `drain()` had already handed the bytes over. Reporting them
151
+ * as a gap is the honest outcome: they are gone, and the viewer's screen is
152
+ * missing them either way. Re-queueing them instead would reorder the stream
153
+ * behind whatever arrived while the POST was in flight, which on a terminal
154
+ * is worse than an acknowledged hole.
155
+ */
156
+ noteDropped(bytes) {
157
+ if (bytes > 0)
158
+ this.droppedBytes += bytes;
159
+ }
160
+ trim() {
161
+ while (this.queuedBytesValue > this.maxQueueBytes && this.chunks.length > 0) {
162
+ const overflow = this.queuedBytesValue - this.maxQueueBytes;
163
+ const oldest = this.chunks[0];
164
+ const oldestBytes = Buffer.byteLength(oldest, 'utf-8');
165
+ if (oldestBytes <= overflow) {
166
+ this.chunks.shift();
167
+ this.queuedBytesValue -= oldestBytes;
168
+ this.droppedBytes += oldestBytes;
169
+ continue;
170
+ }
171
+ // Partially trim the oldest chunk: drop exactly the overflow off its
172
+ // front (at a code-point boundary) and keep the rest.
173
+ const { head } = splitByUtf8Bytes(oldest, overflow);
174
+ const dropped = Buffer.byteLength(head, 'utf-8');
175
+ const kept = oldest.slice(head.length);
176
+ this.chunks[0] = kept;
177
+ this.queuedBytesValue -= dropped;
178
+ this.droppedBytes += dropped;
179
+ // `splitByUtf8Bytes` can stop just SHORT of `overflow` when the next
180
+ // code point straddles the boundary; loop again rather than assuming one
181
+ // pass is enough.
182
+ if (dropped === 0) {
183
+ // Cannot make progress (a single code point wider than the overflow):
184
+ // drop the whole chunk rather than spin.
185
+ this.chunks.shift();
186
+ this.queuedBytesValue -= oldestBytes;
187
+ this.droppedBytes += oldestBytes;
188
+ }
189
+ }
190
+ }
191
+ refill() {
192
+ const at = this.now();
193
+ const elapsedMs = at - this.lastRefillAt;
194
+ if (elapsedMs <= 0)
195
+ return;
196
+ this.lastRefillAt = at;
197
+ this.tokens = Math.min(this.maxBytesPerSecond, this.tokens + (this.maxBytesPerSecond * elapsedMs) / 1000);
198
+ }
199
+ /**
200
+ * Take the next batch to relay, or `null` when there is nothing to send
201
+ * right now (empty queue with no gap to report, or the rate cap is spent).
202
+ *
203
+ * Spending the rate cap is deliberately NOT an error and does not itself
204
+ * drop anything: the bytes stay queued and go out on a later tick. They are
205
+ * only dropped if the producer keeps running long enough to overflow
206
+ * `maxQueueBytes` — which is exactly the "genuinely more output than we will
207
+ * ever relay" case, and is reported as a gap when it happens.
208
+ */
209
+ drain() {
210
+ this.refill();
211
+ const allowance = Math.min(this.maxBatchBytes, Math.floor(this.tokens));
212
+ if (allowance <= 0)
213
+ return null;
214
+ let taken = '';
215
+ let takenBytes = 0;
216
+ while (this.chunks.length > 0 && takenBytes < allowance) {
217
+ const chunk = this.chunks[0];
218
+ const chunkBytes = Buffer.byteLength(chunk, 'utf-8');
219
+ if (takenBytes + chunkBytes <= allowance) {
220
+ taken += chunk;
221
+ takenBytes += chunkBytes;
222
+ this.chunks.shift();
223
+ this.queuedBytesValue -= chunkBytes;
224
+ continue;
225
+ }
226
+ const { head, tail } = splitByUtf8Bytes(chunk, allowance - takenBytes);
227
+ if (!head)
228
+ break; // next code point doesn't fit — leave it for the next tick
229
+ const headBytes = Buffer.byteLength(head, 'utf-8');
230
+ taken += head;
231
+ takenBytes += headBytes;
232
+ this.chunks[0] = tail;
233
+ this.queuedBytesValue -= headBytes;
234
+ break;
235
+ }
236
+ const droppedBytes = this.droppedBytes;
237
+ if (takenBytes === 0 && droppedBytes === 0)
238
+ return null;
239
+ this.droppedBytes = 0;
240
+ this.tokens -= takenBytes;
241
+ return { data: taken, droppedBytes };
242
+ }
243
+ /** Forget everything queued. Used when a stream ends — those bytes belong to
244
+ * a `streamId` that no longer exists, and carrying them into the next
245
+ * stream would splice one session's screen into another's. */
246
+ reset() {
247
+ this.chunks = [];
248
+ this.queuedBytesValue = 0;
249
+ this.droppedBytes = 0;
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",