@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.82",
4
- "description": "Consolidated command module implementations for Robota SDK CLI",
3
+ "version": "3.0.0-beta.84",
4
+ "description": "Slash-command modules for Robota SDK sessions (/help, /permissions, /mcp, /rewind, …)",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
7
7
  "types": "dist/node/index.d.ts",
@@ -28,19 +28,24 @@
28
28
  "default": "./dist/node/index.cjs"
29
29
  }
30
30
  }
31
- }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.12.0"
32
36
  },
33
37
  "files": [
34
38
  "dist",
35
- "src"
39
+ "src",
40
+ "CHANGELOG.md"
36
41
  ],
37
42
  "dependencies": {
38
- "@robota-sdk/agent-core": "3.0.0-beta.82",
39
- "@robota-sdk/agent-framework": "3.0.0-beta.82",
40
- "@robota-sdk/agent-interface-command": "3.0.0-beta.82",
41
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.82",
42
- "@robota-sdk/agent-interface-session": "3.0.0-beta.82",
43
- "@robota-sdk/agent-preset": "3.0.0-beta.82"
43
+ "@robota-sdk/agent-core": "3.0.0-beta.84",
44
+ "@robota-sdk/agent-framework": "3.0.0-beta.84",
45
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.84",
46
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.84",
47
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.84",
48
+ "@robota-sdk/agent-preset": "3.0.0-beta.84"
44
49
  },
45
50
  "devDependencies": {
46
51
  "@types/node": "^22.19.21",
@@ -55,6 +60,7 @@
55
60
  "url": "https://github.com/woojubb/robota.git",
56
61
  "directory": "packages/agent-command"
57
62
  },
63
+ "homepage": "https://docs.robota.io/en/packages/agent-command/",
58
64
  "publishConfig": {
59
65
  "access": "public"
60
66
  },
@@ -15,6 +15,7 @@ function createMockSession(overrides?: Record<string, unknown>) {
15
15
  { name: 'general-purpose', description: 'General-purpose task execution agent.' },
16
16
  { name: 'Plan', description: 'Read-only planning agent.' },
17
17
  ]),
18
+ setDefaultAgentType: vi.fn(),
18
19
  listAgentJobs: vi.fn().mockReturnValue([]),
19
20
  readBackgroundTaskLog: vi.fn().mockResolvedValue({ taskId: 'agent_1', lines: [] }),
20
21
  spawnAgentJob: vi.fn().mockResolvedValue({
@@ -518,6 +519,67 @@ describe('agent command module', () => {
518
519
  expect(result?.uiIntents).toEqual([{ type: 'show-agent-switcher' }]);
519
520
  });
520
521
 
522
+ it('#3282 §4: /agent <name> with nothing after it selects a default agent instead of failing', async () => {
523
+ const module = createAgentCommandModule();
524
+ const executor = new SystemCommandExecutor([
525
+ ...createSystemCommands(),
526
+ ...(module.systemCommands ?? []),
527
+ ]);
528
+ const session = createMockSession();
529
+
530
+ const result = await executor.execute('agent', session, 'Plan');
531
+
532
+ expect(result?.success).toBe(true);
533
+ expect(result?.message).toBe('Default agent: Plan');
534
+ expect(result?.data).toEqual({ agentType: 'Plan' });
535
+ expect(
536
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
537
+ ).toHaveBeenCalledWith('Plan');
538
+ expect(
539
+ (session as unknown as { spawnAgentJob: ReturnType<typeof vi.fn> }).spawnAgentJob,
540
+ ).not.toHaveBeenCalled();
541
+ });
542
+
543
+ it('#3282 §4: a single unrecognized word is still a prompt for the default agent, not a selection', async () => {
544
+ const module = createAgentCommandModule();
545
+ const executor = new SystemCommandExecutor([
546
+ ...createSystemCommands(),
547
+ ...(module.systemCommands ?? []),
548
+ ]);
549
+ const session = createMockSession();
550
+
551
+ const result = await executor.execute('agent', session, 'NotARealAgent');
552
+
553
+ expect(result?.success).toBe(true);
554
+ expect(
555
+ (session as unknown as { spawnAgentJob: ReturnType<typeof vi.fn> }).spawnAgentJob,
556
+ ).toHaveBeenCalledWith({
557
+ agentType: 'general-purpose',
558
+ label: 'general-purpose',
559
+ mode: 'background',
560
+ prompt: 'NotARealAgent',
561
+ });
562
+ expect(
563
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
564
+ ).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it('#3282 §4: a known agent name followed by a prompt still runs a job, not a selection', async () => {
568
+ const module = createAgentCommandModule();
569
+ const executor = new SystemCommandExecutor([
570
+ ...createSystemCommands(),
571
+ ...(module.systemCommands ?? []),
572
+ ]);
573
+ const session = createMockSession();
574
+
575
+ const result = await executor.execute('agent', session, 'Plan "draft architecture"');
576
+
577
+ expect(result?.success).toBe(true);
578
+ expect(
579
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
580
+ ).not.toHaveBeenCalled();
581
+ });
582
+
521
583
  it('shows text list when agent list subcommand is used explicitly', async () => {
522
584
  const module = createAgentCommandModule();
523
585
  const executor = new SystemCommandExecutor([
@@ -1,6 +1,6 @@
1
- import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
1
+ import { DEFAULT_AGENT_TYPE } from '@robota-sdk/agent-framework';
2
2
 
3
- const DEFAULT_AGENT_TYPE = 'general-purpose';
3
+ import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
4
4
 
5
5
  export type TAgentMode = 'background';
6
6
 
@@ -44,6 +44,21 @@ function executeOpenSwitcher(): ICommandResult {
44
44
  return { message: '', uiIntents: [{ type: 'show-agent-switcher' }], success: true };
45
45
  }
46
46
 
47
+ /**
48
+ * #3282 §4: `/agent <name>` with nothing after the name sets the session's default agent type —
49
+ * the one `/agent <prompt>` falls back to, and the one the agent switcher shows checked. Distinct
50
+ * from `executeRun`, which a name FOLLOWED by a prompt still reaches (a prompt makes it a run, not
51
+ * a selection).
52
+ */
53
+ function executeSelectDefaultAgent(session: IAgentJobHostContext, agentType: string): ICommandResult {
54
+ session.setDefaultAgentType(agentType);
55
+ return {
56
+ message: `Default agent: ${agentType}`,
57
+ success: true,
58
+ data: { agentType },
59
+ };
60
+ }
61
+
47
62
  async function executeList(session: IAgentJobHostContext): Promise<ICommandResult> {
48
63
  const agents = session.listAgentDefinitions();
49
64
  const jobs = session.listAgentJobs();
@@ -223,6 +238,11 @@ export async function executeAgentCommand(
223
238
  if (action === 'send') return executeSend(session, tokens);
224
239
  if (action === 'stop' || action === 'cancel') return executeStop(session, tokens);
225
240
  if (action === 'close') return executeClose(session, tokens);
241
+ // `/agent <name>` alone (a known agent, nothing after it) selects a default rather than failing
242
+ // the "run" parse for want of a prompt — the same one-token shape the switcher sends.
243
+ if (tokens.length === 0 && getAvailableAgentNames(session).has(action)) {
244
+ return executeSelectDefaultAgent(session, action);
245
+ }
226
246
  return executeRun(session, [action, ...tokens]);
227
247
  } catch (error) {
228
248
  return { message: formatError(error), success: false };
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSystemCommandFromEntry } from './command-module-utils.js';
4
+
5
+ const behavior = {
6
+ lifecycle: 'inline',
7
+ requiresPermission: false,
8
+ execute: () => ({ success: true, message: 'ran' }),
9
+ } as const;
10
+
11
+ describe('createSystemCommandFromEntry', () => {
12
+ it('#3189: carries where the entry says it runs, so the catalog and the palette agree', () => {
13
+ const command = createSystemCommandFromEntry(
14
+ {
15
+ name: 'local',
16
+ description: 'Runs on the terminal',
17
+ source: 'test',
18
+ runner: 'client',
19
+ surfaces: ['terminal'],
20
+ },
21
+ behavior,
22
+ );
23
+
24
+ expect(command.runner).toBe('client');
25
+ expect(command.surfaces).toEqual(['terminal']);
26
+ });
27
+
28
+ it('leaves an unstated placement unstated rather than guessing one', () => {
29
+ const command = createSystemCommandFromEntry(
30
+ { name: 'plain', description: 'Runs on the runtime', source: 'test' },
31
+ behavior,
32
+ );
33
+
34
+ expect('runner' in command).toBe(false);
35
+ expect('surfaces' in command).toBe(false);
36
+ });
37
+ });
@@ -25,6 +25,8 @@ export function createSystemCommandFromEntry(
25
25
  ...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
26
26
  ...(entry.safety !== undefined ? { safety: entry.safety } : {}),
27
27
  ...(entry.subcommands !== undefined ? { subcommands: entry.subcommands } : {}),
28
+ ...(entry.runner !== undefined ? { runner: entry.runner } : {}),
29
+ ...(entry.surfaces !== undefined ? { surfaces: entry.surfaces } : {}),
28
30
  ...(behavior.semanticRole !== undefined ? { semanticRole: behavior.semanticRole } : {}),
29
31
  lifecycle: behavior.lifecycle,
30
32
  requiresPermission: behavior.requiresPermission,
@@ -44,7 +44,7 @@ Run it with no arguments.",
44
44
  },
45
45
  "loop": {
46
46
  "argumentHint": "[prompt] | <N><s|m|h|d> [prompt] | <prompt> every <N> <unit> | list | stop <id>",
47
- "description": "Repeat a prompt on a fixed cadence (\`<N><s|m|h|d> [prompt]\`) or self-paced (bare or prompt only: each iteration chooses its next delay or stops). Use it when the user asks for recurring work in this session. \`list\` shows active loops; \`stop <id>\` ends one. Returns the loop id and its next fire time, or the loop list.",
47
+ "description": "Repeat a prompt on a fixed cadence (\`<N><s|m|h|d> <prompt>\`) or self-paced (\`<prompt>\`: each iteration chooses its next delay or stops). Use it when the user asks for recurring work in this session, and always give your own prompt: any form that would fall back to the host's default maintenance prompt — bare \`/loop\` or an interval alone (\`<N><s|m|h|d>\`) — needs a person to confirm it and is always refused for you, whether or not one is attached. \`list\` shows active loops; \`stop <id>\` ends one, even mid-turn. Returns the loop id and its next fire time, the loop list, or a refusal naming the command to suggest.",
48
48
  "kind": "builtin-command",
49
49
  "modelInvocable": true,
50
50
  "name": "loop",
@@ -64,7 +64,7 @@ Subcommands you may run (bare \`mcp\` is also allowed):
64
64
  },
65
65
  "memory": {
66
66
  "argumentHint": "[list | show [topic] | add <user|feedback|project|reference> <topic> <text> | pending | used]",
67
- "description": "Read and write this project’s durable memory. Use \`list\` or \`show [topic]\` to look up stored conventions, preferences or references before asking the user again; \`add <type> <topic> <text>\` to save a durable preference, project convention, feedback item or reference worth reusing across sessions (never secrets, credentials or transient facts); \`pending\` to see candidates awaiting the user’s review; \`used\` to report which memory items informed this turn. Bare lists topics. Returns the requested topics or entries, or a confirmation of what was saved. Approving or rejecting a pending candidate is the user’s review: suggest \`/memory approve <id>\` or \`/memory reject <id>\`.
67
+ "description": "Read and write this project’s durable memory. Use \`list\` or \`show [topic]\` to look up stored conventions, preferences or references before asking the user again; \`add <type> <topic> <text>\` to save a durable preference, project convention, feedback item or reference worth reusing across sessions (never secrets, credentials or transient facts); \`pending\` to see candidates awaiting the user’s review; \`used\` to report which memory items informed this turn. Bare lists topics. In plan mode \`add\` is refused: save after plan mode ends. Returns the requested topics or entries, or a confirmation of what was saved. Approving or rejecting a pending candidate is the user’s review: suggest \`/memory approve <id>\` or \`/memory reject <id>\`.
68
68
  Subcommands you may run (bare \`memory\` is also allowed):
69
69
  - list: List project memory topics
70
70
  - show: Show project memory index or a topic
@@ -1,4 +1,8 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, describe, expect, it } from 'vitest';
2
6
 
3
7
  import type { IProviderDefinition } from '@robota-sdk/agent-core';
4
8
  import type {
@@ -8,6 +12,10 @@ import type {
8
12
 
9
13
  import { createDefaultCommandModules } from '../default-command-modules.js';
10
14
 
15
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
16
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
17
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
18
+
11
19
  const providerDefinitions: readonly IProviderDefinition[] = [
12
20
  {
13
21
  type: 'anthropic',
@@ -26,7 +34,7 @@ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
26
34
  writeTargetSettings: () => undefined,
27
35
  };
28
36
 
29
- const baseOptions = { cwd: '/tmp', userLocalStorageRoot: '/tmp/robota-test', providerDefinitions, providerSettingsAdapter } as const;
37
+ const baseOptions = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
30
38
 
31
39
  function moduleNames(opts: Parameters<typeof createDefaultCommandModules>[0]): string[] {
32
40
  return createDefaultCommandModules(opts).modules.map((module) => module.name);
@@ -42,10 +50,10 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
42
50
  const names = moduleNames(baseOptions);
43
51
  // No-regression: the default set length is the documented 32 modules (SELFHOST-002 added
44
52
  // `/plan`; PEER-004 added `/peers`; CLI-1994 added `/fork`; MCP-2520 added `/mcp`; CLI-1988 added
45
- // `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it). The list below is the
46
- // assertion that matters — a length on its own can be restored by any substitution, and the count
47
- // exists only to catch an addition that also removed something.
48
- expect(names).toHaveLength(35);
53
+ // `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it; #3282 §2 added `/model`).
54
+ // The list below is the assertion that matters — a length on its own can be restored by any
55
+ // substitution, and the count exists only to catch an addition that also removed something.
56
+ expect(names).toHaveLength(39);
49
57
  expect(names).toEqual([
50
58
  'agent-command-skills',
51
59
  'agent-command-help',
@@ -66,6 +74,8 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
66
74
  'agent-command-shell',
67
75
  'agent-command-editor',
68
76
  'agent-command-git',
77
+ 'agent-command-keybindings',
78
+ 'agent-command-theme',
69
79
  'agent-command-memory',
70
80
  'agent-command-mcp-activation',
71
81
  'agent-command-user-local',
@@ -80,9 +90,11 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
80
90
  'agent-command-plugin',
81
91
  'agent-command-settings',
82
92
  'agent-command-peers',
93
+ 'agent-command-events',
83
94
  'agent-command-handoff',
84
95
  'agent-command-remote-control',
85
96
  'agent-command-provider',
97
+ 'agent-command-model',
86
98
  ]);
87
99
  });
88
100
 
@@ -92,14 +104,42 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
92
104
  expect(names).toHaveLength(2);
93
105
  });
94
106
 
95
- it('BEHAVIOR-2003: registers /keybindings only when the file capability is injected', () => {
96
- expect(moduleNames(baseOptions)).not.toContain('agent-command-keybindings');
97
- expect(
98
- moduleNames({
99
- ...baseOptions,
100
- keybindingsFilePort: { ensureFile: async () => '/tmp/keybindings.json' },
101
- }),
102
- ).toContain('agent-command-keybindings');
107
+ it('#3186: /keybindings and /theme exist on a host without a terminal, and say where they work', async () => {
108
+ const { modules } = createDefaultCommandModules(baseOptions);
109
+ const commands = modules.flatMap((module) => module.systemCommands ?? []);
110
+ for (const name of ['keybindings', 'theme']) {
111
+ const command = commands.find((candidate) => candidate.name === name);
112
+ expect(command, name).toBeDefined();
113
+ const result = await command!.execute({} as never, '');
114
+ expect(result).toMatchObject({ success: false });
115
+ expect(result.message).toMatch(/robota terminal/);
116
+ }
117
+ });
118
+
119
+ it('#3189: exactly /editor, /keybindings, /shell and /theme run on the terminal client', () => {
120
+ const { modules } = createDefaultCommandModules(baseOptions);
121
+ const TERMINAL_CLIENT = ['editor', 'keybindings', 'shell', 'theme'];
122
+
123
+ // The executable commands: what the runtime keeps in its catalog and what the listing reads.
124
+ const clientSystemCommands = modules
125
+ .flatMap((module) => module.systemCommands ?? [])
126
+ .filter((command) => command.runner === 'client');
127
+ expect(clientSystemCommands.map((command) => command.name).sort()).toEqual(TERMINAL_CLIENT);
128
+ for (const command of clientSystemCommands) {
129
+ expect(command.surfaces, command.name).toEqual(['terminal']);
130
+ }
131
+
132
+ // The palette entries, which must say the same — the two views cannot disagree.
133
+ const clientEntries = modules
134
+ .flatMap((module) => module.commandSources ?? [])
135
+ .flatMap((source) => source.getCommands())
136
+ .filter((entry) => entry.runner === 'client');
137
+ expect(clientEntries.map((entry) => entry.name).sort()).toEqual(TERMINAL_CLIENT);
138
+ for (const entry of clientEntries) {
139
+ expect(entry.surfaces, entry.name).toEqual(['terminal']);
140
+ // Running on the client does not change who may run it: all four stay user-only.
141
+ expect(entry.modelInvocable, entry.name).toBe(false);
142
+ }
103
143
  });
104
144
 
105
145
  it('TC-02: disabledCommandModules blacklist removes the named module', () => {
@@ -7,7 +7,11 @@
7
7
  * each command's entry.
8
8
  */
9
9
 
10
- import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
11
15
 
12
16
  import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
13
17
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
@@ -41,6 +45,10 @@ const themeCataloguePort: IThemeCataloguePort = {
41
45
  }),
42
46
  };
43
47
 
48
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
49
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
50
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
51
+
44
52
  const fixtures: IDoctorFixture[] = [];
45
53
  afterEach(() => {
46
54
  for (const fixture of fixtures.splice(0)) fixture.cleanup();
@@ -52,12 +60,16 @@ function allBuiltInCommands(): ISystemCommand[] {
52
60
  fixtures.push(fixture);
53
61
  const { modules } = createDefaultCommandModules({
54
62
  cwd: '/tmp',
55
- userLocalStorageRoot: '/tmp/robota-test',
63
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
56
64
  providerDefinitions,
57
65
  providerSettingsAdapter,
58
66
  });
67
+ // /keybindings and /theme are in the default set; built here with their capabilities instead.
68
+ const withoutPortless = modules.filter(
69
+ (module) => module.name !== 'agent-command-keybindings' && module.name !== 'agent-command-theme',
70
+ );
59
71
  return [
60
- ...modules,
72
+ ...withoutPortless,
61
73
  createKeybindingsCommandModule({ ensureFile: async () => '/tmp/keybindings.json' }),
62
74
  createThemeCommandModule(themeCataloguePort),
63
75
  createDoctorCommandModule(fixture.inputs, fixture.deps),
@@ -94,8 +106,11 @@ const USER_ONLY_COMMANDS = [
94
106
  'plugin',
95
107
  'reload-plugins',
96
108
  'remote-control',
109
+ 'events',
97
110
  // Account and credential actions.
98
111
  'provider',
112
+ // Cost and capability decisions: the person chooses, never the model on its own (#3282 §2).
113
+ 'model',
99
114
  // Permission widening or permission-mode changes.
100
115
  'mode',
101
116
  'permissions',
@@ -17,7 +17,11 @@
17
17
  * type checker's job.
18
18
  */
19
19
 
20
- import { describe, expect, it } from 'vitest';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ import { afterAll, describe, expect, it } from 'vitest';
21
25
  import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
22
26
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
23
27
 
@@ -55,13 +59,17 @@ const adapter: IProviderCommandSettingsAdapter = {
55
59
  writeTargetSettings: () => undefined,
56
60
  };
57
61
 
62
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
63
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
64
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
65
+
58
66
  const POLICY: IOrgPolicy = { allowedProviders: ['anthropic'], adminContact: 'ops@example.com' };
59
67
 
60
68
  /** Build the provider executor the way the PRODUCT does — through the default-modules factory. */
61
69
  function executorThroughFactory(orgPolicy?: IOrgPolicy): SystemCommandExecutor {
62
70
  const { modules } = createDefaultCommandModules({
63
71
  cwd: '/work',
64
- userLocalStorageRoot: '/tmp/robota-test',
72
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
65
73
  providerDefinitions,
66
74
  providerSettingsAdapter: adapter,
67
75
  ...(orgPolicy === undefined ? {} : { orgPolicy }),
@@ -20,8 +20,10 @@ import { createLanguageCommandModule } from '../language/index.js';
20
20
  import { createMCPActivationCommandModule } from '../mcp-activation/index.js';
21
21
  import { createMemoryCommandModule } from '../memory/index.js';
22
22
  import { createModeCommandModule } from '../mode/index.js';
23
+ import { createModelCommandModule } from '../model/index.js';
23
24
  import { createSandboxCommandModule } from '../sandbox/index.js';
24
25
  import { createOutputStyleCommandModule } from '../output-style/index.js';
26
+ import { createEventsCommandModule } from '../events/index.js';
25
27
  import { createPeersCommandModule } from '../peers/index.js';
26
28
  import { createPermissionsCommandModule } from '../permissions/index.js';
27
29
  import { createPlanCommandModule } from '../plan/index.js';
@@ -72,12 +74,11 @@ export interface IDefaultCommandModulesOptions {
72
74
  * red when the producer dropped it.
73
75
  */
74
76
  orgPolicy?: IOrgPolicy;
75
- /** Optional TUI-owned file capability; absence means `/keybindings` is not registered. */
77
+ /** Optional TUI-owned file capability; absent, `/keybindings` says it belongs to the robota terminal. */
76
78
  keybindingsFilePort?: IKeybindingsFilePort;
77
79
  /**
78
- * SCREEN-2002: the surface's theme catalogue. Absence means `/theme` is not registered at all —
79
- * print mode and `--serve` render no themes, and a command that cannot do anything is better
80
- * missing than present-and-failing.
80
+ * SCREEN-2002: the surface's theme catalogue. Absent (print mode, `--serve`), `/theme` is still
81
+ * listed and says it belongs to the robota terminal, which runs it itself when attached.
81
82
  */
82
83
  themeCataloguePort?: IThemeCataloguePort;
83
84
  /**
@@ -173,10 +174,8 @@ export function createDefaultCommandModules({
173
174
  createShellCommandModule(),
174
175
  createEditorCommandModule(editorTemporaryDirectoryPrefix),
175
176
  createGitCommandModule(),
176
- ...(keybindingsFilePort === undefined
177
- ? []
178
- : [createKeybindingsCommandModule(keybindingsFilePort)]),
179
- ...(themeCataloguePort === undefined ? [] : [createThemeCommandModule(themeCataloguePort)]),
177
+ createKeybindingsCommandModule(keybindingsFilePort),
178
+ createThemeCommandModule(themeCataloguePort),
180
179
  ...(doctorInputs === undefined
181
180
  ? []
182
181
  : [createDoctorCommandModule(doctorInputs, undefined, doctorDisplay)]),
@@ -194,6 +193,7 @@ export function createDefaultCommandModules({
194
193
  createPluginCommandModule(),
195
194
  createSettingsCommandModule(),
196
195
  createPeersCommandModule(),
196
+ createEventsCommandModule(),
197
197
  // HANDOFF-001 (issue #1864). Registered even though no product wires the carrier adapter yet:
198
198
  // the command's own answer to a host without one is to say so, which is a better state than a
199
199
  // capability nobody can see. It is also what makes the carrier's arrival observable.
@@ -205,6 +205,13 @@ export function createDefaultCommandModules({
205
205
  settings: providerSettingsAdapter,
206
206
  ...(orgPolicy === undefined ? {} : { orgPolicy }),
207
207
  }),
208
+ // #3282 §2: shares the provider command's settings adapter and definitions — one settings write
209
+ // owner, never a second copy that could drift from what `/provider switch` reads and writes.
210
+ createModelCommandModule({
211
+ providerDefinitions,
212
+ settings: providerSettingsAdapter,
213
+ ...(orgPolicy === undefined ? {} : { orgPolicy }),
214
+ }),
208
215
  ];
209
216
  const builtModuleNames = modules.map((module) => module.name);
210
217
  return {