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