@yolo-labs/yolobridge 0.20.0 → 0.21.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.',
@@ -566,6 +570,22 @@ function cmdStatus() {
566
570
  process.stdout.write(`${formatStatus(getStatus())}\n`);
567
571
  return 0;
568
572
  }
573
+ async function cmdDeliver(args) {
574
+ const assetId = args.find((a) => !a.startsWith('-') && a !== args[args.indexOf('--to') + 1]);
575
+ const toIdx = args.indexOf('--to');
576
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
577
+ if (!assetId || !targetTileId) {
578
+ process.stderr.write('yolo-bridge deliver: usage — yolo-bridge deliver <assetId> --to <tileId>\n');
579
+ return 64;
580
+ }
581
+ const result = await runDeliver(assetId, targetTileId, { commonApiBaseUrl: apiUrl() });
582
+ if (!result.ok) {
583
+ process.stderr.write(`yolo-bridge deliver: ${result.message}\n`);
584
+ return 1;
585
+ }
586
+ process.stdout.write(`${result.path}\n`);
587
+ return 0;
588
+ }
569
589
  function cmdAllow(args) {
570
590
  const result = runAllow(args);
571
591
  if (!result.ok) {
@@ -581,13 +601,22 @@ async function cmdShare(args) {
581
601
  process.stderr.write('yolo-bridge share: a file path is required.\n\n yolo-bridge share ./cut.mp4\n');
582
602
  return 64;
583
603
  }
584
- const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl() });
604
+ // `--to <tileId>` also writes the file into that tile's session pod.
605
+ const toIdx = args.indexOf('--to');
606
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
607
+ if (toIdx >= 0 && (!targetTileId || targetTileId.startsWith('-'))) {
608
+ process.stderr.write('yolo-bridge share: `--to` needs a tile id.\n');
609
+ return 64;
610
+ }
611
+ const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl(), targetTileId });
585
612
  if (!result.ok) {
586
613
  // Every one of these is an operator-actionable condition, not a bug, so it
587
614
  // prints as a sentence with no stack trace.
588
615
  process.stderr.write(`yolo-bridge share: ${result.message}\n`);
589
616
  return 1;
590
617
  }
618
+ if (result.deliveredPath)
619
+ process.stdout.write(`${result.deliveredPath}\n`);
591
620
  return 0;
592
621
  }
593
622
  async function cmdWorkspaces() {
@@ -646,6 +675,8 @@ async function main() {
646
675
  return cmdDetach();
647
676
  case 'allow':
648
677
  return cmdAllow(rest);
678
+ case 'deliver':
679
+ return cmdDeliver(rest);
649
680
  case 'share':
650
681
  return cmdShare(rest);
651
682
  case 'status':
@@ -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.21.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",