@robota-sdk/agent-command 3.0.0-beta.81 → 3.0.0-beta.83

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +1044 -0
  2. package/dist/node/index.cjs +76 -69
  3. package/dist/node/index.d.cts +100 -8
  4. package/dist/node/index.d.cts.map +1 -1
  5. package/dist/node/index.d.ts +100 -8
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +71 -64
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +19 -9
  10. package/src/command-module-utils.test.ts +37 -0
  11. package/src/command-module-utils.ts +2 -0
  12. package/src/default/__tests__/default-command-modules.test.ts +50 -11
  13. package/src/default/__tests__/model-exposure.test.ts +16 -3
  14. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  15. package/src/default/default-command-modules.ts +7 -8
  16. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  17. package/src/devices/devices-command-module.ts +108 -24
  18. package/src/devices/devices-command-port.ts +62 -1
  19. package/src/devices/index.ts +3 -0
  20. package/src/doctor/doctor-node-deps.ts +6 -7
  21. package/src/editor/editor-command-module.ts +5 -0
  22. package/src/events/__tests__/events-command.test.ts +73 -0
  23. package/src/events/events-command-module.ts +49 -0
  24. package/src/events/events-command.ts +69 -0
  25. package/src/events/index.ts +2 -0
  26. package/src/git/__tests__/git-command-module.test.ts +10 -2
  27. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  28. package/src/handoff/__tests__/handoff-command.test.ts +3 -3
  29. package/src/handoff/handoff-command-module.ts +6 -5
  30. package/src/handoff/handoff-command.ts +4 -4
  31. package/src/help/__tests__/help-command-module.test.ts +3 -0
  32. package/src/help/__tests__/help-command.test.ts +3 -0
  33. package/src/index.ts +8 -0
  34. package/src/keybindings/keybindings-command-module.ts +17 -3
  35. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
  36. package/src/mcp-activation/mcp-activation-command.ts +8 -3
  37. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  38. package/src/peers/__tests__/peers-command.test.ts +158 -0
  39. package/src/peers/peers-command-module.ts +4 -2
  40. package/src/peers/peers-command.ts +82 -4
  41. package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
  42. package/src/rewind/rewind-command.ts +23 -2
  43. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  44. package/src/schedule/loop-command.ts +3 -1
  45. package/src/shell/shell-command-module.ts +5 -0
  46. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  47. package/src/terminal-client/index.ts +5 -0
  48. package/src/terminal-client/terminal-client-commands.ts +109 -0
  49. package/src/theme/__tests__/theme-command.test.ts +1 -1
  50. package/src/theme/theme-command-module.ts +12 -2
  51. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
@@ -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, or to another of the user's devices linked over the device mesh, 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 and devices it could go to. User-only: the model cannot run it; when the user wants to continue this work in another session or on another device, suggest they run `/handoff <session-or-device-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-or-device-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'),
@@ -55,12 +55,14 @@ function createCommandHostContext() {
55
55
  displayName: 'Help',
56
56
  description: 'Show available commands',
57
57
  modelInvocable: true,
58
+ runner: 'runtime',
58
59
  },
59
60
  {
60
61
  name: 'provider',
61
62
  displayName: 'Provider Setup',
62
63
  description: 'Manage provider profiles',
63
64
  modelInvocable: true,
65
+ runner: 'runtime',
64
66
  },
65
67
  // SEC-008: `plugin` installs and enables code, so it is NOT model-invocable — the fixture says
66
68
  // what the real command says rather than the value that happened to compile.
@@ -69,6 +71,7 @@ function createCommandHostContext() {
69
71
  displayName: 'Plugins',
70
72
  description: 'Manage plugins',
71
73
  modelInvocable: false,
74
+ runner: 'runtime',
72
75
  },
73
76
  ],
74
77
  listEditCheckpoints: () => [],
@@ -86,6 +86,7 @@ describe('formatCommandHelpMessage — example field', () => {
86
86
  description: 'Compress context window',
87
87
  example: '/compact Summarize the current context',
88
88
  modelInvocable: true,
89
+ runner: 'runtime',
89
90
  },
90
91
  ]);
91
92
 
@@ -102,6 +103,7 @@ describe('formatCommandHelpMessage — example field', () => {
102
103
  description: 'Manage provider profiles',
103
104
  example: '/provider switch production',
104
105
  modelInvocable: true,
106
+ runner: 'runtime',
105
107
  },
106
108
  ]);
107
109
 
@@ -117,6 +119,7 @@ describe('formatCommandHelpMessage — example field', () => {
117
119
  displayName: 'Help',
118
120
  description: 'Show available commands',
119
121
  modelInvocable: true,
122
+ runner: 'runtime',
120
123
  },
121
124
  ]);
122
125
 
package/src/index.ts CHANGED
@@ -79,8 +79,11 @@ export {
79
79
  type IDeviceListEntry,
80
80
  type IDevicesCommandPort,
81
81
  type IDevicesInitResult,
82
+ type IDevicesAddResult,
83
+ type IDevicesJoinResult,
82
84
  type IDevicesRecoverResult,
83
85
  type IDevicesRevokeResult,
86
+ type IDevicesMeshStatus,
84
87
  type IDevicesView,
85
88
  type TDevicesOutcome,
86
89
  type TDevicesRefusal,
@@ -146,4 +149,9 @@ export * from './session/index.js';
146
149
  export * from './settings/index.js';
147
150
  export * from './skills/index.js';
148
151
  export * from './statusline/index.js';
152
+ export {
153
+ createTerminalClientCommands,
154
+ type ITerminalClientCommand,
155
+ type ITerminalClientCommandOptions,
156
+ } from './terminal-client/index.js';
149
157
  export * from './user-local/index.js';
@@ -21,13 +21,23 @@ export function createKeybindingsCommandEntry(): ICommand {
21
21
  source: 'keybindings',
22
22
  // User-only: UI preference.
23
23
  modelInvocable: false,
24
+ // Key bindings belong to the terminal the user sits at, so that terminal runs it, even when
25
+ // attached.
26
+ runner: 'client',
27
+ surfaces: ['terminal'],
24
28
  };
25
29
  }
26
30
 
27
- async function executeKeybindingsCommand(
28
- file: IKeybindingsFilePort,
31
+ // A host without a terminal (the desktop app's sidecar) still answers, so the command is never
32
+ // "unknown" there — it says where key bindings live instead.
33
+ const KEYBINDINGS_UNAVAILABLE =
34
+ 'Key bindings belong to the robota terminal, and this surface has none. Run /keybindings in the robota terminal.';
35
+
36
+ export async function executeKeybindingsCommand(
37
+ file: IKeybindingsFilePort | undefined,
29
38
  context: ICommandHostTerminalHandoff & ICommandHostWorkspace,
30
39
  ): Promise<ICommandResult> {
40
+ if (!file) return { success: false, message: KEYBINDINGS_UNAVAILABLE };
31
41
  if (!context.canHandoffTerminal()) {
32
42
  return { success: false, message: 'Keybindings editor is unavailable here.' };
33
43
  }
@@ -54,7 +64,9 @@ export class KeybindingsCommandSource implements ICommandSource {
54
64
  }
55
65
  }
56
66
 
57
- export function createKeybindingsCommandModule(file: IKeybindingsFilePort): ICommandModule {
67
+ export function createKeybindingsCommandModule(
68
+ file: IKeybindingsFilePort | undefined,
69
+ ): ICommandModule {
58
70
  const entry = createKeybindingsCommandEntry();
59
71
  const command: ISystemCommand = {
60
72
  name: entry.name,
@@ -63,6 +75,8 @@ export function createKeybindingsCommandModule(file: IKeybindingsFilePort): ICom
63
75
  requiresPermission: false,
64
76
  userInvocable: true,
65
77
  modelInvocable: false,
78
+ runner: entry.runner,
79
+ surfaces: entry.surfaces,
66
80
  lifecycle: 'inline',
67
81
  execute: (context) => executeKeybindingsCommand(file, context),
68
82
  };
@@ -484,6 +484,47 @@ describe('/mcp login', () => {
484
484
  expect(h.added).toEqual([]);
485
485
  });
486
486
 
487
+ it('asks nothing for a sign-in that has already ended', async () => {
488
+ const ended = new AbortController();
489
+ ended.abort();
490
+ let choice: string | undefined;
491
+ const h = harness(
492
+ async (request) => {
493
+ choice = await request.confirmBrowser!(PROMPT, ended.signal);
494
+ await expect(request.readRedirect!(PROMPT, ended.signal)).rejects.toThrow();
495
+ return {
496
+ serverId: request.serverId,
497
+ failure: 'cancelled',
498
+ preRegisteredClient: false,
499
+ tools: [],
500
+ };
501
+ },
502
+ { type: 'answer', values: ['open'], text: 'http://127.0.0.1:1/callback?code=c&state=s' },
503
+ );
504
+ await h.run('login files');
505
+ expect(choice).toBe('cancel');
506
+ expect(h.asked).toEqual([]);
507
+ });
508
+
509
+ it('never opens the browser for a sign-in that ended while the user was choosing', async () => {
510
+ const signIn = new AbortController();
511
+ let choice: string | undefined;
512
+ const h = harness(
513
+ async (request) => {
514
+ choice = await request.confirmBrowser!(PROMPT, signIn.signal);
515
+ return signedIn(request.serverId);
516
+ },
517
+ () => {
518
+ // The sign-in times out while the question is still open; the user then picks `open`.
519
+ signIn.abort();
520
+ return { type: 'answer', values: ['open'] };
521
+ },
522
+ );
523
+ await h.run('login files');
524
+ expect(h.asked).toHaveLength(1);
525
+ expect(choice).toBe('cancel');
526
+ });
527
+
487
528
  it('never asks for a client secret, and names the terminal command instead', async () => {
488
529
  const h = harness(async (request) => signedIn(request.serverId));
489
530
  const result = await h.run('login files --client-secret');
@@ -259,7 +259,10 @@ function browserConfirmer(
259
259
  ): ICommandMCPOAuthLoginRequest['confirmBrowser'] {
260
260
  const ui = context.getUserInteraction();
261
261
  if (ui === undefined) return undefined;
262
- return async (prompt) => {
262
+ return async (prompt, signal) => {
263
+ // The ask port cannot withdraw a question, so a sign-in that has already ended is not asked
264
+ // about, and an answer that arrives after it ended never opens a browser for it.
265
+ if (signal.aborted) return 'cancel';
263
266
  loginPrompts += 1;
264
267
  const answer = await ui.ask({
265
268
  id: `mcp-login-browser-${loginPrompts}`,
@@ -275,7 +278,7 @@ function browserConfirmer(
275
278
  maxSelect: 1,
276
279
  default: { values: ['open'] },
277
280
  });
278
- if (answer.type !== 'answer') return 'cancel';
281
+ if (answer.type !== 'answer' || signal.aborted) return 'cancel';
279
282
  const choice = answer.values[0];
280
283
  return choice === 'open' || choice === 'paste' ? choice : 'cancel';
281
284
  };
@@ -288,7 +291,9 @@ function redirectReader(
288
291
  ): ICommandMCPOAuthLoginRequest['readRedirect'] {
289
292
  const ui = context.getUserInteraction();
290
293
  if (ui === undefined) return undefined;
291
- return async (prompt) => {
294
+ return async (prompt, signal) => {
295
+ // A sign-in that has already ended (cancelled or timed out) does not ask for a paste.
296
+ signal.throwIfAborted();
292
297
  loginPrompts += 1;
293
298
  const answer = await ui.ask({
294
299
  id: `mcp-login-redirect-${loginPrompts}`,
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createPeersCommandModule } from '../peers-command-module.js';
4
+
5
+ describe('/peers command module', () => {
6
+ it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
7
+ const module = createPeersCommandModule();
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(executable?.description).toBe(palette?.description);
14
+ });
15
+
16
+ it('tells the model it covers linked devices, what it returns, and what to suggest', () => {
17
+ const description =
18
+ createPeersCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
19
+ expect(description).toMatch(/linked over the device mesh/);
20
+ expect(description).toMatch(/returns/i);
21
+ expect(description).toMatch(/user-only/i);
22
+ expect(description).toContain('`/peers`');
23
+ });
24
+ });
@@ -142,6 +142,84 @@ describe('what the operator is told', () => {
142
142
  });
143
143
  });
144
144
 
145
+ describe('linked devices of the device mesh', () => {
146
+ const DEVICE = 'D'.repeat(43);
147
+
148
+ function hostWithDevices(
149
+ peers: readonly TPeerSummary[],
150
+ devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
151
+ ): ICommandHostAdapterAccess {
152
+ return {
153
+ getCommandHostAdapters: () => ({
154
+ localPeers: { list: () => peers, ownSessionId: () => OWN, listDevices: () => devices },
155
+ }),
156
+ } as ICommandHostAdapterAccess;
157
+ }
158
+
159
+ it('lists a linked device beside the sessions on this host, addressable by its id', async () => {
160
+ const result = await executePeersCommand(
161
+ hostWithDevices(
162
+ [
163
+ { sessionId: OWN, liveness: 'alive' },
164
+ { sessionId: 'session-other', liveness: 'alive' },
165
+ ],
166
+ [{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }],
167
+ ),
168
+ );
169
+ expect(result.success).toBe(true);
170
+ expect(result.message).toContain('session-other');
171
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
172
+ expect(result.message).toContain('/peers send <session-or-device-id>');
173
+ });
174
+
175
+ it('lists a linked device even when this is the only session on this host', async () => {
176
+ const result = await executePeersCommand(
177
+ hostWithDevices(
178
+ [{ sessionId: OWN, liveness: 'alive' }],
179
+ [{ deviceId: DEVICE, locality: 'same-host' }],
180
+ ),
181
+ );
182
+ expect(result.message).not.toContain('No other live session');
183
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+on this machine`));
184
+ });
185
+ });
186
+
187
+ describe('when local discovery is off but the device mesh is not', () => {
188
+ const DEVICE = 'D'.repeat(43);
189
+
190
+ function hostWithoutDiscovery(
191
+ devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
192
+ ): ICommandHostAdapterAccess {
193
+ return {
194
+ getCommandHostAdapters: () => ({
195
+ localPeers: {
196
+ list: () => [],
197
+ ownSessionId: () => OWN,
198
+ listDevices: () => devices,
199
+ localDiscoveryOff: 'the rendezvous directory was not admitted',
200
+ },
201
+ }),
202
+ } as ICommandHostAdapterAccess;
203
+ }
204
+
205
+ it('lists the linked devices and says why sessions on this host are not listed', async () => {
206
+ const result = await executePeersCommand(
207
+ hostWithoutDiscovery([{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }]),
208
+ );
209
+ expect(result.success).toBe(true);
210
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
211
+ expect(result.message).toContain('not admitted');
212
+ expect(result.message).not.toContain('Live sessions');
213
+ expect(result.message).toContain('/peers send <device-id>');
214
+ });
215
+
216
+ it('with no device linked, says discovery is off rather than that nobody is there', async () => {
217
+ const result = await executePeersCommand(hostWithoutDiscovery([]));
218
+ expect(result.message).toContain('not admitted');
219
+ expect(result.message).not.toContain('No other live session');
220
+ });
221
+ });
222
+
145
223
  describe('when the host wires no discovery', () => {
146
224
  it('says the feature is unavailable rather than reporting no peers', async () => {
147
225
  // The two are different facts and the difference matters: "nobody is there" invites the operator
@@ -239,3 +317,83 @@ describe('PEER-006 — /peers send', () => {
239
317
  expect(result.success).toBe(false);
240
318
  });
241
319
  });
320
+
321
+ describe('/peers send-file', () => {
322
+ type TPrepareFile = NonNullable<NonNullable<ICommandHostAdapters['localPeers']>['prepareFile']>;
323
+
324
+ function hostWithFiles(prepareFile: TPrepareFile): ICommandHostAdapterAccess {
325
+ return {
326
+ getCwd: () => '/work',
327
+ getCommandHostAdapters: () => ({
328
+ localPeers: {
329
+ list: () => [{ sessionId: 'other', liveness: 'alive' as const }],
330
+ ownSessionId: () => OWN,
331
+ prepareFile,
332
+ },
333
+ }),
334
+ } as unknown as ICommandHostAdapterAccess;
335
+ }
336
+
337
+ it('sends the named file as the operator, spaces in the path and all', async () => {
338
+ const calls: unknown[] = [];
339
+ const result = await executePeersCommand(
340
+ hostWithFiles(async (target, path, options) => {
341
+ calls.push([target, path, options]);
342
+ return {
343
+ ok: true,
344
+ file: {
345
+ path: '/work/my notes.txt',
346
+ size: 5,
347
+ sha256: 'e'.repeat(64),
348
+ send: async () => ({ state: 'delivered' }),
349
+ },
350
+ };
351
+ }),
352
+ 'send-file other my notes.txt',
353
+ );
354
+
355
+ expect(calls).toEqual([['other', 'my notes.txt', { origin: 'operator', cwd: '/work' }]]);
356
+ expect(result.success).toBe(true);
357
+ expect(result.message).toContain(`5 bytes, sha256 ${'e'.repeat(64)}`);
358
+ });
359
+
360
+ it('carries a refusal back to the operator', async () => {
361
+ const result = await executePeersCommand(
362
+ hostWithFiles(async () => ({
363
+ ok: true,
364
+ file: {
365
+ path: '/work/a.txt',
366
+ size: 1,
367
+ sha256: 'e'.repeat(64),
368
+ send: async () => ({ state: 'refused', reason: 'the receiving side did not accept it' }),
369
+ },
370
+ })),
371
+ 'send-file other a.txt',
372
+ );
373
+
374
+ expect(result.success).toBe(false);
375
+ expect(result.message).toContain('did not accept it');
376
+ });
377
+
378
+ it('says what it needs when the path is missing', async () => {
379
+ const result = await executePeersCommand(
380
+ hostWithFiles(async () => ({ ok: false, reason: 'unused' })),
381
+ 'send-file other',
382
+ );
383
+ expect(result.message).toContain('Usage: /peers send-file');
384
+ expect(result.success).toBe(false);
385
+ });
386
+
387
+ it('says so when the host cannot send files', async () => {
388
+ const result = await executePeersCommand(
389
+ {
390
+ getCommandHostAdapters: () => ({
391
+ localPeers: { list: () => [], ownSessionId: () => OWN },
392
+ }),
393
+ } as ICommandHostAdapterAccess,
394
+ 'send-file other a.txt',
395
+ );
396
+ expect(result.message).toContain('cannot send files');
397
+ expect(result.success).toBe(false);
398
+ });
399
+ });
@@ -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 Robota sessions on this host and the user's other devices linked over the device mesh, send one a message, or send one a copy of a file. Returns the sessions and devices with the ids to address them by (linked devices even when local discovery is off and sessions on this host cannot be listed), or how a send ended. User-only: the model cannot run it; it answers a peer's message with its reply tool, and when the user wants to reach another session or device, suggest they run `/peers`.",
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
  }
@@ -33,6 +33,16 @@ function describe(peer: TPeerSummary, ownSessionId: string): string {
33
33
  return ` ${peer.sessionId}${peer.name ? ` ${peer.name}` : ''} status ${status}${self ? '' : workspace(peer)}${liveness}${self}`;
34
34
  }
35
35
 
36
+ type TDeviceSummary = ReturnType<
37
+ NonNullable<NonNullable<ICommandHostAdapters['localPeers']>['listDevices']>
38
+ >[number];
39
+
40
+ /** A linked device: its full id, since that is what `send` takes, and where it runs. */
41
+ function describeDevice(device: TDeviceSummary): string {
42
+ const where = device.locality === 'same-host' ? 'on this machine' : 'on another machine';
43
+ return ` ${device.deviceId}${device.name ? ` ${device.name}` : ''} ${where}`;
44
+ }
45
+
36
46
  /** The relation this session verified. A claim it could not confirm is named as such, not shown. */
37
47
  function workspace(peer: TPeerSummary): string {
38
48
  if (peer.workspaceClaim === 'mismatched') return ' workspace claim mismatched, not believed';
@@ -103,8 +113,47 @@ async function executeSend(
103
113
  return describeSend(await adapter.send(parsed.target, parsed.text), parsed.target);
104
114
  }
105
115
 
116
+ /**
117
+ * `/peers send-file <session-id> <path>` — the operator sends a copy of a file.
118
+ *
119
+ * The operator typed the path, so any regular file they can read may go, including one outside the
120
+ * workspace or one that looks like it holds secrets: this is the one way to send those. Everything
121
+ * after the session id is the path, spaces and all.
122
+ */
123
+ async function executeSendFile(
124
+ adapter: NonNullable<ICommandHostAdapters['localPeers']>,
125
+ args: string,
126
+ cwd: string,
127
+ ): Promise<ICommandResult> {
128
+ if (adapter.prepareFile === undefined) {
129
+ return { message: 'This environment cannot send files to other sessions.', success: false };
130
+ }
131
+ const parsed = parseSend(args);
132
+ if (parsed === undefined) {
133
+ return {
134
+ message: 'Usage: /peers send-file <session-id> <path>. Run /peers for the session ids.',
135
+ success: false,
136
+ };
137
+ }
138
+ const prepared = await adapter.prepareFile(parsed.target, parsed.text, {
139
+ origin: 'operator',
140
+ cwd,
141
+ });
142
+ if (!prepared.ok) return { message: `Not sent: ${prepared.reason}`, success: false };
143
+ const { file } = prepared;
144
+ const result = await file.send();
145
+ if (result.state === 'delivered' || result.state === 'acknowledged') {
146
+ return {
147
+ message: `Sent ${file.path} to ${parsed.target}: ${file.size} bytes, sha256 ${file.sha256}.`,
148
+ success: true,
149
+ };
150
+ }
151
+ const reason = result.reason !== undefined ? ` ${result.reason}` : '';
152
+ return { message: `${file.path} was not sent to ${parsed.target}.${reason}`, success: false };
153
+ }
154
+
106
155
  export async function executePeersCommand(
107
- context: ICommandHostAdapterAccess,
156
+ context: ICommandHostAdapterAccess & { getCwd?(): string },
108
157
  args = '',
109
158
  ): Promise<ICommandResult> {
110
159
  const adapter = context.getCommandHostAdapters?.().localPeers;
@@ -115,9 +164,32 @@ export async function executePeersCommand(
115
164
  // `send` must be a WHOLE word: `startsWith('send')` would also claim a session id beginning with
116
165
  // those four letters, and a uuid that happens to start `send…` is not a subcommand.
117
166
  const trimmed = args.trim();
167
+ const sendFileVerb = /^send-file(?=\s|$)/.exec(trimmed);
168
+ if (sendFileVerb !== null) {
169
+ return executeSendFile(
170
+ adapter,
171
+ trimmed.slice(sendFileVerb[0].length),
172
+ context.getCwd?.() ?? '',
173
+ );
174
+ }
118
175
  const sendVerb = /^send(?=\s|$)/.exec(trimmed);
119
176
  if (sendVerb !== null) return executeSend(adapter, trimmed.slice(sendVerb[0].length));
120
177
 
178
+ const devices = adapter.listDevices?.() ?? [];
179
+ const discoveryOff = adapter.localDiscoveryOff;
180
+ if (discoveryOff !== undefined) {
181
+ const off = `Sessions on this host are not listed: local peer discovery is off for this session (${discoveryOff}).`;
182
+ return {
183
+ message:
184
+ devices.length === 0
185
+ ? `${off}\nNo device is linked right now.`
186
+ : `Linked devices:\n${devices.map(describeDevice).join('\n')}\n\n${off}` +
187
+ `\n\nSend to one: /peers send <device-id> <message>` +
188
+ `\nSend a file: /peers send-file <device-id> <path>`,
189
+ success: true,
190
+ };
191
+ }
192
+
121
193
  const own = adapter.ownSessionId();
122
194
  // The workspace-judged listing when the host has one; it reads git, so only this view asks for it.
123
195
  const peers = (
@@ -125,7 +197,7 @@ export async function executePeersCommand(
125
197
  ).filter(addressable);
126
198
  const others = peers.filter((peer) => peer.sessionId !== own);
127
199
 
128
- if (others.length === 0) {
200
+ if (others.length === 0 && devices.length === 0) {
129
201
  return {
130
202
  message:
131
203
  'No other live session is announced. Start a second session on this host, as this user, ' +
@@ -134,9 +206,15 @@ export async function executePeersCommand(
134
206
  };
135
207
  }
136
208
 
137
- const lines = peers.map((peer) => describe(peer, own));
209
+ const sections = [`Live sessions:\n${peers.map((peer) => describe(peer, own)).join('\n')}`];
210
+ if (devices.length > 0) {
211
+ sections.push(`Linked devices:\n${devices.map(describeDevice).join('\n')}`);
212
+ }
213
+ const target = devices.length > 0 ? '<session-or-device-id>' : '<session-id>';
138
214
  return {
139
- message: `Live sessions:\n${lines.join('\n')}\n\nSend to one: /peers send <session-id> <message>`,
215
+ message:
216
+ `${sections.join('\n\n')}\n\nSend to one: /peers send ${target} <message>` +
217
+ `\nSend a file: /peers send-file ${target} <path>`,
140
218
  success: true,
141
219
  };
142
220
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import type {
3
3
  IEditCheckpointInspection,
4
4
  IEditCheckpointRestoreResult,
@@ -252,4 +252,34 @@ describe('executeRewindCommand', () => {
252
252
  expect(result?.success).toBe(false);
253
253
  expect(result?.message).toBe('Unknown edit checkpoint');
254
254
  });
255
+
256
+ describe('in a session built without a checkpoint store', () => {
257
+ afterEach(() => {
258
+ vi.restoreAllMocks();
259
+ });
260
+
261
+ it('tells a restricted workspace to trust it, for every subcommand', async () => {
262
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
263
+ const session = createInteractiveSession();
264
+
265
+ for (const args of ['', 'list', 'inspect turn-0001', 'restore turn-0001', 'branches']) {
266
+ const result = await session.executeCommand('rewind', args);
267
+ expect(result?.success).toBe(false);
268
+ expect(result?.message).toBe(
269
+ 'Edit checkpoints need a trusted workspace: run robota trust --yes, then restart robota.',
270
+ );
271
+ }
272
+ });
273
+
274
+ it('says why on a host that cannot prove a project write is safe, without naming trust', async () => {
275
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
276
+ const session = createInteractiveSession();
277
+
278
+ const result = await session.executeCommand('rewind', 'list');
279
+
280
+ expect(result?.success).toBe(false);
281
+ expect(result?.message).toContain('cannot prove a write stays inside the project');
282
+ expect(result?.message).not.toContain('trust');
283
+ });
284
+ });
255
285
  });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EditCheckpointsUnavailableError,
2
3
  inspectCommandEditCheckpoint,
3
4
  listCommandEditCheckpoints,
4
5
  restoreCommandEditCheckpoint,
@@ -121,13 +122,33 @@ function formatRollbackResult(result: IEditCheckpointRestoreResult): ICommandRes
121
122
  };
122
123
  }
123
124
 
125
+ /** A restricted workspace is the one case the user fixes with a command; the rest say why. */
126
+ function unavailableMessage(error: EditCheckpointsUnavailableError): string {
127
+ return error.reason === 'restricted-workspace'
128
+ ? 'Edit checkpoints need a trusted workspace: run robota trust --yes, then restart robota.'
129
+ : error.message;
130
+ }
131
+
124
132
  function formatError(error: Error | string): ICommandResult {
125
133
  return {
126
- message: error instanceof Error ? error.message : String(error),
134
+ message:
135
+ error instanceof EditCheckpointsUnavailableError
136
+ ? unavailableMessage(error)
137
+ : error instanceof Error
138
+ ? error.message
139
+ : String(error),
127
140
  success: false,
128
141
  };
129
142
  }
130
143
 
144
+ function list(context: ICommandHostCheckpoints): ICommandResult {
145
+ try {
146
+ return formatList(listCommandEditCheckpoints(context));
147
+ } catch (error) {
148
+ return formatError(error instanceof Error ? error : String(error));
149
+ }
150
+ }
151
+
131
152
  function inspect(
132
153
  context: ICommandHostCheckpoints,
133
154
  checkpointId: string | undefined,
@@ -222,7 +243,7 @@ export async function executeRewindCommand(
222
243
  const subcommand = args[SUBCOMMAND_INDEX] ?? 'list';
223
244
 
224
245
  if (subcommand === 'list') {
225
- return formatList(listCommandEditCheckpoints(context));
246
+ return list(context);
226
247
  }
227
248
 
228
249
  if (subcommand === 'inspect') {