@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.
@@ -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 new one.
424
+ * (card 08), instead of creating a second one.
430
425
  *
431
- * Deliberately reached ONLY when the account credential cannot do a fresh
432
- * attach. A healthy account token takes the ordinary path, byte for byte as
433
- * before resuming is a recovery route, not a new default, so it cannot
434
- * change what a normal `yolo-bridge attach` does.
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 case it exists for is the one this card creates: a daemon that has run
437
- * for days renewing its scoped credential, whose account access token expired
438
- * on day one and whose refresh token is deliberately no longer on disk. Its
439
- * restart has no account credential at all but it does still hold a
440
- * workspace-scoped one, which is precisely the credential the attachment's
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
- * No window check here on purpose: the SERVER is the authority on whether a
445
- * credential is still renewable (card 07's grace-window comment), and the
446
- * renewal that runs before the stream opens asks it. If the answer is no, the
447
- * daemon stops with card 07's own re-attach remedy rather than inventing a
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 (!initialRefresh.ok) {
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
- attachmentId = stored.attachmentId;
458
- tileId = stored.tileId;
459
- attachedAt = stored.attachedAt;
460
- rememberScopedCredential(stored.scopedToken, stored.scopedTokenExpiresAtMs);
461
- resumed = true;
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
- else {
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
@@ -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
- * plain text no ANSI/SGR escape codes. Deliberately not using
130
- * `@xterm/addon-serialize`: that addon's `serialize()` reconstructs a
131
- * VT100-replayable stream (colors, cursor moves included) for re-feeding
132
- * into another terminal, which is the wrong shape for `read_tile_output`
133
- * — the consumer on the other end (an orchestrator tile, possibly an
134
- * LLM) wants clean text, not escape sequences. Walking `buffer.active`
135
- * directly and calling `translateToString` per line gives exactly that.
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
- lines.push(line ? line.translateToString(true) : '');
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.7.1",
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",