@yolo-labs/yolobridge 0.2.0 → 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.
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { realpathSync } from 'node:fs';
21
+ import { hostname } from 'node:os';
21
22
  import { runLogin } from './login-cmd.js';
22
23
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
23
24
  import { runDetach } from './detach-cmd.js';
@@ -93,6 +94,10 @@ function printHelp() {
93
94
  ' [--agent-id <id>] Registry identity for local MCP access, if different from --agent',
94
95
  ' (e.g. a raw executable path, or an agent whose binary name differs',
95
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.',
96
101
  ' detach Detach the current workspace attachment.',
97
102
  ' status Print local login/attach state.',
98
103
  ' --help Print this help.',
@@ -138,8 +143,16 @@ export function parseAttachArgs(args) {
138
143
  let hostLabel;
139
144
  let agentBin;
140
145
  let agentId;
146
+ let fresh = false;
141
147
  for (let i = 0; i < args.length; i++) {
142
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
+ }
143
156
  if (a === '--label' || a === '--agent' || a === '--agent-id') {
144
157
  const value = args[i + 1];
145
158
  if (value === undefined || value.startsWith('--')) {
@@ -161,7 +174,41 @@ export function parseAttachArgs(args) {
161
174
  workspaceId = a;
162
175
  }
163
176
  }
164
- return { workspaceId, hostLabel, agentBin, agentId };
177
+ return { workspaceId, hostLabel, agentBin, agentId, fresh };
178
+ }
179
+ /**
180
+ * Everything the attach handshake tells the workspace about this machine,
181
+ * resolved from already-known values — pure, so it's unit-testable without
182
+ * touching a real `os`/`process` (the caller passes the real ones in).
183
+ *
184
+ * Two things happen here:
185
+ *
186
+ * - **`hostLabel` gains a default.** It was previously set ONLY by an
187
+ * explicit `--label`, so the overwhelmingly common `yolo-bridge attach`
188
+ * with no flags produced a tile named a bare "YoloBridge" with nothing
189
+ * identifying WHICH machine had attached — actively confusing for an
190
+ * operator running a daemon on more than one. The machine's own hostname
191
+ * is the obvious default and is already what `--label` is usually set to
192
+ * by hand. An explicit `--label` still wins.
193
+ * - **`remoteHost` is assembled**: the launch directory, the OS platform
194
+ * string, and which agent binary this attach drives.
195
+ *
196
+ * What is deliberately NOT collected, and should not be added without its
197
+ * own consent story: environment variables, anything listing the contents
198
+ * of `cwd`, the OS username or any other account identity, network
199
+ * addresses, or installed-software inventory. This is the operator's own
200
+ * machine being described back to the operator; it is not a survey of it.
201
+ */
202
+ export function resolveAttachHostInfo(input) {
203
+ const label = input.label?.trim();
204
+ return {
205
+ hostLabel: label || input.hostname.trim() || undefined,
206
+ remoteHost: {
207
+ cwd: input.cwd,
208
+ platform: input.platform,
209
+ agent: input.agent,
210
+ },
211
+ };
165
212
  }
166
213
  async function cmdAttach(args) {
167
214
  // Printed unconditionally, first thing, regardless of how the rest of
@@ -175,7 +222,7 @@ async function cmdAttach(args) {
175
222
  const parsed = parseAttachArgs(args);
176
223
  if ('error' in parsed) {
177
224
  process.stderr.write(`yolo-bridge attach: ${parsed.error}\n`);
178
- 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');
179
226
  return 64;
180
227
  }
181
228
  let workspaceId = parsed.workspaceId;
@@ -187,6 +234,7 @@ async function cmdAttach(args) {
187
234
  // match, which is every built-in agent this daemon has been used with so
188
235
  // far (claude, codex).
189
236
  const resolvedAgentId = parsed.agentId ?? agentBin ?? DEFAULT_AGENT_BIN;
237
+ const fresh = parsed.fresh === true;
190
238
  if (workspaceId) {
191
239
  const resolved = await resolveWorkspaceIdOrName(workspaceId, { commonApiBaseUrl: apiUrl() });
192
240
  if (!resolved.ok) {
@@ -215,7 +263,7 @@ async function cmdAttach(args) {
215
263
  process.stderr.write(`yolo-bridge attach: ${pick.message}\n`);
216
264
  break;
217
265
  }
218
- 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');
219
267
  return 64;
220
268
  }
221
269
  workspaceId = pick.workspaceId;
@@ -231,6 +279,16 @@ async function cmdAttach(args) {
231
279
  process.on('SIGINT', onSignal);
232
280
  process.on('SIGTERM', onSignal);
233
281
  const spawnCwd = process.cwd();
282
+ // Everything the workspace tile shows about WHERE this session runs, all
283
+ // resolved here in one place (see resolveAttachHostInfo's doc comment for
284
+ // what is and isn't collected).
285
+ const attachHostInfo = resolveAttachHostInfo({
286
+ label: hostLabel,
287
+ hostname: hostname(),
288
+ cwd: spawnCwd,
289
+ platform: process.platform,
290
+ agent: resolvedAgentId,
291
+ });
234
292
  let mcpProxyHandle;
235
293
  let mcpConfigCleanup;
236
294
  let mcpTrustRemoval;
@@ -239,7 +297,9 @@ async function cmdAttach(args) {
239
297
  result = await runAttachFromDisk({
240
298
  workspaceId,
241
299
  commonApiBaseUrl: apiUrl(),
242
- hostLabel,
300
+ hostLabel: attachHostInfo.hostLabel,
301
+ remoteHost: attachHostInfo.remoteHost,
302
+ fresh,
243
303
  shouldStop: () => stopRequested,
244
304
  // Fires once the real tileId exists (docs/YOLOBRIDGE_PLAN.md's "Local
245
305
  // MCP access" section) — starts the local MCP proxy and writes
@@ -248,7 +308,7 @@ async function cmdAttach(args) {
248
308
  // skipped, not fatal — MCP access is an enhancement on a tile that
249
309
  // already works without it (send_to_tile/read_tile_output are
250
310
  // unaffected either way).
251
- onAttached: async ({ getAccessToken, clearScreen }) => {
311
+ onAttached: async ({ tileId, getAccessToken, clearScreen }) => {
252
312
  // Isolated from `startLocalAgent` below on purpose (Codex review,
253
313
  // 2026-08-24): `startMcpProxy` itself never throws, but
254
314
  // `writeLocalMcpConfig`/`writeLocalMcpTrust` do plain synchronous
@@ -266,6 +326,11 @@ async function cmdAttach(args) {
266
326
  getAccessToken,
267
327
  workspaceId,
268
328
  agentId: resolvedAgentId,
329
+ // Self-identity for the spawned agent: the tile it is running in.
330
+ // Without it, an agent asked to message "the other tile" has to
331
+ // guess which studio_list_tiles row is itself — and a backwards
332
+ // guess sends the prompt into its OWN input.
333
+ callerTileId: tileId,
269
334
  log: (line) => process.stdout.write(`${line}\n`),
270
335
  });
271
336
  // `.mcp.json` + `.claude/settings.json` are Claude Code-specific
@@ -56,10 +56,21 @@ export function loadAuth(env = process.env, io = defaultIO) {
56
56
  try {
57
57
  const parsed = JSON.parse(raw);
58
58
  if (typeof parsed.accessToken === 'string' &&
59
- typeof parsed.refreshToken === 'string' &&
59
+ // Absent is VALID (the scoped path deliberately drops it); present must
60
+ // still be a string. A malformed value is treated as absent rather than
61
+ // as a corrupt file: the access token half is what this record is for,
62
+ // and refusing to load it would log the operator out over a field the
63
+ // daemon may not even need.
64
+ (parsed.refreshToken === undefined || typeof parsed.refreshToken === 'string') &&
60
65
  typeof parsed.tokenType === 'string' &&
61
66
  typeof parsed.expiresAtMs === 'number') {
62
- return parsed;
67
+ const { accessToken, refreshToken, tokenType, expiresAtMs } = parsed;
68
+ return {
69
+ accessToken,
70
+ tokenType,
71
+ expiresAtMs,
72
+ ...(refreshToken ? { refreshToken } : {}),
73
+ };
63
74
  }
64
75
  return undefined;
65
76
  }
@@ -67,8 +78,24 @@ export function loadAuth(env = process.env, io = defaultIO) {
67
78
  return undefined;
68
79
  }
69
80
  }
81
+ /**
82
+ * Writes `auth.json`. A missing (or empty) `refreshToken` is written out as an
83
+ * ABSENT KEY, never as `""`.
84
+ *
85
+ * Normalised here rather than left to callers on purpose: an empty string is a
86
+ * value that every `typeof x === 'string'` check downstream accepts, so it
87
+ * would sail through the loader and be handed to auth-service's refresh
88
+ * endpoint as a credential, turning "we deliberately dropped this" into an
89
+ * unexplained 401. Absent is the honest encoding of absent.
90
+ */
70
91
  export function saveAuth(auth, env = process.env, io = defaultIO) {
71
- io.writeFile(authPath(env), `${JSON.stringify(auth, null, 2)}\n`);
92
+ const record = {
93
+ accessToken: auth.accessToken,
94
+ ...(auth.refreshToken ? { refreshToken: auth.refreshToken } : {}),
95
+ tokenType: auth.tokenType,
96
+ expiresAtMs: auth.expiresAtMs,
97
+ };
98
+ io.writeFile(authPath(env), `${JSON.stringify(record, null, 2)}\n`);
72
99
  }
73
100
  export function clearAuth(env = process.env, io = defaultIO) {
74
101
  io.removeFile(authPath(env));
@@ -83,7 +110,15 @@ export function loadAttachment(env = process.env, io = defaultIO) {
83
110
  typeof parsed.tileId === 'string' &&
84
111
  typeof parsed.attachmentId === 'string' &&
85
112
  typeof parsed.attachedAt === 'string') {
86
- return parsed;
113
+ const { workspaceId, tileId, attachmentId, attachedAt } = parsed;
114
+ // Both-or-neither. A half-pair is dropped rather than rejecting the whole
115
+ // record: the attachment IDENTITY is still perfectly good (detach and the
116
+ // status display need only that), and the daemon simply falls back to a
117
+ // fresh attach instead of resuming.
118
+ const scoped = typeof parsed.scopedToken === 'string' && typeof parsed.scopedTokenExpiresAtMs === 'number'
119
+ ? { scopedToken: parsed.scopedToken, scopedTokenExpiresAtMs: parsed.scopedTokenExpiresAtMs }
120
+ : {};
121
+ return { workspaceId, tileId, attachmentId, attachedAt, ...scoped };
87
122
  }
88
123
  return undefined;
89
124
  }
@@ -94,6 +129,25 @@ export function loadAttachment(env = process.env, io = defaultIO) {
94
129
  export function saveAttachment(attachment, env = process.env, io = defaultIO) {
95
130
  io.writeFile(attachmentPath(env), `${JSON.stringify(attachment, null, 2)}\n`);
96
131
  }
132
+ /**
133
+ * Strips the workspace-scoped credential from `attachment.json`, leaving the
134
+ * attachment identity behind.
135
+ *
136
+ * Distinct from `clearAttachment` because the two answer different questions.
137
+ * `detach` clears the whole record on success — but on FAILURE it deliberately
138
+ * keeps it, so the retry path (`cli.ts`, or a manual `yolo-bridge detach`)
139
+ * knows what to retry against. The stored credential has no such second use:
140
+ * the server refuses it the moment the attachment stops being live, so leaving
141
+ * it on disk is residue that can only ever be leaked, never spent. No-ops when
142
+ * there is nothing stored, and never creates a file.
143
+ */
144
+ export function clearStoredScopedToken(env = process.env, io = defaultIO) {
145
+ const attachment = loadAttachment(env, io);
146
+ if (!attachment || attachment.scopedToken === undefined)
147
+ return;
148
+ const { scopedToken: _dropped, scopedTokenExpiresAtMs: _droppedExpiry, ...rest } = attachment;
149
+ saveAttachment(rest, env, io);
150
+ }
97
151
  export function clearAttachment(env = process.env, io = defaultIO) {
98
152
  io.removeFile(attachmentPath(env));
99
153
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Out-of-band connection-state channel for the `yolo-bridge attach` daemon.
3
+ *
4
+ * **Why this module exists (bug, 2026-08-25).** `attach` spawns the user's
5
+ * local coding agent under a real PTY and pipes that PTY straight to this
6
+ * process's own `process.stdout` (`local-agent.ts`'s module header), so from
7
+ * the moment `startLocalAgent` runs the terminal belongs to a full-screen
8
+ * TUI that owns the alternate screen buffer and repaints on its own
9
+ * schedule. The daemon's own `log()` defaults to `process.stdout.write` —
10
+ * the SAME stream — so every connection-state line it emitted from inside
11
+ * the reconnect loop (`Stream error: …`, `Reconnecting in 1000ms
12
+ * (attempt 1)...`, `Stream connected.`) was injected into the middle of a
13
+ * frame the TUI believed it had drawn. The result is a garbled/overlapping
14
+ * display that persists until the agent happens to do a full repaint: a
15
+ * transient wifi blip the daemon recovers from entirely on its own still
16
+ * trashed the user's screen.
17
+ *
18
+ * Writing the same text to `process.stderr` instead is NOT a fix: in an
19
+ * interactive session both file descriptors point at the same tty, so the
20
+ * bytes land in exactly the same place.
21
+ *
22
+ * The fix is to stop putting human-readable status into a stream a TUI is
23
+ * actively rendering to, and route it to a channel that has nothing to do
24
+ * with the terminal. That channel is this file: a small JSON record under
25
+ * the daemon's existing config dir (`~/.config/yolobridge/`, alongside
26
+ * `auth.json`/`attachment.json`) holding the current connection state plus
27
+ * a bounded tail of recent transitions, written through the same injectable
28
+ * `ConfigStoreIO` every other piece of local state already uses.
29
+ *
30
+ * The information is deliberately NOT dropped — it is surfaced two ways:
31
+ * - locally, by `yolo-bridge status` (status-cmd.ts), which reads this
32
+ * record and renders the current state plus recent transitions;
33
+ * - in the workspace, by the tile's own status: yolobridge tile status is
34
+ * derived server-side from `lastHeartbeatAt` (docs/YOLOBRIDGE_PLAN.md's
35
+ * "Status derivation" — ≤30s `running`, ≤90s `paused`, older
36
+ * `stopped`), so a real drop already degrades the tile without the
37
+ * daemon having to narrate it into the PTY.
38
+ *
39
+ * Scoped to one attachment (`attachmentId`): a record left over from a
40
+ * previous attach is ignored rather than shown as if it described the
41
+ * current one, which avoids needing a cleanup call at every teardown site.
42
+ */
43
+ import * as path from 'node:path';
44
+ import { defaultIO, configDir } from './config-store.js';
45
+ /** How many prior transitions to keep. Enough to show "it blipped three
46
+ * times in the last minute" without turning this into a log file. */
47
+ export const MAX_RECENT_EVENTS = 20;
48
+ function connectionPath(env) {
49
+ return path.join(configDir(env), 'connection.json');
50
+ }
51
+ export function loadConnectionState(env = process.env, io = defaultIO) {
52
+ const raw = io.readFile(connectionPath(env));
53
+ if (!raw)
54
+ return undefined;
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (typeof parsed.attachmentId === 'string' &&
58
+ parsed.current !== undefined &&
59
+ typeof parsed.current.state === 'string' &&
60
+ typeof parsed.current.at === 'string') {
61
+ return {
62
+ attachmentId: parsed.attachmentId,
63
+ current: parsed.current,
64
+ recent: Array.isArray(parsed.recent) ? parsed.recent : [],
65
+ };
66
+ }
67
+ return undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ /**
74
+ * Starts a fresh record for `attachmentId`. Called once, right after the
75
+ * attach succeeds, so `status` never renders a previous attach's history as
76
+ * if it belonged to the live one.
77
+ */
78
+ export function resetConnectionState(attachmentId, event, env = process.env, io = defaultIO) {
79
+ writeState({ attachmentId, current: event, recent: [] }, env, io);
80
+ }
81
+ /**
82
+ * Appends `event` as the new current state, rolling the previous one into
83
+ * the bounded `recent` tail. A record belonging to a different attachment
84
+ * is replaced rather than appended to.
85
+ */
86
+ export function recordConnectionEvent(attachmentId, event, env = process.env, io = defaultIO) {
87
+ const existing = loadConnectionState(env, io);
88
+ if (!existing || existing.attachmentId !== attachmentId) {
89
+ writeState({ attachmentId, current: event, recent: [] }, env, io);
90
+ return;
91
+ }
92
+ const recent = [...existing.recent, existing.current].slice(-MAX_RECENT_EVENTS);
93
+ writeState({ attachmentId, current: event, recent }, env, io);
94
+ }
95
+ function writeState(state, env, io) {
96
+ io.writeFile(connectionPath(env), `${JSON.stringify(state, null, 2)}\n`);
97
+ }
98
+ /** One-line human rendering, used by `yolo-bridge status`. */
99
+ export function formatConnectionEvent(event) {
100
+ const parts = [event.state];
101
+ if (event.state === 'reconnecting' && typeof event.attempt === 'number') {
102
+ parts.push(`(attempt ${event.attempt}${typeof event.retryInMs === 'number' ? `, retrying in ${event.retryInMs}ms` : ''})`);
103
+ }
104
+ parts.push(`at ${event.at}`);
105
+ if (event.detail)
106
+ parts.push(`— ${event.detail}`);
107
+ return parts.join(' ');
108
+ }
@@ -18,20 +18,58 @@
18
18
  * cli.ts).
19
19
  */
20
20
  import { detach as apiDetach } from './api-client.js';
21
- import { loadAuth, loadAttachment, clearAttachment } from './config-store.js';
21
+ import { loadAuth, loadAttachment, clearAttachment, clearStoredScopedToken, } from './config-store.js';
22
22
  export async function runDetach(deps) {
23
- const auth = loadAuth(deps.env, deps.io);
24
- if (!auth)
23
+ // Still a real precondition, but no longer the credential: `auth.json` is
24
+ // what makes this a set-up machine at all, and its absence has a much better
25
+ // remedy to offer than a 403 would.
26
+ if (!loadAuth(deps.env, deps.io)) {
25
27
  return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
28
+ }
26
29
  const attachment = loadAttachment(deps.env, deps.io);
27
30
  if (!attachment)
28
31
  return { ok: false, reason: 'not-attached', message: 'No active attachment found.' };
32
+ // READ THE CREDENTIAL BEFORE ANY CLEARING BELOW. `DELETE .../attach/:id` is a
33
+ // daemon-only route behind Boundary B (card 09): an account token is refused
34
+ // there with 403 YOLOBRIDGE_SCOPED_TOKEN_REQUIRED, so this command must
35
+ // present the workspace-scoped credential `attach` persisted alongside the
36
+ // attachment identity — the same one the running daemon uses.
37
+ const scopedToken = attachment.scopedToken;
38
+ if (!scopedToken) {
39
+ // No credential the daemon surface will accept, and nothing on this machine
40
+ // can mint one for an attachment that already exists. Say so plainly rather
41
+ // than sending an account token to be refused: the operator's real remedy
42
+ // is to let the tile go stale on its own (the server stops it once the
43
+ // heartbeat lapses) or to re-attach.
44
+ return {
45
+ ok: false,
46
+ reason: 'error',
47
+ message: 'No workspace-scoped credential is stored for this attachment, so it cannot be '
48
+ + 'detached from this machine. The tile stops on its own once its heartbeat lapses; '
49
+ + 'run `yolo-bridge attach` to reconnect.',
50
+ };
51
+ }
29
52
  try {
30
- await apiDetach({ commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl: deps.fetchImpl }, attachment.workspaceId, attachment.attachmentId);
53
+ await apiDetach({ commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: scopedToken, fetchImpl: deps.fetchImpl }, attachment.workspaceId, attachment.attachmentId);
31
54
  }
32
55
  catch (err) {
56
+ // The attachment RECORD **and** its credential are both kept on a genuine
57
+ // failure, because the retry needs both.
58
+ //
59
+ // Card 08 stripped the credential here, reasoning it was leakable residue
60
+ // the server would refuse anyway. Card 09 invalidated that: the scoped
61
+ // credential is now the ONLY thing Boundary B accepts on this route, so
62
+ // discarding it made every retry — automatic or manual — take the
63
+ // no-credential branch above. The attachment stays live server-side, this
64
+ // machine can no longer remove it, and the next `attach` creates a SECOND
65
+ // attachment and tile. (Codex review, gpt-5.6-sol, 2026-08-25: two P1s.)
66
+ //
67
+ // The credential is discarded only on a SUCCESSFUL or confirmed-gone
68
+ // detach — the same rule the record already follows, and for the same
69
+ // reason: the two are only useful together.
33
70
  return { ok: false, reason: 'error', message: err instanceof Error ? err.message : String(err) };
34
71
  }
72
+ clearStoredScopedToken(deps.env, deps.io);
35
73
  clearAttachment(deps.env, deps.io);
36
74
  return { ok: true };
37
75
  }
package/dist/mcp-proxy.js CHANGED
@@ -167,7 +167,7 @@ async function fetchAllScopes(apiUrl, fetchImpl, tracker) {
167
167
  /** One cached, self-refreshing, workspace-wide token — scoped to whatever
168
168
  * `agentId`'s registry entry actually allows out of the full scope
169
169
  * universe (see this file's header comment). */
170
- function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, fetchImpl, tracker) {
170
+ function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, callerTileId, fetchImpl, tracker) {
171
171
  let cached;
172
172
  async function mint() {
173
173
  const scopes = await fetchAllScopes(apiUrl, fetchImpl, tracker);
@@ -179,6 +179,7 @@ function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, fetchImpl,
179
179
  workspaceId,
180
180
  agentId,
181
181
  scopes,
182
+ ...(callerTileId ? { callerTileId } : {}),
182
183
  ttlSeconds: REQUESTED_TTL_SECONDS,
183
184
  }),
184
185
  signal,
@@ -243,7 +244,7 @@ export async function startMcpProxy(opts) {
243
244
  const log = opts.log ?? (() => { });
244
245
  const fetchImpl = opts.fetchImpl ?? fetch;
245
246
  const tracker = new RequestTracker();
246
- const tokenCache = makeTokenCache(opts.apiUrl, opts.getAccessToken, opts.workspaceId, opts.agentId, fetchImpl, tracker);
247
+ const tokenCache = makeTokenCache(opts.apiUrl, opts.getAccessToken, opts.workspaceId, opts.agentId, opts.callerTileId, fetchImpl, tracker);
247
248
  // Bounded (STARTUP_MINT_TIMEOUT_MS): a stalled scope-discovery/mint fetch
248
249
  // must not block `onAttached` from ever reaching `startLocalAgent`
249
250
  // (Codex review, 2026-08-24). `abortAll()` only affects requests in
@@ -11,8 +11,18 @@
11
11
  * a pidfile / lockfile next to attachment.json to close that gap; not
12
12
  * implemented here — flagged as a known limitation, not silently glossed
13
13
  * over.
14
+ *
15
+ * Partially narrowed since (2026-08-25): the daemon now records every
16
+ * connection transition to `connection.json` (connection-state.ts) instead
17
+ * of narrating it into the terminal the local agent's TUI is rendering
18
+ * into, and this command renders that record. That still isn't liveness —
19
+ * a crashed daemon leaves its last transition frozen on disk, so
20
+ * `Connection: connected` means "the last thing it managed to record",
21
+ * not "it is connected right now" — but a drop/reconnect blip is no
22
+ * longer invisible just because it can't be printed to the screen.
14
23
  */
15
24
  import { loadAuth, loadAttachment } from './config-store.js';
25
+ import { loadConnectionState, formatConnectionEvent } from './connection-state.js';
16
26
  export function getStatus(deps = {}) {
17
27
  const now = deps.now ?? Date.now;
18
28
  const auth = loadAuth(deps.env, deps.io);
@@ -30,6 +40,14 @@ export function getStatus(deps = {}) {
30
40
  report.tileId = attachment.tileId;
31
41
  report.attachmentId = attachment.attachmentId;
32
42
  report.attachedAt = attachment.attachedAt;
43
+ const connection = loadConnectionState(deps.env, deps.io);
44
+ // Only report a record that belongs to the CURRENT attachment — a
45
+ // leftover from a previous attach describes a connection that no
46
+ // longer exists and would be actively misleading here.
47
+ if (connection && connection.attachmentId === attachment.attachmentId) {
48
+ report.connection = connection.current;
49
+ report.connectionHistory = connection.recent;
50
+ }
33
51
  }
34
52
  return report;
35
53
  }
@@ -47,6 +65,18 @@ export function formatStatus(report) {
47
65
  else {
48
66
  lines.push(`Attached: yes — workspace=${report.workspaceId} tile=${report.tileId} attachmentId=${report.attachmentId} since=${report.attachedAt}`);
49
67
  lines.push('(local file only — does not confirm the attach daemon process is still running/connected)');
68
+ if (report.connection) {
69
+ lines.push(`Connection: ${formatConnectionEvent(report.connection)}`);
70
+ const history = report.connectionHistory ?? [];
71
+ if (history.length > 0) {
72
+ // Newest first — a reconnect blip is easiest to read as "what just
73
+ // happened", not "what happened when this attach started".
74
+ lines.push('Recent connection events (newest first):');
75
+ for (const event of [...history].reverse()) {
76
+ lines.push(` ${formatConnectionEvent(event)}`);
77
+ }
78
+ }
79
+ }
50
80
  }
51
81
  return lines.join('\n');
52
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.2.0",
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",