@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.
- package/dist/api-client.js +136 -0
- package/dist/attach-cmd.js +299 -0
- package/dist/browser-open.js +46 -0
- package/dist/cli.js +353 -0
- package/dist/config-store.js +99 -0
- package/dist/detach-cmd.js +37 -0
- package/dist/device-auth.js +149 -0
- package/dist/frame-actions.js +34 -0
- package/dist/heartbeat.js +31 -0
- package/dist/local-agent.js +437 -0
- package/dist/login-cmd.js +50 -0
- package/dist/reconnect.js +41 -0
- package/dist/sse-frame-parser.js +66 -0
- package/dist/status-cmd.js +52 -0
- package/dist/workspaces-cmd.js +37 -0
- package/package.json +34 -0
|
@@ -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
|
+
}
|