@yolo-labs/yolobridge 0.21.0 → 0.23.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/attach-cmd.js +24 -2
- package/dist/cli.js +13 -0
- package/dist/detach-sequence.js +80 -0
- package/dist/frame-actions.js +4 -0
- package/dist/local-agent.js +43 -1
- package/dist/output-stream.js +15 -0
- package/package.json +1 -1
package/dist/attach-cmd.js
CHANGED
|
@@ -22,8 +22,8 @@ import { SseFrameParser } from './sse-frame-parser.js';
|
|
|
22
22
|
import { actionForFrame } from './frame-actions.js';
|
|
23
23
|
import { startHeartbeat, defaultTimers } from './heartbeat.js';
|
|
24
24
|
import { nextBackoffMs, isFatalCredentialRefusal, fatalCredentialRefusalMessage, } from './reconnect.js';
|
|
25
|
-
import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, } from './local-agent.js';
|
|
26
|
-
import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, } from './output-stream.js';
|
|
25
|
+
import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, writeInputToLocalAgent, } from './local-agent.js';
|
|
26
|
+
import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, INTERACTIVE_ECHO_FLUSH_MS, } from './output-stream.js';
|
|
27
27
|
import * as apiClient from './api-client.js';
|
|
28
28
|
import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
|
|
29
29
|
import { loadAuth, saveAuth, loadAttachment, saveAttachment, clearAttachment, } from './config-store.js';
|
|
@@ -78,6 +78,7 @@ export async function runAttachDaemon(deps) {
|
|
|
78
78
|
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
79
79
|
const clearScreen = deps.clearScreen ?? (() => process.stdout.write('\x1b[2J\x1b[3J\x1b[H'));
|
|
80
80
|
const deliverPrompt = deps.deliverPrompt ?? deliverPromptToLocalAgent;
|
|
81
|
+
const writeInput = deps.writeInput ?? writeInputToLocalAgent;
|
|
81
82
|
const captureOutput = deps.captureOutput ?? captureLocalAgentOutput;
|
|
82
83
|
const timers = deps.timers ?? defaultTimers;
|
|
83
84
|
const now = deps.now ?? Date.now;
|
|
@@ -925,6 +926,27 @@ export async function runAttachDaemon(deps) {
|
|
|
925
926
|
case 'prompt':
|
|
926
927
|
await deliverPrompt(action.prompt);
|
|
927
928
|
break;
|
|
929
|
+
case 'input': {
|
|
930
|
+
// Raw keystrokes from a console client. Written verbatim —
|
|
931
|
+
// no Enter appended, no readiness gate — see
|
|
932
|
+
// `writeInputToLocalAgent`. Nothing logs the bytes.
|
|
933
|
+
writeInput(action.data);
|
|
934
|
+
// The PTY echoes within ~1ms. Without this the echo waits out
|
|
935
|
+
// the 80ms batch window, which buys nothing for a payload
|
|
936
|
+
// this small and spends ~40% of the latency budget that is
|
|
937
|
+
// ours rather than the network's. `flushOutputStream` re-checks
|
|
938
|
+
// that this session is still current, so a stale timer no-ops.
|
|
939
|
+
const echoSession = outputStream;
|
|
940
|
+
if (echoSession) {
|
|
941
|
+
// A plain timer, not `timers`: that seam exists so tests can
|
|
942
|
+
// drive the heartbeat/flush CADENCE, and widening it for a
|
|
943
|
+
// 5ms nudge would touch every existing double. `unref` so a
|
|
944
|
+
// pending echo can never hold the process open at exit.
|
|
945
|
+
const t = setTimeout(() => { void flushOutputStream(echoSession); }, INTERACTIVE_ECHO_FLUSH_MS);
|
|
946
|
+
t.unref?.();
|
|
947
|
+
}
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
928
950
|
case 'read-output': {
|
|
929
951
|
const captured = await captureOutput();
|
|
930
952
|
// Taken AFTER the (async) capture and synchronously, so
|
package/dist/cli.js
CHANGED
|
@@ -443,6 +443,10 @@ async function cmdAttach(args) {
|
|
|
443
443
|
// real `onExit` handler below: stop + detach immediately rather than
|
|
444
444
|
// let the daemon loop ride out the full heartbeat-staleness window.
|
|
445
445
|
try {
|
|
446
|
+
// Say where Ctrl+C goes BEFORE the agent takes over the screen.
|
|
447
|
+
// Without this the operator presses it expecting to quit, nothing
|
|
448
|
+
// happens, and there is no way to discover why.
|
|
449
|
+
process.stdout.write('yolo-bridge: Ctrl+C goes to the agent · Ctrl-P Ctrl-Q to detach\n');
|
|
446
450
|
startLocalAgent({
|
|
447
451
|
agentBin,
|
|
448
452
|
// Empty unless the MCP block above successfully resolved argv
|
|
@@ -451,6 +455,15 @@ async function cmdAttach(args) {
|
|
|
451
455
|
// never a launch carrying a flag the binary would reject.
|
|
452
456
|
agentArgs: agentMcpArgs,
|
|
453
457
|
cwd: spawnCwd,
|
|
458
|
+
// The daemon's only reachable stop key. `process.on('SIGINT')`
|
|
459
|
+
// above cannot fire from the keyboard: stdin is in raw mode so the
|
|
460
|
+
// tty never turns Ctrl+C into a signal, and Ctrl+C is deliberately
|
|
461
|
+
// forwarded to the AGENT instead (interrupting a runaway agent is
|
|
462
|
+
// worth more than quitting the daemon). Same teardown either way.
|
|
463
|
+
onDetachRequested: () => {
|
|
464
|
+
process.stdout.write('\nyolo-bridge: detaching...\n');
|
|
465
|
+
onSignal();
|
|
466
|
+
},
|
|
454
467
|
onExit: ({ exitCode, signal }) => {
|
|
455
468
|
localAgentExited = true;
|
|
456
469
|
stopRequested = true;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The detach escape sequence for `yolo-bridge attach`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `cli.ts` registers `process.on('SIGINT', …)` as the daemon's
|
|
5
|
+
* stop path, and `local-agent.ts` puts stdin into RAW MODE so every byte can be
|
|
6
|
+
* forwarded to the agent's PTY. Raw mode is precisely the mode in which the
|
|
7
|
+
* tty stops translating `\x03` into SIGINT — so that handler is not merely at
|
|
8
|
+
* risk of being missed, it is UNREACHABLE from the keyboard for as long as an
|
|
9
|
+
* agent is attached. The operator presses Ctrl+C expecting to quit, gets
|
|
10
|
+
* silence, and the teardown never runs.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ THE FIX IS NOT TO GIVE Ctrl+C BACK TO THE DAEMON. Forwarding it to the
|
|
13
|
+
* agent is the more valuable behaviour by a wide margin — interrupting a
|
|
14
|
+
* runaway agent is the thing an operator actually needs mid-session, and a
|
|
15
|
+
* daemon that quit instead would take the agent down with it. So Ctrl+C keeps
|
|
16
|
+
* going to the agent and the daemon gets its own key.
|
|
17
|
+
*
|
|
18
|
+
* `Ctrl-P Ctrl-Q`, following `docker attach`. Chosen because it is vanishingly
|
|
19
|
+
* rare in agent TUIs: `Ctrl-C`, `Ctrl-D`, `Ctrl-Z` and a lone `Ctrl-Q` are all
|
|
20
|
+
* in active use by the CLIs this daemon spawns, and stealing any of them would
|
|
21
|
+
* trade one broken key for another.
|
|
22
|
+
*/
|
|
23
|
+
/** `Ctrl-P` — the prefix. Held back until the next byte decides its meaning. */
|
|
24
|
+
export const DETACH_PREFIX_BYTE = 0x10;
|
|
25
|
+
/** `Ctrl-Q` — only a detach when it IMMEDIATELY follows the prefix. */
|
|
26
|
+
export const DETACH_SUFFIX_BYTE = 0x11;
|
|
27
|
+
/**
|
|
28
|
+
* Splits a stdin stream into "detach" and "everything else".
|
|
29
|
+
*
|
|
30
|
+
* Byte-oriented and chunk-agnostic on purpose: in raw mode each keypress
|
|
31
|
+
* usually arrives as its own chunk, but nothing guarantees it, so the two
|
|
32
|
+
* bytes of the sequence may land together or apart and must behave identically
|
|
33
|
+
* either way.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ A PREFIX FOLLOWED BY ANYTHING ELSE FORWARDS BOTH BYTES. Swallowing the
|
|
36
|
+
* `Ctrl-P` would silently break it for agents that use it, which is the same
|
|
37
|
+
* class of bug this whole change exists to fix.
|
|
38
|
+
*/
|
|
39
|
+
export function createDetachSequenceFilter(opts) {
|
|
40
|
+
let prefixPending = false;
|
|
41
|
+
let detached = false;
|
|
42
|
+
return {
|
|
43
|
+
push(data) {
|
|
44
|
+
// Once detached, further keystrokes belong to a session that is going
|
|
45
|
+
// away; forwarding them would race the teardown.
|
|
46
|
+
if (detached)
|
|
47
|
+
return;
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < data.length; i++) {
|
|
50
|
+
const code = data.charCodeAt(i);
|
|
51
|
+
if (prefixPending) {
|
|
52
|
+
prefixPending = false;
|
|
53
|
+
if (code === DETACH_SUFFIX_BYTE) {
|
|
54
|
+
// Emit whatever preceded the sequence, then stop. The prefix and
|
|
55
|
+
// suffix are consumed and never reach the agent.
|
|
56
|
+
if (out)
|
|
57
|
+
opts.emit(out);
|
|
58
|
+
detached = true;
|
|
59
|
+
opts.onDetach();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Not the suffix: the prefix was an ordinary keystroke after all.
|
|
63
|
+
out += String.fromCharCode(DETACH_PREFIX_BYTE);
|
|
64
|
+
// Fall through so THIS byte is handled normally — including the case
|
|
65
|
+
// where it is itself another prefix.
|
|
66
|
+
}
|
|
67
|
+
if (code === DETACH_PREFIX_BYTE) {
|
|
68
|
+
prefixPending = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out += String.fromCharCode(code);
|
|
72
|
+
}
|
|
73
|
+
if (out)
|
|
74
|
+
opts.emit(out);
|
|
75
|
+
},
|
|
76
|
+
dispose() {
|
|
77
|
+
prefixPending = false;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/frame-actions.js
CHANGED
|
@@ -27,6 +27,10 @@ export function actionForFrame(frame) {
|
|
|
27
27
|
return { kind: 'ping' };
|
|
28
28
|
case 'prompt':
|
|
29
29
|
return { kind: 'prompt', attachmentId: String(data.attachmentId ?? ''), prompt: String(data.prompt ?? '') };
|
|
30
|
+
case 'input':
|
|
31
|
+
// No coercion beyond String(): these are the operator's own keystrokes
|
|
32
|
+
// and anything clever here would corrupt a control sequence.
|
|
33
|
+
return { kind: 'input', attachmentId: String(data.attachmentId ?? ''), data: String(data.data ?? '') };
|
|
30
34
|
case 'read-output':
|
|
31
35
|
return {
|
|
32
36
|
kind: 'read-output',
|
package/dist/local-agent.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
35
|
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { createDetachSequenceFilter } from './detach-sequence.js';
|
|
36
37
|
import * as pty from 'node-pty';
|
|
37
38
|
import { splitByUtf8Bytes } from './output-stream.js';
|
|
38
39
|
import { AnsiScanner, TerminalModeTracker, buildModePrologue, resolveGroundStart, } from './ansi-replay-state.js';
|
|
@@ -737,8 +738,16 @@ export function startLocalAgent(opts = {}) {
|
|
|
737
738
|
fanOutRawData(data, pushRaw(data));
|
|
738
739
|
});
|
|
739
740
|
if (inStream && typeof inStream.on === 'function') {
|
|
741
|
+
// Every byte passes through the detach filter on its way to the PTY. It
|
|
742
|
+
// forwards everything except the `Ctrl-P Ctrl-Q` sequence — including a
|
|
743
|
+
// lone `Ctrl-P`, which agents use for history and which must not be eaten.
|
|
744
|
+
const detachFilter = createDetachSequenceFilter({
|
|
745
|
+
emit: (chunk) => { ptyProcess.write(chunk); },
|
|
746
|
+
onDetach: () => { opts.onDetachRequested?.(); },
|
|
747
|
+
});
|
|
748
|
+
state.detachFilter = detachFilter;
|
|
740
749
|
const stdinListener = (data) => {
|
|
741
|
-
|
|
750
|
+
detachFilter.push(typeof data === 'string' ? data : data.toString('utf-8'));
|
|
742
751
|
};
|
|
743
752
|
if (inStream.isTTY && typeof inStream.setRawMode === 'function') {
|
|
744
753
|
inStream.setRawMode(true);
|
|
@@ -818,6 +827,7 @@ function handleLocalResize(state) {
|
|
|
818
827
|
}
|
|
819
828
|
}
|
|
820
829
|
function teardownStdio(state) {
|
|
830
|
+
state.detachFilter?.dispose();
|
|
821
831
|
if (state.resizeSource && state.resizeListener && typeof state.resizeSource.removeListener === 'function') {
|
|
822
832
|
state.resizeSource.removeListener('resize', state.resizeListener);
|
|
823
833
|
}
|
|
@@ -858,6 +868,38 @@ export function stopLocalAgent() {
|
|
|
858
868
|
// already dead
|
|
859
869
|
}
|
|
860
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* Write raw bytes straight into the PTY, exactly as typed.
|
|
873
|
+
*
|
|
874
|
+
* ⚠️ DELIBERATELY NOT `deliverPromptToLocalAgent`. That function is for one
|
|
875
|
+
* coherent instruction: it waits on a readiness gate, writes the text, pauses,
|
|
876
|
+
* then writes `\r`. Every one of those is wrong for a keystroke —
|
|
877
|
+
* a lone `\x03` would get an Enter appended, an arrow-key escape sequence would
|
|
878
|
+
* be split by the pause, and the readiness gate would block on a prompt that a
|
|
879
|
+
* mid-session TUI never shows.
|
|
880
|
+
*
|
|
881
|
+
* So this does the minimum: if there is a live PTY, write the bytes. No
|
|
882
|
+
* interpretation, no framing, no Enter.
|
|
883
|
+
*
|
|
884
|
+
* ⚠️ NOTHING HERE LOGS `data`. These are the operator's keystrokes on their own
|
|
885
|
+
* machine — the same rule the output path already follows, and the reason a
|
|
886
|
+
* console session logs that it opened and closed and never what was typed.
|
|
887
|
+
*
|
|
888
|
+
* Returns false when there is no agent to write to, so a caller can say
|
|
889
|
+
* "nothing is attached" rather than silently swallowing input.
|
|
890
|
+
*/
|
|
891
|
+
export function writeInputToLocalAgent(data) {
|
|
892
|
+
if (!current || !data)
|
|
893
|
+
return false;
|
|
894
|
+
try {
|
|
895
|
+
current.ptyProcess.write(data);
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// The child is gone; onExit will clear `current`.
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
861
903
|
/**
|
|
862
904
|
* Best-effort readiness check before `deliverPromptToLocalAgent` writes
|
|
863
905
|
* into the PTY — docs/YOLOBRIDGE_PLAN.md's "[P1] Blind prompt delivery can
|
package/dist/output-stream.js
CHANGED
|
@@ -42,6 +42,21 @@
|
|
|
42
42
|
* output reads as live, long enough that a chatty PTY costs ~12 POSTs/second
|
|
43
43
|
* rather than one per `onData`. */
|
|
44
44
|
export const DEFAULT_FLUSH_INTERVAL_MS = 80;
|
|
45
|
+
/**
|
|
46
|
+
* Flush delay after a console keystroke, rather than waiting out the batch
|
|
47
|
+
* window.
|
|
48
|
+
*
|
|
49
|
+
* The 80ms above is right for a passive VIEWER: it trades a little liveness for
|
|
50
|
+
* ~12 POSTs/second instead of one per `onData`. It is wrong for an interactive
|
|
51
|
+
* session, where a single echoed keystroke IS the entire payload and the batch
|
|
52
|
+
* saves nothing while costing up to 80ms of a round trip already near 200ms —
|
|
53
|
+
* measured 2026-08-28, and roughly 40% of the budget that is ours to spend
|
|
54
|
+
* rather than the network's.
|
|
55
|
+
*
|
|
56
|
+
* 5ms is long enough that a burst of keystrokes still coalesces into one POST,
|
|
57
|
+
* short enough to be invisible next to the ~160ms the network costs.
|
|
58
|
+
*/
|
|
59
|
+
export const INTERACTIVE_ECHO_FLUSH_MS = 5;
|
|
45
60
|
/** ~192 KiB/s sustained. Comfortably above a fast agent's real output rate
|
|
46
61
|
* (a streaming LLM response is a few KiB/s), far below what `cat`ting a
|
|
47
62
|
* large file would produce. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|