@yolo-labs/yolobridge 0.7.1 → 0.8.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",