@yolo-labs/yolobridge 0.22.0 → 0.24.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.
@@ -324,3 +324,100 @@ export async function deliverShare(cfg, workspaceId, attachmentId, assetId, targ
324
324
  }
325
325
  return (await res.json());
326
326
  }
327
+ /**
328
+ * Send raw console keystrokes toward a daemon.
329
+ *
330
+ * ⚠️ Authenticates as the USER, not as a daemon. The console runs on a machine
331
+ * the operator is sitting at; the daemon's workspace-scoped token belongs to the
332
+ * machine being typed INTO and never leaves it. The route enforces the same
333
+ * thing from the other side by refusing a scoped token outright.
334
+ *
335
+ * Returns the server's delivery verdict rather than a bare boolean, because the
336
+ * distinction is real: 'written' means a replica put the frame on the daemon's
337
+ * own stream, 'relayed' means it was handed off and nobody has confirmed it.
338
+ */
339
+ export async function sendConsoleInput(cfg, workspaceId, attachmentId, data) {
340
+ const fetchImpl = cfg.fetchImpl ?? fetch;
341
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/input`, {
342
+ method: 'POST',
343
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ data }),
345
+ });
346
+ if (!res.ok) {
347
+ const { message, code } = await parseErrorBody(res);
348
+ throw new YoloBridgeApiError(message, res.status, code);
349
+ }
350
+ const body = (await res.json().catch(() => ({})));
351
+ return body.delivery === 'written' ? 'written' : 'relayed';
352
+ }
353
+ /** Register as an output viewer, which is what makes the daemon stream at all. */
354
+ export async function subscribeOutput(cfg, workspaceId, tileId, subscriptionId) {
355
+ const fetchImpl = cfg.fetchImpl ?? fetch;
356
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output/subscribe`, {
357
+ method: 'POST',
358
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ subscriptionId }),
360
+ });
361
+ if (!res.ok) {
362
+ const { message, code } = await parseErrorBody(res);
363
+ throw new YoloBridgeApiError(`could not subscribe to output: ${message}`, res.status, code);
364
+ }
365
+ return (await res.json().catch(() => ({})));
366
+ }
367
+ export async function unsubscribeOutput(cfg, workspaceId, tileId, subscriptionId) {
368
+ const fetchImpl = cfg.fetchImpl ?? fetch;
369
+ await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output/unsubscribe`, {
370
+ method: 'POST',
371
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ subscriptionId }),
373
+ }).catch(() => { });
374
+ }
375
+ /** The workspace event stream, where relayed PTY output arrives. */
376
+ export async function openWorkspaceEventStream(cfg, workspaceId) {
377
+ const fetchImpl = cfg.fetchImpl ?? fetch;
378
+ // ⚠️ THE TOKEN GOES IN THE QUERY STRING, not the Authorization header.
379
+ // `routes/events.ts` reads `req.query.token` exclusively — because browsers'
380
+ // EventSource cannot send custom headers — and when it is absent the route
381
+ // answers HTTP 200 with an SSE `error` frame saying "Token required". So a
382
+ // header-only client is ACCEPTED by fetch and then shows nothing, forever,
383
+ // with no failure anywhere to notice. (codex P1.)
384
+ const url = `${base(cfg)}/v1/events/stream`
385
+ + `?workspaceId=${encodeURIComponent(workspaceId)}`
386
+ + `&token=${encodeURIComponent(cfg.accessToken)}`;
387
+ const res = await fetchImpl(url, { headers: { ...authHeaders(cfg), Accept: 'text/event-stream' } });
388
+ if (!res.ok || !res.body) {
389
+ const { message, code } = await parseErrorBody(res);
390
+ throw new YoloBridgeApiError(`event stream failed: ${message}`, res.status, code);
391
+ }
392
+ return res;
393
+ }
394
+ /**
395
+ * Fetch the RAW replay seed for a tile — the tail of the daemon's PTY byte
396
+ * stream plus the absolute offsets that say where it ends.
397
+ *
398
+ * ⚠️ `mode=raw`, not the default. The default returns a SERIALIZED SCREEN, which
399
+ * is a rendering, not a byte stream: writing it into a terminal and then
400
+ * appending live chunks puts the cursor somewhere the daemon never put it. Only
401
+ * the raw form can be continued from.
402
+ *
403
+ * Returns undefined rather than throwing when the seed is unavailable — a
404
+ * detached or briefly unreachable daemon answers 409, and a console that can
405
+ * still stream live output should start blank rather than refuse to run.
406
+ */
407
+ export async function readRawOutputSeed(cfg, workspaceId, tileId) {
408
+ const fetchImpl = cfg.fetchImpl ?? fetch;
409
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output?mode=raw`, { headers: authHeaders(cfg) });
410
+ if (!res.ok)
411
+ return undefined;
412
+ const body = (await res.json().catch(() => undefined));
413
+ return body && typeof body.raw === 'string' ? body : undefined;
414
+ }
415
+ export async function resolveAttachmentTile(cfg, workspaceId, attachmentId) {
416
+ const fetchImpl = cfg.fetchImpl ?? fetch;
417
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}`, { headers: authHeaders(cfg) });
418
+ if (!res.ok)
419
+ return undefined;
420
+ const body = (await res.json().catch(() => ({})));
421
+ const tiles = body.tiles ?? body.workspace?.tiles ?? [];
422
+ return tiles.find((t) => t?.yoloBridge?.attachmentId === attachmentId)?.id;
423
+ }
@@ -22,8 +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, isFatalCredentialRefusal, fatalCredentialRefusalMessage, } from './reconnect.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';
25
+ import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, writeInputToLocalAgent, } from './local-agent.js';
26
+ import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, INTERACTIVE_ECHO_FLUSH_MS, } from './output-stream.js';
27
27
  import * as apiClient from './api-client.js';
28
28
  import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
29
29
  import { loadAuth, saveAuth, loadAttachment, saveAttachment, clearAttachment, } from './config-store.js';
@@ -78,6 +78,7 @@ export async function runAttachDaemon(deps) {
78
78
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
79
79
  const clearScreen = deps.clearScreen ?? (() => process.stdout.write('\x1b[2J\x1b[3J\x1b[H'));
80
80
  const deliverPrompt = deps.deliverPrompt ?? deliverPromptToLocalAgent;
81
+ const writeInput = deps.writeInput ?? writeInputToLocalAgent;
81
82
  const captureOutput = deps.captureOutput ?? captureLocalAgentOutput;
82
83
  const timers = deps.timers ?? defaultTimers;
83
84
  const now = deps.now ?? Date.now;
@@ -925,6 +926,27 @@ export async function runAttachDaemon(deps) {
925
926
  case 'prompt':
926
927
  await deliverPrompt(action.prompt);
927
928
  break;
929
+ case 'input': {
930
+ // Raw keystrokes from a console client. Written verbatim —
931
+ // no Enter appended, no readiness gate — see
932
+ // `writeInputToLocalAgent`. Nothing logs the bytes.
933
+ writeInput(action.data);
934
+ // The PTY echoes within ~1ms. Without this the echo waits out
935
+ // the 80ms batch window, which buys nothing for a payload
936
+ // this small and spends ~40% of the latency budget that is
937
+ // ours rather than the network's. `flushOutputStream` re-checks
938
+ // that this session is still current, so a stale timer no-ops.
939
+ const echoSession = outputStream;
940
+ if (echoSession) {
941
+ // A plain timer, not `timers`: that seam exists so tests can
942
+ // drive the heartbeat/flush CADENCE, and widening it for a
943
+ // 5ms nudge would touch every existing double. `unref` so a
944
+ // pending echo can never hold the process open at exit.
945
+ const t = setTimeout(() => { void flushOutputStream(echoSession); }, INTERACTIVE_ECHO_FLUSH_MS);
946
+ t.unref?.();
947
+ }
948
+ break;
949
+ }
928
950
  case 'read-output': {
929
951
  const captured = await captureOutput();
930
952
  // Taken AFTER the (async) capture and synchronously, so
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ import { hostname } from 'node:os';
23
23
  import { runLogin } from './login-cmd.js';
24
24
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
25
25
  import { runShare, runDeliver } from './share-cmd.js';
26
+ import { runConsole } from './console-cmd.js';
26
27
  import { runAllow } from './approved-paths.js';
27
28
  import { runDetach } from './detach-cmd.js';
28
29
  import { getStatus, formatStatus } from './status-cmd.js';
@@ -104,6 +105,10 @@ function printHelp() {
104
105
  ' allow <path> Let the ATTACHED AGENT send files from this path. You type',
105
106
  ' this; nothing in the cloud can. Also --list and --remove <path>.',
106
107
  ' The daemon\'s own working directory is always allowed.',
108
+ ' console [workspaceId] Attach a REAL TERMINAL to a bridged session on another',
109
+ ' [--attachment <id>] machine. Ctrl+C goes to the agent; Ctrl-P Ctrl-Q leaves,',
110
+ ' and the agent keeps running. Defaults to this machine\'s',
111
+ ' own attachment when run with no arguments.',
107
112
  ' share <path> Share a local file with the attached workspace, so a cloud',
108
113
  ' agent can see it. Push only — nothing reads your disk remotely.',
109
114
  ' [--to <tileId>] Also write it into that tile\'s session, so its agent can open it.',
@@ -583,6 +588,27 @@ function cmdStatus() {
583
588
  process.stdout.write(`${formatStatus(getStatus())}\n`);
584
589
  return 0;
585
590
  }
591
+ async function cmdConsole(args) {
592
+ const attIdx = args.indexOf('--attachment');
593
+ const attachmentId = attIdx >= 0 ? args[attIdx + 1] : undefined;
594
+ if (attIdx >= 0 && (!attachmentId || attachmentId.startsWith('-'))) {
595
+ process.stderr.write('yolo-bridge console: `--attachment` needs an id.\n');
596
+ return 64;
597
+ }
598
+ const workspaceId = args.find((a, i) => !a.startsWith('-') && i !== attIdx + 1);
599
+ const result = await runConsole({ commonApiBaseUrl: apiUrl(), workspaceId, attachmentId });
600
+ if (!result.ok) {
601
+ process.stderr.write(`yolo-bridge console: ${result.message}\n`);
602
+ return 1;
603
+ }
604
+ if (result.reason === 'stream-ended') {
605
+ // Distinguished from a deliberate detach: the operator did not ask to
606
+ // leave, so say why the session ended rather than exiting silently.
607
+ process.stderr.write('yolo-bridge console: the connection ended.\n');
608
+ return 1;
609
+ }
610
+ return 0;
611
+ }
586
612
  async function cmdDeliver(args) {
587
613
  const assetId = args.find((a) => !a.startsWith('-') && a !== args[args.indexOf('--to') + 1]);
588
614
  const toIdx = args.indexOf('--to');
@@ -688,6 +714,8 @@ async function main() {
688
714
  return cmdDetach();
689
715
  case 'allow':
690
716
  return cmdAllow(rest);
717
+ case 'console':
718
+ return cmdConsole(rest);
691
719
  case 'deliver':
692
720
  return cmdDeliver(rest);
693
721
  case 'share':
@@ -0,0 +1,888 @@
1
+ /**
2
+ * `yolo-bridge console` — attach a real terminal to a bridged session running on
3
+ * ANOTHER machine.
4
+ *
5
+ * ⚠️ THIS JOINS; IT DOES NOT ATTACH. `attach` CREATES an attachment, spawns the
6
+ * agent and mints a workspace-scoped daemon credential ON THE MACHINE RUNNING
7
+ * THE AGENT. `console` connects to an attachment that already exists, from a
8
+ * different machine, and authenticates as the USER with an ordinary account
9
+ * credential.
10
+ *
11
+ * The daemon's scoped token must never leave the machine it was minted on — it
12
+ * is the credential the whole scoped-credential design exists to confine — so
13
+ * this client neither has it nor needs it. The server enforces the same rule
14
+ * from the other side: the input route REFUSES a scoped token.
15
+ *
16
+ * WHY A TERMINAL AND NOT THE TILE. Modifier handling, IME, clipboard, focus
17
+ * stealing, browser shortcuts colliding with app shortcuts — a local terminal
18
+ * emulator already solves every one of these correctly, and doing raw input in a
19
+ * browser means re-solving all of them badly. It also puts the keyboard on a
20
+ * machine the operator logged in from, rather than inside the cloud surface that
21
+ * also hosts agents reading untrusted content.
22
+ *
23
+ * ⚠️ EXITING THE CONSOLE DOES NOT DETACH THE DAEMON. This is a viewer joining
24
+ * and leaving; the agent keeps running and the attachment survives. Ctrl+C goes
25
+ * to the AGENT, exactly as it does locally; `Ctrl-P Ctrl-Q` leaves the console.
26
+ */
27
+ import { createDetachSequenceFilter } from './detach-sequence.js';
28
+ import { sendConsoleInput, resolveAttachmentTile, readRawOutputSeed, subscribeOutput, unsubscribeOutput, openWorkspaceEventStream, YoloBridgeApiError, } from './api-client.js';
29
+ import { loadAuth, saveAuth, loadAttachment } from './config-store.js';
30
+ import { refreshAccessToken } from './device-auth.js';
31
+ const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
32
+ /**
33
+ * Refresh the account token once it has less than this much life left.
34
+ *
35
+ * The same 5min margin `attach` uses. Production access tokens live 24h, so a
36
+ * console left open overnight — the exact session this command exists for —
37
+ * WILL cross the boundary, and every request after it 401s. Worse, the lease
38
+ * renewal swallows its errors, so the symptom is not an error but the output
39
+ * quietly stopping when the lease lapses. (codex P1.)
40
+ */
41
+ const REFRESH_BUFFER_MS = 5 * 60_000;
42
+ /**
43
+ * How long keystrokes are gathered before one POST goes out.
44
+ *
45
+ * NOT a latency tax — it is the opposite. A round trip to the API measured ~80ms
46
+ * warm from a real operator machine (2026-08-28), so a POST per character would
47
+ * put a fast typist's keystrokes in a queue behind each other. Coalescing a few
48
+ * milliseconds of typing into ONE request keeps them in step.
49
+ *
50
+ * 8ms is well under human inter-keystroke time (~100ms even when typing fast),
51
+ * so a deliberate keypress is never delayed noticeably, while a burst — a paste,
52
+ * a held arrow key — collapses into a single request.
53
+ */
54
+ export const INPUT_COALESCE_MS = 8;
55
+ /**
56
+ * Largest payload one input request may carry.
57
+ *
58
+ * ⚠️ MIRRORS A SERVER CONSTANT. `routes/yolobridge.ts` rejects `data.length >
59
+ * 8192` with a 413, and the console reports a failed send but cannot un-drop the
60
+ * bytes — so a paste just over the line vanishes WHOLESALE rather than
61
+ * truncating. Coalescing makes this reachable in ordinary use: a paste, or a
62
+ * burst held back while a slow request is in flight, arrives as one buffer.
63
+ * Kept strictly BELOW the server's number so the two can never be off by one.
64
+ * (codex P1.)
65
+ */
66
+ export const MAX_INPUT_CHUNK = 4096;
67
+ /**
68
+ * How long teardown waits for already-typed input to reach the agent.
69
+ *
70
+ * Long enough that a healthy link delivers everything anyone could have typed;
71
+ * short enough that a dead one still hands the shell straight back.
72
+ */
73
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 3000;
74
+ /** How long teardown waits on the best-effort unsubscribe before letting go.
75
+ * The output lease expires by itself; this only makes it prompt. */
76
+ const UNSUBSCRIBE_TIMEOUT_MS = 1500;
77
+ /** How many times a seed may immediately chain into another before giving up. */
78
+ const MAX_CHAINED_RESEEDS = 5;
79
+ const RESYNC_GAVE_UP = '\r\n[console] could not resync the screen \u2014 output below may be misaligned\r\n';
80
+ /**
81
+ * Coalesce keystrokes and send them STRICTLY IN ORDER, one request at a time.
82
+ *
83
+ * ⚠️ ORDER IS THE WHOLE POINT. Firing each flush as an independent unawaited
84
+ * POST lets a slower earlier request land after a faster later one, and in a
85
+ * terminal that is not a dropped keystroke — it is `rm -rf` becoming `rm f-r`,
86
+ * or the two halves of an escape sequence arriving inverted. Latency here is
87
+ * recoverable; reordering is not. (codex P1.)
88
+ *
89
+ * Single-flight rather than a queue of requests, because holding the bytes
90
+ * locally while one request is in flight makes the coalescing ADAPTIVE: the
91
+ * worse the network, the more characters ride in each request, so a slow link
92
+ * degrades into fewer-but-fuller round trips instead of a growing backlog.
93
+ */
94
+ export function createOrderedInputSender(deps) {
95
+ const coalesceMs = deps.coalesceMs ?? INPUT_COALESCE_MS;
96
+ const maxChunk = Math.max(1, deps.maxChunk ?? MAX_INPUT_CHUNK);
97
+ let pending = '';
98
+ let inFlight = false;
99
+ let timer;
100
+ let disposed = false;
101
+ /** Set by `drain`: refuse new keystrokes, but keep sending the queued ones. */
102
+ let closing = false;
103
+ let settled = Promise.resolve();
104
+ const flush = () => {
105
+ timer = undefined;
106
+ // Already sending: leave the bytes in `pending`. The settle handler below
107
+ // picks up everything that accumulated, preserving order.
108
+ if (inFlight || disposed)
109
+ return;
110
+ let cut = Math.min(pending.length, maxChunk);
111
+ // Never split a surrogate pair: half of one is not a character, and it
112
+ // would go over the wire as a replacement byte the agent never typed.
113
+ if (cut < pending.length) {
114
+ const c = pending.charCodeAt(cut - 1);
115
+ if (c >= 0xd800 && c <= 0xdbff)
116
+ cut -= 1;
117
+ }
118
+ const payload = pending.slice(0, cut);
119
+ pending = pending.slice(cut);
120
+ if (!payload)
121
+ return;
122
+ inFlight = true;
123
+ settled = Promise.resolve(deps.send(payload))
124
+ .catch((err) => deps.onError?.(err))
125
+ .finally(() => {
126
+ inFlight = false;
127
+ if (pending && !disposed)
128
+ flush();
129
+ });
130
+ void settled;
131
+ };
132
+ return {
133
+ push(chunk) {
134
+ if (disposed || closing || !chunk)
135
+ return;
136
+ pending += chunk;
137
+ if (!timer) {
138
+ timer = setTimeout(flush, coalesceMs);
139
+ timer.unref?.();
140
+ }
141
+ },
142
+ /**
143
+ * Deliver what is already queued, refusing anything new.
144
+ *
145
+ * ⚠️ THE LAST KEYSTROKE IS USUALLY THE IMPORTANT ONE. A command typed and
146
+ * then followed straight away by the detach chord sits inside the 8ms
147
+ * coalescing window, or behind one in-flight request, and `dispose()` alone
148
+ * throws it away silently -- while the operator watched themselves type it.
149
+ * (codex P2.)
150
+ */
151
+ async drain(timeoutMs = DEFAULT_DRAIN_TIMEOUT_MS) {
152
+ closing = true;
153
+ if (timer) {
154
+ clearTimeout(timer);
155
+ timer = undefined;
156
+ }
157
+ // ⚠️ BOUNDED BY TIME, NOT BY COUNT. A count is a silent DATA cap (it
158
+ // discards the tail of a big paste); no bound at all means a server that
159
+ // stops answering hangs the process forever with the terminal already
160
+ // restored — the operator pressed the detach chord and never got their
161
+ // shell back. Time bounds the wait without bounding the data: a healthy
162
+ // link drains far more than anyone can type in a fraction of this.
163
+ // (codex P2, both directions.)
164
+ const deadline = Date.now() + timeoutMs;
165
+ // ⚠️ NO ITERATION CAP. A fixed count is a silent data cap: at MAX_INPUT_CHUNK
166
+ // per pass, 64 rounds quietly discarded everything past ~256KiB of a paste
167
+ // followed by the detach chord. This terminates without one — `push` is
168
+ // closed, so nothing can extend `pending`, and every pass removes a chunk
169
+ // from it BEFORE the send (so even a send that always throws makes
170
+ // progress). The no-progress guard is belt-and-braces, not the bound.
171
+ // (codex P2.)
172
+ while (!disposed && (pending || inFlight)) {
173
+ const left = deadline - Date.now();
174
+ if (left <= 0)
175
+ break;
176
+ const before = pending.length;
177
+ if (!inFlight)
178
+ flush();
179
+ // Racing a timer, not just awaiting: a send that never settles must not
180
+ // hold this loop open past the deadline.
181
+ await Promise.race([
182
+ settled.catch(() => { }),
183
+ new Promise((r) => { const t = setTimeout(r, left); t.unref?.(); }),
184
+ ]);
185
+ if (!inFlight && pending.length >= before && before > 0)
186
+ break;
187
+ }
188
+ // In-flight bytes are gone either way — they were handed to fetch and we
189
+ // will never learn the outcome — so only what never left counts.
190
+ return { undelivered: pending.length };
191
+ },
192
+ dispose() {
193
+ disposed = true;
194
+ closing = true;
195
+ if (timer) {
196
+ clearTimeout(timer);
197
+ timer = undefined;
198
+ }
199
+ },
200
+ };
201
+ }
202
+ /**
203
+ * UTF-8 byte length. The daemon measures every offset and cut in UTF-8 BYTES;
204
+ * `String.length` is UTF-16 code units and under-counts every box-drawing
205
+ * character and emoji an agent prints.
206
+ *
207
+ * (Same contract as webapp's `yolobridge-stream-sink.ts`, which is the fuller
208
+ * implementation — it also re-seeds on a lost relay or a daemon-side drop. This
209
+ * package cannot import from the webapp, and the console's needs are narrower:
210
+ * seed once at join, then follow. Keep the OFFSET SEMANTICS identical; that is
211
+ * the part both sides must agree on.)
212
+ */
213
+ export function utf8Length(s) {
214
+ let bytes = 0;
215
+ for (let i = 0; i < s.length;) {
216
+ const code = s.codePointAt(i);
217
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
218
+ i += code > 0xffff ? 2 : 1;
219
+ }
220
+ return bytes;
221
+ }
222
+ /** Drop the first `byteOffset` UTF-8 bytes. The daemon only cuts chunks at
223
+ * code-point boundaries, so this lands on one too. */
224
+ export function sliceFromUtf8Offset(s, byteOffset) {
225
+ if (byteOffset <= 0)
226
+ return s;
227
+ let bytes = 0;
228
+ let i = 0;
229
+ while (i < s.length && bytes < byteOffset) {
230
+ const code = s.codePointAt(i);
231
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
232
+ i += code > 0xffff ? 2 : 1;
233
+ }
234
+ return s.slice(i);
235
+ }
236
+ /**
237
+ * The inline banner marking a discontinuity.
238
+ *
239
+ * Deliberately loud and deliberately IN the stream: it marks the exact point
240
+ * the screen stopped being trustworthy, which nothing outside the terminal can
241
+ * do. A viewer that quietly showed a corrupted screen would be worse than one
242
+ * that showed nothing. `\r\n` (not `\n`) because a terminal needs the carriage
243
+ * return — a bare newline staircases the banner off the previous cursor column.
244
+ */
245
+ export function gapMarker(reason) {
246
+ const what = reason === 'new-epoch'
247
+ ? 'the session restarted — resyncing the screen'
248
+ : 'output was skipped — resyncing the screen';
249
+ return `\r\n\u001b[33m── ${what} ──\u001b[0m\r\n`;
250
+ }
251
+ /**
252
+ * How much output may pile up while a seed is in flight. A seed is one HTTP
253
+ * round trip, so this is generous; the bound exists so a seed that never lands
254
+ * — a sleeping laptop, a wedged daemon — cannot grow memory without limit.
255
+ */
256
+ const MAX_HELD_CHARS = 512 * 1024;
257
+ /**
258
+ * Full terminal reset (RIS), written immediately before a raw replay.
259
+ *
260
+ * ⚠️ A REPLAY IS NOT APPENDABLE OUTPUT. It is the daemon's screen, and its
261
+ * bytes — newlines, cursor moves, scrolling — are interpreted relative to
262
+ * wherever the cursor already is. Written onto whatever this terminal happens
263
+ * to hold (the console's own banner at join; the PREVIOUS rendering of the
264
+ * session on a resync) the replay lands shifted, and a resync that was supposed
265
+ * to repair the screen doubles it instead. RIS puts the cursor home and clears
266
+ * the screen, scroll region, SGR and modes, which is exactly the clean state a
267
+ * replay assumes — and the seed's `prologue` then restores the sticky modes
268
+ * that genuinely were set. (codex P1.)
269
+ */
270
+ const TERMINAL_RESET = '\u001bc';
271
+ export function createOutputReconciler() {
272
+ const held = [];
273
+ let heldChars = 0;
274
+ let seeded = false;
275
+ let reseed;
276
+ let cursor;
277
+ let epoch;
278
+ /**
279
+ * Apply one chunk against the current position.
280
+ *
281
+ * ⚠️ A DISCONTINUITY IS NOT SOMETHING TO WRITE THROUGH. A terminal stream is
282
+ * not a log: its bytes are cursor moves and erase-lines interpreted RELATIVE
283
+ * to what is already on screen. So splicing bytes that do not continue this
284
+ * screen — because output was dropped under the rate cap, because a relay
285
+ * frame never arrived, or because the daemon started a whole new PTY — does
286
+ * not produce slightly-wrong text. It produces a screen that is confidently,
287
+ * permanently wrong, and nothing later in the stream repairs it. Both cases
288
+ * therefore demand a fresh seed, with the loss MARKED rather than silent.
289
+ * (codex P1.)
290
+ */
291
+ const apply = (chunk) => {
292
+ if (!chunk.data)
293
+ return '';
294
+ if (epoch !== undefined && chunk.epoch !== undefined && chunk.epoch !== epoch) {
295
+ return requestReseed('new-epoch', chunk);
296
+ }
297
+ if (cursor === undefined || chunk.startOffset === undefined) {
298
+ // ⚠️ ADOPT THE POSITION FROM THE FIRST CHUNK THAT CARRIES ONE. When the
299
+ // seed was unavailable — a briefly unreachable daemon, a 409 — the
300
+ // console still streams, but with no cursor and no epoch it can never
301
+ // afterwards notice dropped output or a restarted PTY. A recoverable seed
302
+ // failure would silently disable corruption detection for the WHOLE
303
+ // session. The live frames carry everything needed to bootstrap it.
304
+ // (codex P2.)
305
+ if (chunk.startOffset !== undefined) {
306
+ cursor = chunk.startOffset + utf8Length(chunk.data);
307
+ if (epoch === undefined)
308
+ epoch = chunk.epoch;
309
+ }
310
+ return chunk.data;
311
+ }
312
+ const len = utf8Length(chunk.data);
313
+ const overlap = cursor - chunk.startOffset;
314
+ // A NEGATIVE overlap is a HOLE: these bytes start past where we are, so
315
+ // something between never arrived.
316
+ if (overlap < 0)
317
+ return requestReseed('lost-output', chunk);
318
+ if (overlap >= len)
319
+ return ''; // wholly inside what we already have
320
+ cursor = chunk.startOffset + len;
321
+ return overlap > 0 ? sliceFromUtf8Offset(chunk.data, overlap) : chunk.data;
322
+ };
323
+ /** Stop trusting the screen: hold this chunk and everything after it until a
324
+ * fresh seed lands, and mark the gap where it actually happened. */
325
+ const requestReseed = (reason, chunk) => {
326
+ seeded = false;
327
+ reseed = reason;
328
+ cursor = undefined;
329
+ epoch = undefined;
330
+ hold(chunk);
331
+ return gapMarker(reason);
332
+ };
333
+ const hold = (chunk) => {
334
+ // Drop the OLDEST when the bound is hit: a seed will replace the screen
335
+ // anyway, so the recent bytes are the ones worth keeping.
336
+ heldChars += chunk.data.length;
337
+ held.push(chunk);
338
+ while (heldChars > MAX_HELD_CHARS && held.length > 1) {
339
+ heldChars -= held.shift().data.length;
340
+ }
341
+ };
342
+ return {
343
+ applySeed(seed) {
344
+ seeded = true;
345
+ reseed = undefined;
346
+ const out = [];
347
+ if (seed?.raw !== undefined) {
348
+ // Reset FIRST — see TERMINAL_RESET. Only when there is actually a
349
+ // replay to write: resetting with nothing to put back would wipe the
350
+ // screen for no reason.
351
+ out.push(TERMINAL_RESET);
352
+ // The prologue restores sticky modes — alt screen, scroll region, wrap
353
+ // — set by escapes older than the oldest retained byte. Without it a
354
+ // truncated replay draws on the wrong canvas from its first byte.
355
+ if (seed.prologue)
356
+ out.push(seed.prologue);
357
+ out.push(seed.raw);
358
+ epoch = seed.epoch;
359
+ cursor = seed.endOffset ?? (seed.baseOffset !== undefined
360
+ ? seed.baseOffset + utf8Length(seed.raw)
361
+ : undefined);
362
+ }
363
+ // Draining, not iterating: `apply` can request ANOTHER reseed partway
364
+ // through (a second epoch change inside the backlog), and the chunks
365
+ // after that point must stay held rather than be written blind.
366
+ const backlog = held.splice(0, held.length);
367
+ heldChars = 0;
368
+ for (const chunk of backlog) {
369
+ if (!seeded) {
370
+ hold(chunk);
371
+ continue;
372
+ }
373
+ out.push(apply(chunk));
374
+ }
375
+ return out.join('');
376
+ },
377
+ push(chunk) {
378
+ // ⚠️ HOLD, do not drop and do not write. Writing before the seed lands
379
+ // puts bytes on screen that the seed is about to overwrite; dropping
380
+ // leaves a hole nothing can recover.
381
+ if (!seeded) {
382
+ hold(chunk);
383
+ return '';
384
+ }
385
+ return apply(chunk);
386
+ },
387
+ takeReseedRequest() {
388
+ const r = reseed;
389
+ reseed = undefined;
390
+ return r;
391
+ },
392
+ };
393
+ }
394
+ /** Convenience wrapper for the join case, and the shape the tests exercise. */
395
+ export function reconcileSeed(seed, held) {
396
+ const r = createOutputReconciler();
397
+ for (const c of held)
398
+ r.push(c);
399
+ return r.applySeed(seed);
400
+ }
401
+ /**
402
+ * Resolve what to connect to.
403
+ *
404
+ * ⚠️ Uses the ACCOUNT credential from `auth.json`, never `attachment.scopedToken`
405
+ * — see this file's header. The stored attachment is consulted only for the
406
+ * workspace/attachment/tile IDENTIFIERS, which are not secrets, and only as a
407
+ * convenience when the operator did not pass them explicitly.
408
+ */
409
+ export function resolveConsoleTarget(deps) {
410
+ const auth = loadAuth(deps.env, deps.io);
411
+ if (!auth) {
412
+ return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
413
+ }
414
+ const stored = loadAttachment(deps.env, deps.io);
415
+ const workspaceId = deps.workspaceId ?? stored?.workspaceId;
416
+ // ⚠️ THE STORED RECORD IS A MATCHED SET, not three independent defaults.
417
+ // Falling back to the local attachment id under an explicitly-named DIFFERENT
418
+ // workspace assembles a target out of two unrelated sessions: `yolo-bridge
419
+ // console other-workspace` would go looking for THIS machine's attachment
420
+ // over there. Saying "--attachment is required" is the honest answer.
421
+ // (codex P2.)
422
+ const attachmentId = deps.attachmentId
423
+ ?? (stored && stored.workspaceId === workspaceId ? stored.attachmentId : undefined);
424
+ // ⚠️ The stored tileId describes THIS machine's own attachment. Inheriting it
425
+ // for a DIFFERENT attachment — the entire point of the command — would
426
+ // subscribe to and filter on the wrong tile, so the console would connect and
427
+ // then show another session's screen, or nothing at all. Only carry it when
428
+ // the attachment genuinely matches; otherwise it is resolved from the server.
429
+ // (codex P1.)
430
+ const tileId = deps.tileId
431
+ ?? (stored && stored.attachmentId === attachmentId ? stored.tileId : undefined);
432
+ if (!workspaceId || !attachmentId) {
433
+ return {
434
+ ok: false,
435
+ reason: 'no-target',
436
+ message: 'Nothing to connect to. Pass the workspace and attachment explicitly:\n'
437
+ + ' yolo-bridge console <workspaceId> --attachment <attachmentId>\n'
438
+ + 'The workspace tile offers "Open in terminal…", which copies that command for you.',
439
+ };
440
+ }
441
+ return {
442
+ ok: true,
443
+ // ⚠️ auth.accessToken — the USER's credential. Never the daemon's.
444
+ cfg: { commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl: deps.fetchImpl },
445
+ auth,
446
+ workspaceId,
447
+ attachmentId,
448
+ tileId,
449
+ };
450
+ }
451
+ /**
452
+ * Pull `yolobridge.output.chunk` payloads for one tile out of the workspace
453
+ * event stream.
454
+ *
455
+ * Exported for testing because the parsing is the part that silently does
456
+ * nothing when it is wrong: a mismatched event name or tile filter produces a
457
+ * console that connects, accepts typing, and shows a blank screen forever.
458
+ */
459
+ export function extractOutputChunks(sseText, tileId) {
460
+ return extractOutputFrames(sseText, tileId).map((c) => c.data);
461
+ }
462
+ /**
463
+ * The same parse, keeping the `epoch`/`startOffset` the reconciler needs.
464
+ *
465
+ * `seq` only orders what was SENT, so it cannot say whether a chunk continues
466
+ * the seeded screen, repeats part of it, or skips past it. An ABSOLUTE offset
467
+ * answers all three.
468
+ */
469
+ export function extractOutputFrames(sseText, tileId) {
470
+ const out = [];
471
+ for (const line of sseText.split(/\r?\n/)) {
472
+ const m = /^data:\s?(.*)$/.exec(line);
473
+ if (!m)
474
+ continue;
475
+ try {
476
+ const evt = JSON.parse(m[1]);
477
+ if (evt.type !== 'yolobridge.output.chunk')
478
+ continue;
479
+ const d = evt.data;
480
+ if (!d)
481
+ continue;
482
+ // A workspace can host more than one bridged tile; without this filter a
483
+ // console would render another session's screen into this one.
484
+ if (tileId && d.tileId !== tileId)
485
+ continue;
486
+ if (typeof d.data === 'string' && d.data) {
487
+ out.push({
488
+ data: d.data,
489
+ ...(typeof d.epoch === 'string' ? { epoch: d.epoch } : {}),
490
+ ...(typeof d.startOffset === 'number' ? { startOffset: d.startOffset } : {}),
491
+ });
492
+ }
493
+ }
494
+ catch {
495
+ /* keepalives and non-JSON frames are not errors */
496
+ }
497
+ }
498
+ return out;
499
+ }
500
+ /**
501
+ * Run an interactive console session until the operator detaches or the stream
502
+ * ends.
503
+ *
504
+ * ⚠️ THE TERMINAL MUST BE RESTORED ON EVERY EXIT PATH — clean detach, dropped
505
+ * stream, thrown error, signal. A raw-mode terminal left behind after the
506
+ * process dies is worse than any failure this function can report, because the
507
+ * operator's shell stops echoing and they have to blindly type `reset`.
508
+ */
509
+ export async function runConsole(deps) {
510
+ const target = resolveConsoleTarget(deps);
511
+ if (!target.ok)
512
+ return target;
513
+ const stdout = deps.stdout ?? process.stdout;
514
+ const stdin = deps.stdin ?? process.stdin;
515
+ const { cfg, workspaceId, attachmentId } = target;
516
+ let auth = target.auth;
517
+ let tileId = target.tileId;
518
+ const subscriptionId = `console-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
519
+ let rawModeSet = false;
520
+ let stream;
521
+ // Held out here so BOTH the detach chord and the teardown can reach it.
522
+ let reader;
523
+ /** Set when a mid-session refresh fails, so the exit reports WHY. */
524
+ let authFailure;
525
+ let renewTimer;
526
+ let stdinListener;
527
+ const signals = deps.signals ?? process;
528
+ // Hoisted so TEARDOWN owns them. Cleaning them up at the end of the happy
529
+ // path is not cleanup at all: a rejected `read()` jumps straight to catch,
530
+ // and a coalescing timer or an in-flight send would then deliver keystrokes
531
+ // AFTER the console reported failure and handed the shell back. (codex P2.)
532
+ let filter;
533
+ let sender;
534
+ /** Latched in teardown so a late-landing seed cannot draw on a restored shell. */
535
+ let finished = false;
536
+ /** The renewal tick currently running, so teardown can let it finish first. */
537
+ let renewInFlight;
538
+ const signalHandlers = [];
539
+ /** Idempotent, and called from every exit path. */
540
+ const restore = () => {
541
+ if (renewTimer) {
542
+ clearInterval(renewTimer);
543
+ renewTimer = undefined;
544
+ }
545
+ if (stdinListener && typeof stdin.off === 'function')
546
+ stdin.off('data', stdinListener);
547
+ stdinListener = undefined;
548
+ if (rawModeSet && stdin.isTTY && typeof stdin.setRawMode === 'function') {
549
+ stdin.setRawMode(false);
550
+ rawModeSet = false;
551
+ }
552
+ try {
553
+ stdin.pause?.();
554
+ }
555
+ catch { /* already gone */ }
556
+ for (const [sig, fn] of signalHandlers)
557
+ signals.off?.(sig, fn);
558
+ signalHandlers.length = 0;
559
+ };
560
+ // ⚠️ A SIGNAL MUST NOT LEAVE THE TERMINAL IN RAW MODE. `restore()` otherwise
561
+ // runs only on normal completion, so an external `kill` — a supervisor, a
562
+ // window manager, an impatient operator in another pane — kills this process
563
+ // while their SHELL survives, and that shell stops echoing until they blindly
564
+ // type `reset`. This file's header calls that worse than any error it can
565
+ // report, so it has to hold for the signal path too. (codex P2.)
566
+ //
567
+ // NOT SIGINT: raw mode disables the tty's own SIGINT generation, so Ctrl+C
568
+ // reaches the agent as a byte and no signal is raised here at all.
569
+ for (const sig of ['SIGTERM', 'SIGHUP', 'SIGQUIT']) {
570
+ const fn = () => {
571
+ restore();
572
+ // Re-raise with the handler already removed, so the exit status is the
573
+ // honest one for the signal rather than a synthesized code.
574
+ try {
575
+ signals.kill?.(process.pid, sig);
576
+ }
577
+ catch { /* nothing left to signal */ }
578
+ };
579
+ signalHandlers.push([sig, fn]);
580
+ signals.on?.(sig, fn);
581
+ }
582
+ /**
583
+ * Rotate the account credential in place when it is close to expiry.
584
+ *
585
+ * ⚠️ MUTATES `cfg.accessToken`. api-client reads it at call time, so every
586
+ * later input POST and lease renewal picks the new token up with no
587
+ * re-plumbing. It also writes auth.json, so a `status` or a restart sees the
588
+ * fresh token rather than the one this process rotated past.
589
+ *
590
+ * NOT applied to the already-open SSE stream: its token went into the URL at
591
+ * connect time and cannot be swapped without reopening. That is why this runs
592
+ * BEFORE connecting — a console must never start on a token that is already
593
+ * dead — and why an expiry mid-stream surfaces as the stream ending, which
594
+ * exits the command visibly, rather than as a silent freeze.
595
+ */
596
+ const ensureFreshToken = async () => {
597
+ if (Date.now() < auth.expiresAtMs - REFRESH_BUFFER_MS)
598
+ return { ok: true };
599
+ if (!auth.refreshToken) {
600
+ return {
601
+ ok: false,
602
+ message: 'Your session has expired and no refresh token is stored here — run `yolo-bridge login`.',
603
+ };
604
+ }
605
+ const doRefresh = deps.refreshAccessTokenImpl ?? refreshAccessToken;
606
+ const result = await doRefresh(deps.authBaseUrl ?? DEFAULT_AUTH_URL, auth.refreshToken, deps.fetchImpl);
607
+ if (result.status !== 'ok') {
608
+ return { ok: false, message: `Could not refresh your session (${result.message}) — run \`yolo-bridge login\`.` };
609
+ }
610
+ auth = {
611
+ accessToken: result.tokens.accessToken,
612
+ refreshToken: result.tokens.refreshToken,
613
+ tokenType: auth.tokenType,
614
+ expiresAtMs: result.tokens.expiresAtMs,
615
+ };
616
+ cfg.accessToken = auth.accessToken;
617
+ try {
618
+ saveAuth(auth, deps.env, deps.io);
619
+ }
620
+ catch { /* a read-only config dir must not kill the session */ }
621
+ return { ok: true };
622
+ };
623
+ try {
624
+ const fresh = await ensureFreshToken();
625
+ if (!fresh.ok)
626
+ return { ok: false, reason: 'not-logged-in', message: fresh.message };
627
+ // Resolve the tile when it was not inherited — a remote attachment has no
628
+ // local record, and WITHOUT a tile there is no subscription, so output
629
+ // streaming (which is demand-driven) never starts and the console is blank.
630
+ if (!tileId) {
631
+ tileId = await resolveAttachmentTile(cfg, workspaceId, attachmentId);
632
+ if (!tileId) {
633
+ return {
634
+ ok: false,
635
+ reason: 'error',
636
+ message: `No bridged tile found for attachment ${attachmentId} in that workspace.`,
637
+ };
638
+ }
639
+ }
640
+ // ⚠️ ORDER IS LOAD-BEARING: LISTEN → SUBSCRIBE → SEED.
641
+ //
642
+ // Output streaming is demand-driven, so nothing flows until the subscribe.
643
+ // But the subscribe and the seed are independent round trips, and whichever
644
+ // of the three happens first decides which failure you get:
645
+ // subscribe before listening → the first chunks are emitted to nobody;
646
+ // seed before listening → the chunks after the snapshot are lost.
647
+ // Opening the stream first leaves only DUPLICATION, and absolute offsets
648
+ // resolve that exactly. A hole, nothing can. (codex P1.)
649
+ stream = await openWorkspaceEventStream(cfg, workspaceId);
650
+ if (tileId) {
651
+ // Captured so the renewal closure below cannot see it widen back to
652
+ // undefined — a `let` loses its narrowing across a closure boundary.
653
+ const subscribedTileId = tileId;
654
+ const lease = await subscribeOutput(cfg, workspaceId, subscribedTileId, subscriptionId);
655
+ const everyMs = Math.max(1000, Math.floor((lease.renewWithinMs ?? lease.leaseMs ?? 20_000) / 2));
656
+ // ⚠️ setInterval does NOT wait for the previous tick. A slow auth-service
657
+ // call would overlap two renewals, and near expiry BOTH would present the
658
+ // same refresh token — with rotation, one succeeds and the other fails,
659
+ // setting `authFailure` and killing a console whose credential is
660
+ // perfectly fine. A single in-flight tick removes the race entirely.
661
+ // (codex P2.)
662
+ let renewing = false;
663
+ renewTimer = setInterval(() => {
664
+ if (renewing || finished)
665
+ return;
666
+ renewing = true;
667
+ renewInFlight = (async () => {
668
+ // Refresh FIRST, and STOP if it fails. The renewal below swallows its
669
+ // errors by design, so continuing on a dead token means the lease
670
+ // silently lapses and an overnight console just stops receiving
671
+ // output — no error, no exit, a frozen screen the operator reads as a
672
+ // hung agent. Ending the session with a message is the honest
673
+ // failure. (codex P1.)
674
+ const fresh = await ensureFreshToken();
675
+ if (!fresh.ok) {
676
+ authFailure = fresh.message;
677
+ if (renewTimer) {
678
+ clearInterval(renewTimer);
679
+ renewTimer = undefined;
680
+ }
681
+ // Wake the blocked read so the loop exits now rather than at the
682
+ // next heartbeat — the same reason detach cancels it.
683
+ void reader?.cancel().catch(() => { });
684
+ return;
685
+ }
686
+ // ⚠️ CHECK AGAIN AFTER THE AWAIT. Clearing the interval does not stop
687
+ // a callback already running, so a renewal begun just before the
688
+ // operator detached could land AFTER teardown unsubscribed —
689
+ // recreating the subscription and leaving the daemon streaming to
690
+ // nobody until the fresh lease expired. (codex P2.)
691
+ if (finished)
692
+ return;
693
+ await subscribeOutput(cfg, workspaceId, subscribedTileId, subscriptionId).catch(() => { });
694
+ })().finally(() => { renewing = false; });
695
+ void renewInFlight;
696
+ }, everyMs);
697
+ renewTimer.unref?.();
698
+ }
699
+ stdout.write(`yolo-bridge console — attached to ${attachmentId}\r\n`
700
+ + 'Ctrl+C goes to the agent · Ctrl-P Ctrl-Q to leave (the agent keeps running)\r\n\r\n');
701
+ // Created before the input filter, which cancels it on detach.
702
+ reader = stream.body.getReader();
703
+ // The seed lands asynchronously; every chunk that arrives first is HELD by
704
+ // the reconciler and replayed in order once it does.
705
+ const reconciler = createOutputReconciler();
706
+ /**
707
+ * Fetch a raw seed and apply it, with the chunks held meanwhile.
708
+ *
709
+ * Runs at join AND on every later discontinuity. Single-flight: a second
710
+ * request while one is in flight is pointless — the in-flight seed is
711
+ * already newer than the gap that triggered it.
712
+ */
713
+ let seeding;
714
+ /** Bounds a reseed that keeps finding a gap, so it cannot spin forever. */
715
+ let reseedAttempts = 0;
716
+ const seedNow = (announceFailure) => {
717
+ if (seeding)
718
+ return seeding;
719
+ seeding = (tileId
720
+ ? readRawOutputSeed(cfg, workspaceId, tileId).catch(() => undefined)
721
+ : Promise.resolve(undefined)).then((seed) => {
722
+ // ⚠️ NEVER DRAW ON A RESTORED SHELL. A stalled raw-output fetch can
723
+ // land long after the operator detached; writing then scribbles into
724
+ // whatever they are doing now. And it must not be AWAITED on the way
725
+ // out either -- that put terminal restoration behind a request with no
726
+ // bound, leaving the shell in RAW MODE for its whole duration.
727
+ // (codex P1.)
728
+ if (finished)
729
+ return;
730
+ const bytes = reconciler.applySeed(seed);
731
+ if (bytes)
732
+ stdout.write(bytes);
733
+ if (!seed && announceFailure) {
734
+ // Not fatal — live output still works. But say so, because a blank
735
+ // screen that is ABOUT to fill looks the same as one that never will.
736
+ stdout.write('\r\n[console] could not read the current screen — showing new output only\r\n');
737
+ }
738
+ }).finally(() => {
739
+ seeding = undefined;
740
+ // ⚠️ THE BACKLOG DRAIN CAN ITSELF FIND A GAP. The stream loop already
741
+ // checked `takeReseedRequest()` for those chunks when they arrived, so
742
+ // nothing looks again -- and on an idle session nothing else ever runs.
743
+ // The reconciler would then hold EVERY later byte forever: a console
744
+ // frozen with no error anywhere. (codex P1.)
745
+ if (reconciler.takeReseedRequest()) {
746
+ if (reseedAttempts < MAX_CHAINED_RESEEDS) {
747
+ reseedAttempts += 1;
748
+ void seedNow(false);
749
+ }
750
+ else {
751
+ // Stop re-syncing rather than spin: show live output and say the
752
+ // screen may be wrong. Honest, and still usable.
753
+ stdout.write(RESYNC_GAVE_UP);
754
+ const rest = reconciler.applySeed(undefined);
755
+ if (rest)
756
+ stdout.write(rest);
757
+ }
758
+ }
759
+ else {
760
+ reseedAttempts = 0;
761
+ }
762
+ });
763
+ return seeding;
764
+ };
765
+ // Deliberately NOT awaited anywhere on the exit path — see the `finished`
766
+ // guard inside. A stalled seed must never hold the terminal in raw mode.
767
+ void seedNow(true);
768
+ // ── input ────────────────────────────────────────────────────────────────
769
+ let detached = false;
770
+ sender = createOrderedInputSender({
771
+ send: (payload) => sendConsoleInput(cfg, workspaceId, attachmentId, payload),
772
+ onError: (err) => {
773
+ const msg = err instanceof YoloBridgeApiError ? err.message : String(err);
774
+ stdout.write(`\r\n[console] input not delivered: ${msg}\r\n`);
775
+ },
776
+ });
777
+ // The SAME filter the local `attach` uses, so the detach chord behaves
778
+ // identically in both places rather than being two implementations that
779
+ // drift.
780
+ filter = createDetachSequenceFilter({
781
+ emit: (chunk) => sender?.push(chunk),
782
+ onDetach: () => {
783
+ detached = true;
784
+ // ⚠️ CANCEL THE READ, do not merely set the flag. The loop below is
785
+ // blocked in `reader.read()`, and on a quiet agent nothing wakes it
786
+ // until the next chunk or heartbeat — up to ~30s of a terminal stuck in
787
+ // RAW MODE after the operator asked to leave. That is precisely the
788
+ // outcome this command treats as worse than any error it can report.
789
+ // (codex P1.)
790
+ void reader?.cancel().catch(() => { });
791
+ },
792
+ });
793
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
794
+ stdin.setRawMode(true);
795
+ rawModeSet = true;
796
+ }
797
+ stdin.resume?.();
798
+ stdin.setEncoding?.('utf-8');
799
+ stdinListener = (chunk) => filter?.push(typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
800
+ stdin.on('data', stdinListener);
801
+ // ── output ───────────────────────────────────────────────────────────────
802
+ const decoder = new TextDecoder();
803
+ const activeReader = reader;
804
+ let buf = '';
805
+ while (!detached) {
806
+ const { value, done } = await activeReader.read();
807
+ if (done)
808
+ break;
809
+ buf += decoder.decode(value, { stream: true });
810
+ // Frames are blank-line separated; keep any partial tail for next read.
811
+ const lastBreak = buf.lastIndexOf('\n\n');
812
+ if (lastBreak === -1)
813
+ continue;
814
+ const complete = buf.slice(0, lastBreak);
815
+ buf = buf.slice(lastBreak + 2);
816
+ for (const chunk of extractOutputFrames(complete, tileId)) {
817
+ const bytes = reconciler.push(chunk);
818
+ if (bytes)
819
+ stdout.write(bytes);
820
+ }
821
+ // A gap or a restarted PTY: the screen is no longer trustworthy and every
822
+ // further chunk is held until a fresh seed replaces it.
823
+ if (reconciler.takeReseedRequest())
824
+ void seedNow(false);
825
+ }
826
+ if (authFailure)
827
+ return { ok: false, reason: 'not-logged-in', message: authFailure };
828
+ return { ok: true, reason: detached ? 'detached' : 'stream-ended' };
829
+ }
830
+ catch (err) {
831
+ const message = err instanceof YoloBridgeApiError ? err.message : err?.message || 'console failed';
832
+ return { ok: false, reason: 'error', message };
833
+ }
834
+ finally {
835
+ finished = true;
836
+ // restore() FIRST: it un-raws the terminal and unhooks stdin, so no new
837
+ // keystroke can enter the sender. Then drain what was ALREADY typed — the
838
+ // command someone entered a moment before the chord is theirs, not ours to
839
+ // discard — and only then let go.
840
+ filter?.dispose();
841
+ restore();
842
+ const drained = await sender?.drain().catch(() => ({ undelivered: 0 }));
843
+ sender?.dispose();
844
+ if (drained && drained.undelivered > 0) {
845
+ // Say it. Input the operator typed and did not send is exactly the thing
846
+ // they must not have to guess about.
847
+ stdout.write(`[console] ${drained.undelivered} character(s) of input could not be delivered\r\n`);
848
+ }
849
+ // Let an in-flight renewal finish BEFORE unsubscribing, so the two cannot
850
+ // land in the wrong order. Bounded for the same reason everything else here
851
+ // is: teardown must not depend on the API answering.
852
+ if (renewInFlight) {
853
+ await Promise.race([
854
+ renewInFlight.catch(() => { }),
855
+ new Promise((r) => {
856
+ const t = setTimeout(r, UNSUBSCRIBE_TIMEOUT_MS);
857
+ t.unref?.();
858
+ }),
859
+ ]);
860
+ }
861
+ // Best-effort, and BOUNDED. `fetch` has no timeout of its own, so awaiting
862
+ // this against a stalled API left the terminal restored but the process
863
+ // alive — the operator never gets their shell prompt back. The lease
864
+ // expires on its own anyway; this only makes it prompt. (codex P2.)
865
+ if (tileId) {
866
+ await Promise.race([
867
+ unsubscribeOutput(cfg, workspaceId, tileId, subscriptionId).catch(() => { }),
868
+ new Promise((r) => {
869
+ const t = setTimeout(r, UNSUBSCRIBE_TIMEOUT_MS);
870
+ t.unref?.();
871
+ }),
872
+ ]);
873
+ }
874
+ // ⚠️ Cancel the READER, not the body. Once `getReader()` has been called the
875
+ // body is LOCKED, and `body.cancel()` then returns a REJECTED promise — which
876
+ // a `try/catch` cannot catch, so it surfaces as an unhandledRejection and, on
877
+ // Node's default, kills the process on an otherwise clean detach. Awaiting
878
+ // the reader's own cancel is the supported way to release it.
879
+ try {
880
+ if (reader)
881
+ await reader.cancel();
882
+ else
883
+ await stream?.body?.cancel();
884
+ }
885
+ catch { /* already closed */ }
886
+ stdout.write('\r\n');
887
+ }
888
+ }
@@ -27,6 +27,10 @@ export function actionForFrame(frame) {
27
27
  return { kind: 'ping' };
28
28
  case 'prompt':
29
29
  return { kind: 'prompt', attachmentId: String(data.attachmentId ?? ''), prompt: String(data.prompt ?? '') };
30
+ case 'input':
31
+ // No coercion beyond String(): these are the operator's own keystrokes
32
+ // and anything clever here would corrupt a control sequence.
33
+ return { kind: 'input', attachmentId: String(data.attachmentId ?? ''), data: String(data.data ?? '') };
30
34
  case 'read-output':
31
35
  return {
32
36
  kind: 'read-output',
@@ -868,6 +868,38 @@ export function stopLocalAgent() {
868
868
  // already dead
869
869
  }
870
870
  }
871
+ /**
872
+ * Write raw bytes straight into the PTY, exactly as typed.
873
+ *
874
+ * ⚠️ DELIBERATELY NOT `deliverPromptToLocalAgent`. That function is for one
875
+ * coherent instruction: it waits on a readiness gate, writes the text, pauses,
876
+ * then writes `\r`. Every one of those is wrong for a keystroke —
877
+ * a lone `\x03` would get an Enter appended, an arrow-key escape sequence would
878
+ * be split by the pause, and the readiness gate would block on a prompt that a
879
+ * mid-session TUI never shows.
880
+ *
881
+ * So this does the minimum: if there is a live PTY, write the bytes. No
882
+ * interpretation, no framing, no Enter.
883
+ *
884
+ * ⚠️ NOTHING HERE LOGS `data`. These are the operator's keystrokes on their own
885
+ * machine — the same rule the output path already follows, and the reason a
886
+ * console session logs that it opened and closed and never what was typed.
887
+ *
888
+ * Returns false when there is no agent to write to, so a caller can say
889
+ * "nothing is attached" rather than silently swallowing input.
890
+ */
891
+ export function writeInputToLocalAgent(data) {
892
+ if (!current || !data)
893
+ return false;
894
+ try {
895
+ current.ptyProcess.write(data);
896
+ return true;
897
+ }
898
+ catch {
899
+ // The child is gone; onExit will clear `current`.
900
+ return false;
901
+ }
902
+ }
871
903
  /**
872
904
  * Best-effort readiness check before `deliverPromptToLocalAgent` writes
873
905
  * into the PTY — docs/YOLOBRIDGE_PLAN.md's "[P1] Blind prompt delivery can
@@ -42,6 +42,21 @@
42
42
  * output reads as live, long enough that a chatty PTY costs ~12 POSTs/second
43
43
  * rather than one per `onData`. */
44
44
  export const DEFAULT_FLUSH_INTERVAL_MS = 80;
45
+ /**
46
+ * Flush delay after a console keystroke, rather than waiting out the batch
47
+ * window.
48
+ *
49
+ * The 80ms above is right for a passive VIEWER: it trades a little liveness for
50
+ * ~12 POSTs/second instead of one per `onData`. It is wrong for an interactive
51
+ * session, where a single echoed keystroke IS the entire payload and the batch
52
+ * saves nothing while costing up to 80ms of a round trip already near 200ms —
53
+ * measured 2026-08-28, and roughly 40% of the budget that is ours to spend
54
+ * rather than the network's.
55
+ *
56
+ * 5ms is long enough that a burst of keystrokes still coalesces into one POST,
57
+ * short enough to be invisible next to the ~160ms the network costs.
58
+ */
59
+ export const INTERACTIVE_ECHO_FLUSH_MS = 5;
45
60
  /** ~192 KiB/s sustained. Comfortably above a fast agent's real output rate
46
61
  * (a streaming LLM response is a few KiB/s), far below what `cat`ting a
47
62
  * large file would produce. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.22.0",
3
+ "version": "0.24.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",