@yolo-labs/yolobridge 0.20.0 → 0.22.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.
@@ -303,3 +303,24 @@ export async function finalizeShare(cfg, workspaceId, attachmentId, assetId) {
303
303
  const body = (await res.json());
304
304
  return { assetId: body?.asset?.assetId ?? assetId };
305
305
  }
306
+ /**
307
+ * Phase 3, optional: write an already-shared asset into ANOTHER tile's session
308
+ * pod, so the agent there can open it as a real file.
309
+ *
310
+ * Only DELIVERY crosses to another tile. The asset itself stays on this
311
+ * attachment's tile — the server enforces that, and nothing here can ask
312
+ * otherwise.
313
+ */
314
+ export async function deliverShare(cfg, workspaceId, attachmentId, assetId, targetTileId) {
315
+ const fetchImpl = cfg.fetchImpl ?? fetch;
316
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/deliver`, {
317
+ method: 'POST',
318
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ assetId, targetTileId }),
320
+ });
321
+ if (!res.ok) {
322
+ const { message, code } = await parseErrorBody(res);
323
+ throw new YoloBridgeApiError(message, res.status, code);
324
+ }
325
+ return (await res.json());
326
+ }
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { realpathSync, existsSync, readFileSync } from 'node:fs';
22
22
  import { hostname } from 'node:os';
23
23
  import { runLogin } from './login-cmd.js';
24
24
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
25
- import { runShare } from './share-cmd.js';
25
+ import { runShare, runDeliver } from './share-cmd.js';
26
26
  import { runAllow } from './approved-paths.js';
27
27
  import { runDetach } from './detach-cmd.js';
28
28
  import { getStatus, formatStatus } from './status-cmd.js';
@@ -106,6 +106,10 @@ function printHelp() {
106
106
  ' The daemon\'s own working directory is always allowed.',
107
107
  ' share <path> Share a local file with the attached workspace, so a cloud',
108
108
  ' agent can see it. Push only — nothing reads your disk remotely.',
109
+ ' [--to <tileId>] Also write it into that tile\'s session, so its agent can open it.',
110
+ ' deliver <assetId> Write an ALREADY-shared file into a tile\'s session, without',
111
+ ' --to <tileId> uploading it again. This is the retry path when a share',
112
+ ' uploaded fine but the delivery failed.',
109
113
  ' status Print local login/attach state.',
110
114
  ' version Print the installed yolo-bridge version (also --version, -v).',
111
115
  ' --help Print this help.',
@@ -439,6 +443,10 @@ async function cmdAttach(args) {
439
443
  // real `onExit` handler below: stop + detach immediately rather than
440
444
  // let the daemon loop ride out the full heartbeat-staleness window.
441
445
  try {
446
+ // Say where Ctrl+C goes BEFORE the agent takes over the screen.
447
+ // Without this the operator presses it expecting to quit, nothing
448
+ // happens, and there is no way to discover why.
449
+ process.stdout.write('yolo-bridge: Ctrl+C goes to the agent · Ctrl-P Ctrl-Q to detach\n');
442
450
  startLocalAgent({
443
451
  agentBin,
444
452
  // Empty unless the MCP block above successfully resolved argv
@@ -447,6 +455,15 @@ async function cmdAttach(args) {
447
455
  // never a launch carrying a flag the binary would reject.
448
456
  agentArgs: agentMcpArgs,
449
457
  cwd: spawnCwd,
458
+ // The daemon's only reachable stop key. `process.on('SIGINT')`
459
+ // above cannot fire from the keyboard: stdin is in raw mode so the
460
+ // tty never turns Ctrl+C into a signal, and Ctrl+C is deliberately
461
+ // forwarded to the AGENT instead (interrupting a runaway agent is
462
+ // worth more than quitting the daemon). Same teardown either way.
463
+ onDetachRequested: () => {
464
+ process.stdout.write('\nyolo-bridge: detaching...\n');
465
+ onSignal();
466
+ },
450
467
  onExit: ({ exitCode, signal }) => {
451
468
  localAgentExited = true;
452
469
  stopRequested = true;
@@ -566,6 +583,22 @@ function cmdStatus() {
566
583
  process.stdout.write(`${formatStatus(getStatus())}\n`);
567
584
  return 0;
568
585
  }
586
+ async function cmdDeliver(args) {
587
+ const assetId = args.find((a) => !a.startsWith('-') && a !== args[args.indexOf('--to') + 1]);
588
+ const toIdx = args.indexOf('--to');
589
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
590
+ if (!assetId || !targetTileId) {
591
+ process.stderr.write('yolo-bridge deliver: usage — yolo-bridge deliver <assetId> --to <tileId>\n');
592
+ return 64;
593
+ }
594
+ const result = await runDeliver(assetId, targetTileId, { commonApiBaseUrl: apiUrl() });
595
+ if (!result.ok) {
596
+ process.stderr.write(`yolo-bridge deliver: ${result.message}\n`);
597
+ return 1;
598
+ }
599
+ process.stdout.write(`${result.path}\n`);
600
+ return 0;
601
+ }
569
602
  function cmdAllow(args) {
570
603
  const result = runAllow(args);
571
604
  if (!result.ok) {
@@ -581,13 +614,22 @@ async function cmdShare(args) {
581
614
  process.stderr.write('yolo-bridge share: a file path is required.\n\n yolo-bridge share ./cut.mp4\n');
582
615
  return 64;
583
616
  }
584
- const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl() });
617
+ // `--to <tileId>` also writes the file into that tile's session pod.
618
+ const toIdx = args.indexOf('--to');
619
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
620
+ if (toIdx >= 0 && (!targetTileId || targetTileId.startsWith('-'))) {
621
+ process.stderr.write('yolo-bridge share: `--to` needs a tile id.\n');
622
+ return 64;
623
+ }
624
+ const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl(), targetTileId });
585
625
  if (!result.ok) {
586
626
  // Every one of these is an operator-actionable condition, not a bug, so it
587
627
  // prints as a sentence with no stack trace.
588
628
  process.stderr.write(`yolo-bridge share: ${result.message}\n`);
589
629
  return 1;
590
630
  }
631
+ if (result.deliveredPath)
632
+ process.stdout.write(`${result.deliveredPath}\n`);
591
633
  return 0;
592
634
  }
593
635
  async function cmdWorkspaces() {
@@ -646,6 +688,8 @@ async function main() {
646
688
  return cmdDetach();
647
689
  case 'allow':
648
690
  return cmdAllow(rest);
691
+ case 'deliver':
692
+ return cmdDeliver(rest);
649
693
  case 'share':
650
694
  return cmdShare(rest);
651
695
  case 'status':
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The detach escape sequence for `yolo-bridge attach`.
3
+ *
4
+ * WHY THIS EXISTS. `cli.ts` registers `process.on('SIGINT', …)` as the daemon's
5
+ * stop path, and `local-agent.ts` puts stdin into RAW MODE so every byte can be
6
+ * forwarded to the agent's PTY. Raw mode is precisely the mode in which the
7
+ * tty stops translating `\x03` into SIGINT — so that handler is not merely at
8
+ * risk of being missed, it is UNREACHABLE from the keyboard for as long as an
9
+ * agent is attached. The operator presses Ctrl+C expecting to quit, gets
10
+ * silence, and the teardown never runs.
11
+ *
12
+ * ⚠️ THE FIX IS NOT TO GIVE Ctrl+C BACK TO THE DAEMON. Forwarding it to the
13
+ * agent is the more valuable behaviour by a wide margin — interrupting a
14
+ * runaway agent is the thing an operator actually needs mid-session, and a
15
+ * daemon that quit instead would take the agent down with it. So Ctrl+C keeps
16
+ * going to the agent and the daemon gets its own key.
17
+ *
18
+ * `Ctrl-P Ctrl-Q`, following `docker attach`. Chosen because it is vanishingly
19
+ * rare in agent TUIs: `Ctrl-C`, `Ctrl-D`, `Ctrl-Z` and a lone `Ctrl-Q` are all
20
+ * in active use by the CLIs this daemon spawns, and stealing any of them would
21
+ * trade one broken key for another.
22
+ */
23
+ /** `Ctrl-P` — the prefix. Held back until the next byte decides its meaning. */
24
+ export const DETACH_PREFIX_BYTE = 0x10;
25
+ /** `Ctrl-Q` — only a detach when it IMMEDIATELY follows the prefix. */
26
+ export const DETACH_SUFFIX_BYTE = 0x11;
27
+ /**
28
+ * Splits a stdin stream into "detach" and "everything else".
29
+ *
30
+ * Byte-oriented and chunk-agnostic on purpose: in raw mode each keypress
31
+ * usually arrives as its own chunk, but nothing guarantees it, so the two
32
+ * bytes of the sequence may land together or apart and must behave identically
33
+ * either way.
34
+ *
35
+ * ⚠️ A PREFIX FOLLOWED BY ANYTHING ELSE FORWARDS BOTH BYTES. Swallowing the
36
+ * `Ctrl-P` would silently break it for agents that use it, which is the same
37
+ * class of bug this whole change exists to fix.
38
+ */
39
+ export function createDetachSequenceFilter(opts) {
40
+ let prefixPending = false;
41
+ let detached = false;
42
+ return {
43
+ push(data) {
44
+ // Once detached, further keystrokes belong to a session that is going
45
+ // away; forwarding them would race the teardown.
46
+ if (detached)
47
+ return;
48
+ let out = '';
49
+ for (let i = 0; i < data.length; i++) {
50
+ const code = data.charCodeAt(i);
51
+ if (prefixPending) {
52
+ prefixPending = false;
53
+ if (code === DETACH_SUFFIX_BYTE) {
54
+ // Emit whatever preceded the sequence, then stop. The prefix and
55
+ // suffix are consumed and never reach the agent.
56
+ if (out)
57
+ opts.emit(out);
58
+ detached = true;
59
+ opts.onDetach();
60
+ return;
61
+ }
62
+ // Not the suffix: the prefix was an ordinary keystroke after all.
63
+ out += String.fromCharCode(DETACH_PREFIX_BYTE);
64
+ // Fall through so THIS byte is handled normally — including the case
65
+ // where it is itself another prefix.
66
+ }
67
+ if (code === DETACH_PREFIX_BYTE) {
68
+ prefixPending = true;
69
+ continue;
70
+ }
71
+ out += String.fromCharCode(code);
72
+ }
73
+ if (out)
74
+ opts.emit(out);
75
+ },
76
+ dispose() {
77
+ prefixPending = false;
78
+ },
79
+ };
80
+ }
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { createRequire } from 'node:module';
35
35
  import { randomUUID } from 'node:crypto';
36
+ import { createDetachSequenceFilter } from './detach-sequence.js';
36
37
  import * as pty from 'node-pty';
37
38
  import { splitByUtf8Bytes } from './output-stream.js';
38
39
  import { AnsiScanner, TerminalModeTracker, buildModePrologue, resolveGroundStart, } from './ansi-replay-state.js';
@@ -737,8 +738,16 @@ export function startLocalAgent(opts = {}) {
737
738
  fanOutRawData(data, pushRaw(data));
738
739
  });
739
740
  if (inStream && typeof inStream.on === 'function') {
741
+ // Every byte passes through the detach filter on its way to the PTY. It
742
+ // forwards everything except the `Ctrl-P Ctrl-Q` sequence — including a
743
+ // lone `Ctrl-P`, which agents use for history and which must not be eaten.
744
+ const detachFilter = createDetachSequenceFilter({
745
+ emit: (chunk) => { ptyProcess.write(chunk); },
746
+ onDetach: () => { opts.onDetachRequested?.(); },
747
+ });
748
+ state.detachFilter = detachFilter;
740
749
  const stdinListener = (data) => {
741
- ptyProcess.write(typeof data === 'string' ? data : data.toString('utf-8'));
750
+ detachFilter.push(typeof data === 'string' ? data : data.toString('utf-8'));
742
751
  };
743
752
  if (inStream.isTTY && typeof inStream.setRawMode === 'function') {
744
753
  inStream.setRawMode(true);
@@ -818,6 +827,7 @@ function handleLocalResize(state) {
818
827
  }
819
828
  }
820
829
  function teardownStdio(state) {
830
+ state.detachFilter?.dispose();
821
831
  if (state.resizeSource && state.resizeListener && typeof state.resizeSource.removeListener === 'function') {
822
832
  state.resizeSource.removeListener('resize', state.resizeListener);
823
833
  }
@@ -25,7 +25,7 @@
25
25
  * Do not write a message here claiming more than that.
26
26
  */
27
27
  import { checkPathApproved } from './approved-paths.js';
28
- import { runShare, describeShareFailure } from './share-cmd.js';
28
+ import { runShare, runDeliver, describeShareFailure } from './share-cmd.js';
29
29
  /** Namespaced so it can never collide with a forwarded cloud tool name. */
30
30
  export const SHARE_FILE_TOOL = 'yolobridge_share_file';
31
31
  export const LOCAL_TOOL_DEFINITIONS = [
@@ -41,8 +41,30 @@ export const LOCAL_TOOL_DEFINITIONS = [
41
41
  type: 'string',
42
42
  description: 'Path to the local file to send. Must be inside an approved directory.',
43
43
  },
44
+ assetId: {
45
+ type: 'string',
46
+ description: 'Optional. Deliver an ALREADY-shared asset instead of uploading again — use this to '
47
+ + 'retry a delivery that failed after a successful upload. Requires targetTileId; '
48
+ + '`path` is ignored when this is given.',
49
+ },
50
+ targetTileId: {
51
+ type: 'string',
52
+ description: 'Optional. A tile in this workspace to ALSO write the file into, so the agent running '
53
+ + 'there can open it directly. Use studio_list_tiles to find the id. Omit to leave the '
54
+ + 'file in the workspace only.',
55
+ },
44
56
  },
45
- required: ['path'],
57
+ // TWO CALL SHAPES, so `path` cannot be unconditionally required:
58
+ // { path } — share a local file
59
+ // { assetId, targetTileId } — deliver one already shared, no re-upload
60
+ // A schema-aware client validates before dispatching, so requiring `path`
61
+ // outright made the retry form unreachable no matter what the handler
62
+ // accepted. (codex P1, gpt-5.6-sol — the unit tests call the handler
63
+ // directly and never saw it.)
64
+ anyOf: [
65
+ { required: ['path'] },
66
+ { required: ['assetId', 'targetTileId'] },
67
+ ],
46
68
  additionalProperties: false,
47
69
  },
48
70
  },
@@ -82,6 +104,24 @@ export async function runLocalToolCall(msg, ctx) {
82
104
  if (name !== SHARE_FILE_TOOL) {
83
105
  return isNotification ? undefined : toolResult(id, `Unknown local tool: ${String(name)}`, true);
84
106
  }
107
+ const targetTileId = typeof args.targetTileId === 'string' && args.targetTileId ? args.targetTileId : undefined;
108
+ const retryAssetId = typeof args.assetId === 'string' && args.assetId ? args.assetId : undefined;
109
+ // Delivery-only retry: the bytes are already in the workspace, so there is no
110
+ // path to approve and nothing to read from disk.
111
+ if (retryAssetId) {
112
+ if (!targetTileId) {
113
+ return isNotification ? undefined : toolResult(id, '`targetTileId` is required when retrying delivery of an assetId.', true);
114
+ }
115
+ const redeliver = await (ctx.deliverImpl ?? runDeliver)(retryAssetId, targetTileId, {
116
+ commonApiBaseUrl: ctx.commonApiBaseUrl,
117
+ expectedWorkspaceId: ctx.workspaceId,
118
+ });
119
+ if (isNotification)
120
+ return undefined;
121
+ return redeliver.ok
122
+ ? toolResult(id, `Delivered asset ${retryAssetId} to tile ${targetTileId} at ${redeliver.path}.`)
123
+ : toolResult(id, redeliver.message, true);
124
+ }
85
125
  const rawPath = typeof args.path === 'string' ? args.path : '';
86
126
  if (!rawPath)
87
127
  return isNotification ? undefined : toolResult(id, 'A `path` is required.', true);
@@ -93,6 +133,7 @@ export async function runLocalToolCall(msg, ctx) {
93
133
  // Property 2: open what the check RESOLVED, not what the caller passed.
94
134
  const result = await (ctx.shareImpl ?? runShare)(check.resolvedPath, {
95
135
  commonApiBaseUrl: ctx.commonApiBaseUrl,
136
+ targetTileId,
96
137
  // The approval was checked against THIS workspace. The attachment on disk
97
138
  // can have been replaced by a second `attach` since the proxy started, so
98
139
  // bind the upload to the same workspace or refuse. (codex P2.)
@@ -103,7 +144,12 @@ export async function runLocalToolCall(msg, ctx) {
103
144
  return undefined;
104
145
  if (!result.ok)
105
146
  return toolResult(id, result.message, true);
106
- return toolResult(id, `Shared as asset ${result.assetId}. It is now visible in the cloud workspace.`);
147
+ // When it was delivered, the PATH is the useful half it is what the
148
+ // target agent needs to be told in order to open the file.
149
+ return toolResult(id, result.deliveredPath
150
+ ? `Shared as asset ${result.assetId} and written into tile ${targetTileId} at ${result.deliveredPath}. `
151
+ + 'Tell that tile\'s agent to open that path.'
152
+ : `Shared as asset ${result.assetId}. It is now visible in the cloud workspace.`);
107
153
  }
108
154
  catch (err) {
109
155
  return isNotification ? undefined : toolResult(id, describeShareFailure(err), true);
package/dist/share-cmd.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import { createReadStream } from 'node:fs';
16
16
  import { stat } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
- import { presignShare, finalizeShare, YoloBridgeApiError, } from './api-client.js';
18
+ import { presignShare, finalizeShare, deliverShare, YoloBridgeApiError, } from './api-client.js';
19
19
  import { loadAuth, loadAttachment } from './config-store.js';
20
20
  /**
21
21
  * Mirrors common-api's `MAX_ASSET_BYTES`. The server is authoritative and
@@ -147,7 +147,22 @@ export async function shareFile(rawPath, deps) {
147
147
  }
148
148
  const finalized = await finalizeShare(cfg, deps.workspaceId, deps.attachmentId, presigned.assetId);
149
149
  write(`Shared ${file.filename} → ${finalized.assetId}`);
150
- return finalized;
150
+ if (!deps.targetTileId)
151
+ return finalized;
152
+ // The upload has ALREADY SUCCEEDED. If delivery fails the file is genuinely
153
+ // in the workspace, so say that rather than reporting a flat failure and
154
+ // inviting a re-upload of something already there.
155
+ try {
156
+ const delivered = await deliverShare(cfg, deps.workspaceId, deps.attachmentId, finalized.assetId, deps.targetTileId);
157
+ write(`Delivered to ${deps.targetTileId} at ${delivered.path}`);
158
+ return { ...finalized, deliveredPath: delivered.path };
159
+ }
160
+ catch (err) {
161
+ const why = err instanceof YoloBridgeApiError ? err.message : err?.message || 'unknown error';
162
+ throw new ShareError(`${file.filename} was uploaded (asset ${finalized.assetId}) but could not be delivered to `
163
+ + `${deps.targetTileId}: ${why}. The file IS in the workspace — retry the DELIVERY only, with `
164
+ + `\`yolo-bridge deliver ${finalized.assetId} --to ${deps.targetTileId}\`, rather than sharing it again.`);
165
+ }
151
166
  }
152
167
  /** Turn an API error into something the operator can act on. */
153
168
  export function describeShareFailure(err) {
@@ -174,6 +189,44 @@ export function describeShareFailure(err) {
174
189
  * the config store rather than from daemon memory — the same credential the
175
190
  * running daemon uses, and the only one the upload routes accept (Boundary B).
176
191
  */
192
+ /**
193
+ * The preconditions every daemon-credentialed command shares: logged in,
194
+ * attached, attached to the EXPECTED workspace, and holding a scoped token.
195
+ *
196
+ * Factored out so `runShare` and `runDeliver` cannot drift apart on which
197
+ * checks they perform or what they say when one fails.
198
+ */
199
+ function resolveShareIdentity(deps) {
200
+ if (!loadAuth(deps.env, deps.io)) {
201
+ return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
202
+ }
203
+ const attachment = loadAttachment(deps.env, deps.io);
204
+ if (!attachment) {
205
+ return { ok: false, reason: 'not-attached', message: 'No active attachment — run `yolo-bridge attach` first.' };
206
+ }
207
+ if (deps.expectedWorkspaceId && attachment.workspaceId !== deps.expectedWorkspaceId) {
208
+ return {
209
+ ok: false,
210
+ reason: 'workspace-changed',
211
+ message: 'This machine is now attached to a different workspace than the one this request was '
212
+ + 'authorised against. Nothing was sent. Re-run `yolo-bridge attach` or retry.',
213
+ };
214
+ }
215
+ if (!attachment.scopedToken) {
216
+ return {
217
+ ok: false,
218
+ reason: 'no-scoped-credential',
219
+ message: 'No workspace-scoped credential is stored for this attachment, so files cannot be shared '
220
+ + 'from this machine. Run `yolo-bridge attach` to reconnect.',
221
+ };
222
+ }
223
+ return {
224
+ ok: true,
225
+ cfg: { commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: attachment.scopedToken, fetchImpl: deps.fetchImpl },
226
+ workspaceId: attachment.workspaceId,
227
+ attachmentId: attachment.attachmentId,
228
+ };
229
+ }
177
230
  export async function runShare(rawPath, deps) {
178
231
  // Same precondition ordering as detach: `auth.json` is what makes this a
179
232
  // set-up machine, and its absence has a far better remedy to offer than a
@@ -210,14 +263,37 @@ export async function runShare(rawPath, deps) {
210
263
  fetchImpl: deps.fetchImpl,
211
264
  };
212
265
  try {
213
- const { assetId } = await shareFile(rawPath, {
266
+ const { assetId, deliveredPath } = await shareFile(rawPath, {
214
267
  cfg,
215
268
  workspaceId: attachment.workspaceId,
216
269
  attachmentId: attachment.attachmentId,
270
+ targetTileId: deps.targetTileId,
217
271
  fetchImpl: deps.fetchImpl,
218
272
  write: deps.write,
219
273
  });
220
- return { ok: true, assetId };
274
+ return { ok: true, assetId, ...(deliveredPath ? { deliveredPath } : {}) };
275
+ }
276
+ catch (err) {
277
+ return { ok: false, reason: 'error', message: describeShareFailure(err) };
278
+ }
279
+ }
280
+ /**
281
+ * Deliver an ALREADY-SHARED asset into a tile's session, without re-uploading.
282
+ *
283
+ * WHY THIS EXISTS: `shareFile` tells the operator, after a delivery failure,
284
+ * that the file IS in the workspace and to retry the delivery rather than the
285
+ * upload. That instruction was false until this existed — every entry point
286
+ * began with a fresh presign, so the only available "retry" duplicated the
287
+ * asset. (codex P2, gpt-5.6-sol.) An error message must name a recovery the
288
+ * caller can actually perform.
289
+ */
290
+ export async function runDeliver(assetId, targetTileId, deps) {
291
+ const preflight = await resolveShareIdentity(deps);
292
+ if (!preflight.ok)
293
+ return preflight;
294
+ try {
295
+ const delivered = await deliverShare(preflight.cfg, preflight.workspaceId, preflight.attachmentId, assetId, targetTileId);
296
+ return { ok: true, path: delivered.path };
221
297
  }
222
298
  catch (err) {
223
299
  return { ok: false, reason: 'error', message: describeShareFailure(err) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.20.0",
3
+ "version": "0.22.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",