@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.
- package/CHANGELOG.md +1044 -0
- package/dist/node/index.cjs +76 -69
- package/dist/node/index.d.cts +100 -8
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +100 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +71 -64
- package/dist/node/index.js.map +1 -1
- package/package.json +19 -9
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/default-command-modules.test.ts +50 -11
- package/src/default/__tests__/model-exposure.test.ts +16 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +7 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/git-command-module.test.ts +10 -2
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -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/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +8 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
- package/src/mcp-activation/mcp-activation-command.ts +8 -3
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +158 -0
- package/src/peers/peers-command-module.ts +4 -2
- package/src/peers/peers-command.ts +82 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +3 -1
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- 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:
|
|
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
|
-
//
|
|
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 <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}
|
|
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
|
-
|
|
28
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
246
|
+
return list(context);
|
|
226
247
|
}
|
|
227
248
|
|
|
228
249
|
if (subcommand === 'inspect') {
|