@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.84

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 (94) hide show
  1. package/CHANGELOG.md +1071 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +81 -72
  4. package/dist/node/index.d.cts +136 -52
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +136 -52
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +79 -70
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +16 -10
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/command-module-utils.test.ts +37 -0
  15. package/src/command-module-utils.ts +2 -0
  16. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  17. package/src/default/__tests__/default-command-modules.test.ts +54 -14
  18. package/src/default/__tests__/model-exposure.test.ts +18 -3
  19. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  20. package/src/default/default-command-modules.ts +15 -8
  21. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  22. package/src/devices/devices-command-module.ts +108 -24
  23. package/src/devices/devices-command-port.ts +62 -1
  24. package/src/devices/index.ts +3 -0
  25. package/src/doctor/doctor-node-deps.ts +6 -7
  26. package/src/editor/editor-command-module.ts +5 -0
  27. package/src/effort/effort-command.test.ts +65 -2
  28. package/src/effort/effort-command.ts +19 -5
  29. package/src/events/__tests__/events-command.test.ts +73 -0
  30. package/src/events/events-command-module.ts +49 -0
  31. package/src/events/events-command.ts +69 -0
  32. package/src/events/index.ts +2 -0
  33. package/src/git/__tests__/fake-git-port.ts +9 -2
  34. package/src/git/__tests__/git-command-module.test.ts +10 -2
  35. package/src/git/git-command-module.ts +3 -4
  36. package/src/git/git-commit.ts +6 -3
  37. package/src/git/index.ts +4 -1
  38. package/src/goal/goal-command.ts +3 -1
  39. package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
  40. package/src/handoff/handoff-command-module.ts +1 -1
  41. package/src/handoff/handoff-command.ts +2 -2
  42. package/src/help/__tests__/help-command-module.test.ts +3 -0
  43. package/src/help/__tests__/help-command.test.ts +3 -0
  44. package/src/index.ts +14 -0
  45. package/src/keybindings/keybindings-command-module.ts +17 -3
  46. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
  47. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  48. package/src/mcp-activation/mcp-activation-command.ts +62 -4
  49. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  50. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  51. package/src/memory/memory-command-module.ts +4 -2
  52. package/src/memory/memory-command.ts +42 -2
  53. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  54. package/src/mode/mode-command.ts +1 -1
  55. package/src/model/__tests__/model-command-module.test.ts +80 -0
  56. package/src/model/__tests__/model-command.test.ts +240 -0
  57. package/src/model/index.ts +6 -0
  58. package/src/model/model-command-module.ts +56 -0
  59. package/src/model/model-command.ts +223 -0
  60. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  61. package/src/peers/__tests__/peers-command.test.ts +78 -0
  62. package/src/peers/peers-command-module.ts +1 -1
  63. package/src/peers/peers-command.ts +33 -4
  64. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  65. package/src/plugin/plugin-command.ts +47 -4
  66. package/src/provider/__tests__/org-policy.test.ts +4 -0
  67. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  68. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  69. package/src/provider/provider-command-execution.ts +54 -8
  70. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  71. package/src/provider/provider-command-profile-operations.ts +14 -0
  72. package/src/provider/provider-command-profile.ts +7 -4
  73. package/src/provider/provider-command-setup.ts +14 -1
  74. package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
  75. package/src/rewind/rewind-command.ts +23 -2
  76. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  77. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  78. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  79. package/src/schedule/loop-command.ts +98 -4
  80. package/src/schedule/schedule-command-module.ts +8 -4
  81. package/src/settings/settings-command-module.ts +2 -2
  82. package/src/shell/shell-command-module.ts +5 -0
  83. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  84. package/src/terminal-client/index.ts +5 -0
  85. package/src/terminal-client/terminal-client-commands.ts +109 -0
  86. package/src/theme/__tests__/theme-command.test.ts +1 -1
  87. package/src/theme/theme-command-module.ts +12 -2
  88. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
  89. package/src/git/__tests__/git-diff.test.ts +0 -125
  90. package/src/git/__tests__/git-process.test.ts +0 -221
  91. package/src/git/__tests__/git-status.test.ts +0 -117
  92. package/src/git/git-diff.ts +0 -129
  93. package/src/git/git-process.ts +0 -169
  94. package/src/git/git-status.ts +0 -134
@@ -44,7 +44,12 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
44
44
  calls.push(`init:${options.name ?? ''}`);
45
45
  return {
46
46
  ok: true as const,
47
- value: { userId: USER, deviceId: DEVICE_A, signingKeyId: SIGNING, keyStorage: 'OS keychain' },
47
+ value: {
48
+ userId: USER,
49
+ deviceId: DEVICE_A,
50
+ signingKeyId: SIGNING,
51
+ keyStorage: 'OS keychain',
52
+ },
48
53
  };
49
54
  }),
50
55
  recover: vi.fn(async () => {
@@ -63,6 +68,20 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
63
68
  calls.push(`revoke:${target}`);
64
69
  return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
65
70
  }),
71
+ add: vi.fn(async () => {
72
+ calls.push('add');
73
+ return {
74
+ ok: true as const,
75
+ value: { deviceId: DEVICE_B, name: 'desktop', confirmed: true },
76
+ };
77
+ }),
78
+ join: vi.fn(async (options) => {
79
+ calls.push(`join:${options.name ?? ''}`);
80
+ return {
81
+ ok: true as const,
82
+ value: { userId: USER, deviceId: DEVICE_B, name: 'desktop', keyStorage: 'OS keychain' },
83
+ };
84
+ }),
66
85
  };
67
86
  }
68
87
 
@@ -105,7 +124,19 @@ describe('/devices command', () => {
105
124
  'init',
106
125
  'revoke',
107
126
  'recover',
127
+ 'add',
128
+ 'join',
108
129
  ]);
130
+ // What the model reads names the one-time code as terminal-only, so it never asks for it.
131
+ expect(palette?.description).toMatch(/enrol/i);
132
+ expect(palette?.description).toMatch(/terminal/i);
133
+ });
134
+
135
+ it('tells the model it also shows the device mesh status', () => {
136
+ const description =
137
+ createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0]?.description;
138
+ expect(description).toMatch(/device mesh/);
139
+ expect(description).toMatch(/Operator-only/);
109
140
  });
110
141
 
111
142
  it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
@@ -125,6 +156,17 @@ describe('/devices command', () => {
125
156
  vi.mocked(port.list).mockResolvedValueOnce(undefined);
126
157
  const result = await start(port).command('devices', 'list');
127
158
  expect(result?.message).toMatch(/\/devices init/);
159
+ // A device of a user who already has others joins them; `init` would never link to them.
160
+ expect(result?.message).toMatch(/\/devices join/);
161
+ expect(result?.message).toMatch(/separate identity/);
162
+ });
163
+
164
+ it('describes init as for the first device, and join as the way to the devices a user has', () => {
165
+ const entry = createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0];
166
+ const init = entry?.subcommands?.find((sub) => sub.name === 'init');
167
+ expect(init?.description).toMatch(/first device/);
168
+ expect(init?.description).toMatch(/separate identity/);
169
+ expect(entry?.description).toMatch(/separate identity/);
128
170
  });
129
171
 
130
172
  it('runs init, recover and revoke on the operator terminal', async () => {
@@ -156,7 +198,7 @@ describe('/devices command', () => {
156
198
  it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
157
199
  const port = fakePort();
158
200
  const harness = start(port, handoff(false));
159
- for (const args of ['init', 'recover', 'revoke abcdef']) {
201
+ for (const args of ['init', 'recover', 'revoke abcdef', 'add', 'join', 'join desktop']) {
160
202
  const result = await harness.command('devices', args);
161
203
  expect(result?.success).toBe(false);
162
204
  expect(result?.message).toMatch(/interactive terminal/i);
@@ -167,15 +209,17 @@ describe('/devices command', () => {
167
209
  it('refuses a model invocation', async () => {
168
210
  const port = fakePort();
169
211
  const harness = start(port);
170
- const result = await harness.session.executeModelCommand('devices', 'init');
171
- expect(result?.success ?? false).toBe(false);
212
+ for (const args of ['init', 'add', 'join']) {
213
+ const result = await harness.session.executeModelCommand('devices', args);
214
+ expect(result?.success ?? false).toBe(false);
215
+ }
172
216
  expect(port.calls).toEqual([]);
173
217
  });
174
218
 
175
219
  it('refuses every verb from a remote surface', async () => {
176
220
  const port = fakePort();
177
221
  const harness = start(port);
178
- for (const args of ['', 'init', 'recover', 'revoke abcdef']) {
222
+ for (const args of ['', 'init', 'recover', 'revoke abcdef', 'add', 'join']) {
179
223
  const result = await harness.session.executeCommand('devices', args, 'remote');
180
224
  expect(result?.success).toBe(false);
181
225
  expect(result?.message).toMatch(/operator/i);
@@ -183,15 +227,58 @@ describe('/devices command', () => {
183
227
  expect(port.calls).toEqual([]);
184
228
  });
185
229
 
186
- it('names add and join as not yet available rather than guessing', async () => {
230
+ it('shows the device mesh: whether it is on, how it finds devices, and what is linked', async () => {
231
+ const port = {
232
+ ...fakePort(),
233
+ meshStatus: () => ({
234
+ state: 'on' as const,
235
+ sources: ['local network (mDNS)', 'Mainline DHT'],
236
+ linked: [{ deviceId: DEVICE_B, name: 'desktop', locality: 'another-host' as const }],
237
+ }),
238
+ };
239
+ const result = await start(port).command('devices', '');
240
+ expect(result?.message).toMatch(/Device mesh: on/);
241
+ expect(result?.message).toContain('local network (mDNS), Mainline DHT');
242
+ expect(result?.message).toMatch(
243
+ new RegExp(`${DEVICE_B.slice(0, 10)}\\s+desktop\\s+on another machine`),
244
+ );
245
+ });
246
+
247
+ it('says how to turn the device mesh on when it is off', async () => {
248
+ const port = {
249
+ ...fakePort(),
250
+ meshStatus: () => ({ state: 'off' as const, sources: [], linked: [] }),
251
+ };
252
+ const result = await start(port).command('devices', 'list');
253
+ expect(result?.message).toMatch(/Device mesh: off/);
254
+ expect(result?.message).toContain('transports.mesh.enabled');
255
+ });
256
+
257
+ it('runs add and join on the operator terminal and reports ids and names only', async () => {
258
+ const port = fakePort();
259
+ const runs = vi.fn();
260
+ const harness = start(port, handoff(true, runs));
261
+ const add = await harness.command('devices', 'add');
262
+ expect(add?.success).toBe(true);
263
+ expect(add?.message).toContain('desktop');
264
+ expect(add?.message).toContain(DEVICE_B.slice(0, 10));
265
+ const join = await harness.command('devices', 'join my desktop');
266
+ expect(join?.success).toBe(true);
267
+ expect(join?.message).toContain(DEVICE_B.slice(0, 10));
268
+ expect(join?.message).toContain('OS keychain');
269
+ expect(port.calls).toEqual(['add', 'join:my desktop']);
270
+ expect(runs).toHaveBeenCalledTimes(2);
271
+ });
272
+
273
+ it('says what to do when an enrollment is refused', async () => {
187
274
  const port = fakePort();
188
275
  const harness = start(port);
189
- for (const args of ['add', 'join abc']) {
190
- const result = await harness.command('devices', args);
191
- expect(result?.success).toBe(false);
192
- expect(result?.message).toMatch(/not available yet/i);
193
- }
194
- expect(port.calls).toEqual([]);
276
+ vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-not-accepted' });
277
+ expect((await harness.command('devices', 'join'))?.message).toMatch(/devices add/);
278
+ vi.mocked(port.add).mockResolvedValueOnce({ ok: false, reason: 'no-relay' });
279
+ expect((await harness.command('devices', 'add'))?.message).toMatch(/relayUrl/);
280
+ vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-on-command-line' });
281
+ expect((await harness.command('devices', 'join ABCDE'))?.message).toMatch(/devices add/);
195
282
  });
196
283
 
197
284
  it('requires a device id for revoke', async () => {
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * `/devices` — this device's place in the user's device identity: list the roster, create the
3
- * identity, revoke a device, recover from the recovery phrase.
3
+ * identity, enrol another device or join with one, revoke a device, recover from the recovery phrase.
4
4
  *
5
5
  * Operator-only, twice over: the command is not model-invocable, and it refuses any invocation that
6
6
  * is not the operator's own (a remote surface, the model). Every verb that touches a secret or
7
7
  * asks for a confirmation runs inside the terminal handoff, so what is typed goes to the host's
8
- * terminal and never through the session's input, prompt history or conversation. Without an
8
+ * terminal and never through the session's input, prompt history or conversation — the enrollment
9
+ * code included, which is why `join` reads it there instead of taking it as an argument. Without an
9
10
  * interactive terminal those verbs refuse rather than fall back to anything else.
10
11
  */
11
12
  import { createSystemCommandFromEntry } from '../command-module-utils.js';
12
13
 
13
14
  import type {
14
15
  IDevicesCommandPort,
16
+ IDevicesMeshStatus,
15
17
  IDevicesView,
16
18
  TDevicesOutcome,
17
19
  TDevicesRefusal,
@@ -24,23 +26,28 @@ import type {
24
26
  import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
25
27
 
26
28
  const SHORT_ID_CHARS = 10;
27
- const USAGE = 'Usage: /devices [list|init [name]|revoke <device-id>|recover]';
29
+ const USAGE = 'Usage: /devices [list|init [name]|add|join [name]|revoke <device-id>|recover]';
28
30
 
29
31
  export function createDevicesCommandEntry(): ICommand {
30
32
  return {
31
33
  name: 'devices',
32
34
  displayName: 'Devices',
33
35
  description:
34
- "Manage this device's identity among the user's devices: list the roster, create the identity and its recovery phrase, revoke a device, or rotate the signing key from the phrase. Operator-only; the phrase is handled on the terminal and results carry only device ids and names.",
36
+ "Manage this device's identity among the user's devices: list the roster with the device mesh status (whether it is on, how this device finds the others, which are linked), create the identity and its recovery phrase (init, for the user's first device only: on a device of a user who already has others it makes a separate identity that can never link to them), enrol another device (add shows a one-time code; join on the new device reads it), revoke a device, or rotate the signing key from the phrase. A new identity or new lists take effect in the running session's device mesh at once. Operator-only; the phrase and the enrollment code are handled on the terminal only, and results carry only device ids and names.",
35
37
  source: 'devices',
36
38
  modelInvocable: false,
37
39
  userInvocable: true,
38
- argumentHint: '[list|init [name]|revoke <device-id>|recover]',
40
+ argumentHint: '[list|init [name]|add|join [name]|revoke <device-id>|recover]',
39
41
  subcommands: [
40
- { name: 'list', description: 'List your devices', source: 'devices' },
42
+ {
43
+ name: 'list',
44
+ description: 'List your devices and the device mesh status',
45
+ source: 'devices',
46
+ },
41
47
  {
42
48
  name: 'init',
43
- description: 'Create your device identity and recovery phrase',
49
+ description:
50
+ 'Create your device identity and recovery phrase, on your first device only (on another, a separate identity that can never link to your devices; use add/join there)',
44
51
  source: 'devices',
45
52
  argumentHint: '[name]',
46
53
  },
@@ -55,26 +62,57 @@ export function createDevicesCommandEntry(): ICommand {
55
62
  description: 'Rotate the signing key from your recovery phrase',
56
63
  source: 'devices',
57
64
  },
65
+ {
66
+ name: 'add',
67
+ description: 'Enrol another device: show a one-time code for it',
68
+ source: 'devices',
69
+ },
70
+ {
71
+ name: 'join',
72
+ description: 'Join your devices with the code another device shows',
73
+ source: 'devices',
74
+ argumentHint: '[name]',
75
+ },
58
76
  ],
59
77
  };
60
78
  }
61
79
 
62
80
  const REFUSALS: Readonly<Record<TDevicesRefusal, string>> = {
63
- 'no-terminal': 'This needs an interactive terminal; the recovery phrase is never shown or read anywhere else.',
64
- 'not-initialized': 'This device has no identity yet. Run `/devices init` first.',
65
- 'already-initialized': 'This device already has an identity. Use `/devices recover` to rotate its signing key.',
81
+ 'no-terminal':
82
+ 'This needs an interactive terminal; the recovery phrase is never shown or read anywhere else.',
83
+ 'not-initialized':
84
+ 'This device has no identity yet. If you already use Robota on another device, run `/devices add` there and `/devices join` here, typing the code it shows. Run `/devices init` only for your first device: it creates a separate identity that can never link to your other devices.',
85
+ 'already-initialized':
86
+ 'This device already has an identity. Use `/devices recover` to rotate its signing key.',
66
87
  cancelled: 'Cancelled. Nothing was changed.',
67
88
  'confirmation-failed': 'The confirmation did not match. Nothing was changed.',
68
89
  'phrase-invalid': 'That is not a valid recovery phrase. Nothing was changed.',
69
90
  'phrase-mismatch':
70
91
  'That phrase (with that passphrase) belongs to a different identity than this device. Nothing was changed.',
71
- 'no-signing-key': 'This device does not hold the signing key. Revoke from a device that does, or `/devices recover`.',
72
- 'signing-key-expired': 'The signing key on this device has expired. Run `/devices recover` to issue a new one.',
92
+ 'no-signing-key':
93
+ 'This device does not hold the signing key. Revoke from a device that does, or `/devices recover`.',
94
+ 'signing-key-expired':
95
+ 'The signing key on this device has expired. Run `/devices recover` to issue a new one.',
73
96
  'unknown-device': 'No device in the roster has that id.',
74
97
  'ambiguous-device': 'More than one device starts with that id; give more of it.',
75
- 'self-revocation': 'This device cannot revoke itself. Revoke it from another device, or `/devices recover`.',
98
+ 'self-revocation':
99
+ 'This device cannot revoke itself. Revoke it from another device, or `/devices recover`.',
76
100
  'changed-concurrently':
77
101
  'The device identity changed in another session while this was waiting. Nothing was changed; try again.',
102
+ 'no-relay':
103
+ 'Enrolling a device needs a signaling relay. Set `transports.webrtc.options.relayUrl` in your settings on both devices.',
104
+ 'code-invalid': 'That is not an enrollment code. Nothing was changed.',
105
+ 'code-on-command-line':
106
+ 'Do not put the code on the command line, where history keeps it. Run `/devices add` again on the other device for a new code, then `/devices join` here and type it at the prompt.',
107
+ 'code-not-accepted':
108
+ 'No device accepted that code: it is wrong, expired or already used, or the connection was tampered with. Run `/devices add` on the other device for a new code.',
109
+ 'enrollment-expired': 'The code expired before a device joined. Nothing was changed.',
110
+ 'too-many-attempts':
111
+ 'Too many attempts failed to prove the code, so it no longer works. Nothing was changed; run `/devices add` again.',
112
+ 'enrollment-declined': 'The enrollment was declined. Nothing was changed.',
113
+ 'enrollment-timed-out': 'Nobody answered in time. Nothing was changed.',
114
+ 'enrollment-failed':
115
+ 'The signaling relay or the connection to the other device failed before the enrollment finished. Nothing was changed; try again.',
78
116
  };
79
117
 
80
118
  function short(id: string): string {
@@ -89,7 +127,27 @@ function refused(reason: TDevicesRefusal): ICommandResult {
89
127
  return { success: false, message: REFUSALS[reason] };
90
128
  }
91
129
 
92
- function formatList(view: IDevicesView | undefined): ICommandResult {
130
+ function formatMesh(mesh: IDevicesMeshStatus): string[] {
131
+ if (mesh.state === 'off') {
132
+ return [
133
+ 'Device mesh: off. Set `transports.mesh.enabled` to true in your user settings to link your devices.',
134
+ ];
135
+ }
136
+ if (mesh.state === 'failed') {
137
+ return [`Device mesh: could not start: ${mesh.reason ?? 'no reason was reported'}.`];
138
+ }
139
+ const found = mesh.sources.length > 0 ? `; finds devices by ${mesh.sources.join(', ')}` : '';
140
+ const linked = mesh.linked.map(
141
+ (device) =>
142
+ ` ${short(device.deviceId)} ${device.name ?? ''} ${device.locality === 'same-host' ? 'on this machine' : 'on another machine'}`,
143
+ );
144
+ return [
145
+ `Device mesh: ${mesh.state}${found}.`,
146
+ ...(linked.length > 0 ? ['Linked now:', ...linked] : ['No device is linked right now.']),
147
+ ];
148
+ }
149
+
150
+ function formatList(view: IDevicesView | undefined, mesh?: IDevicesMeshStatus): ICommandResult {
93
151
  if (view === undefined) return { success: true, message: REFUSALS['not-initialized'] };
94
152
  const lines = view.devices.map((device) => {
95
153
  const notes = [
@@ -105,6 +163,7 @@ function formatList(view: IDevicesView | undefined): ICommandResult {
105
163
  `Devices of user ${short(view.userId)}:`,
106
164
  ...lines,
107
165
  `${view.revokedDeviceCount} revoked. Roster and revocation list valid until ${new Date(view.listsExpireAt).toISOString()}.`,
166
+ ...(mesh !== undefined ? formatMesh(mesh) : []),
108
167
  ].join('\n'),
109
168
  };
110
169
  }
@@ -139,7 +198,8 @@ export async function executeDevicesCommand(
139
198
  if (!isOperator(context)) {
140
199
  return {
141
200
  success: false,
142
- message: '`/devices` runs only for the operator at this terminal, never from a remote surface or the model.',
201
+ message:
202
+ '`/devices` runs only for the operator at this terminal, never from a remote surface or the model.',
143
203
  };
144
204
  }
145
205
  const trimmed = args.trim();
@@ -152,7 +212,7 @@ export async function executeDevicesCommand(
152
212
  case '':
153
213
  case 'list':
154
214
  try {
155
- return formatList(await port.list());
215
+ return formatList(await port.list(), port.meshStatus?.());
156
216
  } catch (error) {
157
217
  return {
158
218
  success: false,
@@ -167,7 +227,9 @@ export async function executeDevicesCommand(
167
227
  [
168
228
  `Device identity created. This device: ${short(value.deviceId)}; user ${short(value.userId)}.`,
169
229
  `It holds signing key ${short(value.signingKeyId)}.`,
170
- ...(value.keyStorage !== undefined ? [`Private keys are kept in: ${value.keyStorage}.`] : []),
230
+ ...(value.keyStorage !== undefined
231
+ ? [`Private keys are kept in: ${value.keyStorage}.`]
232
+ : []),
171
233
  'Keep the recovery phrase offline; it is the only way to recover this identity.',
172
234
  ].join('\n'),
173
235
  );
@@ -180,23 +242,45 @@ export async function executeDevicesCommand(
180
242
  `Recovered. New signing key ${short(value.signingKeyId)}; ${value.revokedSigningKeyCount} old signing key(s) revoked.`,
181
243
  `This device (${short(value.deviceId)}) is certified again.`,
182
244
  ...(value.droppedDeviceCount > 0
183
- ? [`${value.droppedDeviceCount} other device(s) were certified by a retired signing key and must enrol again.`]
245
+ ? [
246
+ `${value.droppedDeviceCount} other device(s) were certified by a retired signing key and must enrol again.`,
247
+ ]
184
248
  : []),
185
249
  ].join('\n'),
186
250
  );
187
251
  case 'revoke':
188
- if (rest.length === 0) return { success: false, message: 'Usage: /devices revoke <device-id>' };
252
+ if (rest.length === 0)
253
+ return { success: false, message: 'Usage: /devices revoke <device-id>' };
189
254
  return onTerminal(
190
255
  context,
191
256
  () => port.revoke(rest),
192
- (value) => `Revoked ${value.name} (${short(value.deviceId)}). A new roster and revocation list were issued.`,
257
+ (value) =>
258
+ `Revoked ${value.name} (${short(value.deviceId)}). A new roster and revocation list were issued.`,
193
259
  );
194
260
  case 'add':
261
+ return onTerminal(
262
+ context,
263
+ () => port.add(),
264
+ (value) =>
265
+ [
266
+ `Enrolled ${value.name} (${short(value.deviceId)}). A new roster was issued.`,
267
+ ...(value.confirmed
268
+ ? []
269
+ : ['The new device did not confirm it kept its identity; check `/devices` there.']),
270
+ ].join('\n'),
271
+ );
195
272
  case 'join':
196
- return {
197
- success: false,
198
- message: `\`/devices ${verb}\` is not available yet: enrolling another device over the device connection is not built yet.`,
199
- };
273
+ return onTerminal(
274
+ context,
275
+ () => port.join(rest.length > 0 ? { name: rest } : {}),
276
+ (value) =>
277
+ [
278
+ `Joined the devices of user ${short(value.userId)}. This device: ${value.name} (${short(value.deviceId)}).`,
279
+ ...(value.keyStorage !== undefined
280
+ ? [`Private keys are kept in: ${value.keyStorage}.`]
281
+ : []),
282
+ ].join('\n'),
283
+ );
200
284
  default:
201
285
  return { success: false, message: `Unknown argument "${verb}". ${USAGE}` };
202
286
  }
@@ -43,7 +43,25 @@ export type TDevicesRefusal =
43
43
  | 'ambiguous-device'
44
44
  | 'self-revocation'
45
45
  /** Another process changed the identity while this operation waited on the operator. */
46
- | 'changed-concurrently';
46
+ | 'changed-concurrently'
47
+ /** No signaling relay is configured to meet the other device through. */
48
+ | 'no-relay'
49
+ /** What was typed is not an enrollment code. */
50
+ | 'code-invalid'
51
+ /** The code was typed on the command line, where history keeps it. */
52
+ | 'code-on-command-line'
53
+ /** No device waits for that code or proved it: wrong, expired, already used, or something in between. */
54
+ | 'code-not-accepted'
55
+ /** Nobody joined before the code expired. */
56
+ | 'enrollment-expired'
57
+ /** Too many attempts failed to prove the code; it no longer works. */
58
+ | 'too-many-attempts'
59
+ /** The operator of either device declined. */
60
+ | 'enrollment-declined'
61
+ /** An operator did not answer in time. */
62
+ | 'enrollment-timed-out'
63
+ /** The connection or the exchange with the other device failed partway. */
64
+ | 'enrollment-failed';
47
65
 
48
66
  export type TDevicesOutcome<T> =
49
67
  | { readonly ok: true; readonly value: T }
@@ -70,13 +88,56 @@ export interface IDevicesRevokeResult {
70
88
  readonly name: string;
71
89
  }
72
90
 
91
+ /** This session's endpoint in the device mesh. */
92
+ export interface IDevicesMeshStatus {
93
+ /** `off`: the setting is off, or this run does not open the mesh. `failed`: it could not open. */
94
+ readonly state: 'off' | 'starting' | 'on' | 'failed';
95
+ /** Why it could not open, for `failed`. */
96
+ readonly reason?: string;
97
+ /** How this device looks for the others, in the operator's words. */
98
+ readonly sources: readonly string[];
99
+ /** The devices with an admitted link now. */
100
+ readonly linked: readonly {
101
+ readonly deviceId: string;
102
+ readonly name?: string;
103
+ readonly locality: 'same-host' | 'another-host';
104
+ }[];
105
+ }
106
+
107
+ export interface IDevicesAddResult {
108
+ readonly deviceId: string;
109
+ readonly name: string;
110
+ /** Whether the new device said it kept what it was given. */
111
+ readonly confirmed: boolean;
112
+ }
113
+
114
+ export interface IDevicesJoinResult {
115
+ readonly userId: string;
116
+ readonly deviceId: string;
117
+ readonly name: string;
118
+ /** Where the private keys were kept, for the operator. */
119
+ readonly keyStorage?: string;
120
+ }
121
+
73
122
  export interface IDevicesCommandPort {
74
123
  /** This device's view of the roster, or `undefined` when it has no identity yet. */
75
124
  list(): Promise<IDevicesView | undefined>;
125
+ /** The device mesh as this session runs it. Absent on a host with no device mesh. */
126
+ meshStatus?(): IDevicesMeshStatus;
76
127
  /** Create the identity. Runs on the operator terminal (it shows the phrase once). */
77
128
  init(options: { readonly name?: string }): Promise<TDevicesOutcome<IDevicesInitResult>>;
78
129
  /** Rotate the signing key from the phrase. Runs on the operator terminal. */
79
130
  recover(): Promise<TDevicesOutcome<IDevicesRecoverResult>>;
80
131
  /** Revoke a device by id prefix with the signing key. Confirms on the operator terminal. */
81
132
  revoke(deviceIdPrefix: string): Promise<TDevicesOutcome<IDevicesRevokeResult>>;
133
+ /**
134
+ * Enrol another device with the signing key: show a one-time code, and certify the device that
135
+ * proves it once both operators confirm the string both devices show. Runs on the operator terminal.
136
+ */
137
+ add(): Promise<TDevicesOutcome<IDevicesAddResult>>;
138
+ /**
139
+ * Join the user's devices with a code another device shows, once both operators confirm the string
140
+ * both devices show. Runs on the operator terminal.
141
+ */
142
+ join(options: { readonly name?: string }): Promise<TDevicesOutcome<IDevicesJoinResult>>;
82
143
  }
@@ -3,7 +3,10 @@ export type {
3
3
  IDeviceListEntry,
4
4
  IDevicesCommandPort,
5
5
  IDevicesInitResult,
6
+ IDevicesMeshStatus,
6
7
  IDevicesRecoverResult,
8
+ IDevicesAddResult,
9
+ IDevicesJoinResult,
7
10
  IDevicesRevokeResult,
8
11
  IDevicesView,
9
12
  TDevicesOutcome,
@@ -1,5 +1,5 @@
1
1
  /** Node defaults for {@link IDoctorDeps}: a TCP connect probe and read-only filesystem facts. */
2
- import { accessSync, constants, existsSync, statSync } from 'node:fs';
2
+ import { accessSync, constants, existsSync, statSync, type Stats } from 'node:fs';
3
3
  import { createConnection } from 'node:net';
4
4
  import { delimiter, isAbsolute, join } from 'node:path';
5
5
 
@@ -41,16 +41,15 @@ export function probeEndpointViaSocket(
41
41
  /** Existence, directory-ness, `W_OK` and mode — probed without writing. */
42
42
  export function inspectPathFacts(path: string): IDoctorPathFacts {
43
43
  if (!existsSync(path)) return { exists: false, isDirectory: false, writable: false };
44
- let isDirectory = false;
45
- let mode: number | undefined;
44
+ let stat: Stats;
46
45
  try {
47
- const stat = statSync(path);
48
- isDirectory = stat.isDirectory();
49
- mode = stat.mode & MODE_BITS;
46
+ stat = statSync(path);
50
47
  } catch {
51
48
  // allow-fallback: a path that exists but cannot be stat'ed is reported as not a writable directory
52
49
  return { exists: true, isDirectory: false, writable: false };
53
50
  }
51
+ const isDirectory = stat.isDirectory();
52
+ const mode = stat.mode & MODE_BITS;
54
53
  let writable = false;
55
54
  try {
56
55
  accessSync(path, constants.W_OK);
@@ -59,7 +58,7 @@ export function inspectPathFacts(path: string): IDoctorPathFacts {
59
58
  // allow-fallback: W_OK refused IS the fact being reported
60
59
  writable = false;
61
60
  }
62
- return { exists: true, isDirectory, writable, ...(mode === undefined ? {} : { mode }) };
61
+ return { exists: true, isDirectory, writable, mode };
63
62
  }
64
63
 
65
64
  /** A bare command resolves through `PATH`; a path-form command must exist as given. */
@@ -14,6 +14,9 @@ export function createEditorCommandEntry(): ICommand {
14
14
  source: 'editor',
15
15
  // User-only: opens the user's terminal editor; UI-only.
16
16
  modelInvocable: false,
17
+ // It takes over the terminal the user sits at, so that terminal runs it, even when attached.
18
+ runner: 'client',
19
+ surfaces: ['terminal'],
17
20
  };
18
21
  }
19
22
 
@@ -26,6 +29,8 @@ function createEditorSystemCommand(temporaryDirectoryPrefix?: string): ISystemCo
26
29
  requiresPermission: false,
27
30
  userInvocable: true,
28
31
  modelInvocable: false,
32
+ runner: entry.runner,
33
+ surfaces: entry.surfaces,
29
34
  lifecycle: 'inline',
30
35
  execute: (context, args) => executeEditorCommand(context, args, temporaryDirectoryPrefix),
31
36
  };
@@ -5,6 +5,37 @@ import { executeEffortCommand } from './effort-command.js';
5
5
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
6
6
 
7
7
  describe('/effort', () => {
8
+ it('#3282 §2: reports plainly when the model has no matching effort control at all', async () => {
9
+ const host = createTestCommandHost({
10
+ overrides: {
11
+ getCommandHostAdapters: () => ({
12
+ settings: { read: () => ({}), write: vi.fn() },
13
+ effort: {
14
+ getResolution: () => ({
15
+ requested: 'low',
16
+ effective: 'low',
17
+ source: 'command',
18
+ disposition: 'not-applied',
19
+ modelDefault: 'high',
20
+ }),
21
+ apply: async () => ({
22
+ requested: 'low',
23
+ effective: 'low',
24
+ source: 'command',
25
+ disposition: 'not-applied',
26
+ modelDefault: 'high',
27
+ }),
28
+ },
29
+ }),
30
+ },
31
+ });
32
+
33
+ const result = await executeEffortCommand(host, 'low');
34
+
35
+ expect(result.success).toBe(true);
36
+ expect(result.message).toBe("This model doesn't support effort levels, so it will use its default.");
37
+ });
38
+
8
39
  it('applies a requested level and returns the complete resolution', async () => {
9
40
  const applyModelOptions = vi.fn();
10
41
  const writeSettings = vi.fn();
@@ -36,6 +67,7 @@ describe('/effort', () => {
36
67
  const result = await executeEffortCommand(host, 'low');
37
68
 
38
69
  expect(result.success).toBe(true);
70
+ expect(result.message).toBe('Effort: Low');
39
71
  expect(result.data).toMatchObject({
40
72
  effort: { requested: 'low', effective: 'low', source: 'command', disposition: 'applied' },
41
73
  });
@@ -50,7 +82,7 @@ describe('/effort', () => {
50
82
  const result = await executeEffortCommand(host, '');
51
83
 
52
84
  expect(result.success).toBe(true);
53
- expect(result.message).toContain('high');
85
+ expect(result.message).toBe('Effort: High');
54
86
  expect(applyModelOptions).not.toHaveBeenCalled();
55
87
  });
56
88
 
@@ -133,8 +165,39 @@ describe('/effort', () => {
133
165
  const result = await executeEffortCommand(host, '');
134
166
 
135
167
  expect(result.success).toBe(true);
136
- expect(result.message).toContain('effective=medium');
168
+ expect(result.message).toBe('Effort: Medium');
137
169
  expect(applyModelOptions).not.toHaveBeenCalled();
138
170
  expect(writeSettings).not.toHaveBeenCalled();
139
171
  });
172
+
173
+ it('#3282 §2: uses the full plain word for xhigh, not the internal id', async () => {
174
+ const host = createTestCommandHost({
175
+ overrides: {
176
+ getCommandHostAdapters: () => ({
177
+ settings: { read: () => ({}), write: vi.fn() },
178
+ effort: {
179
+ getResolution: () => ({
180
+ requested: 'auto',
181
+ effective: 'high',
182
+ source: 'model-default',
183
+ disposition: 'model-default',
184
+ modelDefault: 'high',
185
+ }),
186
+ apply: async (selection) => ({
187
+ requested: selection,
188
+ effective: selection === 'auto' ? 'high' : selection,
189
+ source: 'command',
190
+ disposition: 'applied',
191
+ modelDefault: 'high',
192
+ }),
193
+ },
194
+ }),
195
+ },
196
+ });
197
+
198
+ const result = await executeEffortCommand(host, 'xhigh');
199
+
200
+ expect(result.success).toBe(true);
201
+ expect(result.message).toBe('Effort: Extra high');
202
+ });
140
203
  });