@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
@@ -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 needs the device connection, which 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
  };
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { executeEventsCommand } from '../events-command.js';
4
+ import { createEventsCommandModule } from '../events-command-module.js';
5
+
6
+ import type { ICommandExternalEventsAdapter } from '@robota-sdk/agent-framework';
7
+
8
+ function adapter(): ICommandExternalEventsAdapter & { revoke: ReturnType<typeof vi.fn> } {
9
+ return {
10
+ list: () => [
11
+ {
12
+ grantId: 'ci',
13
+ principal: 'client',
14
+ state: 'open',
15
+ counters: { accepted: 2, refused: { expired: 1 }, settled: { completed: 2 } },
16
+ },
17
+ ],
18
+ revoke: vi.fn((grantId: string) => (grantId === 'ci' ? 'revoked' : 'unknown-grant')),
19
+ };
20
+ }
21
+
22
+ const context = (externalEvents?: ICommandExternalEventsAdapter) => ({
23
+ getCommandHostAdapters: () => (externalEvents ? { externalEvents } : {}),
24
+ });
25
+
26
+ describe('/events command module', () => {
27
+ it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
28
+ const module = createEventsCommandModule();
29
+ const palette = module.commandSources?.[0]?.getCommands()[0];
30
+ const executable = module.systemCommands?.[0];
31
+ expect(palette?.name).toBe('events');
32
+ expect(executable?.modelInvocable).toBe(false);
33
+ expect(palette?.modelInvocable).toBe(false);
34
+ expect(executable?.userInvocable).toBe(true);
35
+ expect(executable?.description).toBe(palette?.description);
36
+ });
37
+
38
+ it('tells the model what it does, what it returns, that it is user-only, and what to suggest', () => {
39
+ const description =
40
+ createEventsCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
41
+ expect(description).toMatch(/external event grants/i);
42
+ expect(description).toMatch(/returns/i);
43
+ expect(description).toMatch(/user-only/i);
44
+ expect(description).toContain('`/events revoke <grant-id>`');
45
+ });
46
+ });
47
+
48
+ describe('/events', () => {
49
+ it('lists grants with their principal kind and counts', async () => {
50
+ const result = await executeEventsCommand(context(adapter()), '');
51
+ expect(result.success).toBe(true);
52
+ expect(result.message).toContain(
53
+ 'ci client open accepted 2; refused: expired 1; settled: completed 2',
54
+ );
55
+ });
56
+
57
+ it('revokes one grant by label and says when the label is unknown', async () => {
58
+ const events = adapter();
59
+ expect((await executeEventsCommand(context(events), 'revoke ci')).message).toMatch(
60
+ /Revoked external event grant ci/,
61
+ );
62
+ expect(events.revoke).toHaveBeenCalledWith('ci');
63
+ const unknown = await executeEventsCommand(context(events), 'revoke nope');
64
+ expect(unknown.success).toBe(false);
65
+ expect(unknown.message).toMatch(/no external event grant nope/);
66
+ expect((await executeEventsCommand(context(events), 'revoke')).success).toBe(false);
67
+ });
68
+
69
+ it('says how grants are given when the session holds none', async () => {
70
+ const result = await executeEventsCommand(context(), '');
71
+ expect(result.message).toMatch(/only when a session starts/);
72
+ });
73
+ });
@@ -0,0 +1,49 @@
1
+ import { executeEventsCommand } from './events-command.js';
2
+
3
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
4
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
5
+
6
+ export function createEventsCommandEntry(): ICommand {
7
+ return {
8
+ name: 'events',
9
+ displayName: 'Events',
10
+ description:
11
+ "List this session's external event grants (each grant's label, whether it pins a subject or a client, whether it is open or revoked, and its counts of accepted, refused and settled events), or revoke one with `/events revoke <grant-id>`. Returns the grants, or whether the revocation took effect. User-only: the model cannot run it; when the user wants to stop events from outside, suggest `/events revoke <grant-id>`.",
12
+ source: 'events',
13
+ argumentHint: '[revoke <grant-id>]',
14
+ // User-only: a grant is the owner's standing decision that someone outside may put turns into
15
+ // this session. Withdrawing it is the owner's call too, and listing it lives on the same command.
16
+ modelInvocable: false,
17
+ };
18
+ }
19
+
20
+ function createEventsSystemCommand(): ISystemCommand {
21
+ const entry = createEventsCommandEntry();
22
+ return {
23
+ name: entry.name,
24
+ displayName: entry.displayName,
25
+ description: entry.description,
26
+ requiresPermission: false,
27
+ userInvocable: true,
28
+ modelInvocable: false,
29
+ ...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
30
+ lifecycle: 'inline',
31
+ execute: (context, args) => executeEventsCommand(context, args),
32
+ };
33
+ }
34
+
35
+ export class EventsCommandSource implements ICommandSource {
36
+ readonly name = 'events';
37
+
38
+ getCommands(): ICommand[] {
39
+ return [createEventsCommandEntry()];
40
+ }
41
+ }
42
+
43
+ export function createEventsCommandModule(): ICommandModule {
44
+ return {
45
+ name: 'agent-command-events',
46
+ commandSources: [new EventsCommandSource()],
47
+ systemCommands: [createEventsSystemCommand()],
48
+ };
49
+ }
@@ -0,0 +1,69 @@
1
+ import type {
2
+ ICommandExternalEventGrant,
3
+ ICommandHostAdapterAccess,
4
+ } from '@robota-sdk/agent-framework';
5
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
6
+
7
+ const GRANT_ID = /^[a-zA-Z0-9_-]{1,64}$/u;
8
+
9
+ function counts(values: Readonly<Partial<Record<string, number>>>): string {
10
+ const entries = Object.entries(values).filter(([, count]) => (count ?? 0) > 0);
11
+ return entries.length === 0
12
+ ? 'none'
13
+ : entries.map(([key, count]) => `${key} ${count}`).join(', ');
14
+ }
15
+
16
+ /** Label, principal kind, state and counts. The principal itself is never shown. */
17
+ function describe(grant: ICommandExternalEventGrant): string {
18
+ const { counters } = grant;
19
+ return (
20
+ ` ${grant.grantId} ${grant.principal} ${grant.state} accepted ${counters.accepted}; ` +
21
+ `refused: ${counts(counters.refused)}; settled: ${counts(counters.settled)}`
22
+ );
23
+ }
24
+
25
+ /**
26
+ * `/events` — this session's external-event grants, and `/events revoke <grant-id>` to withdraw one.
27
+ * Grants are given only when the session starts; nothing here creates one.
28
+ */
29
+ export async function executeEventsCommand(
30
+ context: ICommandHostAdapterAccess,
31
+ args = '',
32
+ ): Promise<ICommandResult> {
33
+ const adapter = context.getCommandHostAdapters?.().externalEvents;
34
+ if (!adapter) {
35
+ return {
36
+ message:
37
+ 'This session holds no external event grant. Grants are given only when a session starts.',
38
+ success: true,
39
+ };
40
+ }
41
+ const [verb, grantId, extra] = args
42
+ .trim()
43
+ .split(/\s+/u)
44
+ .filter((word) => word !== '');
45
+ if (verb === undefined) {
46
+ const grants = adapter.list();
47
+ return {
48
+ message:
49
+ grants.length === 0
50
+ ? 'This session holds no external event grant.'
51
+ : `External event grants:\n${grants.map(describe).join('\n')}\n\nWithdraw one: /events revoke <grant-id>`,
52
+ success: true,
53
+ };
54
+ }
55
+ if (
56
+ verb !== 'revoke' ||
57
+ grantId === undefined ||
58
+ extra !== undefined ||
59
+ !GRANT_ID.test(grantId)
60
+ ) {
61
+ return { message: 'Usage: /events | /events revoke <grant-id>', success: false };
62
+ }
63
+ return adapter.revoke(grantId) === 'revoked'
64
+ ? {
65
+ message: `Revoked external event grant ${grantId}. Later events for it are refused.`,
66
+ success: true,
67
+ }
68
+ : { message: `This session holds no external event grant ${grantId}.`, success: false };
69
+ }
@@ -0,0 +1,2 @@
1
+ /** Only the module factory leaves this directory; the command is registered by default. */
2
+ export { createEventsCommandModule } from './events-command-module.js';
@@ -1,5 +1,9 @@
1
1
  /** BEHAVIOR-2437 TC-05 — registration, the descriptor, and the verb parse through the real session. */
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
3
7
 
4
8
  import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
5
9
 
@@ -35,7 +39,11 @@ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
35
39
  readTargetSettings: () => ({}) as TProviderSettingsDocument,
36
40
  writeTargetSettings: () => undefined,
37
41
  };
38
- const BASE_OPTIONS = { cwd: '/tmp', userLocalStorageRoot: '/tmp/robota-test', providerDefinitions, providerSettingsAdapter } as const;
42
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
43
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
44
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
45
+
46
+ const BASE_OPTIONS = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
39
47
 
40
48
  const STATUS_FIXTURE = '# branch.head main\0? scratch.log\0';
41
49
 
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createHandoffCommandModule } from '../handoff-command-module.js';
4
+
5
+ describe('/handoff command module', () => {
6
+ it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
7
+ const module = createHandoffCommandModule();
8
+ const palette = module.commandSources?.[0]?.getCommands()[0];
9
+ const executable = module.systemCommands?.[0];
10
+ expect(executable?.modelInvocable).toBe(false);
11
+ expect(palette?.modelInvocable).toBe(false);
12
+ expect(executable?.userInvocable).toBe(true);
13
+ expect(palette?.userInvocable).toBe(true);
14
+ expect(executable?.description).toBe(palette?.description);
15
+ });
16
+
17
+ it('tells the model what it does and which command to suggest, since it cannot run it', () => {
18
+ const description =
19
+ createHandoffCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
20
+ expect(description).toContain('/handoff <session-or-device-id>');
21
+ expect(description).toMatch(/linked over the device mesh/);
22
+ expect(description).toMatch(/user-only/i);
23
+ expect(description).toContain('saved, not started');
24
+ });
25
+ });
@@ -138,8 +138,8 @@ describe('every outcome says where the session is now', () => {
138
138
  const result = await executeHandoffCommand(context, 'laptop');
139
139
 
140
140
  expect(result.success).toBe(true);
141
- expect(result.message).toContain('laptop is running this session now');
142
- expect(result.message).toContain('read-only');
141
+ expect(result.message).toContain('laptop saved this session and has not started it');
142
+ expect(result.message).toContain('this one ends');
143
143
  });
144
144
 
145
145
  it('says the session is still here when the transfer stops, and why', async () => {
@@ -155,7 +155,7 @@ describe('every outcome says where the session is now', () => {
155
155
  expect(result.success).toBe(false);
156
156
  expect(result.message).toContain('no provider credential');
157
157
  expect(result.message).toContain('still on this machine');
158
- expect(result.message).not.toContain('read-only');
158
+ expect(result.message).not.toContain('this one ends');
159
159
  });
160
160
 
161
161
  it('names the waiting phase as safe to leave, since that is when it looks stuck', async () => {