@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.
- package/dist/api-client.js +21 -0
- package/dist/cli.js +46 -2
- package/dist/detach-sequence.js +80 -0
- package/dist/local-agent.js +11 -1
- package/dist/local-mcp-tools.js +49 -3
- package/dist/share-cmd.js +80 -4
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/local-agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/local-mcp-tools.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|