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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +1044 -0
  2. package/dist/node/index.cjs +76 -69
  3. package/dist/node/index.d.cts +100 -8
  4. package/dist/node/index.d.cts.map +1 -1
  5. package/dist/node/index.d.ts +100 -8
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +71 -64
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +19 -9
  10. package/src/command-module-utils.test.ts +37 -0
  11. package/src/command-module-utils.ts +2 -0
  12. package/src/default/__tests__/default-command-modules.test.ts +50 -11
  13. package/src/default/__tests__/model-exposure.test.ts +16 -3
  14. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  15. package/src/default/default-command-modules.ts +7 -8
  16. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  17. package/src/devices/devices-command-module.ts +108 -24
  18. package/src/devices/devices-command-port.ts +62 -1
  19. package/src/devices/index.ts +3 -0
  20. package/src/doctor/doctor-node-deps.ts +6 -7
  21. package/src/editor/editor-command-module.ts +5 -0
  22. package/src/events/__tests__/events-command.test.ts +73 -0
  23. package/src/events/events-command-module.ts +49 -0
  24. package/src/events/events-command.ts +69 -0
  25. package/src/events/index.ts +2 -0
  26. package/src/git/__tests__/git-command-module.test.ts +10 -2
  27. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  28. package/src/handoff/__tests__/handoff-command.test.ts +3 -3
  29. package/src/handoff/handoff-command-module.ts +6 -5
  30. package/src/handoff/handoff-command.ts +4 -4
  31. package/src/help/__tests__/help-command-module.test.ts +3 -0
  32. package/src/help/__tests__/help-command.test.ts +3 -0
  33. package/src/index.ts +8 -0
  34. package/src/keybindings/keybindings-command-module.ts +17 -3
  35. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
  36. package/src/mcp-activation/mcp-activation-command.ts +8 -3
  37. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  38. package/src/peers/__tests__/peers-command.test.ts +158 -0
  39. package/src/peers/peers-command-module.ts +4 -2
  40. package/src/peers/peers-command.ts +82 -4
  41. package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
  42. package/src/rewind/rewind-command.ts +23 -2
  43. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  44. package/src/schedule/loop-command.ts +3 -1
  45. package/src/shell/shell-command-module.ts +5 -0
  46. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  47. package/src/terminal-client/index.ts +5 -0
  48. package/src/terminal-client/terminal-client-commands.ts +109 -0
  49. package/src/theme/__tests__/theme-command.test.ts +1 -1
  50. package/src/theme/theme-command-module.ts +12 -2
  51. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
@@ -7,10 +7,12 @@
7
7
  * The pre-fix regexes used `\s+(.+)$`; because `.` also matches a space, the split point between
8
8
  * `\s+` and `(.+)` was ambiguous and a non-matching argument was rejected in O(n^2). Measured
9
9
  * pre-fix on the inputs below: ~17s (`/schedule cron`) and ~15s (`/monitor`); post-fix, <1ms.
10
+ * `/loop`'s leading interval had the same `\s+([\s\S]+)$` shape and is held to the same bound.
10
11
  */
11
12
 
12
13
  import { describe, expect, it, vi } from 'vitest';
13
14
 
15
+ import { executeLoopCommand } from '../loop-command.js';
14
16
  import { executeMonitorCommand } from '../schedule-command.js';
15
17
  import { parseScheduleSpec } from '../schedule-spec-parser.js';
16
18
 
@@ -78,4 +80,35 @@ describe('schedule argument parsing is not polynomial-ReDoS-able', () => {
78
80
  }),
79
81
  );
80
82
  });
83
+
84
+ it('parses a pumped leading-interval `/loop` in linear time', async () => {
85
+ // The shape CodeQL named for `\s+([\s\S]+)$`: an interval, then a long run of whitespace.
86
+ const spawnScheduledWake = vi.fn();
87
+ const host = createTestAgentJobHost({ spawnScheduledWake });
88
+ const hostile = `0s\t${'\t\t'.repeat(PUMP_LENGTH)}x`;
89
+
90
+ const started = performance.now();
91
+ const result = await executeLoopCommand(host, vi.fn(), hostile);
92
+ const took = performance.now() - started;
93
+
94
+ expect(result.success).toBe(false);
95
+ expect(spawnScheduledWake).not.toHaveBeenCalled();
96
+ expect(took).toBeLessThan(BUDGET_MS);
97
+ });
98
+
99
+ it.each([
100
+ ['5m check the build', 'check the build'],
101
+ ['5m \t\n check the build ', 'check the build'],
102
+ ['5M\ncheck', 'check'],
103
+ ])('still reads a leading-interval `/loop` identically: %j', async (args, instruction) => {
104
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_1' });
105
+ const host = createTestAgentJobHost({ spawnScheduledWake });
106
+
107
+ const result = await executeLoopCommand(host, vi.fn(), args);
108
+
109
+ expect(result.success).toBe(true);
110
+ expect(spawnScheduledWake).toHaveBeenCalledWith(
111
+ expect.objectContaining({ agentInstruction: instruction }),
112
+ );
113
+ });
81
114
  });
@@ -115,7 +115,9 @@ function parseCreate(
115
115
  const requestedMs = parseDuration(intervalOnly[1]!, intervalOnly[2]!.toLowerCase());
116
116
  return requestedMs === undefined ? undefined : { instruction: boundedDefault, requestedMs };
117
117
  }
118
- const leading = /^(\d+)(s|m|h|d)\s+([\s\S]+)$/i.exec(args);
118
+ // One required space, then the rest verbatim (trimmed below): `\s+` before `[\s\S]+` split the
119
+ // same spaces two ways, which CodeQL reads as polynomial backtracking.
120
+ const leading = /^(\d+)(s|m|h|d)\s([\s\S]+)$/i.exec(args);
119
121
  if (leading) {
120
122
  const instruction = leading[3]!.trim();
121
123
  const requestedMs = parseDuration(leading[1]!, leading[2]!.toLowerCase());
@@ -14,6 +14,9 @@ export function createShellCommandEntry(): ICommand {
14
14
  source: 'shell',
15
15
  // User-only: the user's own shell passthrough; the model has its permission-gated shell tool.
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 createShellSystemCommand(executable?: string): ISystemCommand {
26
29
  requiresPermission: true,
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) => executeShellCommand(context, args, executable),
31
36
  };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * #3189 — the commands a terminal attached to a workspace daemon runs in its own process.
3
+ *
4
+ * The attached terminal routes by this set, not by the daemon's catalog, so the set must name exactly
5
+ * the commands the default assembly marks `runner: 'client'` — a command in one list and not the
6
+ * other would either run on the daemon or never run at all. And each client command must answer as
7
+ * the in-process command does, because it is the same command running somewhere else.
8
+ */
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, describe, expect, it, vi } from 'vitest';
14
+
15
+ import { createDefaultCommandModules } from '../../default/index.js';
16
+ import { createEditorCommandModule } from '../../editor/index.js';
17
+ import { createKeybindingsCommandModule } from '../../keybindings/index.js';
18
+ import { createShellCommandModule } from '../../shell/index.js';
19
+ import { createThemeCommandModule } from '../../theme/index.js';
20
+ import { createTerminalClientCommands } from '../terminal-client-commands.js';
21
+
22
+ import type { ITerminalClientCommand } from '../terminal-client-commands.js';
23
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
24
+ import type {
25
+ ICommandHostContext,
26
+ ICommandHostTerminalHandoff,
27
+ ICommandHostWorkspace,
28
+ ICommandModule,
29
+ IProviderCommandSettingsAdapter,
30
+ TProviderSettingsDocument,
31
+ } from '@robota-sdk/agent-framework';
32
+ import type {
33
+ IThemeCatalogueEntry,
34
+ IThemeCataloguePort,
35
+ } from '@robota-sdk/agent-interface-command';
36
+
37
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
38
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
39
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
40
+
41
+ const providerDefinitions: readonly IProviderDefinition[] = [];
42
+ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
43
+ readMergedSettings: () => ({}) as TProviderSettingsDocument,
44
+ readTargetSettings: () => ({}) as TProviderSettingsDocument,
45
+ writeTargetSettings: () => undefined,
46
+ };
47
+
48
+ const THEMES: IThemeCatalogueEntry[] = [
49
+ { id: 'dark', name: 'Dark', appearance: 'dark', source: 'built-in' },
50
+ { id: 'light', name: 'Light', appearance: 'light', source: 'built-in' },
51
+ ];
52
+ const themeCatalogue: IThemeCataloguePort = {
53
+ listThemes: () => THEMES,
54
+ getTheme: (id) => THEMES.find((theme) => theme.id === id),
55
+ getAppearance: () => ({
56
+ settings: { theme: 'dark', syntaxHighlighting: true, reducedMotion: false },
57
+ }),
58
+ };
59
+
60
+ function allPortsGiven(): readonly ITerminalClientCommand[] {
61
+ return createTerminalClientCommands({
62
+ keybindingsFile: { ensureFile: async () => '/tmp/keybindings.json' },
63
+ themeCatalogue,
64
+ });
65
+ }
66
+
67
+ function find(commands: readonly ITerminalClientCommand[], name: string): ITerminalClientCommand {
68
+ const command = commands.find((candidate) => candidate.name === name);
69
+ if (!command) throw new Error(`no terminal client command "${name}"`);
70
+ return command;
71
+ }
72
+
73
+ /** A terminal the command cannot take over: a host with no interactive TTY. */
74
+ function hostWithoutTerminal(): ICommandHostTerminalHandoff & ICommandHostWorkspace {
75
+ return {
76
+ canHandoffTerminal: () => false,
77
+ runWithTerminal: vi.fn(async () => {
78
+ throw new Error('a refused handoff must not be attempted');
79
+ }),
80
+ getCwd: () => '/tmp',
81
+ getCommandInvocationSource: () => 'user',
82
+ };
83
+ }
84
+
85
+ /** What the in-process command answers for the same host and arguments. */
86
+ async function inProcess(module: ICommandModule, host: unknown, args: string) {
87
+ const command = module.systemCommands?.[0];
88
+ if (!command) throw new Error(`module ${module.name} has no command`);
89
+ return command.execute(host as ICommandHostContext, args);
90
+ }
91
+
92
+ describe('createTerminalClientCommands (#3189)', () => {
93
+ it('names exactly the commands the default assembly marks as client-run', () => {
94
+ const { modules } = createDefaultCommandModules({
95
+ cwd: '/tmp',
96
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
97
+ providerDefinitions,
98
+ providerSettingsAdapter,
99
+ });
100
+ const clientRun = modules
101
+ .flatMap((module) => module.systemCommands ?? [])
102
+ .filter((command) => command.runner === 'client')
103
+ .map((command) => command.name)
104
+ .sort();
105
+
106
+ expect(clientRun.length).toBeGreaterThan(0);
107
+ expect(
108
+ allPortsGiven()
109
+ .map((command) => command.name)
110
+ .sort(),
111
+ ).toEqual(clientRun);
112
+ });
113
+
114
+ it('leaves out a client command whose module the selection disables, as the default assembly does', () => {
115
+ const selection = { disabledCommandModules: ['agent-command-shell'] };
116
+ const { modules } = createDefaultCommandModules({
117
+ cwd: '/tmp',
118
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
119
+ providerDefinitions,
120
+ providerSettingsAdapter,
121
+ ...selection,
122
+ });
123
+ const clientRun = modules
124
+ .flatMap((module) => module.systemCommands ?? [])
125
+ .filter((command) => command.runner === 'client')
126
+ .map((command) => command.name)
127
+ .sort();
128
+
129
+ const names = createTerminalClientCommands({ themeCatalogue, ...selection })
130
+ .map((command) => command.name)
131
+ .sort();
132
+
133
+ expect(names).not.toContain('shell');
134
+ expect(names).toEqual(clientRun);
135
+ });
136
+
137
+ it('keeps only the enabled client modules, and a disabled name wins over an enabled one', () => {
138
+ const names = createTerminalClientCommands({
139
+ enabledCommandModules: ['agent-command-theme', 'agent-command-editor', 'agent-command-help'],
140
+ disabledCommandModules: ['agent-command-editor'],
141
+ }).map((command) => command.name);
142
+
143
+ expect(names).toEqual(['theme']);
144
+ });
145
+
146
+ it('with no selection, offers all four client commands', () => {
147
+ expect(
148
+ createTerminalClientCommands({})
149
+ .map((command) => command.name)
150
+ .sort(),
151
+ ).toEqual(['editor', 'keybindings', 'shell', 'theme']);
152
+ });
153
+
154
+ it('/shell refuses a host that cannot hand off the terminal exactly as the in-process command does', async () => {
155
+ const host = hostWithoutTerminal();
156
+
157
+ const client = await find(allPortsGiven(), 'shell').execute(host, 'ls');
158
+
159
+ expect(client).toEqual(await inProcess(createShellCommandModule(), host, 'ls'));
160
+ expect(client.success).toBe(false);
161
+ expect(host.runWithTerminal).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('/editor and /keybindings refuse a host without a terminal as their in-process commands do', async () => {
165
+ const keybindingsFile = { ensureFile: vi.fn(async () => '/tmp/keybindings.json') };
166
+ const commands = createTerminalClientCommands({ keybindingsFile, themeCatalogue });
167
+
168
+ const editorHost = hostWithoutTerminal();
169
+ const editor = await find(commands, 'editor').execute(editorHost, 'draft');
170
+ expect(editor).toEqual(await inProcess(createEditorCommandModule(), editorHost, 'draft'));
171
+ expect(editor.success).toBe(false);
172
+
173
+ const keybindingsHost = hostWithoutTerminal();
174
+ const keybindings = await find(commands, 'keybindings').execute(keybindingsHost, '');
175
+ expect(keybindings).toEqual(
176
+ await inProcess(createKeybindingsCommandModule(keybindingsFile), keybindingsHost, ''),
177
+ );
178
+ expect(keybindings.success).toBe(false);
179
+ expect(keybindingsFile.ensureFile).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('/theme with no arguments asks the terminal to open its theme picker', async () => {
183
+ const result = await find(allPortsGiven(), 'theme').execute(hostWithoutTerminal(), '');
184
+
185
+ expect(result).toMatchObject({ success: true, uiIntents: [{ type: 'show-theme-picker' }] });
186
+ expect(result.hostActions).toBeUndefined();
187
+ });
188
+
189
+ it('/theme with a known id returns one appearance settings patch for the terminal to write', async () => {
190
+ const result = await find(allPortsGiven(), 'theme').execute(hostWithoutTerminal(), 'light');
191
+
192
+ expect(result.success).toBe(true);
193
+ expect(result.hostActions).toEqual([
194
+ { type: 'appearance-settings-patch', patch: { theme: 'light' } },
195
+ ]);
196
+ expect(result).toEqual(await inProcess(createThemeCommandModule(themeCatalogue), {}, 'light'));
197
+ });
198
+
199
+ it('without their ports, /keybindings and /theme still run on the terminal and answer as in-process', async () => {
200
+ const commands = createTerminalClientCommands({});
201
+ // The set does not shrink: a command missing from it would be sent to the daemon instead.
202
+ expect(commands.map((command) => command.name).sort()).toEqual(
203
+ allPortsGiven()
204
+ .map((command) => command.name)
205
+ .sort(),
206
+ );
207
+
208
+ const host = hostWithoutTerminal();
209
+ expect(await find(commands, 'keybindings').execute(host, '')).toEqual(
210
+ await inProcess(createKeybindingsCommandModule(undefined), host, ''),
211
+ );
212
+ expect(await find(commands, 'theme').execute(host, 'light')).toEqual(
213
+ await inProcess(createThemeCommandModule(undefined), host, 'light'),
214
+ );
215
+ });
216
+ });
@@ -0,0 +1,5 @@
1
+ export {
2
+ createTerminalClientCommands,
3
+ type ITerminalClientCommand,
4
+ type ITerminalClientCommandOptions,
5
+ } from './terminal-client-commands.js';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * #3189: the commands that belong to the terminal the user sits at — the ones the default assembly
3
+ * marks `runner: 'client'`. A terminal attached to a workspace daemon runs these in its own process,
4
+ * on its own terminal and working directory, and sends nothing to the daemon.
5
+ *
6
+ * Each one is the in-process command's own execute function, so an attached terminal answers exactly
7
+ * as a standalone one does; only where it runs changes.
8
+ */
9
+ import { selectCommandModules } from '@robota-sdk/agent-framework';
10
+
11
+ import { createEditorCommandModule } from '../editor/editor-command-module.js';
12
+ import { executeEditorCommand } from '../editor/editor-command.js';
13
+ import {
14
+ createKeybindingsCommandModule,
15
+ executeKeybindingsCommand,
16
+ } from '../keybindings/keybindings-command-module.js';
17
+ import { createShellCommandModule } from '../shell/shell-command-module.js';
18
+ import { executeShellCommand } from '../shell/shell-command.js';
19
+ import { createThemeCommandModule, executeThemeCommand } from '../theme/theme-command-module.js';
20
+
21
+ import type { IKeybindingsFilePort } from '../keybindings/keybindings-command-module.js';
22
+ import type {
23
+ ICommandHostTerminalHandoff,
24
+ ICommandHostWorkspace,
25
+ ICommandModule,
26
+ } from '@robota-sdk/agent-framework';
27
+ import type { ICommandResult, IThemeCataloguePort } from '@robota-sdk/agent-interface-command';
28
+
29
+ /** A command the terminal runs itself, with the terminal's own handoff and working directory. */
30
+ export interface ITerminalClientCommand {
31
+ readonly name: string;
32
+ execute(
33
+ host: ICommandHostTerminalHandoff & ICommandHostWorkspace,
34
+ args: string,
35
+ ): ICommandResult | Promise<ICommandResult>;
36
+ }
37
+
38
+ /** The terminal's own capabilities — the same ones its in-process command modules are given. */
39
+ export interface ITerminalClientCommandOptions {
40
+ /** The shell `/shell` runs; absent → the platform shell, as for the in-process command. */
41
+ readonly shellExecutable?: string;
42
+ /** Product-owned prefix for `/editor`'s temporary directory; absent → the command's default. */
43
+ readonly editorTemporaryDirectoryPrefix?: string;
44
+ /** Absent → `/keybindings` answers where key bindings live, as the in-process command does. */
45
+ readonly keybindingsFile?: IKeybindingsFilePort;
46
+ /** Absent → `/theme` answers where themes live, as the in-process command does. */
47
+ readonly themeCatalogue?: IThemeCataloguePort;
48
+ /**
49
+ * The same module selection the default assembly is given (`agent-command-shell`, …): a module it
50
+ * leaves out is left out here too, so an attached terminal offers no command a standalone one
51
+ * would not. Absent → every client-run module.
52
+ */
53
+ readonly enabledCommandModules?: readonly string[];
54
+ /** Applied after `enabledCommandModules`, as in the default assembly (deny > allow). */
55
+ readonly disabledCommandModules?: readonly string[];
56
+ }
57
+
58
+ type TClientExecute = ITerminalClientCommand['execute'];
59
+
60
+ /**
61
+ * Every client-run command the module selection keeps, whether or not its port is given. The set
62
+ * shrinks only with the selection, never with a missing port: the attached terminal routes by it, so
63
+ * a selected command left out would run on the daemon instead — and without its port the command
64
+ * still answers as its in-process twin does.
65
+ */
66
+ export function createTerminalClientCommands(
67
+ options: ITerminalClientCommandOptions,
68
+ ): readonly ITerminalClientCommand[] {
69
+ const {
70
+ shellExecutable,
71
+ editorTemporaryDirectoryPrefix,
72
+ keybindingsFile,
73
+ themeCatalogue,
74
+ enabledCommandModules,
75
+ disabledCommandModules,
76
+ } = options;
77
+ // Each client command is paired with its in-process module, so selection matches by the module's
78
+ // own name and the command keeps that module's command name.
79
+ const candidates: ReadonlyArray<readonly [ICommandModule, TClientExecute]> = [
80
+ [
81
+ createShellCommandModule(shellExecutable),
82
+ (host, args) => executeShellCommand(host, args, shellExecutable),
83
+ ],
84
+ [
85
+ createEditorCommandModule(editorTemporaryDirectoryPrefix),
86
+ (host, args) => executeEditorCommand(host, args, editorTemporaryDirectoryPrefix),
87
+ ],
88
+ [
89
+ createKeybindingsCommandModule(keybindingsFile),
90
+ (host) => executeKeybindingsCommand(keybindingsFile, host),
91
+ ],
92
+ [
93
+ createThemeCommandModule(themeCatalogue),
94
+ (_host, args) => executeThemeCommand(themeCatalogue, args),
95
+ ],
96
+ ];
97
+ const selected = new Set(
98
+ selectCommandModules(
99
+ candidates.map(([module]) => module),
100
+ enabledCommandModules,
101
+ disabledCommandModules,
102
+ ),
103
+ );
104
+ return candidates
105
+ .filter(([module]) => selected.has(module))
106
+ .flatMap(([module, execute]) =>
107
+ (module.systemCommands ?? []).map((command) => ({ name: command.name, execute })),
108
+ );
109
+ }
@@ -73,7 +73,7 @@ describe('/theme without a catalogue (SCREEN-2002 TC-09)', () => {
73
73
  const result = executeThemeCommand(undefined, 'light');
74
74
 
75
75
  expect(result.success).toBe(false);
76
- expect(result.message).toBe('Themes are not available in this environment.');
76
+ expect(result.message).toMatch(/Run \/theme in the robota terminal/);
77
77
  expect(result.hostActions).toBeUndefined();
78
78
  });
79
79
  });
@@ -16,7 +16,10 @@ const THEME_DESCRIPTION =
16
16
  'List, preview or switch the terminal colour theme, and toggle syntax highlighting and motion';
17
17
  const THEME_ARGUMENT_HINT = 'list | <theme-id> | syntax on|off | motion on|off';
18
18
 
19
- const THEME_UNAVAILABLE = 'Themes are not available in this environment.';
19
+ // A host without a terminal (the desktop app's sidecar) still answers, so the command is never
20
+ // "unknown" there — it says where themes live instead.
21
+ const THEME_UNAVAILABLE =
22
+ 'Themes belong to the robota terminal, and this surface has none. Run /theme in the robota terminal.';
20
23
 
21
24
  function formatToggle(value: boolean): string {
22
25
  return value ? 'on' : 'off';
@@ -179,6 +182,9 @@ export function createThemeCommandEntry(): ICommand {
179
182
  userInvocable: true,
180
183
  // User-only: UI preference.
181
184
  modelInvocable: false,
185
+ // Themes belong to the terminal the user sits at, so that terminal runs it, even when attached.
186
+ runner: 'client',
187
+ surfaces: ['terminal'],
182
188
  };
183
189
  }
184
190
 
@@ -190,7 +196,9 @@ export class ThemeCommandSource implements ICommandSource {
190
196
  }
191
197
  }
192
198
 
193
- export function createThemeCommandModule(catalogue: IThemeCataloguePort): ICommandModule {
199
+ export function createThemeCommandModule(
200
+ catalogue: IThemeCataloguePort | undefined,
201
+ ): ICommandModule {
194
202
  const entry = createThemeCommandEntry();
195
203
  const command: ISystemCommand = {
196
204
  name: entry.name,
@@ -200,6 +208,8 @@ export function createThemeCommandModule(catalogue: IThemeCataloguePort): IComma
200
208
  requiresPermission: false,
201
209
  userInvocable: true,
202
210
  modelInvocable: false,
211
+ runner: entry.runner,
212
+ surfaces: entry.surfaces,
203
213
  lifecycle: 'inline',
204
214
  execute: (_context, args) => executeThemeCommand(catalogue, args),
205
215
  };
@@ -21,8 +21,8 @@ afterEach(async () => {
21
21
  });
22
22
 
23
23
  describe('user-local command', () => {
24
- it('does not advertise a specific product from the neutral command entry', () => {
25
- const module = createUserLocalCommandModule('/tmp/selected-storage');
24
+ it('does not advertise a specific product from the neutral command entry', async () => {
25
+ const module = createUserLocalCommandModule(await createTempRoot('robota-selected-storage-'));
26
26
  expect(module.commandSources?.[0]?.getCommands()[0]?.description).not.toMatch(/Robota/i);
27
27
  });
28
28