@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.
- package/dist/api-client.js +32 -0
- package/dist/attach-cmd.js +124 -1
- package/dist/frame-actions.js +21 -0
- package/dist/local-agent.js +27 -0
- package/dist/output-stream.js +251 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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`, {
|
package/dist/attach-cmd.js
CHANGED
|
@@ -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.
|
package/dist/frame-actions.js
CHANGED
|
@@ -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:
|
package/dist/local-agent.js
CHANGED
|
@@ -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.
|
|
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",
|