@yolo-labs/yolobridge 0.1.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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Heartbeat scheduler: `POST /events {type:'heartbeat'}` every ~10s while
3
+ * attached (docs/YOLOBRIDGE_PLAN.md build-order step 5; matches Phase 4's
4
+ * 30s/90s `running`→`paused`→`stopped` staleness thresholds with margin —
5
+ * `common-api/src/services/yolobridge-service.ts`'s `deriveTileStatus`).
6
+ *
7
+ * Timer functions are injectable so tests drive the schedule manually
8
+ * (call the captured callback directly) instead of sleeping for real.
9
+ */
10
+ export const defaultTimers = {
11
+ setInterval: (fn, ms) => setInterval(fn, ms),
12
+ clearInterval: (handle) => clearInterval(handle),
13
+ };
14
+ export const HEARTBEAT_INTERVAL_MS = 10_000;
15
+ /**
16
+ * `send` is called once immediately is NOT done here — callers send an
17
+ * initial heartbeat themselves right after a successful `connected` frame
18
+ * so "attached but daemon never sent one" isn't a visible gap; this
19
+ * scheduler only owns the recurring tick. Errors thrown by `send` are
20
+ * swallowed with `onError` (a single failed heartbeat POST — e.g. a
21
+ * transient network blip — should not crash the daemon loop; the next
22
+ * tick just tries again).
23
+ */
24
+ export function startHeartbeat(send, onError, intervalMs = HEARTBEAT_INTERVAL_MS, timers = defaultTimers) {
25
+ const handle = timers.setInterval(() => {
26
+ send().catch(onError);
27
+ }, intervalMs);
28
+ return {
29
+ stop: () => timers.clearInterval(handle),
30
+ };
31
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Real local prompt-delivery and output-capture for `yolo-bridge attach`.
3
+ *
4
+ * docs/YOLOBRIDGE_PLAN.md's "⚠ Not yet functional" section settled the
5
+ * mechanism (2026-08-19): `node-pty` spawns a genuine PTY running the
6
+ * user's local coding agent (zero external binary dependency — already a
7
+ * proven pattern in this codebase, `containers/services/terminal-mux/server.js`
8
+ * depends on it too, just as an outer viewer around tmux rather than the
9
+ * core mechanism here). `@xterm/headless` mirrors the PTY's raw byte
10
+ * stream into a real screen-buffer model (the same engine xterm.js uses
11
+ * for rendering, without a DOM), giving `captureLocalAgentOutput` direct
12
+ * structured buffer access instead of text-scraping.
13
+ *
14
+ * Ownership model (this is the load-bearing change from the original
15
+ * "reach into an already-running session" framing): `startLocalAgent`
16
+ * SPAWNS the agent — yolo-bridge owns the PTY. The real process's stdin
17
+ * is piped into the PTY and the PTY's raw output is piped to the real
18
+ * process's stdout, so the human running `yolo-bridge attach` sees and
19
+ * can drive the exact same session that remote prompts land in — not a
20
+ * separate shadow copy.
21
+ *
22
+ * Module-level singleton: Decision Q3 in the plan is "one tile per
23
+ * attach" — there is only ever one local agent PTY per daemon process, so
24
+ * a singleton (rather than threading a handle through every call site) is
25
+ * a faithful match for that decision, and it's what keeps
26
+ * `deliverPromptToLocalAgent(prompt)` / `captureLocalAgentOutput()`
27
+ * exactly the same two free functions with the same signatures that
28
+ * attach-cmd.ts (and its test suite) already depend on and inject spies
29
+ * over — see attach-cmd.ts's `AttachDaemonDeps.deliverPrompt` /
30
+ * `.captureOutput`, defaulted to these two exports. Callers that already
31
+ * inject fakes for those two hooks (attach-cmd.test.ts) never touch this
32
+ * module at all, so nothing there needed to change.
33
+ */
34
+ import { createRequire } from 'node:module';
35
+ import * as pty from 'node-pty';
36
+ // `@xterm/headless`'s published CJS bundle is a heavily minified/webpacked
37
+ // single file — `cjs-module-lexer` (Node ESM's static CJS-named-export
38
+ // detector) can't find `Terminal` on it, so a plain
39
+ // `import { Terminal } from '@xterm/headless'` fails at runtime with
40
+ // "Named export 'Terminal' not found" even though it type-checks fine
41
+ // (the package's .d.ts declares named exports). `createRequire` sidesteps
42
+ // static detection entirely and reads the real `module.exports` at
43
+ // runtime, which does have `Terminal` on it.
44
+ const require = createRequire(import.meta.url);
45
+ const { Terminal } = require('@xterm/headless');
46
+ /** Default agent binary: overridable via `--agent` (cli.ts) or this env var. */
47
+ export const DEFAULT_AGENT_BIN = process.env.YOLOBRIDGE_AGENT_BIN || 'claude';
48
+ const DEFAULT_COLS = 120;
49
+ const DEFAULT_ROWS = 40;
50
+ /** How recently the PTY must have produced output to be considered "busy". */
51
+ const DEFAULT_BUSY_WINDOW_MS = 2_000;
52
+ /**
53
+ * Readiness gate defaults — see `isReadyToReceiveInput`'s doc comment below
54
+ * for the reasoning (docs/YOLOBRIDGE_PLAN.md's "[P1] Blind prompt delivery"
55
+ * Codex finding). Distinct from `DEFAULT_BUSY_WINDOW_MS`: that one gates a
56
+ * UX-facing `busy` signal read_tile_output reports to callers (2s, tuned
57
+ * for "does this look like it's still thinking"); this one gates WRITE
58
+ * safety and only needs to rule out active mid-render, so it can be much
59
+ * shorter.
60
+ */
61
+ const DEFAULT_READINESS_QUIET_MS = 200;
62
+ /** Rows from the bottom of the viewport the cursor must sit within to
63
+ * count as "at the input line", once the terminal has scrolled at least
64
+ * once (see isReadyToReceiveInput). */
65
+ const DEFAULT_CURSOR_BOTTOM_SLACK = 2;
66
+ /** Bounded wait for readiness before proceeding anyway — see
67
+ * deliverPromptToLocalAgent's doc comment on why this doesn't refuse or
68
+ * hang indefinitely instead. */
69
+ const DEFAULT_READINESS_TIMEOUT_MS = 5_000;
70
+ const DEFAULT_READINESS_POLL_MS = 100;
71
+ /**
72
+ * Delay between writing the prompt text and writing the Enter keystroke in
73
+ * `deliverPromptToLocalAgent` — see that function's doc comment. 150ms was
74
+ * enough to fix `codex` in manual testing with no observable added latency;
75
+ * not exposed as an option since it's a workaround for target-CLI input
76
+ * handling, not a tunable a caller should need to reason about.
77
+ */
78
+ const PASTE_TO_ENTER_DELAY_MS = 150;
79
+ /**
80
+ * Delay before Enter for a MULTILINE prompt specifically — see the
81
+ * "multiline delivery" section of `deliverPromptToLocalAgent`'s doc comment.
82
+ * Verified empirically (2026-08-22) against real `claude` (v2.1.240): 150ms
83
+ * and 300ms both left a 3-line prompt sitting unsent in the composer
84
+ * indefinitely (not a premature-split, a SILENT NEVER-SUBMITS); 800ms
85
+ * reliably submitted it. 1000ms is that empirical floor plus headroom, not a
86
+ * tuned-to-the-millisecond value — this only affects the already-rare
87
+ * multiline path, so the extra ~200ms over the verified-working 800ms is
88
+ * immaterial to UX. `codex` and `bash` submit correctly at the original
89
+ * 150ms already; using the longer delay for them too is harmless (just
90
+ * slower), so this applies unconditionally to every multiline delivery
91
+ * rather than trying to detect which target needs it.
92
+ */
93
+ const MULTILINE_PASTE_TO_ENTER_DELAY_MS = 1_000;
94
+ function sleep(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
97
+ let current;
98
+ function sanitizeEnv(env) {
99
+ const out = {};
100
+ for (const [k, v] of Object.entries(env)) {
101
+ if (typeof v === 'string')
102
+ out[k] = v;
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * Explicit `cols`/`rows` win. Otherwise, when wiring to the real
108
+ * `process.stdout` (no injected sink — i.e. an actual interactive
109
+ * `attach` run, not a test double), inherit the real terminal's size if
110
+ * it reports one (a non-TTY stdout, e.g. piped/redirected, reports
111
+ * `undefined`). Falls back to the fixed default otherwise.
112
+ */
113
+ function resolveCols(opts) {
114
+ if (opts.cols)
115
+ return opts.cols;
116
+ if (opts.stdout !== undefined)
117
+ return DEFAULT_COLS;
118
+ return process.stdout.columns || DEFAULT_COLS;
119
+ }
120
+ function resolveRows(opts) {
121
+ if (opts.rows)
122
+ return opts.rows;
123
+ if (opts.stdout !== undefined)
124
+ return DEFAULT_ROWS;
125
+ return process.stdout.rows || DEFAULT_ROWS;
126
+ }
127
+ /**
128
+ * Serializes the terminal's current buffer (scrollback + viewport) to
129
+ * plain text — no ANSI/SGR escape codes. Deliberately not using
130
+ * `@xterm/addon-serialize`: that addon's `serialize()` reconstructs a
131
+ * VT100-replayable stream (colors, cursor moves included) for re-feeding
132
+ * into another terminal, which is the wrong shape for `read_tile_output`
133
+ * — the consumer on the other end (an orchestrator tile, possibly an
134
+ * LLM) wants clean text, not escape sequences. Walking `buffer.active`
135
+ * directly and calling `translateToString` per line gives exactly that.
136
+ */
137
+ export function serializeTerminalBuffer(term) {
138
+ const buffer = term.buffer.active;
139
+ const lines = [];
140
+ for (let i = 0; i < buffer.length; i++) {
141
+ const line = buffer.getLine(i);
142
+ lines.push(line ? line.translateToString(true) : '');
143
+ }
144
+ while (lines.length > 0 && lines[lines.length - 1] === '')
145
+ lines.pop();
146
+ return lines.join('\n');
147
+ }
148
+ /**
149
+ * Spawns the local coding agent under a real PTY and wires it up:
150
+ * - PTY output -> headless Terminal (structured buffer for capture)
151
+ * - PTY output -> the real process's stdout (live view for the human)
152
+ * - the real process's stdin -> PTY (human keystrokes reach the agent)
153
+ *
154
+ * Idempotent in the sense that calling this while a previous session is
155
+ * still running stops it first — Decision Q3 (one tile per attach) means
156
+ * there is only ever one local agent per daemon process.
157
+ */
158
+ export function startLocalAgent(opts = {}) {
159
+ if (current)
160
+ stopLocalAgent();
161
+ const agentBin = opts.agentBin ?? DEFAULT_AGENT_BIN;
162
+ const agentArgs = opts.agentArgs ?? [];
163
+ const cols = resolveCols(opts);
164
+ const rows = resolveRows(opts);
165
+ const spawnImpl = opts.spawnImpl ?? pty.spawn;
166
+ const busyWindowMs = opts.busyWindowMs ?? DEFAULT_BUSY_WINDOW_MS;
167
+ const readinessQuietMs = opts.readinessQuietMs ?? DEFAULT_READINESS_QUIET_MS;
168
+ const cursorBottomSlack = opts.cursorBottomSlack ?? DEFAULT_CURSOR_BOTTOM_SLACK;
169
+ const readinessTimeoutMs = opts.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
170
+ const readinessPollMs = opts.readinessPollMs ?? DEFAULT_READINESS_POLL_MS;
171
+ const outStream = opts.stdout ?? process.stdout;
172
+ // `'stdin' in opts` (not `opts.stdin ??`) so a test can pass `stdin: undefined`
173
+ // explicitly to disable stdin piping entirely, distinct from omitting the
174
+ // field (which defaults to wiring up the real `process.stdin`).
175
+ const inStream = 'stdin' in opts ? opts.stdin : process.stdin;
176
+ const ptyProcess = spawnImpl(agentBin, agentArgs, {
177
+ name: 'xterm-256color',
178
+ cols,
179
+ rows,
180
+ cwd: opts.cwd ?? process.cwd(),
181
+ env: sanitizeEnv(opts.env ?? process.env),
182
+ });
183
+ const term = new Terminal({ cols, rows, allowProposedApi: true });
184
+ const state = {
185
+ ptyProcess,
186
+ term,
187
+ lastOutputAt: Date.now(),
188
+ busyWindowMs,
189
+ readinessQuietMs,
190
+ cursorBottomSlack,
191
+ readinessTimeoutMs,
192
+ readinessPollMs,
193
+ writeChain: Promise.resolve(),
194
+ stdin: inStream,
195
+ rawModeEnabled: false,
196
+ };
197
+ current = state;
198
+ ptyProcess.onData((data) => {
199
+ state.lastOutputAt = Date.now();
200
+ outStream.write(data);
201
+ state.writeChain = state.writeChain.then(() => new Promise((resolve) => term.write(data, () => resolve())));
202
+ });
203
+ if (inStream && typeof inStream.on === 'function') {
204
+ const stdinListener = (data) => {
205
+ ptyProcess.write(typeof data === 'string' ? data : data.toString('utf-8'));
206
+ };
207
+ if (inStream.isTTY && typeof inStream.setRawMode === 'function') {
208
+ inStream.setRawMode(true);
209
+ state.rawModeEnabled = true;
210
+ }
211
+ inStream.resume?.();
212
+ inStream.setEncoding?.('utf-8');
213
+ inStream.on('data', stdinListener);
214
+ state.stdinListener = stdinListener;
215
+ }
216
+ ptyProcess.onExit(({ exitCode, signal }) => {
217
+ teardownStdio(state);
218
+ if (current === state)
219
+ current = undefined;
220
+ opts.onExit?.({ exitCode, signal });
221
+ });
222
+ return { stop: stopLocalAgent };
223
+ }
224
+ /**
225
+ * Real bug (found 2026-08-23 chasing a report that `attach` never fully
226
+ * exits on its own — "detaching..." prints, then the process just hangs
227
+ * until force-killed): `startLocalAgent` calls `inStream.resume?.()` to put
228
+ * `process.stdin` into flowing mode so keystrokes reach the PTY. A resumed
229
+ * stdin is a standing libuv handle that keeps Node's event loop alive
230
+ * regardless of `process.exitCode` — removing the `'data'` listener alone
231
+ * does NOT release it; only an explicit `pause()` does. This was missing
232
+ * here, so EVERY `attach` exit path (attach failure, the local agent dying
233
+ * on its own, a server-initiated detach, even a clean Ctrl+C) left stdin
234
+ * resumed and the process wedged. Reproduced against a real pty (`script
235
+ * -qec ... `, not a plain redirected stdin — `/dev/null`-as-stdin reaches
236
+ * EOF on its own and masked this): the process printed its final messages
237
+ * within ~1s but had to be force-killed at a 10s timeout every time,
238
+ * exit code 124. With `stdin.pause()` added below, the same repro exits
239
+ * cleanly on its own well under a second — no timeout/kill needed.
240
+ */
241
+ function teardownStdio(state) {
242
+ const { stdin, stdinListener } = state;
243
+ if (stdin && stdinListener && typeof stdin.removeListener === 'function') {
244
+ stdin.removeListener('data', stdinListener);
245
+ }
246
+ if (state.rawModeEnabled && stdin?.isTTY && typeof stdin.setRawMode === 'function') {
247
+ stdin.setRawMode(false);
248
+ }
249
+ if (stdin && typeof stdin.pause === 'function') {
250
+ stdin.pause();
251
+ }
252
+ }
253
+ /**
254
+ * Stops the local agent session. Kills the PTY process (SIGTERM via
255
+ * node-pty's default `kill()`) and unwires stdio.
256
+ *
257
+ * Decision on detach lifecycle: `yolo-bridge attach` is what SPAWNED this
258
+ * process (see module header), so whatever ends the attach loop — local
259
+ * Ctrl+C, or a server-initiated `detached` frame — also ends the PTY
260
+ * session it owns. Nothing is left "running detached with no owner":
261
+ * cli.ts calls this unconditionally after `runAttachFromDisk` resolves,
262
+ * regardless of which of those two paths triggered the stop. If the
263
+ * agent process already exited on its own, this is a safe no-op (`current`
264
+ * is already cleared by the `onExit` handler above).
265
+ */
266
+ export function stopLocalAgent() {
267
+ if (!current)
268
+ return;
269
+ const state = current;
270
+ current = undefined;
271
+ teardownStdio(state);
272
+ try {
273
+ state.ptyProcess.kill();
274
+ }
275
+ catch {
276
+ // already dead
277
+ }
278
+ }
279
+ /**
280
+ * Best-effort readiness check before `deliverPromptToLocalAgent` writes
281
+ * into the PTY — docs/YOLOBRIDGE_PLAN.md's "[P1] Blind prompt delivery can
282
+ * hit a permission dialog or partial input" Codex finding. Writing
283
+ * text+Enter with no regard for what's on screen could accidentally
284
+ * confirm a highlighted permission-dialog choice, or concatenate onto
285
+ * something the user was mid-typing.
286
+ *
287
+ * Mirrors the SHAPE of the pod side's own confidence-scored injection gate
288
+ * (`containers/services/terminal-mux/server.js`'s "marker + stability +
289
+ * cursor"), adapted to what this module actually has: direct structured
290
+ * `@xterm/headless` buffer access (no capture-pane text-scraping needed),
291
+ * but no per-agent marker set — building an equivalent of that file's
292
+ * `readiness-markers.js` for arbitrary local CLIs (claude, codex, and
293
+ * whatever `--agent` names) is out of scope for this fix, an acknowledged
294
+ * scope cut, not an oversight. No verify-after-inject retry either (the pod
295
+ * side's second defense layer, comparing before/after screen state once the
296
+ * text is written) — this check only gates BEFORE the write.
297
+ *
298
+ * Two signals:
299
+ * - STABLE: no PTY output for `readinessQuietMs`. Rules out writing into
300
+ * a screen that's still actively repainting — a streaming response, a
301
+ * busy spinner, a dialog mid-animation. This is the primary signal and
302
+ * directly addresses both halves of the finding: an active permission
303
+ * dialog is normally still rendering (its highlight/spinner), and
304
+ * "partial input" concern is really "is something being typed right
305
+ * now" — both are "was there recent activity" questions.
306
+ * - CURSOR AT THE INPUT LINE, but ONLY once the terminal has scrolled at
307
+ * least once (`buffer.baseY > 0`): the cursor sits within
308
+ * `cursorBottomSlack` rows of the viewport bottom. JUDGMENT CALL: this
309
+ * is gated on `baseY` rather than being an unconditional requirement —
310
+ * a short session whose content still fits in one screen (baseY === 0,
311
+ * e.g. a freshly-spawned agent's first prompt, or the real-bash test
312
+ * below) legitimately has its cursor wherever the last line landed,
313
+ * which is often nowhere near the physical bottom row; requiring the
314
+ * bonus signal there would stall every delivery to a short/compact
315
+ * session for no real safety benefit. Once the terminal HAS scrolled,
316
+ * though, a cursor that isn't near the bottom is a real signal we're
317
+ * looking at scrolled-away history or a fixed-position dialog/pager
318
+ * rather than the live input line — the pod side's own cursor check
319
+ * has this exact same "assumes bottom" property, it just doesn't need
320
+ * the `baseY` guard because tmux's `cursor_y`/`pane_height` are already
321
+ * relative to the live pane, not a headless buffer that can start at
322
+ * row 0 with nothing rendered yet.
323
+ */
324
+ function isReadyToReceiveInput(state) {
325
+ const quietForMs = Date.now() - state.lastOutputAt;
326
+ if (quietForMs < state.readinessQuietMs)
327
+ return false;
328
+ const buffer = state.term.buffer.active;
329
+ if (buffer.baseY === 0)
330
+ return true;
331
+ const rows = state.term.rows;
332
+ return rows - 1 - buffer.cursorY <= state.cursorBottomSlack;
333
+ }
334
+ /**
335
+ * Polls `isReadyToReceiveInput` until it's true or `readinessTimeoutMs`
336
+ * elapses. Never rejects — a timeout just means the caller proceeds
337
+ * without the extra confidence (see `deliverPromptToLocalAgent`).
338
+ */
339
+ async function waitForReadiness(state) {
340
+ const deadline = Date.now() + state.readinessTimeoutMs;
341
+ while (!isReadyToReceiveInput(state)) {
342
+ const remaining = deadline - Date.now();
343
+ if (remaining <= 0)
344
+ return false;
345
+ await sleep(Math.min(state.readinessPollMs, remaining));
346
+ }
347
+ return true;
348
+ }
349
+ /**
350
+ * Writes `prompt` into the owned PTY the same way the user's own
351
+ * keystrokes would land, followed by a carriage return so the target CLI
352
+ * actually submits it. `\r` (not `\n`) matches what a real terminal sends
353
+ * on Enter.
354
+ *
355
+ * **Waits (bounded) for `isReadyToReceiveInput` before writing anything.**
356
+ * JUDGMENT CALL on what happens if the terminal never settles within
357
+ * `readinessTimeoutMs`: this proceeds and writes anyway, rather than
358
+ * hanging indefinitely or silently refusing. A hard refusal would have no
359
+ * safe fallback — `yolobridge-service.ts`'s `publishPrompt` has a
360
+ * retry-on-RECONNECT loop (Decision Q5) for an OFFLINE daemon, but no
361
+ * retry-on-BUSY loop for a target that's merely slow to settle, so a
362
+ * refusal here would silently drop the prompt with no path to ever resend
363
+ * it. A timeout is therefore "proceed with reduced confidence, own the
364
+ * risk", not "give up" — the readiness check reduces the odds of a bad
365
+ * write, it does not (and, without the pod side's verify-after-inject
366
+ * layer, cannot) guarantee one never happens.
367
+ *
368
+ * **The text and the Enter are two SEPARATE writes, with a short delay
369
+ * between them — not one combined `${prompt}\r` write.** Verified
370
+ * empirically against real CLIs (2026-08-20, manual smoke test): a single
371
+ * combined write works for `bash` and `claude`, but silently fails to
372
+ * submit against `codex` — the text lands in its input box but Enter is
373
+ * never registered, so nothing is ever sent. Splitting into two writes
374
+ * with `PASTE_TO_ENTER_DELAY_MS` between them fixed `codex` with no
375
+ * regression on `claude`/`bash`. This mirrors the pod side's own proven
376
+ * two-step pattern (`containers/services/terminal-mux/server.js`:
377
+ * `tmux paste-buffer` followed by a SEPARATE `tmux send-keys Enter`, not
378
+ * one combined operation) — the same shape turned out to matter here too,
379
+ * not just there.
380
+ *
381
+ * If no session has been started yet (misuse, or a test that didn't call
382
+ * `startLocalAgent` first), lazily starts one with defaults rather than
383
+ * throwing — keeps this function's contract matching the original stub's
384
+ * "always succeeds, delivery is attempted" shape.
385
+ *
386
+ * **Multiline delivery (docs/YOLOBRIDGE_PLAN.md's "[P1] Send multiline
387
+ * prompts as a bracketed paste" Codex finding, fixed 2026-08-22).** A
388
+ * prompt containing embedded `\n` is wrapped in bracketed-paste markers
389
+ * (`\x1b[200~`/`\x1b[201~`) and waits `MULTILINE_PASTE_TO_ENTER_DELAY_MS`
390
+ * (not `PASTE_TO_ENTER_DELAY_MS`) before the Enter write. **The bracketed
391
+ * markers turned out NOT to be the load-bearing part of this fix** — real
392
+ * interop testing (spawning actual `claude`/`codex`/`bash` under `node-pty`,
393
+ * the same rigor as the codex-write-timing fix above) showed `codex`
394
+ * already correctly composes and submits a multiline prompt as ONE message
395
+ * at the original 150ms delay, no markers needed; `bash` is unaffected by
396
+ * markers either way (a multi-statement shell script legitimately runs each
397
+ * line once submitted — that's normal shell semantics, not a delivery bug,
398
+ * and `bash` is only this module's sanity-check fallback, not a primary
399
+ * agent target). The REAL bug was `claude` (v2.1.240): at the original
400
+ * 150ms delay, a multiline prompt was left sitting UNSENT in the composer
401
+ * indefinitely — not a premature partial-submit, a silent no-op that
402
+ * `send_to_tile` would have reported as `delivered: true` while the agent
403
+ * never saw it. Confirmed via the headless-buffer screen capture this
404
+ * module already uses for `read_tile_output`: 150ms/300ms (bracketed or
405
+ * not) left the 3-line prompt in the composer with no response ever
406
+ * starting; 800ms reliably submitted it and the model began responding.
407
+ * Bracketed-paste markers are kept anyway as cheap defense-in-depth (every
408
+ * target tested renders them correctly with no visible artifacts) for
409
+ * whatever agent binary is named next via `--agent` that wasn't tested
410
+ * here — but the delay is what actually closes the finding.
411
+ */
412
+ export async function deliverPromptToLocalAgent(prompt) {
413
+ if (!current)
414
+ startLocalAgent();
415
+ const state = current;
416
+ await waitForReadiness(state);
417
+ const ptyProcess = state.ptyProcess;
418
+ const isMultiline = prompt.includes('\n');
419
+ ptyProcess.write(isMultiline ? `\x1b[200~${prompt}\x1b[201~` : prompt);
420
+ await sleep(isMultiline ? MULTILINE_PASTE_TO_ENTER_DELAY_MS : PASTE_TO_ENTER_DELAY_MS);
421
+ ptyProcess.write('\r');
422
+ }
423
+ /**
424
+ * Serializes the current headless-terminal buffer to plain text and
425
+ * reports a `busy` heuristic: has the PTY produced output within the
426
+ * last `busyWindowMs` (default 2s)? Mirrors the spirit of the pod side's
427
+ * own busy-detection (recent-activity-based) without depending on any
428
+ * pod-only primitive.
429
+ */
430
+ export async function captureLocalAgentOutput() {
431
+ if (!current)
432
+ return { output: '', busy: false };
433
+ await current.writeChain;
434
+ const output = serializeTerminalBuffer(current.term);
435
+ const busy = Date.now() - current.lastOutputAt < current.busyWindowMs;
436
+ return { output, busy };
437
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `yolo-bridge login` — device-authorization flow against auth-service
3
+ * (see device-auth.ts for the exact wire contract). Orchestration only;
4
+ * the HTTP shapes live in device-auth.ts so they can be unit tested
5
+ * independent of this polling loop.
6
+ */
7
+ import { requestDeviceCode, pollDeviceToken } from './device-auth.js';
8
+ import { openBrowserBestEffort } from './browser-open.js';
9
+ import { saveAuth } from './config-store.js';
10
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ export async function runLogin(deps) {
12
+ const { authBaseUrl, fetchImpl, env, io } = deps;
13
+ const sleep = deps.sleep ?? defaultSleep;
14
+ const openBrowser = deps.openBrowser ?? openBrowserBestEffort;
15
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
16
+ const code = await requestDeviceCode(authBaseUrl, fetchImpl);
17
+ log('To finish logging in, open this URL in your browser:');
18
+ log(` ${code.verificationUri}`);
19
+ log('and enter this code when prompted:');
20
+ log(` ${code.userCode}`);
21
+ log('');
22
+ log('Waiting for approval...');
23
+ openBrowser(code.verificationUri);
24
+ const deadline = Date.now() + code.expiresInSec * 1000;
25
+ const intervalMs = Math.max(1, code.intervalSec) * 1000;
26
+ while (Date.now() < deadline) {
27
+ await sleep(intervalMs);
28
+ const poll = await pollDeviceToken(authBaseUrl, code.deviceCode, fetchImpl);
29
+ if (poll.status === 'pending')
30
+ continue;
31
+ if (poll.status === 'denied') {
32
+ return { ok: false, reason: 'denied', message: 'Login was denied.' };
33
+ }
34
+ if (poll.status === 'expired') {
35
+ return { ok: false, reason: 'expired', message: 'Device code expired before login was completed.' };
36
+ }
37
+ if (poll.status === 'error') {
38
+ return { ok: false, reason: 'error', message: poll.message };
39
+ }
40
+ saveAuth({
41
+ accessToken: poll.tokens.accessToken,
42
+ refreshToken: poll.tokens.refreshToken,
43
+ tokenType: poll.tokens.tokenType,
44
+ expiresAtMs: poll.tokens.expiresAtMs,
45
+ }, env, io);
46
+ log('Logged in.');
47
+ return { ok: true };
48
+ }
49
+ return { ok: false, reason: 'expired', message: 'Device code expired before login was completed.' };
50
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reconnect backoff for the attach daemon's SSE stream.
3
+ *
4
+ * Plan calls for "sleep/wake-aware" resilience (docs/YOLOBRIDGE_PLAN.md,
5
+ * Architecture → Auth / CLI daemon section). What's implemented: plain
6
+ * exponential backoff with a cap and jitter, which recovers naturally
7
+ * after a laptop sleep/wake — the stream just looks like a very long
8
+ * disconnect, and the next scheduled attempt reconnects it. What's NOT
9
+ * implemented: an actual OS-level sleep/wake signal (e.g. macOS
10
+ * `powermetrics`/IOKit notifications, a wall-clock-jump detector that
11
+ * resets backoff to zero) that would let the daemon reconnect *the
12
+ * instant* the machine wakes rather than waiting out whatever backoff
13
+ * step it was on when the lid closed. Flagged in the final report as a
14
+ * deliberate scope cut, not an oversight — true sleep/wake detection is
15
+ * platform-specific plumbing with no shared Node API across macOS/Linux/
16
+ * Windows, and a bounded backoff (cap below) already keeps the worst case
17
+ * to a single missed heartbeat window's multiple, not indefinite.
18
+ */
19
+ export const DEFAULT_BACKOFF = {
20
+ baseMs: 1_000,
21
+ maxMs: 30_000,
22
+ factor: 2,
23
+ jitter: 0.2,
24
+ random: Math.random,
25
+ };
26
+ /**
27
+ * `attempt` is 1-indexed (the first reconnect attempt after a drop).
28
+ * Pure function — no timers, no I/O — so the exponential/cap/jitter math
29
+ * is fully testable without waiting on a real clock.
30
+ */
31
+ export function nextBackoffMs(attempt, opts = {}) {
32
+ const { baseMs, maxMs, factor, jitter, random } = { ...DEFAULT_BACKOFF, ...opts };
33
+ const raw = baseMs * Math.pow(factor, Math.max(0, attempt - 1));
34
+ const capped = Math.min(raw, maxMs);
35
+ if (jitter <= 0)
36
+ return capped;
37
+ const jitterRange = capped * jitter;
38
+ // random() in [0,1) → offset in [-jitterRange, +jitterRange)
39
+ const offset = (random() * 2 - 1) * jitterRange;
40
+ return Math.max(0, Math.round(capped + offset));
41
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Parser for the exact SSE framing `yolobridge-service.ts`'s `writeFrame`
3
+ * emits (`common-api/src/services/yolobridge-service.ts:122-136`):
4
+ *
5
+ * event: <type>\n
6
+ * data: <json>\n
7
+ * \n
8
+ *
9
+ * No `id:` line (deliberate — see that file's comment: this stream has no
10
+ * replay buffer). Frame types actually written by the server today:
11
+ * - `connected` { attachmentId, workspaceId, timestamp } (holdStream)
12
+ * - `ping` { t } (holdStream keepalive, ~30s)
13
+ * - `prompt` { attachmentId, prompt } (publishPrompt)
14
+ * - `read-output` { attachmentId, requestId } (requestReadOutput)
15
+ * - `detached` { attachmentId } (detachDaemon)
16
+ *
17
+ * Pure incremental parser: feed it raw chunks as they arrive off the wire
18
+ * (chunk boundaries need not align with frame boundaries — a `data:` line
19
+ * can legitimately split across two `write()` calls under backpressure),
20
+ * get back zero or more complete frames per `push()` call. No network or
21
+ * timer code in this file — fully unit-testable without a real stream.
22
+ */
23
+ export class SseFrameParser {
24
+ buffer = '';
25
+ /** Feed a raw chunk (already decoded to a string). Returns any complete frames it produced. */
26
+ push(chunk) {
27
+ this.buffer += chunk;
28
+ const frames = [];
29
+ // Frames are separated by a blank line (`\n\n`). Split conservatively:
30
+ // keep the trailing partial block in the buffer for the next push().
31
+ let sepIndex;
32
+ while ((sepIndex = this.buffer.indexOf('\n\n')) !== -1) {
33
+ const block = this.buffer.slice(0, sepIndex);
34
+ this.buffer = this.buffer.slice(sepIndex + 2);
35
+ const frame = parseBlock(block);
36
+ if (frame)
37
+ frames.push(frame);
38
+ }
39
+ return frames;
40
+ }
41
+ }
42
+ function parseBlock(block) {
43
+ let event = 'message'; // SSE default event name when no `event:` line is present.
44
+ const dataLines = [];
45
+ for (const line of block.split('\n')) {
46
+ if (line.startsWith('event:')) {
47
+ event = line.slice('event:'.length).trim();
48
+ }
49
+ else if (line.startsWith('data:')) {
50
+ dataLines.push(line.slice('data:'.length).trimStart());
51
+ }
52
+ // Any other line (comments, unrecognized fields) is ignored — this
53
+ // stream never sends `id:`/`retry:`.
54
+ }
55
+ if (dataLines.length === 0)
56
+ return null;
57
+ const raw = dataLines.join('\n');
58
+ let data;
59
+ try {
60
+ data = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ data = undefined;
64
+ }
65
+ return { event, data, raw };
66
+ }