@yolo-labs/yolobridge 0.7.1 → 0.9.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 +49 -31
- package/dist/cli.js +17 -3
- package/dist/local-agent.js +164 -9
- package/package.json +1 -1
package/dist/attach-cmd.js
CHANGED
|
@@ -419,54 +419,72 @@ export async function runAttachDaemon(deps) {
|
|
|
419
419
|
return { ok: true };
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
-
// Cover the case where the daemon is (re)started against a token that's
|
|
423
|
-
// already within the refresh buffer of expiry (e.g. `attach` run right
|
|
424
|
-
// after a long-down period) — refresh before the very first network
|
|
425
|
-
// call, not just before subsequent reconnects.
|
|
426
|
-
const initialRefresh = await ensureFreshToken();
|
|
427
422
|
/**
|
|
428
423
|
* RESUME an existing attachment from its persisted scoped credential
|
|
429
|
-
* (card 08), instead of creating a
|
|
424
|
+
* (card 08), instead of creating a second one.
|
|
430
425
|
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
426
|
+
* **LIVENESS decides, not the account token's health** (D7). Card 08 gated
|
|
427
|
+
* this on the account refresh having failed, which meant a daemon restarting
|
|
428
|
+
* with a healthy account token ignored a perfectly good stored attachment and
|
|
429
|
+
* called `attach` again — a SECOND server-side attachment and a second tile.
|
|
430
|
+
* The first stops heartbeating and is reaped, but the operator is left
|
|
431
|
+
* looking at a duplicate. Account-token health says nothing about whether the
|
|
432
|
+
* attachment is still alive, so it was never the right question.
|
|
435
433
|
*
|
|
436
|
-
* The
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* own routes want. Without this the operator is sent back to
|
|
442
|
-
* `yolo-bridge login` for a session that never actually lost anything.
|
|
434
|
+
* The probe is card 07's own renewal route
|
|
435
|
+
* (`POST .../yolobridge/attach/:attachmentId/refresh`). That route re-reads
|
|
436
|
+
* live attachment state on every call and 403s a detached one, so it is
|
|
437
|
+
* simultaneously the liveness check AND the source of a fresh credential —
|
|
438
|
+
* there is deliberately no second probe to drift out of agreement with it.
|
|
443
439
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* second, possibly-drifted opinion about the window here.
|
|
440
|
+
* A failure of ANY kind (403 detached, 401 past the renewal window, a
|
|
441
|
+
* network error) is not fatal here: it just means there is nothing to
|
|
442
|
+
* resume, and the ordinary attach path below runs. The server, not this
|
|
443
|
+
* binary's copy of the window, is the authority on which it was.
|
|
449
444
|
*/
|
|
450
445
|
let resumed = false;
|
|
451
|
-
if (!
|
|
446
|
+
if (!deps.fresh) {
|
|
452
447
|
const stored = loadAttachment(env, io);
|
|
453
448
|
if (stored &&
|
|
454
449
|
stored.workspaceId === workspaceId &&
|
|
455
450
|
stored.scopedToken !== undefined &&
|
|
456
451
|
stored.scopedTokenExpiresAtMs !== undefined) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
452
|
+
try {
|
|
453
|
+
// A one-off config rather than `scopedCfg()`: the stored token is not
|
|
454
|
+
// adopted as THE credential until the server has confirmed it is
|
|
455
|
+
// still good, so a failed probe leaves the daemon's own state
|
|
456
|
+
// untouched and the fall-through is a clean ordinary attach.
|
|
457
|
+
const renewed = await apiClient.refreshScopedToken({ commonApiBaseUrl, accessToken: stored.scopedToken, fetchImpl }, workspaceId, stored.attachmentId);
|
|
458
|
+
attachmentId = stored.attachmentId;
|
|
459
|
+
tileId = stored.tileId;
|
|
460
|
+
attachedAt = stored.attachedAt;
|
|
461
|
+
rememberScopedCredential(renewed.scopedToken, renewed.scopedTokenExpiresAt);
|
|
462
|
+
resumed = true;
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
// Pre-spawn, so `log` is safe here (nothing owns the terminal yet) —
|
|
466
|
+
// and worth saying out loud: the operator is about to get a NEW tile
|
|
467
|
+
// where they may have expected the old one back.
|
|
468
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
469
|
+
log(`Stored attachment is no longer resumable (${message}) — attaching fresh.`);
|
|
470
|
+
}
|
|
462
471
|
}
|
|
463
|
-
|
|
472
|
+
}
|
|
473
|
+
if (!resumed) {
|
|
474
|
+
// ONLY the attach path needs the account credential — attach is the call
|
|
475
|
+
// that creates the scope, and nothing after it presents an account token.
|
|
476
|
+
// Keeping this refresh inside the branch is the ordering win D7 exists
|
|
477
|
+
// for: a daemon whose account token expired days ago, but whose
|
|
478
|
+
// attachment is still live, resumes above and never reaches this line.
|
|
479
|
+
// (It also still covers the original reason it existed: an `attach` run
|
|
480
|
+
// right after a long-down period, against a token already inside the
|
|
481
|
+
// refresh buffer, rotates before the very first network call.)
|
|
482
|
+
const initialRefresh = await ensureFreshToken();
|
|
483
|
+
if (!initialRefresh.ok) {
|
|
464
484
|
log(`Token refresh failed: ${initialRefresh.message}`);
|
|
465
485
|
log('Run `yolo-bridge login` again.');
|
|
466
486
|
return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
|
|
467
487
|
}
|
|
468
|
-
}
|
|
469
|
-
if (!resumed) {
|
|
470
488
|
try {
|
|
471
489
|
const result = await apiClient.attach(accountCfg, workspaceId, hostLabel, remoteHost);
|
|
472
490
|
attachmentId = result.attachmentId;
|
package/dist/cli.js
CHANGED
|
@@ -94,6 +94,10 @@ function printHelp() {
|
|
|
94
94
|
' [--agent-id <id>] Registry identity for local MCP access, if different from --agent',
|
|
95
95
|
' (e.g. a raw executable path, or an agent whose binary name differs',
|
|
96
96
|
' from its registry id like qwen-code/qwen). Defaults to --agent.',
|
|
97
|
+
' [--fresh] Always create a NEW attachment and tile. By default an attach that',
|
|
98
|
+
' finds a still-live attachment for this workspace on this machine',
|
|
99
|
+
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
100
|
+
' that check entirely.',
|
|
97
101
|
' detach Detach the current workspace attachment.',
|
|
98
102
|
' status Print local login/attach state.',
|
|
99
103
|
' --help Print this help.',
|
|
@@ -139,8 +143,16 @@ export function parseAttachArgs(args) {
|
|
|
139
143
|
let hostLabel;
|
|
140
144
|
let agentBin;
|
|
141
145
|
let agentId;
|
|
146
|
+
let fresh = false;
|
|
142
147
|
for (let i = 0; i < args.length; i++) {
|
|
143
148
|
const a = args[i];
|
|
149
|
+
// Boolean — handled before the value-taking flags so it never swallows
|
|
150
|
+
// the following argument (`attach --fresh w1` must still see `w1` as the
|
|
151
|
+
// positional workspace id).
|
|
152
|
+
if (a === '--fresh') {
|
|
153
|
+
fresh = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
144
156
|
if (a === '--label' || a === '--agent' || a === '--agent-id') {
|
|
145
157
|
const value = args[i + 1];
|
|
146
158
|
if (value === undefined || value.startsWith('--')) {
|
|
@@ -162,7 +174,7 @@ export function parseAttachArgs(args) {
|
|
|
162
174
|
workspaceId = a;
|
|
163
175
|
}
|
|
164
176
|
}
|
|
165
|
-
return { workspaceId, hostLabel, agentBin, agentId };
|
|
177
|
+
return { workspaceId, hostLabel, agentBin, agentId, fresh };
|
|
166
178
|
}
|
|
167
179
|
/**
|
|
168
180
|
* Everything the attach handshake tells the workspace about this machine,
|
|
@@ -210,7 +222,7 @@ async function cmdAttach(args) {
|
|
|
210
222
|
const parsed = parseAttachArgs(args);
|
|
211
223
|
if ('error' in parsed) {
|
|
212
224
|
process.stderr.write(`yolo-bridge attach: ${parsed.error}\n`);
|
|
213
|
-
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
|
|
225
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>] [--fresh]\n');
|
|
214
226
|
return 64;
|
|
215
227
|
}
|
|
216
228
|
let workspaceId = parsed.workspaceId;
|
|
@@ -222,6 +234,7 @@ async function cmdAttach(args) {
|
|
|
222
234
|
// match, which is every built-in agent this daemon has been used with so
|
|
223
235
|
// far (claude, codex).
|
|
224
236
|
const resolvedAgentId = parsed.agentId ?? agentBin ?? DEFAULT_AGENT_BIN;
|
|
237
|
+
const fresh = parsed.fresh === true;
|
|
225
238
|
if (workspaceId) {
|
|
226
239
|
const resolved = await resolveWorkspaceIdOrName(workspaceId, { commonApiBaseUrl: apiUrl() });
|
|
227
240
|
if (!resolved.ok) {
|
|
@@ -250,7 +263,7 @@ async function cmdAttach(args) {
|
|
|
250
263
|
process.stderr.write(`yolo-bridge attach: ${pick.message}\n`);
|
|
251
264
|
break;
|
|
252
265
|
}
|
|
253
|
-
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
|
|
266
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>] [--fresh]\n');
|
|
254
267
|
return 64;
|
|
255
268
|
}
|
|
256
269
|
workspaceId = pick.workspaceId;
|
|
@@ -286,6 +299,7 @@ async function cmdAttach(args) {
|
|
|
286
299
|
commonApiBaseUrl: apiUrl(),
|
|
287
300
|
hostLabel: attachHostInfo.hostLabel,
|
|
288
301
|
remoteHost: attachHostInfo.remoteHost,
|
|
302
|
+
fresh,
|
|
289
303
|
shouldStop: () => stopRequested,
|
|
290
304
|
// Fires once the real tileId exists (docs/YOLOBRIDGE_PLAN.md's "Local
|
|
291
305
|
// MCP access" section) — starts the local MCP proxy and writes
|
package/dist/local-agent.js
CHANGED
|
@@ -124,22 +124,177 @@ function resolveRows(opts) {
|
|
|
124
124
|
return DEFAULT_ROWS;
|
|
125
125
|
return process.stdout.rows || DEFAULT_ROWS;
|
|
126
126
|
}
|
|
127
|
+
const DEFAULT_ATTRS = {
|
|
128
|
+
bold: false,
|
|
129
|
+
dim: false,
|
|
130
|
+
italic: false,
|
|
131
|
+
underline: false,
|
|
132
|
+
inverse: false,
|
|
133
|
+
strikethrough: false,
|
|
134
|
+
fgMode: 'default',
|
|
135
|
+
fgColor: 0,
|
|
136
|
+
bgMode: 'default',
|
|
137
|
+
bgColor: 0,
|
|
138
|
+
};
|
|
139
|
+
function attrsAreDefault(a) {
|
|
140
|
+
return (!a.bold &&
|
|
141
|
+
!a.dim &&
|
|
142
|
+
!a.italic &&
|
|
143
|
+
!a.underline &&
|
|
144
|
+
!a.inverse &&
|
|
145
|
+
!a.strikethrough &&
|
|
146
|
+
a.fgMode === 'default' &&
|
|
147
|
+
a.bgMode === 'default');
|
|
148
|
+
}
|
|
149
|
+
/** `38;…` / `48;…` (or the compact 30-37/90-97/40-47/100-107 forms). */
|
|
150
|
+
function colorCodes(mode, color, fg) {
|
|
151
|
+
if (mode === 'default')
|
|
152
|
+
return [fg ? '39' : '49'];
|
|
153
|
+
if (mode === 'rgb') {
|
|
154
|
+
// Typings: RGB mode packs the colour as 0xRRGGBB.
|
|
155
|
+
const r = (color >> 16) & 0xff;
|
|
156
|
+
const g = (color >> 8) & 0xff;
|
|
157
|
+
const b = color & 0xff;
|
|
158
|
+
return [`${fg ? 38 : 48};2;${r};${g};${b}`];
|
|
159
|
+
}
|
|
160
|
+
// Palette: 0-255. 0-7 and 8-15 have compact single-code forms; the rest
|
|
161
|
+
// need the indexed form.
|
|
162
|
+
if (color < 8)
|
|
163
|
+
return [String((fg ? 30 : 40) + color)];
|
|
164
|
+
if (color < 16)
|
|
165
|
+
return [String((fg ? 90 : 100) + (color - 8))];
|
|
166
|
+
return [`${fg ? 38 : 48};5;${color}`];
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The SGR parameters that move `prev` to `next` — EMPTY when nothing
|
|
170
|
+
* changed, which is what keeps the payload small (see
|
|
171
|
+
* `serializeTerminalBuffer`).
|
|
172
|
+
*/
|
|
173
|
+
function sgrDiff(prev, next) {
|
|
174
|
+
const codes = [];
|
|
175
|
+
// Bold and dim share one "off" code (22), so turning either off means
|
|
176
|
+
// re-asserting whichever of the two survives.
|
|
177
|
+
if ((prev.bold && !next.bold) || (prev.dim && !next.dim)) {
|
|
178
|
+
codes.push('22');
|
|
179
|
+
if (next.bold)
|
|
180
|
+
codes.push('1');
|
|
181
|
+
if (next.dim)
|
|
182
|
+
codes.push('2');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
if (!prev.bold && next.bold)
|
|
186
|
+
codes.push('1');
|
|
187
|
+
if (!prev.dim && next.dim)
|
|
188
|
+
codes.push('2');
|
|
189
|
+
}
|
|
190
|
+
if (prev.italic !== next.italic)
|
|
191
|
+
codes.push(next.italic ? '3' : '23');
|
|
192
|
+
if (prev.underline !== next.underline)
|
|
193
|
+
codes.push(next.underline ? '4' : '24');
|
|
194
|
+
if (prev.inverse !== next.inverse)
|
|
195
|
+
codes.push(next.inverse ? '7' : '27');
|
|
196
|
+
if (prev.strikethrough !== next.strikethrough)
|
|
197
|
+
codes.push(next.strikethrough ? '9' : '29');
|
|
198
|
+
if (prev.fgMode !== next.fgMode || prev.fgColor !== next.fgColor) {
|
|
199
|
+
codes.push(...colorCodes(next.fgMode, next.fgColor, true));
|
|
200
|
+
}
|
|
201
|
+
if (prev.bgMode !== next.bgMode || prev.bgColor !== next.bgColor) {
|
|
202
|
+
codes.push(...colorCodes(next.bgMode, next.bgColor, false));
|
|
203
|
+
}
|
|
204
|
+
return codes;
|
|
205
|
+
}
|
|
127
206
|
/**
|
|
128
|
-
* Serializes the terminal's current buffer (scrollback + viewport) to
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* —
|
|
134
|
-
*
|
|
135
|
-
*
|
|
207
|
+
* Serializes the terminal's current buffer (scrollback + viewport) to text
|
|
208
|
+
* that keeps the agent's COLOUR and text styling, as SGR escapes only.
|
|
209
|
+
*
|
|
210
|
+
* Still deliberately not `@xterm/addon-serialize`: that addon reconstructs
|
|
211
|
+
* a fully VT100-replayable stream — cursor moves, scroll regions, mode
|
|
212
|
+
* switches — for re-feeding into another terminal, which is the wrong
|
|
213
|
+
* shape for `read_tile_output`. Its consumers (the browser tile, and an
|
|
214
|
+
* orchestrator/LLM reading the same capture) want the SCREEN as lines,
|
|
215
|
+
* with the styling that makes an agent's output readable, and nothing
|
|
216
|
+
* that repositions a cursor. So we walk `buffer.active` cell by cell and
|
|
217
|
+
* re-emit just the SGR state.
|
|
218
|
+
*
|
|
219
|
+
* Payload discipline is the reason this walks cells rather than emitting
|
|
220
|
+
* per cell: an escape is written ONLY where the attribute state actually
|
|
221
|
+
* changes, so a screen of unstyled text emits ZERO escapes and is
|
|
222
|
+
* byte-identical to what the old `translateToString(true)` produced. That
|
|
223
|
+
* matters — this capture is polled on an interval and crosses the
|
|
224
|
+
* network on every poll.
|
|
225
|
+
*
|
|
226
|
+
* Each line is self-contained: any line that ends with non-default
|
|
227
|
+
* attributes is closed with a reset, so state cannot bleed into the next
|
|
228
|
+
* line (the webapp splits this on `\n` and renders lines independently).
|
|
229
|
+
*
|
|
230
|
+
* NOT preserved, by design: cursor position, the alternate-screen flag,
|
|
231
|
+
* scroll regions, hyperlinks (OSC 8), and blink/invisible/overline — none
|
|
232
|
+
* of them survive into a static, line-split view.
|
|
136
233
|
*/
|
|
137
234
|
export function serializeTerminalBuffer(term) {
|
|
138
235
|
const buffer = term.buffer.active;
|
|
139
236
|
const lines = [];
|
|
140
237
|
for (let i = 0; i < buffer.length; i++) {
|
|
141
238
|
const line = buffer.getLine(i);
|
|
142
|
-
|
|
239
|
+
if (!line) {
|
|
240
|
+
lines.push('');
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// Collect first, so trailing blanks can be trimmed before any escape
|
|
244
|
+
// is emitted for them (matching the old `translateToString(true)`).
|
|
245
|
+
const cells = [];
|
|
246
|
+
for (let x = 0; x < line.length; x++) {
|
|
247
|
+
const cell = line.getCell(x);
|
|
248
|
+
if (!cell)
|
|
249
|
+
continue;
|
|
250
|
+
// Width 0 = the right half of a wide (CJK/emoji) glyph; its content
|
|
251
|
+
// already came out of the width-2 cell before it. Emitting it too
|
|
252
|
+
// would duplicate the character.
|
|
253
|
+
if (cell.getWidth() === 0)
|
|
254
|
+
continue;
|
|
255
|
+
// An untouched cell has no content at all; it renders as a space.
|
|
256
|
+
const chars = cell.getChars();
|
|
257
|
+
cells.push({
|
|
258
|
+
text: chars === '' ? ' ' : chars,
|
|
259
|
+
attrs: {
|
|
260
|
+
bold: !!cell.isBold(),
|
|
261
|
+
dim: !!cell.isDim(),
|
|
262
|
+
italic: !!cell.isItalic(),
|
|
263
|
+
underline: !!cell.isUnderline(),
|
|
264
|
+
inverse: !!cell.isInverse(),
|
|
265
|
+
strikethrough: !!cell.isStrikethrough(),
|
|
266
|
+
fgMode: cell.isFgRGB() ? 'rgb' : cell.isFgPalette() ? 'palette' : 'default',
|
|
267
|
+
// In default mode the colour NUMBER is meaningless (the typings
|
|
268
|
+
// say "should be 0"; the runtime actually reports -1). Normalise
|
|
269
|
+
// it so two default cells compare equal and emit no escape.
|
|
270
|
+
fgColor: cell.isFgDefault() ? 0 : cell.getFgColor(),
|
|
271
|
+
bgMode: cell.isBgRGB() ? 'rgb' : cell.isBgPalette() ? 'palette' : 'default',
|
|
272
|
+
bgColor: cell.isBgDefault() ? 0 : cell.getBgColor(),
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// Right-trim, as before — but only cells that are blank AND unstyled.
|
|
277
|
+
// A run of spaces carrying a background colour is real, visible output
|
|
278
|
+
// (a status bar, a selection); dropping it would lose the paint.
|
|
279
|
+
while (cells.length > 0) {
|
|
280
|
+
const last = cells[cells.length - 1];
|
|
281
|
+
if (last.text === ' ' && attrsAreDefault(last.attrs))
|
|
282
|
+
cells.pop();
|
|
283
|
+
else
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
let out = '';
|
|
287
|
+
let state = DEFAULT_ATTRS;
|
|
288
|
+
for (const cell of cells) {
|
|
289
|
+
const codes = sgrDiff(state, cell.attrs);
|
|
290
|
+
if (codes.length > 0)
|
|
291
|
+
out += `\x1b[${codes.join(';')}m`;
|
|
292
|
+
out += cell.text;
|
|
293
|
+
state = cell.attrs;
|
|
294
|
+
}
|
|
295
|
+
if (!attrsAreDefault(state))
|
|
296
|
+
out += '\x1b[0m';
|
|
297
|
+
lines.push(out);
|
|
143
298
|
}
|
|
144
299
|
while (lines.length > 0 && lines[lines.length - 1] === '')
|
|
145
300
|
lines.pop();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|