@robota-sdk/agent-command 3.0.0-beta.81 → 3.0.0-beta.82
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/node/index.cjs +35 -34
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +57 -56
- package/dist/node/index.js.map +1 -1
- package/package.json +12 -7
- package/src/devices/devices-command-module.ts +1 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +24 -0
- package/src/handoff/__tests__/handoff-command.test.ts +3 -3
- package/src/handoff/handoff-command-module.ts +6 -5
- package/src/handoff/handoff-command.ts +4 -4
- package/src/peers/__tests__/peers-command.test.ts +80 -0
- package/src/peers/peers-command-module.ts +4 -2
- package/src/peers/peers-command.ts +51 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-command",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.82",
|
|
4
4
|
"description": "Consolidated command module implementations for Robota SDK CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"src"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
39
|
-
"@robota-sdk/agent-framework": "3.0.0-beta.
|
|
40
|
-
"@robota-sdk/agent-interface-
|
|
41
|
-
"@robota-sdk/agent-interface-
|
|
42
|
-
"@robota-sdk/agent-
|
|
43
|
-
"@robota-sdk/agent-
|
|
38
|
+
"@robota-sdk/agent-core": "3.0.0-beta.82",
|
|
39
|
+
"@robota-sdk/agent-framework": "3.0.0-beta.82",
|
|
40
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.82",
|
|
41
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.82",
|
|
42
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.82",
|
|
43
|
+
"@robota-sdk/agent-preset": "3.0.0-beta.82"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.19.21",
|
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
"vitest": "^3.2.6"
|
|
51
51
|
},
|
|
52
52
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "https://github.com/woojubb/robota.git",
|
|
56
|
+
"directory": "packages/agent-command"
|
|
57
|
+
},
|
|
53
58
|
"publishConfig": {
|
|
54
59
|
"access": "public"
|
|
55
60
|
},
|
|
@@ -195,7 +195,7 @@ export async function executeDevicesCommand(
|
|
|
195
195
|
case 'join':
|
|
196
196
|
return {
|
|
197
197
|
success: false,
|
|
198
|
-
message: `\`/devices ${verb}\` is not available yet: enrolling another device
|
|
198
|
+
message: `\`/devices ${verb}\` is not available yet: enrolling another device over the device connection is not built yet.`,
|
|
199
199
|
};
|
|
200
200
|
default:
|
|
201
201
|
return { success: false, message: `Unknown argument "${verb}". ${USAGE}` };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createHandoffCommandModule } from '../handoff-command-module.js';
|
|
4
|
+
|
|
5
|
+
describe('/handoff command module', () => {
|
|
6
|
+
it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
|
|
7
|
+
const module = createHandoffCommandModule();
|
|
8
|
+
const palette = module.commandSources?.[0]?.getCommands()[0];
|
|
9
|
+
const executable = module.systemCommands?.[0];
|
|
10
|
+
expect(executable?.modelInvocable).toBe(false);
|
|
11
|
+
expect(palette?.modelInvocable).toBe(false);
|
|
12
|
+
expect(executable?.userInvocable).toBe(true);
|
|
13
|
+
expect(palette?.userInvocable).toBe(true);
|
|
14
|
+
expect(executable?.description).toBe(palette?.description);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('tells the model what it does and which command to suggest, since it cannot run it', () => {
|
|
18
|
+
const description =
|
|
19
|
+
createHandoffCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
|
|
20
|
+
expect(description).toContain('/handoff <session-id>');
|
|
21
|
+
expect(description).toMatch(/user-only/i);
|
|
22
|
+
expect(description).toContain('saved, not started');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -138,8 +138,8 @@ describe('every outcome says where the session is now', () => {
|
|
|
138
138
|
const result = await executeHandoffCommand(context, 'laptop');
|
|
139
139
|
|
|
140
140
|
expect(result.success).toBe(true);
|
|
141
|
-
expect(result.message).toContain('laptop
|
|
142
|
-
expect(result.message).toContain('
|
|
141
|
+
expect(result.message).toContain('laptop saved this session and has not started it');
|
|
142
|
+
expect(result.message).toContain('this one ends');
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it('says the session is still here when the transfer stops, and why', async () => {
|
|
@@ -155,7 +155,7 @@ describe('every outcome says where the session is now', () => {
|
|
|
155
155
|
expect(result.success).toBe(false);
|
|
156
156
|
expect(result.message).toContain('no provider credential');
|
|
157
157
|
expect(result.message).toContain('still on this machine');
|
|
158
|
-
expect(result.message).not.toContain('
|
|
158
|
+
expect(result.message).not.toContain('this one ends');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
it('names the waiting phase as safe to leave, since that is when it looks stuck', async () => {
|
|
@@ -7,13 +7,14 @@ export function createHandoffCommandEntry(): ICommand {
|
|
|
7
7
|
return {
|
|
8
8
|
name: 'handoff',
|
|
9
9
|
displayName: 'Hand off',
|
|
10
|
-
description:
|
|
10
|
+
description:
|
|
11
|
+
'Push this conversation to another running Robota session of the same user on this machine, after the operator confirms what stays behind (uncommitted changes, running processes; credentials never travel). The receiving operator must also accept; the session arrives saved, not started, and this one ends once it is saved there. With no argument it lists the sessions it could go to. User-only: the model cannot run it; when the user wants to continue this work in another session, suggest they run `/handoff <session-id>`.',
|
|
11
12
|
source: 'handoff',
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// which is a fact about them and not about the task.
|
|
15
|
-
// User-only: moves the session to another machine; the user decides where it lives.
|
|
13
|
+
// User-only: a hand-off moves authority over the operator's work to another place, a decision
|
|
14
|
+
// about where the person is, not about the task.
|
|
16
15
|
modelInvocable: false,
|
|
16
|
+
userInvocable: true,
|
|
17
|
+
argumentHint: '[session-id]',
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -33,7 +33,7 @@ type THandoffHost = ICommandHostAdapterAccess & Partial<ICommandHostUserInteract
|
|
|
33
33
|
function whereIsIt(progress: IHandoffProgress): string {
|
|
34
34
|
return progress.stillMine
|
|
35
35
|
? 'This session is still on this machine, and still yours to use.'
|
|
36
|
-
: 'This session now belongs to the destination
|
|
36
|
+
: 'This session now belongs to the destination, and this one ends.';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function describeDestinations(
|
|
@@ -47,8 +47,8 @@ function usage(): ICommandResult {
|
|
|
47
47
|
success: true,
|
|
48
48
|
message: [
|
|
49
49
|
'Usage:',
|
|
50
|
-
' /handoff
|
|
51
|
-
' /handoff <
|
|
50
|
+
' /handoff list where this session could move to',
|
|
51
|
+
' /handoff <session-id> move it there, after confirming what stays behind',
|
|
52
52
|
].join('\n'),
|
|
53
53
|
};
|
|
54
54
|
}
|
|
@@ -153,7 +153,7 @@ export async function executeHandoffCommand(
|
|
|
153
153
|
message: [
|
|
154
154
|
...progressLines,
|
|
155
155
|
final.state === 'done'
|
|
156
|
-
? `Hand-off complete. ${target}
|
|
156
|
+
? `Hand-off complete. ${target} saved this session and has not started it; resume it there.`
|
|
157
157
|
: `Hand-off stopped: ${final.reason ?? 'no reason was reported'}.`,
|
|
158
158
|
whereIsIt(final),
|
|
159
159
|
].join('\n'),
|
|
@@ -239,3 +239,83 @@ describe('PEER-006 — /peers send', () => {
|
|
|
239
239
|
expect(result.success).toBe(false);
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
|
+
|
|
243
|
+
describe('/peers send-file', () => {
|
|
244
|
+
type TPrepareFile = NonNullable<NonNullable<ICommandHostAdapters['localPeers']>['prepareFile']>;
|
|
245
|
+
|
|
246
|
+
function hostWithFiles(prepareFile: TPrepareFile): ICommandHostAdapterAccess {
|
|
247
|
+
return {
|
|
248
|
+
getCwd: () => '/work',
|
|
249
|
+
getCommandHostAdapters: () => ({
|
|
250
|
+
localPeers: {
|
|
251
|
+
list: () => [{ sessionId: 'other', liveness: 'alive' as const }],
|
|
252
|
+
ownSessionId: () => OWN,
|
|
253
|
+
prepareFile,
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
} as unknown as ICommandHostAdapterAccess;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it('sends the named file as the operator, spaces in the path and all', async () => {
|
|
260
|
+
const calls: unknown[] = [];
|
|
261
|
+
const result = await executePeersCommand(
|
|
262
|
+
hostWithFiles(async (target, path, options) => {
|
|
263
|
+
calls.push([target, path, options]);
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
file: {
|
|
267
|
+
path: '/work/my notes.txt',
|
|
268
|
+
size: 5,
|
|
269
|
+
sha256: 'e'.repeat(64),
|
|
270
|
+
send: async () => ({ state: 'delivered' }),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
'send-file other my notes.txt',
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(calls).toEqual([['other', 'my notes.txt', { origin: 'operator', cwd: '/work' }]]);
|
|
278
|
+
expect(result.success).toBe(true);
|
|
279
|
+
expect(result.message).toContain(`5 bytes, sha256 ${'e'.repeat(64)}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('carries a refusal back to the operator', async () => {
|
|
283
|
+
const result = await executePeersCommand(
|
|
284
|
+
hostWithFiles(async () => ({
|
|
285
|
+
ok: true,
|
|
286
|
+
file: {
|
|
287
|
+
path: '/work/a.txt',
|
|
288
|
+
size: 1,
|
|
289
|
+
sha256: 'e'.repeat(64),
|
|
290
|
+
send: async () => ({ state: 'refused', reason: 'the receiving side did not accept it' }),
|
|
291
|
+
},
|
|
292
|
+
})),
|
|
293
|
+
'send-file other a.txt',
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(result.success).toBe(false);
|
|
297
|
+
expect(result.message).toContain('did not accept it');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('says what it needs when the path is missing', async () => {
|
|
301
|
+
const result = await executePeersCommand(
|
|
302
|
+
hostWithFiles(async () => ({ ok: false, reason: 'unused' })),
|
|
303
|
+
'send-file other',
|
|
304
|
+
);
|
|
305
|
+
expect(result.message).toContain('Usage: /peers send-file');
|
|
306
|
+
expect(result.success).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('says so when the host cannot send files', async () => {
|
|
310
|
+
const result = await executePeersCommand(
|
|
311
|
+
{
|
|
312
|
+
getCommandHostAdapters: () => ({
|
|
313
|
+
localPeers: { list: () => [], ownSessionId: () => OWN },
|
|
314
|
+
}),
|
|
315
|
+
} as ICommandHostAdapterAccess,
|
|
316
|
+
'send-file other a.txt',
|
|
317
|
+
);
|
|
318
|
+
expect(result.message).toContain('cannot send files');
|
|
319
|
+
expect(result.success).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -7,11 +7,13 @@ export function createPeersCommandEntry(): ICommand {
|
|
|
7
7
|
return {
|
|
8
8
|
name: 'peers',
|
|
9
9
|
displayName: 'Peers',
|
|
10
|
-
description:
|
|
10
|
+
description:
|
|
11
|
+
'List the other live sessions on this host, send one a message, or send one a copy of a file',
|
|
11
12
|
source: 'peers',
|
|
12
13
|
// The model does not enumerate the operator's other sessions. Discovery is an operator-facing
|
|
13
14
|
// view of who is at the machine, which is a fact about the person and not about the task.
|
|
14
|
-
// User-only: sends messages into other sessions; crossing a session boundary is the
|
|
15
|
+
// User-only: sends messages and files into other sessions; crossing a session boundary is the
|
|
16
|
+
// user's call. The model sends a file only through `peer_send_file`, which asks every time.
|
|
15
17
|
modelInvocable: false,
|
|
16
18
|
};
|
|
17
19
|
}
|
|
@@ -103,8 +103,47 @@ async function executeSend(
|
|
|
103
103
|
return describeSend(await adapter.send(parsed.target, parsed.text), parsed.target);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* `/peers send-file <session-id> <path>` — the operator sends a copy of a file.
|
|
108
|
+
*
|
|
109
|
+
* The operator typed the path, so any regular file they can read may go, including one outside the
|
|
110
|
+
* workspace or one that looks like it holds secrets: this is the one way to send those. Everything
|
|
111
|
+
* after the session id is the path, spaces and all.
|
|
112
|
+
*/
|
|
113
|
+
async function executeSendFile(
|
|
114
|
+
adapter: NonNullable<ICommandHostAdapters['localPeers']>,
|
|
115
|
+
args: string,
|
|
116
|
+
cwd: string,
|
|
117
|
+
): Promise<ICommandResult> {
|
|
118
|
+
if (adapter.prepareFile === undefined) {
|
|
119
|
+
return { message: 'This environment cannot send files to other sessions.', success: false };
|
|
120
|
+
}
|
|
121
|
+
const parsed = parseSend(args);
|
|
122
|
+
if (parsed === undefined) {
|
|
123
|
+
return {
|
|
124
|
+
message: 'Usage: /peers send-file <session-id> <path>. Run /peers for the session ids.',
|
|
125
|
+
success: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const prepared = await adapter.prepareFile(parsed.target, parsed.text, {
|
|
129
|
+
origin: 'operator',
|
|
130
|
+
cwd,
|
|
131
|
+
});
|
|
132
|
+
if (!prepared.ok) return { message: `Not sent: ${prepared.reason}`, success: false };
|
|
133
|
+
const { file } = prepared;
|
|
134
|
+
const result = await file.send();
|
|
135
|
+
if (result.state === 'delivered' || result.state === 'acknowledged') {
|
|
136
|
+
return {
|
|
137
|
+
message: `Sent ${file.path} to ${parsed.target}: ${file.size} bytes, sha256 ${file.sha256}.`,
|
|
138
|
+
success: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const reason = result.reason !== undefined ? ` ${result.reason}` : '';
|
|
142
|
+
return { message: `${file.path} was not sent to ${parsed.target}.${reason}`, success: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
export async function executePeersCommand(
|
|
107
|
-
context: ICommandHostAdapterAccess,
|
|
146
|
+
context: ICommandHostAdapterAccess & { getCwd?(): string },
|
|
108
147
|
args = '',
|
|
109
148
|
): Promise<ICommandResult> {
|
|
110
149
|
const adapter = context.getCommandHostAdapters?.().localPeers;
|
|
@@ -115,6 +154,14 @@ export async function executePeersCommand(
|
|
|
115
154
|
// `send` must be a WHOLE word: `startsWith('send')` would also claim a session id beginning with
|
|
116
155
|
// those four letters, and a uuid that happens to start `send…` is not a subcommand.
|
|
117
156
|
const trimmed = args.trim();
|
|
157
|
+
const sendFileVerb = /^send-file(?=\s|$)/.exec(trimmed);
|
|
158
|
+
if (sendFileVerb !== null) {
|
|
159
|
+
return executeSendFile(
|
|
160
|
+
adapter,
|
|
161
|
+
trimmed.slice(sendFileVerb[0].length),
|
|
162
|
+
context.getCwd?.() ?? '',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
118
165
|
const sendVerb = /^send(?=\s|$)/.exec(trimmed);
|
|
119
166
|
if (sendVerb !== null) return executeSend(adapter, trimmed.slice(sendVerb[0].length));
|
|
120
167
|
|
|
@@ -136,7 +183,9 @@ export async function executePeersCommand(
|
|
|
136
183
|
|
|
137
184
|
const lines = peers.map((peer) => describe(peer, own));
|
|
138
185
|
return {
|
|
139
|
-
message:
|
|
186
|
+
message:
|
|
187
|
+
`Live sessions:\n${lines.join('\n')}\n\nSend to one: /peers send <session-id> <message>` +
|
|
188
|
+
'\nSend a file: /peers send-file <session-id> <path>',
|
|
140
189
|
success: true,
|
|
141
190
|
};
|
|
142
191
|
}
|