@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.81",
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.81",
39
- "@robota-sdk/agent-framework": "3.0.0-beta.81",
40
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.81",
41
- "@robota-sdk/agent-interface-command": "3.0.0-beta.81",
42
- "@robota-sdk/agent-preset": "3.0.0-beta.81",
43
- "@robota-sdk/agent-interface-session": "3.0.0-beta.81"
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 needs the device connection, which is not built yet.`,
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 is running this session now');
142
- expect(result.message).toContain('read-only');
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('read-only');
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: 'Move this session to another machine, after confirming what stays behind',
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
- // The model does not decide to give this session away. A hand-off moves AUTHORITY over the
13
- // operator's work to a different computer — it is a decision about where the person is sitting,
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. This copy is read-only.';
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 list the machines this session could move to',
51
- ' /handoff <device-id> move it there, after confirming what stays behind',
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} is running this session now.`
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: 'List the other live sessions on this host, or send one a message',
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 user's call.
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: `Live sessions:\n${lines.join('\n')}\n\nSend to one: /peers send <session-id> <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
  }