@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.
- package/dist/api-client.js +46 -1
- package/dist/attach-cmd.js +171 -2
- package/dist/frame-actions.js +24 -0
- package/dist/local-agent.js +206 -4
- package/dist/output-stream.js +282 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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`, {
|
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, 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.
|
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) {
|
|
@@ -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:
|
package/dist/local-agent.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|