@yolo-labs/yolobridge 0.26.0 → 0.27.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/cli.js CHANGED
@@ -38,9 +38,21 @@ import { getStatus, formatStatus } from './status-cmd.js';
38
38
  import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
39
39
  import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
40
40
  import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
41
+ import { startLocalShellServer } from './local-shell-server.js';
41
42
  import { buildAgentMcpArgs } from './agent-mcp-args.js';
42
43
  const DEFAULT_API_URL = 'https://api.yolo.studio';
44
+ const DEFAULT_WEBAPP_ORIGIN = 'https://yolo.studio';
43
45
  const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
46
+ /**
47
+ * The ONE browser origin allowed to reach the local terminal server.
48
+ *
49
+ * ⚠️ Never a wildcard: this authorises reaching a shell on the operator's
50
+ * machine, so it is a single exact origin. Overridable only for local
51
+ * development against a different webapp host.
52
+ */
53
+ function webappOrigin() {
54
+ return process.env.YOLOBRIDGE_WEBAPP_ORIGIN || DEFAULT_WEBAPP_ORIGIN;
55
+ }
44
56
  function apiUrl() {
45
57
  return process.env.YOLOBRIDGE_API_URL || DEFAULT_API_URL;
46
58
  }
@@ -326,6 +338,14 @@ async function cmdAttach(args) {
326
338
  cliVersion: readOwnVersion(),
327
339
  });
328
340
  let mcpProxyHandle;
341
+ /**
342
+ * Serves terminals on 127.0.0.1 for the tile's "open terminal".
343
+ *
344
+ * ⚠️ SEPARATE FROM THE AGENT PTY. `startLocalAgent` owns the one agent
345
+ * session; this owns any shells the operator opens from the workspace. They
346
+ * share a lifetime — both die with the attach — and nothing else.
347
+ */
348
+ let shellServerHandle;
329
349
  // argv fragment pointing the spawned agent at the local MCP proxy, or
330
350
  // `[]` when MCP isn't wired in — see `agent-mcp-args.ts`. Nothing else is
331
351
  // tracked for cleanup any more: as of 2026-08-26 `attach` writes NOTHING
@@ -362,6 +382,22 @@ async function cmdAttach(args) {
362
382
  // prompt. MCP access is an enhancement on a tile that already works
363
383
  // without it; the local agent spawning is not optional.
364
384
  try {
385
+ // The local terminal server. Started BEFORE the agent, like the MCP
386
+ // proxy, so the endpoint exists by the time the tile could ask for
387
+ // it. A failure here must not stop the attach: the agent and its
388
+ // tile are the point, a local terminal is an extra.
389
+ try {
390
+ shellServerHandle = await startLocalShellServer({ allowedOrigin: webappOrigin() });
391
+ // ⚠️ THE URL, NEVER THE SECRET. This line lands in the operator's
392
+ // scrollback, which is exactly where things get copied into bug
393
+ // reports and pasted into chats. The secret authorises spawning a
394
+ // shell on this machine; it reaches the tile over the authenticated
395
+ // workspace channel and is printed nowhere.
396
+ process.stdout.write(`yolo-bridge: local terminals ready at ${shellServerHandle.url} (127.0.0.1 only)\n`);
397
+ }
398
+ catch (err) {
399
+ process.stdout.write(`yolo-bridge: local terminals unavailable (${err instanceof Error ? err.message : String(err)}) — the attach continues without them.\n`);
400
+ }
365
401
  mcpProxyHandle = await startMcpProxy({
366
402
  apiUrl: apiUrl(),
367
403
  getAccessToken,
@@ -537,6 +573,17 @@ async function cmdAttach(args) {
537
573
  // or a reboot, and every skipped run left a file that broke the
538
574
  // operator's own standalone `claude` in that directory. Nothing written
539
575
  // is nothing to clean up.
576
+ // Same "nothing left running detached" rule as the MCP proxy: a shell the
577
+ // operator opened from the workspace must not outlive the attach that
578
+ // served it. `close()` kills every session it owns.
579
+ if (shellServerHandle) {
580
+ try {
581
+ await shellServerHandle.close();
582
+ }
583
+ catch (err) {
584
+ process.stdout.write(`yolo-bridge: local terminal shutdown failed (${err instanceof Error ? err.message : String(err)}).\n`);
585
+ }
586
+ }
540
587
  if (mcpProxyHandle) {
541
588
  try {
542
589
  await mcpProxyHandle.stop();
@@ -0,0 +1,462 @@
1
+ /**
2
+ * A terminal on the operator's OWN machine, served over loopback.
3
+ *
4
+ * This is what "open terminal" in a YoloBridge tile connects to. The browser
5
+ * talks to `127.0.0.1` directly, so a keystroke never leaves the machine that
6
+ * is rendering it.
7
+ *
8
+ * WHY NOT THE CLOUD RELAY
9
+ * -----------------------
10
+ * The obvious implementation routes keystrokes browser → common-api → daemon.
11
+ * Measured from a real operator machine that is ~200ms of echo latency, to type
12
+ * into a shell running on the same laptop as the browser. SSH on a LAN is under
13
+ * 5ms; 200ms is where characters visibly trail your fingers. Measured over this
14
+ * path on that same machine: **p50 6.9ms, p95 8.0ms**, and that figure includes
15
+ * bash actually executing the command, so the transport itself is a fraction of
16
+ * it.
17
+ *
18
+ * ⚠️ THIS IS NOT THE AGENT'S PTY. `local-agent.ts` owns exactly one PTY — the
19
+ * agent `attach` spawned — and Decision Q3 ("one tile per attach") keeps it a
20
+ * singleton. This module spawns SEPARATE shells and is a Map, because "give me
21
+ * a terminal" is a different request from "show me the agent". Mixing them
22
+ * would mean every glance at a running agent shares a keyboard with it.
23
+ *
24
+ * WHY THREE BROWSER MECHANISMS ARE HANDLED, not one — each fails differently,
25
+ * and getting any of them wrong looks identical to "browsers refuse loopback":
26
+ *
27
+ * 1. MIXED CONTENT — an https page loading http:// is normally blocked;
28
+ * loopback is exempt as a potentially-trustworthy origin.
29
+ * 2. CORS — cross-origin, so an explicit allow-origin. Never `*`: that would
30
+ * let any page on the internet reach a shell on this machine.
31
+ * 3. PRIVATE NETWORK ACCESS — Chrome preflights public→private and requires
32
+ * `Access-Control-Allow-Private-Network: true` in response.
33
+ *
34
+ * Verified against Chrome 140 at default security settings (spike, 2026-08-28):
35
+ * a page on https://yolo.studio reached this successfully. Firefox 153 was
36
+ * INCONCLUSIVE headless — the fetch hung rather than being refused, most likely
37
+ * its Local Network Access prompt with nobody present to answer it. Which is
38
+ * why the client must treat "no answer" as a timeout and fall back, never wait
39
+ * forever.
40
+ *
41
+ * ⚠️ KNOWN GAP — RECONNECT FIDELITY FOR TUIs. The backlog is a byte TAIL, cut
42
+ * at a parser-safe boundary. That is enough to resume a shell transcript, and
43
+ * NOT enough to reconstruct a full-screen TUI that painted its layout once and
44
+ * then emitted more than `BACKLOG_CHARS` of cursor-addressed updates: a viewer
45
+ * reconnecting mid-session gets the updates without the screen they address,
46
+ * and sticky modes set before the window are lost. The cloud path already
47
+ * solved this properly — `local-agent.ts` keeps an `@xterm/headless` mirror and
48
+ * serves a serialized screen plus a mode `prologue` — and `@xterm/headless` is
49
+ * already a dependency here. Doing the same for these shells is the next slice;
50
+ * it is called out rather than left to be discovered. (codex P2.)
51
+ *
52
+ * SECURITY, deliberately narrow because this hands out shells:
53
+ * · bound to 127.0.0.1 ONLY — never 0.0.0.0, so it is off the local network
54
+ * entirely (verified: a connect to the machine's own LAN IP is refused at
55
+ * the TCP level, not merely firewalled);
56
+ * · a random secret per daemon run, compared in constant time;
57
+ * · one explicit allowed origin;
58
+ * · sessions are capped and idle-reaped, so a forgotten tab cannot leave
59
+ * shells accumulating forever;
60
+ * · PTY bytes are NEVER logged. They are the operator's live screen and
61
+ * include whatever they type, passwords included.
62
+ */
63
+ import * as http from 'node:http';
64
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
65
+ import { createRequire } from 'node:module';
66
+ const require = createRequire(import.meta.url);
67
+ /** How much recent output a late-joining viewer replays. */
68
+ export const BACKLOG_CHARS = 64 * 1024;
69
+ /** Shells with no viewer for this long are killed. */
70
+ export const IDLE_REAP_MS = 5 * 60_000;
71
+ /** Hard cap on concurrent shells from one daemon. */
72
+ export const MAX_SESSIONS = 8;
73
+ /** Largest single input payload accepted. */
74
+ export const MAX_INPUT_CHARS = 8192;
75
+ /**
76
+ * Trim the replay buffer to a point a terminal can safely resume from.
77
+ *
78
+ * ⚠️ A NAIVE `slice(-N)` CORRUPTS THE SCREEN, and so does guessing. The backlog
79
+ * is a raw PTY byte stream: an arbitrary cut can land inside a CSI, OSC or DCS
80
+ * sequence, and a viewer that resumes there does not get a slightly-wrong
81
+ * screen — it takes escape fragments as literal text, or applies half a mode
82
+ * change, and stays wrong forever.
83
+ *
84
+ * ⚠️ AND LOOKING *FORWARD* FOR AN ESCAPE IS NOT ENOUGH — the first version of
85
+ * this did exactly that and was wrong twice over: the introducer that put the
86
+ * stream mid-sequence may sit BEFORE the cut where a forward scan cannot see
87
+ * it, and a newline does not terminate an OSC string, so "cut at the next
88
+ * newline" can land inside one. (codex P2.)
89
+ *
90
+ * So the parser state is actually tracked. `groundCutAt` walks a minimal VT
91
+ * state machine and returns the first offset at or after `from` where the
92
+ * stream is in GROUND state — no partial sequence, no partial surrogate.
93
+ *
94
+ * This is correct to scan from index 0 because of an invariant this function
95
+ * maintains: **the backlog always begins in ground state**. Every trim cuts to
96
+ * a ground offset, so the next scan starts from one.
97
+ */
98
+ function groundCutAt(buf, from) {
99
+ let state = 'ground';
100
+ let i = 0;
101
+ // Walk to `from`, tracking state; then keep walking until ground.
102
+ while (i < buf.length) {
103
+ if (i >= from && state === 'ground') {
104
+ const code = buf.charCodeAt(i);
105
+ // Never resume on the low half of a surrogate pair.
106
+ if (!(code >= 0xdc00 && code <= 0xdfff))
107
+ return i;
108
+ }
109
+ const ch = buf[i];
110
+ const code = buf.charCodeAt(i);
111
+ switch (state) {
112
+ case 'ground':
113
+ if (code === 0x1b)
114
+ state = 'esc';
115
+ break;
116
+ case 'esc':
117
+ // `[` opens a CSI; `]`, `P`, `X`, `^`, `_` open string-terminated
118
+ // sequences (OSC/DCS/SOS/PM/APC).
119
+ if (ch === '[')
120
+ state = 'csi';
121
+ else if (ch === ']' || ch === 'P' || ch === 'X' || ch === '^' || ch === '_')
122
+ state = 'str';
123
+ // ⚠️ INTERMEDIATE BYTES (0x20-0x2F) DO NOT END THE SEQUENCE. `ESC ( B`
124
+ // — a charset designation — is three bytes, and treating `(` as the
125
+ // end marks the boundary before `B` as ground. Trimming there replays
126
+ // a bare `B` as ordinary text and silently drops the charset switch,
127
+ // which is precisely the "parser-safe boundary" this function promises
128
+ // not to do. Per ECMA-48, stay in escape until a FINAL byte
129
+ // (0x30-0x7E). (codex P2.)
130
+ else if (code >= 0x20 && code <= 0x2f) { /* intermediate — still escaping */ }
131
+ else
132
+ state = 'ground';
133
+ break;
134
+ case 'csi':
135
+ // Parameters and intermediates, terminated by a final byte 0x40-0x7E.
136
+ if (code >= 0x40 && code <= 0x7e)
137
+ state = 'ground';
138
+ break;
139
+ case 'str':
140
+ // BEL, or ST (ESC \). A newline does NOT end these — which is exactly
141
+ // what the previous newline shortcut got wrong.
142
+ if (code === 0x07)
143
+ state = 'ground';
144
+ else if (code === 0x1b && buf[i + 1] === '\\') {
145
+ state = 'ground';
146
+ i += 1;
147
+ }
148
+ break;
149
+ }
150
+ i += 1;
151
+ }
152
+ return buf.length;
153
+ }
154
+ /**
155
+ * Drop the oldest bytes, cutting only at a resumable boundary.
156
+ *
157
+ * The cut only ever moves FORWARD, so it can never resurrect bytes that were
158
+ * already meant to be gone.
159
+ */
160
+ export function trimBacklog(buf, limit = BACKLOG_CHARS) {
161
+ if (buf.length <= limit)
162
+ return buf;
163
+ return buf.slice(groundCutAt(buf, buf.length - limit));
164
+ }
165
+ function defaultSpawn() {
166
+ const pty = require('node-pty');
167
+ return ({ cols, rows }) => pty.spawn(process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'), [], { name: 'xterm-256color', cols, rows, cwd: process.env.HOME, env: process.env });
168
+ }
169
+ /**
170
+ * Constant-time secret comparison that cannot throw on a length mismatch.
171
+ *
172
+ * `timingSafeEqual` throws when the buffers differ in length, and a thrown
173
+ * comparison is both a crash and a length oracle. The length check short-
174
+ * circuits first, which leaks only the length — already visible from the URL.
175
+ */
176
+ export function secretMatches(given, expected) {
177
+ if (typeof given !== 'string')
178
+ return false;
179
+ const a = Buffer.from(given);
180
+ const b = Buffer.from(expected);
181
+ if (a.length !== b.length)
182
+ return false;
183
+ return timingSafeEqual(a, b);
184
+ }
185
+ export async function startLocalShellServer(opts) {
186
+ const allowedOrigin = opts.allowedOrigin;
187
+ const spawnShell = opts.spawnShell ?? defaultSpawn();
188
+ const now = opts.now ?? Date.now;
189
+ const idleReapMs = opts.idleReapMs ?? IDLE_REAP_MS;
190
+ const maxSessions = opts.maxSessions ?? MAX_SESSIONS;
191
+ const secret = randomBytes(24).toString('hex');
192
+ const sessions = new Map();
193
+ const killSession = (s) => {
194
+ // ⚠️ RE-ENTRANT. `kill()` may fire `onExit` SYNCHRONOUSLY — the interface
195
+ // permits it and real PTYs do it — which calls straight back in here.
196
+ // Without this guard the second entry kills again, recursing until the
197
+ // stack blows. Checked BEFORE the flag is set, not after. (codex P2.)
198
+ if (s.exited)
199
+ return;
200
+ s.exited = true;
201
+ for (const v of s.viewers) {
202
+ try {
203
+ v.res.end();
204
+ }
205
+ catch { /* already gone */ }
206
+ }
207
+ s.viewers.clear();
208
+ try {
209
+ s.pty.kill();
210
+ }
211
+ catch { /* already dead */ }
212
+ sessions.delete(s.id);
213
+ };
214
+ /**
215
+ * Send one chunk to one viewer, dropping rather than queueing when it is
216
+ * behind. See `Viewer` for why.
217
+ *
218
+ * The loss is REPORTED when the socket recovers — a viewer that silently
219
+ * skipped output would render a screen that is wrong with no indication,
220
+ * which is worse than a visible gap. Same discipline as the cloud path's
221
+ * `droppedBytes`.
222
+ */
223
+ const writeToViewer = (session, v, data) => {
224
+ if (v.saturated) {
225
+ v.dropped += data.length;
226
+ return;
227
+ }
228
+ let ok = false;
229
+ try {
230
+ ok = v.res.write(`data: ${JSON.stringify(data)}\n\n`);
231
+ }
232
+ catch {
233
+ return;
234
+ }
235
+ if (ok)
236
+ return;
237
+ v.saturated = true;
238
+ v.res.once('drain', () => {
239
+ v.saturated = false;
240
+ if (v.dropped > 0) {
241
+ const n = v.dropped;
242
+ v.dropped = 0;
243
+ // Marked inline, where the gap actually happened.
244
+ try {
245
+ v.res.write(`data: ${JSON.stringify(`\r\n\u001b[33m── ${n} bytes skipped — viewer fell behind ──\u001b[0m\r\n`)}\n\n`);
246
+ }
247
+ catch { /* gone */ }
248
+ }
249
+ // Re-seed from the retained tail so the screen is coherent again rather
250
+ // than resuming mid-stream after a hole.
251
+ try {
252
+ v.res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
253
+ }
254
+ catch { /* gone */ }
255
+ });
256
+ };
257
+ const reaper = setInterval(() => {
258
+ const t = now();
259
+ for (const s of [...sessions.values()]) {
260
+ // ⚠️ Only reap shells nobody is watching. A viewer that is merely quiet
261
+ // is still a viewer — killing on output-silence would take out an idle
262
+ // shell the operator is about to type into.
263
+ if (s.viewers.size === 0 && t - s.lastViewerAtMs > idleReapMs)
264
+ killSession(s);
265
+ }
266
+ }, Math.max(1000, Math.floor(idleReapMs / 4)));
267
+ reaper.unref?.();
268
+ /** CORS + Private Network Access. See this file's header for why all three. */
269
+ const applyCors = (req, res) => {
270
+ if (req.headers.origin === allowedOrigin) {
271
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
272
+ res.setHeader('Vary', 'Origin');
273
+ }
274
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
275
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
276
+ if (req.headers['access-control-request-private-network']) {
277
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
278
+ }
279
+ };
280
+ const server = http.createServer((req, res) => {
281
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
282
+ applyCors(req, res);
283
+ if (req.method === 'OPTIONS') {
284
+ res.writeHead(204);
285
+ res.end();
286
+ return;
287
+ }
288
+ // Liveness probe. Deliberately UNAUTHENTICATED and content-free: it exists
289
+ // so a viewer can discover whether this machine is the one running the
290
+ // daemon before it has any reason to hold a secret. It reveals only that
291
+ // something is listening, which the TCP connect already revealed.
292
+ if (url.pathname === '/health') {
293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify({ ok: true, service: 'yolo-bridge-local-shell' }));
295
+ return;
296
+ }
297
+ if (!secretMatches(url.searchParams.get('secret'), secret)) {
298
+ res.writeHead(403, { 'Content-Type': 'application/json' });
299
+ res.end(JSON.stringify({ error: 'forbidden' }));
300
+ return;
301
+ }
302
+ if (url.pathname === '/open' && req.method === 'POST') {
303
+ if (sessions.size >= maxSessions) {
304
+ res.writeHead(429, { 'Content-Type': 'application/json' });
305
+ res.end(JSON.stringify({ error: `at most ${maxSessions} local terminals` }));
306
+ return;
307
+ }
308
+ const cols = Math.max(20, Math.min(500, Number(url.searchParams.get('cols')) || 100));
309
+ const rows = Math.max(5, Math.min(200, Number(url.searchParams.get('rows')) || 30));
310
+ const id = randomBytes(9).toString('hex');
311
+ let pty;
312
+ try {
313
+ pty = spawnShell({ cols, rows });
314
+ }
315
+ catch (err) {
316
+ // ⚠️ A THROW HERE WOULD KILL THE WHOLE DAEMON. This runs inside the
317
+ // HTTP request callback, so an unhandled exception takes the process
318
+ // down — and with it the agent PTY and every other live terminal —
319
+ // because one `$SHELL` pointed at a missing binary. Report it and
320
+ // leave everything else running. (codex P2.)
321
+ res.writeHead(500, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({
323
+ error: 'could not start a shell',
324
+ detail: err instanceof Error ? err.message : String(err),
325
+ }));
326
+ return;
327
+ }
328
+ const session = { id, pty, backlog: '', viewers: new Set(), lastViewerAtMs: now(), exited: false };
329
+ // ⚠️ REGISTER FIRST, SUBSCRIBE SECOND. A short-lived shell can fire
330
+ // `onExit` the instant the callback is attached — before `sessions.set`
331
+ // would have run. `killSession` would then delete nothing, and the
332
+ // already-dead session would be inserted afterwards, unreachable (404 on
333
+ // every request) yet still holding a slot against the session cap, and
334
+ // unremovable because `killSession` returns early once `exited` is set.
335
+ // A slow leak of the one resource that is capped. (codex P1.)
336
+ sessions.set(id, session);
337
+ pty.onData((data) => {
338
+ // ⚠️ NEVER LOG THIS. It is the operator's live screen.
339
+ session.backlog = trimBacklog(session.backlog + data);
340
+ for (const viewer of session.viewers)
341
+ writeToViewer(session, viewer, data);
342
+ });
343
+ pty.onExit(() => { killSession(session); });
344
+ res.writeHead(200, { 'Content-Type': 'application/json' });
345
+ res.end(JSON.stringify({ sessionId: id, cols, rows }));
346
+ return;
347
+ }
348
+ const session = sessions.get(url.searchParams.get('session') ?? '');
349
+ if (!session || session.exited) {
350
+ res.writeHead(404, { 'Content-Type': 'application/json' });
351
+ res.end(JSON.stringify({ error: 'no such session' }));
352
+ return;
353
+ }
354
+ if (url.pathname === '/stream') {
355
+ res.writeHead(200, {
356
+ 'Content-Type': 'text/event-stream',
357
+ 'Cache-Control': 'no-cache',
358
+ Connection: 'keep-alive',
359
+ });
360
+ // ⚠️ FLUSH THE HEADERS IMMEDIATELY. Node holds them until the first
361
+ // write, so a stream opened on a shell that has not printed anything yet
362
+ // never sends its response head at all — and the client's `fetch` hangs
363
+ // waiting for it, indefinitely. A fresh shell is exactly that case.
364
+ //
365
+ // An SSE comment is the standard way to do this: legal, ignored by
366
+ // EventSource, and it doubles as a "connected" signal the client can use
367
+ // to distinguish "attached and quiet" from "never got there".
368
+ res.write(': connected\n\n');
369
+ // Replay next, so a reconnecting viewer sees the screen rather than
370
+ // waiting for the next keypress to produce output.
371
+ if (session.backlog)
372
+ res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
373
+ const viewer = { res, saturated: false, dropped: 0 };
374
+ session.viewers.add(viewer);
375
+ session.lastViewerAtMs = now();
376
+ req.on('close', () => {
377
+ session.viewers.delete(viewer);
378
+ session.lastViewerAtMs = now();
379
+ });
380
+ return;
381
+ }
382
+ if (url.pathname === '/input' && req.method === 'POST') {
383
+ // ⚠️ COLLECT BYTES, DECODE ONCE. `body += chunk` decodes each Buffer
384
+ // independently, so a multibyte character split across a TCP chunk
385
+ // boundary becomes two replacement characters — silently corrupting
386
+ // pasted or typed Unicode, depending on how the network happened to
387
+ // fragment it. (codex P2.)
388
+ const chunks = [];
389
+ let size = 0;
390
+ req.on('data', (c) => {
391
+ size += c.length;
392
+ // Bounded before parsing: an unbounded body is a memory DoS on a
393
+ // process that owns the operator's shells.
394
+ if (size > MAX_INPUT_CHARS * 4) {
395
+ req.destroy();
396
+ return;
397
+ }
398
+ chunks.push(c);
399
+ });
400
+ req.on('end', () => {
401
+ try {
402
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'))?.data;
403
+ if (typeof data === 'string' && data.length <= MAX_INPUT_CHARS)
404
+ session.pty.write(data);
405
+ }
406
+ catch { /* malformed body is not worth an error page */ }
407
+ res.writeHead(204);
408
+ res.end();
409
+ });
410
+ return;
411
+ }
412
+ if (url.pathname === '/resize' && req.method === 'POST') {
413
+ const cols = Number(url.searchParams.get('cols'));
414
+ const rows = Number(url.searchParams.get('rows'));
415
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
416
+ try {
417
+ session.pty.resize(Math.max(20, Math.min(500, cols)), Math.max(5, Math.min(200, rows)));
418
+ }
419
+ catch { /* raced exit */ }
420
+ }
421
+ res.writeHead(204);
422
+ res.end();
423
+ return;
424
+ }
425
+ if (url.pathname === '/close' && req.method === 'POST') {
426
+ killSession(session);
427
+ res.writeHead(204);
428
+ res.end();
429
+ return;
430
+ }
431
+ res.writeHead(404);
432
+ res.end();
433
+ });
434
+ await new Promise((resolve, reject) => {
435
+ server.once('error', reject);
436
+ // ⚠️ 127.0.0.1 EXPLICITLY. Omitting the host, or using '0.0.0.0', would put
437
+ // a shell on the local network.
438
+ server.listen(0, '127.0.0.1', () => resolve());
439
+ });
440
+ const addr = server.address();
441
+ const port = addr.port;
442
+ return {
443
+ url: `http://127.0.0.1:${port}`,
444
+ port,
445
+ host: addr.address,
446
+ secret,
447
+ get sessionCount() { return sessions.size; },
448
+ async close() {
449
+ clearInterval(reaper);
450
+ for (const s of [...sessions.values()])
451
+ killSession(s);
452
+ await new Promise((resolve) => {
453
+ server.close(() => resolve());
454
+ // ⚠️ REQUIRED, not belt-and-braces. `server.close()` waits for open
455
+ // connections, and an SSE stream never ends on its own — that is the
456
+ // whole point of it. Without this, closing the daemon with a terminal
457
+ // open hangs forever instead of exiting.
458
+ server.closeAllConnections?.();
459
+ });
460
+ },
461
+ };
462
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "YoloBridge \u2014 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",