@yolo-labs/yolobridge 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.js +14 -1
- package/dist/attach-cmd.js +49 -3
- package/dist/frame-actions.js +3 -0
- package/dist/local-agent.js +182 -7
- package/dist/output-stream.js +33 -2
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -191,13 +191,23 @@ export async function postHeartbeat(cfg, workspaceId, attachmentId) {
|
|
|
191
191
|
const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
|
|
192
192
|
return Boolean(body?.recorded);
|
|
193
193
|
}
|
|
194
|
-
export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
|
|
194
|
+
export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy, extra) {
|
|
195
195
|
const body = await postEvent(cfg, workspaceId, {
|
|
196
196
|
attachmentId,
|
|
197
197
|
type: 'read-output-reply',
|
|
198
198
|
requestId,
|
|
199
199
|
output,
|
|
200
200
|
busy,
|
|
201
|
+
...(extra?.cols && extra?.rows ? { cols: extra.cols, rows: extra.rows } : {}),
|
|
202
|
+
...(extra?.raw
|
|
203
|
+
? {
|
|
204
|
+
raw: extra.raw.data,
|
|
205
|
+
epoch: extra.raw.epoch,
|
|
206
|
+
baseOffset: extra.raw.baseOffset,
|
|
207
|
+
endOffset: extra.raw.endOffset,
|
|
208
|
+
truncated: extra.raw.truncated,
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
201
211
|
});
|
|
202
212
|
return Boolean(body?.resolved);
|
|
203
213
|
}
|
|
@@ -230,6 +240,9 @@ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
|
|
|
230
240
|
seq: chunk.seq,
|
|
231
241
|
data: chunk.data,
|
|
232
242
|
droppedBytes: chunk.droppedBytes,
|
|
243
|
+
...(chunk.epoch ? { epoch: chunk.epoch } : {}),
|
|
244
|
+
...(typeof chunk.startOffset === 'number' ? { startOffset: chunk.startOffset } : {}),
|
|
245
|
+
...(chunk.cols && chunk.rows ? { cols: chunk.cols, rows: chunk.rows } : {}),
|
|
233
246
|
});
|
|
234
247
|
return Boolean(body?.relayed);
|
|
235
248
|
}
|
package/dist/attach-cmd.js
CHANGED
|
@@ -22,7 +22,7 @@ import { SseFrameParser } from './sse-frame-parser.js';
|
|
|
22
22
|
import { actionForFrame } from './frame-actions.js';
|
|
23
23
|
import { startHeartbeat, defaultTimers } from './heartbeat.js';
|
|
24
24
|
import { nextBackoffMs } from './reconnect.js';
|
|
25
|
-
import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData } from './local-agent.js';
|
|
25
|
+
import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, } from './local-agent.js';
|
|
26
26
|
import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, } from './output-stream.js';
|
|
27
27
|
import * as apiClient from './api-client.js';
|
|
28
28
|
import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
|
|
@@ -85,6 +85,9 @@ export async function runAttachDaemon(deps) {
|
|
|
85
85
|
const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
|
|
86
86
|
const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
|
|
87
87
|
const subscribeAgentOutput = deps.subscribeAgentOutput ?? onLocalAgentData;
|
|
88
|
+
const takeRawSeed = deps.takeRawSeed ?? takeRawSeedFromAgent;
|
|
89
|
+
const primeRawStream = deps.primeRawStream ?? primeRawStreamFromAgent;
|
|
90
|
+
const getGeometry = deps.getAgentGeometry ?? getLocalAgentGeometry;
|
|
88
91
|
const flushIntervalMs = deps.outputFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
89
92
|
/**
|
|
90
93
|
* THE ACCOUNT identity. Full-account bearer from `yolo-bridge login`, and the
|
|
@@ -629,11 +632,16 @@ export async function runAttachDaemon(deps) {
|
|
|
629
632
|
const bytes = Buffer.byteLength(batch.data, 'utf-8');
|
|
630
633
|
session.posting = true;
|
|
631
634
|
try {
|
|
635
|
+
const geometry = getGeometry();
|
|
632
636
|
await apiClient.postOutputChunk(scopedCfg(), workspaceId, attachmentId, {
|
|
633
637
|
streamId: session.streamId,
|
|
634
638
|
seq: session.seq,
|
|
635
639
|
data: batch.data,
|
|
636
640
|
droppedBytes: batch.droppedBytes,
|
|
641
|
+
epoch: session.epoch,
|
|
642
|
+
startOffset: batch.startOffset,
|
|
643
|
+
cols: geometry?.cols,
|
|
644
|
+
rows: geometry?.rows,
|
|
637
645
|
});
|
|
638
646
|
if (outputStream === session)
|
|
639
647
|
session.seq += 1;
|
|
@@ -679,7 +687,16 @@ export async function runAttachDaemon(deps) {
|
|
|
679
687
|
return;
|
|
680
688
|
}
|
|
681
689
|
stopOutputStream();
|
|
690
|
+
// Prime from the raw ring, atomically with taking the tap below. The seed
|
|
691
|
+
// the viewer fetches is a SEPARATE round trip that can land either side of
|
|
692
|
+
// this moment, so the stream deliberately starts BEHIND it: overlapping
|
|
693
|
+
// bytes are trimmed by the viewer using their absolute offsets, whereas a
|
|
694
|
+
// hole would cost a re-seed. Overlap is free; a gap is not.
|
|
695
|
+
const prime = primeRawStream();
|
|
682
696
|
const buffer = new OutputStreamBuffer(deps.outputStreamOptions);
|
|
697
|
+
buffer.setBaseOffset(prime.startOffset);
|
|
698
|
+
if (prime.data)
|
|
699
|
+
buffer.push(prime.data);
|
|
683
700
|
const session = {
|
|
684
701
|
streamId,
|
|
685
702
|
seq: 0,
|
|
@@ -688,13 +705,22 @@ export async function runAttachDaemon(deps) {
|
|
|
688
705
|
flushHandle: undefined,
|
|
689
706
|
leaseUntilMs: now() + leaseMs,
|
|
690
707
|
posting: false,
|
|
708
|
+
epoch: prime.epoch,
|
|
691
709
|
};
|
|
692
710
|
outputStream = session;
|
|
693
711
|
// Subscribe AFTER the session exists so a synchronous first chunk can't
|
|
694
712
|
// land on a half-built one.
|
|
695
|
-
session.unsubscribe = subscribeAgentOutput((data) => {
|
|
713
|
+
session.unsubscribe = subscribeAgentOutput((data, meta) => {
|
|
696
714
|
if (outputStream !== session)
|
|
697
715
|
return;
|
|
716
|
+
if (meta && meta.epoch !== session.epoch) {
|
|
717
|
+
// The local agent was respawned under us. Everything queued describes a
|
|
718
|
+
// screen that no longer exists; rebase onto the new session's offsets
|
|
719
|
+
// and let the viewer notice the epoch change and re-seed.
|
|
720
|
+
buffer.reset();
|
|
721
|
+
buffer.setBaseOffset(meta.startOffset);
|
|
722
|
+
session.epoch = meta.epoch;
|
|
723
|
+
}
|
|
698
724
|
buffer.push(data);
|
|
699
725
|
});
|
|
700
726
|
session.flushHandle = timers.setInterval(() => {
|
|
@@ -837,8 +863,28 @@ export async function runAttachDaemon(deps) {
|
|
|
837
863
|
break;
|
|
838
864
|
case 'read-output': {
|
|
839
865
|
const captured = await captureOutput();
|
|
866
|
+
// Taken AFTER the (async) capture and synchronously, so
|
|
867
|
+
// `endOffset` names the exact byte position this reply leaves
|
|
868
|
+
// the viewer at. Anything the PTY produces from here on
|
|
869
|
+
// carries a higher offset and is spliced on by the viewer;
|
|
870
|
+
// anything already inside `raw.data` is trimmed by the same
|
|
871
|
+
// arithmetic. That is what makes seed and tap non-atomic in
|
|
872
|
+
// WALL CLOCK yet exactly-once in effect.
|
|
873
|
+
const raw = action.mode === 'raw' ? takeRawSeed() : undefined;
|
|
840
874
|
await apiClient
|
|
841
|
-
.postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy
|
|
875
|
+
.postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy, {
|
|
876
|
+
cols: raw?.cols ?? captured.cols,
|
|
877
|
+
rows: raw?.rows ?? captured.rows,
|
|
878
|
+
raw: raw
|
|
879
|
+
? {
|
|
880
|
+
epoch: raw.epoch,
|
|
881
|
+
baseOffset: raw.baseOffset,
|
|
882
|
+
endOffset: raw.endOffset,
|
|
883
|
+
data: raw.data,
|
|
884
|
+
truncated: raw.truncated,
|
|
885
|
+
}
|
|
886
|
+
: undefined,
|
|
887
|
+
})
|
|
842
888
|
.catch((err) => noteConnection('degraded', {
|
|
843
889
|
detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
844
890
|
}));
|
package/dist/frame-actions.js
CHANGED
|
@@ -32,6 +32,9 @@ export function actionForFrame(frame) {
|
|
|
32
32
|
kind: 'read-output',
|
|
33
33
|
attachmentId: String(data.attachmentId ?? ''),
|
|
34
34
|
requestId: String(data.requestId ?? ''),
|
|
35
|
+
// Anything unrecognized (including an absent field, which is what a
|
|
36
|
+
// server predating raw seeding sends) means the old screen dump.
|
|
37
|
+
mode: data.mode === 'raw' ? 'raw' : 'screen',
|
|
35
38
|
};
|
|
36
39
|
case 'output-stream-start': {
|
|
37
40
|
const raw = typeof data.leaseMs === 'number' ? data.leaseMs : NaN;
|
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
|
/**
|
|
@@ -109,18 +111,177 @@ export function onLocalAgentData(listener) {
|
|
|
109
111
|
/** Fan a PTY chunk out to every tap. A throwing tap must never break the
|
|
110
112
|
* human's own view of the session, which is the very next thing that would
|
|
111
113
|
* happen if this propagated out of the `onData` handler. */
|
|
112
|
-
function fanOutRawData(data) {
|
|
114
|
+
function fanOutRawData(data, meta) {
|
|
113
115
|
if (rawDataListeners.size === 0)
|
|
114
116
|
return;
|
|
115
117
|
for (const listener of rawDataListeners) {
|
|
116
118
|
try {
|
|
117
|
-
listener(data);
|
|
119
|
+
listener(data, meta);
|
|
118
120
|
}
|
|
119
121
|
catch {
|
|
120
122
|
// A broken tap degrades the remote view, never the local session.
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
// ─── The raw replay ring (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming")
|
|
127
|
+
//
|
|
128
|
+
// ⚠️ WHY THIS EXISTS AT ALL, given `serializeTerminalBuffer` already produces a
|
|
129
|
+
// perfectly good screen dump.
|
|
130
|
+
//
|
|
131
|
+
// A line dump is the right primitive for a STATIC snapshot (`read_tile_output`
|
|
132
|
+
// still uses it). It is the WRONG primitive for resuming a LIVE stream, and
|
|
133
|
+
// the difference is not cosmetic. A dump carries the glyphs and their colours
|
|
134
|
+
// and nothing else: not the cursor position, not the scroll region (DECSTBM),
|
|
135
|
+
// not whether the alternate screen is active, not the saved cursor, not
|
|
136
|
+
// autowrap mode. A real TUI — Claude Code, codex, anything with a spinner or a
|
|
137
|
+
// redrawn prompt box — repaints using RELATIVE moves (`\x1b[A`, `\x1b[K`, a
|
|
138
|
+
// bare `\r`). Seed a viewer from a dump and every one of those moves is applied
|
|
139
|
+
// from the wrong origin, so redraws land on the wrong rows: text drawn on top
|
|
140
|
+
// of other text, prompt boxes marching down the screen. That is a state
|
|
141
|
+
// reconstruction bug, and the only fix that does not have a next instance is
|
|
142
|
+
// to stop reconstructing state.
|
|
143
|
+
//
|
|
144
|
+
// So: keep the last `RAW_RING_MAX_BYTES` of the RAW PTY byte stream and replay
|
|
145
|
+
// those bytes into the viewer's terminal. The viewer's xterm then parses
|
|
146
|
+
// exactly what the daemon's terminal parsed and arrives at exactly the same
|
|
147
|
+
// state — no cursor inference, no mode inference, no seam to get wrong.
|
|
148
|
+
//
|
|
149
|
+
// Every byte is stamped with an absolute offset, which is what makes the seed
|
|
150
|
+
// and the tap safe to be two independent, racing operations: overlap is
|
|
151
|
+
// deduplicated by offset and a hole is detected by offset. Neither is possible
|
|
152
|
+
// with `seq`, which only orders what was actually sent.
|
|
153
|
+
/**
|
|
154
|
+
* Raw PTY bytes retained for seeding a viewer. 256 KiB.
|
|
155
|
+
*
|
|
156
|
+
* The number is chosen for what it has to CONTAIN, not for memory: a viewer
|
|
157
|
+
* seeded mid-session needs the bytes back to (at least) the TUI's most recent
|
|
158
|
+
* full repaint, and a repainting agent redraws its whole screen many times per
|
|
159
|
+
* minute — a 120×40 full repaint with colour is on the order of 10-30 KiB, so
|
|
160
|
+
* this holds many of them. It is also comfortably under the server's 5 MB JSON
|
|
161
|
+
* body limit, since the whole ring can travel in one seed reply.
|
|
162
|
+
*/
|
|
163
|
+
export const RAW_RING_MAX_BYTES = 256 * 1024;
|
|
164
|
+
/**
|
|
165
|
+
* How much of the ring is replayed into a NEW streaming episode's queue.
|
|
166
|
+
*
|
|
167
|
+
* The tap and the seed are separate round trips, so a viewer's seed can end at
|
|
168
|
+
* an offset slightly before (or after) the point the tap starts at. Priming
|
|
169
|
+
* the episode with a recent tail guarantees the stream covers the seam from
|
|
170
|
+
* below; the viewer trims the duplicate prefix by offset. 64 KiB is ~1000×
|
|
171
|
+
* more than one HTTP round trip's worth of agent output, and a miss is not
|
|
172
|
+
* fatal anyway — it reads as a gap and costs one extra re-seed.
|
|
173
|
+
*/
|
|
174
|
+
export const RAW_STREAM_PRIME_BYTES = 64 * 1024;
|
|
175
|
+
function newRawRing() {
|
|
176
|
+
return { epoch: randomUUID(), chunks: [], bytes: 0, baseOffset: 0, endOffset: 0 };
|
|
177
|
+
}
|
|
178
|
+
let rawRing = newRawRing();
|
|
179
|
+
/** Append to the ring and return where the chunk landed. Synchronous and
|
|
180
|
+
* called from `onData`, so a tap and a seed taken in the same tick can never
|
|
181
|
+
* disagree about the offset. */
|
|
182
|
+
function pushRaw(data) {
|
|
183
|
+
const startOffset = rawRing.endOffset;
|
|
184
|
+
const bytes = Buffer.byteLength(data, 'utf-8');
|
|
185
|
+
rawRing.chunks.push(data);
|
|
186
|
+
rawRing.bytes += bytes;
|
|
187
|
+
rawRing.endOffset += bytes;
|
|
188
|
+
trimRawRing();
|
|
189
|
+
return { epoch: rawRing.epoch, startOffset };
|
|
190
|
+
}
|
|
191
|
+
/** Drop from the FRONT until the ring is within its cap, advancing
|
|
192
|
+
* `baseOffset` by exactly what was dropped. Cuts only at code-point
|
|
193
|
+
* boundaries (`splitByUtf8Bytes`) so a replayed tail is never a lone
|
|
194
|
+
* surrogate. */
|
|
195
|
+
function trimRawRing() {
|
|
196
|
+
while (rawRing.bytes > RAW_RING_MAX_BYTES && rawRing.chunks.length > 0) {
|
|
197
|
+
const overflow = rawRing.bytes - RAW_RING_MAX_BYTES;
|
|
198
|
+
const oldest = rawRing.chunks[0];
|
|
199
|
+
const oldestBytes = Buffer.byteLength(oldest, 'utf-8');
|
|
200
|
+
if (oldestBytes <= overflow) {
|
|
201
|
+
rawRing.chunks.shift();
|
|
202
|
+
rawRing.bytes -= oldestBytes;
|
|
203
|
+
rawRing.baseOffset += oldestBytes;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const { head, tail } = splitByUtf8Bytes(oldest, overflow);
|
|
207
|
+
const droppedBytes = Buffer.byteLength(head, 'utf-8');
|
|
208
|
+
if (droppedBytes === 0) {
|
|
209
|
+
// A single code point wider than the overflow — drop the chunk rather
|
|
210
|
+
// than spin.
|
|
211
|
+
rawRing.chunks.shift();
|
|
212
|
+
rawRing.bytes -= oldestBytes;
|
|
213
|
+
rawRing.baseOffset += oldestBytes;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
rawRing.chunks[0] = tail;
|
|
217
|
+
rawRing.bytes -= droppedBytes;
|
|
218
|
+
rawRing.baseOffset += droppedBytes;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Take the current replay tail plus the PTY's grid size, as ONE synchronous
|
|
223
|
+
* observation.
|
|
224
|
+
*
|
|
225
|
+
* Atomic by construction: nothing can interleave between reading the ring and
|
|
226
|
+
* reading the offsets, so `endOffset` is exactly the position a viewer reaches
|
|
227
|
+
* by writing `data`. Bytes the PTY produces after this call carry offsets
|
|
228
|
+
* ≥ `endOffset` and are therefore either spliced on cleanly or (if they were
|
|
229
|
+
* already in `data`) trimmed away by the viewer — never lost, never applied
|
|
230
|
+
* twice.
|
|
231
|
+
*/
|
|
232
|
+
export function takeRawSeed() {
|
|
233
|
+
if (!current)
|
|
234
|
+
return undefined;
|
|
235
|
+
return {
|
|
236
|
+
epoch: rawRing.epoch,
|
|
237
|
+
baseOffset: rawRing.baseOffset,
|
|
238
|
+
endOffset: rawRing.endOffset,
|
|
239
|
+
data: rawRing.chunks.join(''),
|
|
240
|
+
cols: current.cols,
|
|
241
|
+
rows: current.rows,
|
|
242
|
+
truncated: rawRing.baseOffset > 0,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export function primeRawStream(maxBytes = RAW_STREAM_PRIME_BYTES) {
|
|
246
|
+
const all = rawRing.chunks.join('');
|
|
247
|
+
const total = Buffer.byteLength(all, 'utf-8');
|
|
248
|
+
if (total <= maxBytes) {
|
|
249
|
+
return { epoch: rawRing.epoch, startOffset: rawRing.baseOffset, data: all };
|
|
250
|
+
}
|
|
251
|
+
const { head, tail } = splitByUtf8Bytes(all, total - maxBytes);
|
|
252
|
+
return {
|
|
253
|
+
epoch: rawRing.epoch,
|
|
254
|
+
startOffset: rawRing.baseOffset + Buffer.byteLength(head, 'utf-8'),
|
|
255
|
+
data: tail,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** The PTY's grid, which the tile must render at EXACTLY (it cannot be
|
|
259
|
+
* resized — the human at the keyboard is watching the same PTY). */
|
|
260
|
+
export function getLocalAgentGeometry() {
|
|
261
|
+
if (!current)
|
|
262
|
+
return undefined;
|
|
263
|
+
return { cols: current.cols, rows: current.rows };
|
|
264
|
+
}
|
|
265
|
+
/** The same recent-activity heuristic `captureLocalAgentOutput` reports, without
|
|
266
|
+
* paying for a buffer serialization. */
|
|
267
|
+
export function isLocalAgentBusy() {
|
|
268
|
+
if (!current)
|
|
269
|
+
return false;
|
|
270
|
+
return Date.now() - current.lastOutputAt < current.busyWindowMs;
|
|
271
|
+
}
|
|
272
|
+
/** Test seam: forget the ring and start a fresh epoch. */
|
|
273
|
+
export function __resetRawRing() {
|
|
274
|
+
rawRing = newRawRing();
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Test seam: the daemon's OWN headless terminal — the thing a remote viewer
|
|
278
|
+
* must end up cell-for-cell identical to. Exposed only so a test can assert
|
|
279
|
+
* that equality directly (glyphs, attributes AND cursor position) instead of
|
|
280
|
+
* eyeballing a serialized dump, which cannot see cursor drift at all.
|
|
281
|
+
*/
|
|
282
|
+
export function __getLocalAgentTerminal() {
|
|
283
|
+
return current?.term;
|
|
284
|
+
}
|
|
124
285
|
function sanitizeEnv(env) {
|
|
125
286
|
const out = {};
|
|
126
287
|
for (const [k, v] of Object.entries(env)) {
|
|
@@ -362,9 +523,16 @@ export function startLocalAgent(opts = {}) {
|
|
|
362
523
|
env: sanitizeEnv(opts.env ?? process.env),
|
|
363
524
|
});
|
|
364
525
|
const term = new Terminal({ cols, rows, allowProposedApi: true });
|
|
526
|
+
// A NEW PTY is a new byte stream: everything retained belongs to a screen
|
|
527
|
+
// that no longer exists, and offsets restart. The fresh `epoch` is what a
|
|
528
|
+
// viewer keyed to the old one sees, and it re-seeds rather than splicing two
|
|
529
|
+
// sessions' bytes together.
|
|
530
|
+
rawRing = newRawRing();
|
|
365
531
|
const state = {
|
|
366
532
|
ptyProcess,
|
|
367
533
|
term,
|
|
534
|
+
cols,
|
|
535
|
+
rows,
|
|
368
536
|
lastOutputAt: Date.now(),
|
|
369
537
|
busyWindowMs,
|
|
370
538
|
readinessQuietMs,
|
|
@@ -380,7 +548,11 @@ export function startLocalAgent(opts = {}) {
|
|
|
380
548
|
state.lastOutputAt = Date.now();
|
|
381
549
|
outStream.write(data);
|
|
382
550
|
state.writeChain = state.writeChain.then(() => new Promise((resolve) => term.write(data, () => resolve())));
|
|
383
|
-
|
|
551
|
+
// Ring FIRST, then the taps, with the offset the ring assigned — a tap and
|
|
552
|
+
// a `takeRawSeed()` taken in the same tick must agree on where this chunk
|
|
553
|
+
// sits, or the viewer's dedupe/gap logic is reasoning about two different
|
|
554
|
+
// coordinate systems.
|
|
555
|
+
fanOutRawData(data, pushRaw(data));
|
|
384
556
|
});
|
|
385
557
|
if (inStream && typeof inStream.on === 'function') {
|
|
386
558
|
const stdinListener = (data) => {
|
|
@@ -401,7 +573,7 @@ export function startLocalAgent(opts = {}) {
|
|
|
401
573
|
current = undefined;
|
|
402
574
|
opts.onExit?.({ exitCode, signal });
|
|
403
575
|
});
|
|
404
|
-
return { stop: stopLocalAgent };
|
|
576
|
+
return { stop: stopLocalAgent, cols, rows };
|
|
405
577
|
}
|
|
406
578
|
/**
|
|
407
579
|
* Real bug (found 2026-08-23 chasing a report that `attach` never fully
|
|
@@ -615,5 +787,8 @@ export async function captureLocalAgentOutput() {
|
|
|
615
787
|
await current.writeChain;
|
|
616
788
|
const output = serializeTerminalBuffer(current.term);
|
|
617
789
|
const busy = Date.now() - current.lastOutputAt < current.busyWindowMs;
|
|
618
|
-
|
|
790
|
+
// The grid the screen above was laid out FOR. A viewer that renders it at
|
|
791
|
+
// any other width re-wraps every long line — the dump has no width of its
|
|
792
|
+
// own, only the one the PTY composed it at.
|
|
793
|
+
return { output, busy, cols: current.cols, rows: current.rows };
|
|
619
794
|
}
|
package/dist/output-stream.js
CHANGED
|
@@ -110,6 +110,13 @@ export class OutputStreamBuffer {
|
|
|
110
110
|
chunks = [];
|
|
111
111
|
queuedBytesValue = 0;
|
|
112
112
|
droppedBytes = 0;
|
|
113
|
+
/** Absolute offset of the first byte still queued (== `writeOffset` when the
|
|
114
|
+
* queue is empty). Advanced by everything that LEAVES the queue, whether it
|
|
115
|
+
* was drained or trimmed, so it always names the next byte a viewer has not
|
|
116
|
+
* been offered. */
|
|
117
|
+
headOffset = 0;
|
|
118
|
+
/** Absolute offset one past the last byte ever pushed. */
|
|
119
|
+
writeOffset = 0;
|
|
113
120
|
/** Token bucket, in bytes. Starts FULL so the first burst after an idle
|
|
114
121
|
* period (the common case — a viewer opens the tile and the agent starts
|
|
115
122
|
* talking) is never throttled. */
|
|
@@ -122,6 +129,22 @@ export class OutputStreamBuffer {
|
|
|
122
129
|
this.now = opts.now ?? Date.now;
|
|
123
130
|
this.tokens = this.maxBytesPerSecond;
|
|
124
131
|
this.lastRefillAt = this.now();
|
|
132
|
+
this.headOffset = opts.startOffset ?? 0;
|
|
133
|
+
this.writeOffset = this.headOffset;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Rebase this buffer onto a new absolute byte position. Only meaningful on
|
|
137
|
+
* an EMPTY buffer — it is called when a streaming episode starts (priming
|
|
138
|
+
* from local-agent.ts's raw ring) and when the PTY session underneath is
|
|
139
|
+
* replaced, both of which discard whatever was queued first.
|
|
140
|
+
*/
|
|
141
|
+
setBaseOffset(offset) {
|
|
142
|
+
this.headOffset = offset;
|
|
143
|
+
this.writeOffset = offset;
|
|
144
|
+
}
|
|
145
|
+
/** Absolute offset of the next byte a viewer has not been offered yet. */
|
|
146
|
+
get nextOffset() {
|
|
147
|
+
return this.headOffset;
|
|
125
148
|
}
|
|
126
149
|
get queuedBytes() {
|
|
127
150
|
return this.queuedBytesValue;
|
|
@@ -142,7 +165,9 @@ export class OutputStreamBuffer {
|
|
|
142
165
|
if (!chunk)
|
|
143
166
|
return;
|
|
144
167
|
this.chunks.push(chunk);
|
|
145
|
-
|
|
168
|
+
const pushed = Buffer.byteLength(chunk, 'utf-8');
|
|
169
|
+
this.queuedBytesValue += pushed;
|
|
170
|
+
this.writeOffset += pushed;
|
|
146
171
|
this.trim();
|
|
147
172
|
}
|
|
148
173
|
/**
|
|
@@ -166,6 +191,7 @@ export class OutputStreamBuffer {
|
|
|
166
191
|
this.chunks.shift();
|
|
167
192
|
this.queuedBytesValue -= oldestBytes;
|
|
168
193
|
this.droppedBytes += oldestBytes;
|
|
194
|
+
this.headOffset += oldestBytes;
|
|
169
195
|
continue;
|
|
170
196
|
}
|
|
171
197
|
// Partially trim the oldest chunk: drop exactly the overflow off its
|
|
@@ -176,6 +202,7 @@ export class OutputStreamBuffer {
|
|
|
176
202
|
this.chunks[0] = kept;
|
|
177
203
|
this.queuedBytesValue -= dropped;
|
|
178
204
|
this.droppedBytes += dropped;
|
|
205
|
+
this.headOffset += dropped;
|
|
179
206
|
// `splitByUtf8Bytes` can stop just SHORT of `overflow` when the next
|
|
180
207
|
// code point straddles the boundary; loop again rather than assuming one
|
|
181
208
|
// pass is enough.
|
|
@@ -185,6 +212,7 @@ export class OutputStreamBuffer {
|
|
|
185
212
|
this.chunks.shift();
|
|
186
213
|
this.queuedBytesValue -= oldestBytes;
|
|
187
214
|
this.droppedBytes += oldestBytes;
|
|
215
|
+
this.headOffset += oldestBytes;
|
|
188
216
|
}
|
|
189
217
|
}
|
|
190
218
|
}
|
|
@@ -211,6 +239,7 @@ export class OutputStreamBuffer {
|
|
|
211
239
|
const allowance = Math.min(this.maxBatchBytes, Math.floor(this.tokens));
|
|
212
240
|
if (allowance <= 0)
|
|
213
241
|
return null;
|
|
242
|
+
const startOffset = this.headOffset;
|
|
214
243
|
let taken = '';
|
|
215
244
|
let takenBytes = 0;
|
|
216
245
|
while (this.chunks.length > 0 && takenBytes < allowance) {
|
|
@@ -238,7 +267,8 @@ export class OutputStreamBuffer {
|
|
|
238
267
|
return null;
|
|
239
268
|
this.droppedBytes = 0;
|
|
240
269
|
this.tokens -= takenBytes;
|
|
241
|
-
|
|
270
|
+
this.headOffset += takenBytes;
|
|
271
|
+
return { data: taken, droppedBytes, startOffset };
|
|
242
272
|
}
|
|
243
273
|
/** Forget everything queued. Used when a stream ends — those bytes belong to
|
|
244
274
|
* a `streamId` that no longer exists, and carrying them into the next
|
|
@@ -247,5 +277,6 @@ export class OutputStreamBuffer {
|
|
|
247
277
|
this.chunks = [];
|
|
248
278
|
this.queuedBytesValue = 0;
|
|
249
279
|
this.droppedBytes = 0;
|
|
280
|
+
this.headOffset = this.writeOffset;
|
|
250
281
|
}
|
|
251
282
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
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",
|