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

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 (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import type {
3
3
  ICommandHostContext,
4
4
  IEditCheckpointRestoreResult,
5
+ IPermissionRuleLayer,
6
+ IPermissionsCommandState,
5
7
  } from '@robota-sdk/agent-framework';
6
8
  import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
7
9
  import { createPermissionsCommandModule } from '../permissions-command-module.js';
10
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
8
11
 
9
12
  type TPermissionModeName = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions';
10
13
  type TSetPermissionModeSpy = ReturnType<typeof vi.fn<(nextMode: TPermissionModeName) => void>>;
@@ -28,41 +31,55 @@ function createCheckpointResult(): IEditCheckpointRestoreResult {
28
31
  function createCommandHostContext(options?: {
29
32
  mode?: TPermissionModeName;
30
33
  sessionAllowed?: readonly string[];
31
- }): ICommandHostContext & { setPermissionMode: TSetPermissionModeSpy } {
34
+ rules?: { allow: string[]; deny: string[]; ask: string[] };
35
+ denials?: IPermissionsCommandState['recentDenials'];
36
+ layers?: readonly IPermissionRuleLayer[];
37
+ retryDenial?: (index: number) => IPermissionsCommandState['recentDenials'][number] | undefined;
38
+ }): ReturnType<typeof createTestCommandHost> & { setPermissionMode: TSetPermissionModeSpy } {
32
39
  let mode = options?.mode ?? 'default';
33
40
  const setPermissionMode = vi.fn((nextMode: TPermissionModeName) => {
34
41
  mode = nextMode;
35
42
  });
36
43
 
37
44
  return {
38
- getSession: () => {
39
- throw new Error('permissions command should use the permission mode adapter');
40
- },
41
- getCommandHostAdapters: () => ({
42
- permissionMode: {
43
- getPermissionMode: () => mode,
44
- setPermissionMode,
45
- listSessionAllowedTools: () => options?.sessionAllowed ?? [],
45
+ ...createTestCommandHost({
46
+ overrides: {
47
+ getSession: () => {
48
+ throw new Error('permissions command should use the permission mode adapter');
49
+ },
50
+ getCommandHostAdapters: () => ({
51
+ permissionMode: {
52
+ getPermissionMode: () => mode,
53
+ setPermissionMode,
54
+ listSessionAllowedTools: () => options?.sessionAllowed ?? [],
55
+ getPermissionRules: () => options?.rules ?? { allow: [], deny: [], ask: [] },
56
+ listRecentDenials: () => options?.denials ?? [],
57
+ retryDenial: (index: number) => options?.retryDenial?.(index),
58
+ },
59
+ ...(options?.layers !== undefined
60
+ ? { permissionRules: { readLayers: () => options.layers! } }
61
+ : {}),
62
+ }),
63
+ getContextState: () => ({
64
+ usedTokens: 0,
65
+ maxTokens: 1,
66
+ usedPercentage: 0,
67
+ remainingPercentage: 100,
68
+ }),
69
+ getAutoCompactThreshold: () => 0.835,
70
+ compactContext: async () => undefined,
71
+ getCwd: () => '/workspace',
72
+ listEditCheckpoints: () => [],
73
+ restoreEditCheckpoint: async () => createCheckpointResult(),
74
+ rollbackEditCheckpoint: async () => createCheckpointResult(),
75
+ getUsedMemoryReferences: () => [],
76
+ recordMemoryEvent: () => undefined,
77
+ listBackgroundTasks: () => [],
78
+ readBackgroundTaskLog: async () => ({ taskId: 'task_1', lines: [] }),
79
+ cancelBackgroundTask: async () => undefined,
80
+ closeBackgroundTask: async () => undefined,
46
81
  },
47
82
  }),
48
- getContextState: () => ({
49
- usedTokens: 0,
50
- maxTokens: 1,
51
- usedPercentage: 0,
52
- remainingPercentage: 100,
53
- }),
54
- getAutoCompactThreshold: () => 0.835,
55
- compactContext: async () => undefined,
56
- getCwd: () => '/workspace',
57
- listEditCheckpoints: () => [],
58
- restoreEditCheckpoint: async () => createCheckpointResult(),
59
- rollbackEditCheckpoint: async () => createCheckpointResult(),
60
- getUsedMemoryReferences: () => [],
61
- recordMemoryEvent: () => undefined,
62
- listBackgroundTasks: () => [],
63
- readBackgroundTaskLog: async () => ({ taskId: 'task_1', lines: [] }),
64
- cancelBackgroundTask: async () => undefined,
65
- closeBackgroundTask: async () => undefined,
66
83
  setPermissionMode,
67
84
  };
68
85
  }
@@ -78,7 +95,7 @@ describe('createPermissionsCommandModule', () => {
78
95
  expect.objectContaining({
79
96
  name: 'permissions',
80
97
  description: 'Show/change permission mode and permission rules',
81
- argumentHint: 'plan | default | acceptEdits | bypassPermissions',
98
+ argumentHint: 'plan | default | acceptEdits | bypassPermissions | auto',
82
99
  source: 'permissions',
83
100
  modelInvocable: false,
84
101
  }),
@@ -88,12 +105,13 @@ describe('createPermissionsCommandModule', () => {
88
105
  'default',
89
106
  'acceptEdits',
90
107
  'bypassPermissions',
108
+ 'auto',
91
109
  ]);
92
110
  expect(command).toEqual(
93
111
  expect.objectContaining({
94
112
  name: 'permissions',
95
113
  description: 'Show/change permission mode and permission rules',
96
- argumentHint: 'plan | default | acceptEdits | bypassPermissions',
114
+ argumentHint: 'plan | default | acceptEdits | bypassPermissions | auto',
97
115
  lifecycle: 'inline',
98
116
  modelInvocable: false,
99
117
  }),
@@ -109,7 +127,9 @@ describe('createPermissionsCommandModule', () => {
109
127
  const result = await executor.execute('permissions', createCommandHostContext(), '');
110
128
 
111
129
  expect(result?.success).toBe(true);
112
- expect(result?.message).toBe('Permission mode: default\nNo session-approved tools.');
130
+ expect(result?.message).toBe(
131
+ 'Permission mode: default\n\nRules: none configured.\n\nNo session-approved tools.\n\nRecent denials: none.',
132
+ );
113
133
  expect(result?.data).toEqual({ mode: 'default', sessionAllowed: [] });
114
134
  });
115
135
 
@@ -125,12 +145,59 @@ describe('createPermissionsCommandModule', () => {
125
145
  );
126
146
 
127
147
  expect(result?.success).toBe(true);
128
- expect(result?.message).toBe(
129
- 'Permission mode: acceptEdits\nSession-approved tools: Bash, Read',
130
- );
148
+ expect(result?.message).toContain('Approved this session ("allow always"): Bash, Read');
131
149
  expect(result?.data).toEqual({ mode: 'acceptEdits', sessionAllowed: ['Bash', 'Read'] });
132
150
  });
133
151
 
152
+ it('lists the effective rules under the settings file each comes from, and recent denials (issue #3082)', async () => {
153
+ const executor = new SystemCommandExecutor([
154
+ ...(createPermissionsCommandModule().systemCommands ?? []),
155
+ ]);
156
+ const at = new Date(2026, 8, 25, 9, 5, 7).getTime();
157
+ const result = await executor.execute(
158
+ 'permissions',
159
+ createCommandHostContext({
160
+ rules: { allow: ['Read', 'mcp__docs__*'], deny: ['Bash(rm *)'], ask: ['Bash(git push*)'] },
161
+ layers: [
162
+ {
163
+ source: '~/.robota/settings.json',
164
+ scope: 'user',
165
+ allow: ['Read'],
166
+ deny: ['Bash(rm *)'],
167
+ ask: [],
168
+ },
169
+ {
170
+ source: '.robota/settings.json',
171
+ scope: 'project',
172
+ allow: ['Read', 'Stale'],
173
+ deny: [],
174
+ ask: ['Bash(git push*)'],
175
+ },
176
+ ],
177
+ denials: [{ toolName: 'Bash', argument: 'rm -rf ~', reason: 'policy', at }],
178
+ }),
179
+ '',
180
+ );
181
+ expect(result?.message).toContain(
182
+ [
183
+ 'Rules (checked deny, then ask, then allow):',
184
+ ' ~/.robota/settings.json [user]',
185
+ ' deny: Bash(rm *)',
186
+ ' allow: Read',
187
+ ' .robota/settings.json [project]',
188
+ ' ask: Bash(git push*)',
189
+ ' allow: Read',
190
+ ' this session (CLI flags, preset, commands)',
191
+ ' allow: mcp__docs__*',
192
+ ].join('\n'),
193
+ );
194
+ // A layer's rule the session does not enforce is not listed as if it were.
195
+ expect(result?.message).not.toContain('Stale');
196
+ expect(result?.message).toContain(
197
+ 'Recent denials (most recent first):\n 1. 09:05:07 Bash(rm -rf ~) — denied by a rule or the mode',
198
+ );
199
+ });
200
+
134
201
  it('updates valid permission modes through the SDK command adapter', async () => {
135
202
  const executor = new SystemCommandExecutor([
136
203
  ...(createPermissionsCommandModule().systemCommands ?? []),
@@ -140,9 +207,7 @@ describe('createPermissionsCommandModule', () => {
140
207
  const result = await executor.execute('permissions', context, 'plan');
141
208
 
142
209
  expect(result?.success).toBe(true);
143
- expect(result?.message).toBe(
144
- 'Permission mode set to: plan\nPermission mode: plan\nNo session-approved tools.',
145
- );
210
+ expect(result?.message).toMatch(/^Permission mode set to: plan\nPermission mode: plan\n/);
146
211
  expect(result?.data).toEqual({ mode: 'plan', sessionAllowed: [] });
147
212
  expect(context.setPermissionMode).toHaveBeenCalledWith('plan');
148
213
  });
@@ -157,8 +222,55 @@ describe('createPermissionsCommandModule', () => {
157
222
 
158
223
  expect(result?.success).toBe(false);
159
224
  expect(result?.message).toBe(
160
- 'Invalid mode. Valid: plan | default | acceptEdits | bypassPermissions',
225
+ 'Invalid mode. Valid: plan | default | acceptEdits | bypassPermissions | auto',
161
226
  );
162
227
  expect(context.setPermissionMode).not.toHaveBeenCalled();
163
228
  });
229
+
230
+ it('reports a mode the session refuses instead of throwing', async () => {
231
+ const executor = new SystemCommandExecutor([
232
+ ...(createPermissionsCommandModule().systemCommands ?? []),
233
+ ]);
234
+ const context = createCommandHostContext();
235
+ context.setPermissionMode.mockImplementation(() => {
236
+ throw new Error('Auto mode is unavailable: this session has no permission classifier.');
237
+ });
238
+
239
+ const result = await executor.execute('permissions', context, 'auto');
240
+
241
+ expect(result?.success).toBe(false);
242
+ expect(result?.message).toBe(
243
+ 'Auto mode is unavailable: this session has no permission classifier.',
244
+ );
245
+ });
246
+
247
+ it('retries a classifier denial by the number /permissions shows (issue #3082)', async () => {
248
+ const executor = new SystemCommandExecutor([
249
+ ...(createPermissionsCommandModule().systemCommands ?? []),
250
+ ]);
251
+ const denial = {
252
+ toolName: 'Bash',
253
+ argument: 'npm publish',
254
+ reason: 'classifier' as const,
255
+ detail: 'publishes a package',
256
+ at: new Date(2026, 8, 25, 13, 4, 5).getTime(),
257
+ };
258
+ const retryDenial = vi.fn((index: number) => (index === 0 ? denial : undefined));
259
+ const context = createCommandHostContext({ denials: [denial], retryDenial });
260
+
261
+ const listed = await executor.execute('permissions', context, '');
262
+ expect(listed?.message).toContain(
263
+ '1. 13:04:05 Bash(npm publish) — blocked by the auto-mode classifier: publishes a package',
264
+ );
265
+ expect(listed?.message).toContain('/permissions retry <n>');
266
+
267
+ const retried = await executor.execute('permissions', context, 'retry 1');
268
+ expect(retried?.success).toBe(true);
269
+ expect(retried?.message).toContain('Bash(npm publish) will run once');
270
+ expect(retryDenial).toHaveBeenCalledWith(0);
271
+
272
+ const missing = await executor.execute('permissions', context, 'retry 2');
273
+ expect(missing?.success).toBe(false);
274
+ expect(missing?.message).toMatch(/^Usage: \/permissions retry <n>/);
275
+ });
164
276
  });
@@ -7,7 +7,7 @@ import {
7
7
  import { executePermissionsCommand } from './permissions-command.js';
8
8
 
9
9
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
10
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
10
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
11
11
 
12
12
  export function createPermissionsCommandEntry(): ICommand {
13
13
  return {
@@ -17,6 +17,7 @@ export function createPermissionsCommandEntry(): ICommand {
17
17
  source: 'permissions',
18
18
  argumentHint: PERMISSION_MODE_ARGUMENT_HINT,
19
19
  subcommands: buildPermissionModeSubcommands('permissions'),
20
+ // User-only: edits permission rules; permission widening is the user's.
20
21
  modelInvocable: false,
21
22
  };
22
23
  }
@@ -4,17 +4,58 @@ import {
4
4
  isPermissionMode,
5
5
  parsePermissionModeArgument,
6
6
  readCommandPermissionsState,
7
+ retryCommandPermissionDenial,
7
8
  writeCommandPermissionMode,
8
9
  } from '@robota-sdk/agent-framework';
9
10
 
10
- import type { ICommandHostContext } from '@robota-sdk/agent-framework';
11
- import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
11
+ import type {
12
+ ICommandHostAdapterAccess,
13
+ ICommandHostSessionAccess,
14
+ } from '@robota-sdk/agent-framework';
15
+ import type { TPermissionMode } from '@robota-sdk/agent-core';
16
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
17
+
18
+ /** Switch the mode, or the refusal to show when the session will not enter it. */
19
+ export function tryWritePermissionMode(
20
+ context: ICommandHostAdapterAccess & ICommandHostSessionAccess,
21
+ mode: TPermissionMode,
22
+ ): ICommandResult | undefined {
23
+ try {
24
+ writeCommandPermissionMode(context, mode);
25
+ return undefined;
26
+ } catch (error) {
27
+ return { message: error instanceof Error ? error.message : String(error), success: false };
28
+ }
29
+ }
30
+
31
+ function executeRetry(
32
+ context: ICommandHostAdapterAccess & ICommandHostSessionAccess,
33
+ args: string,
34
+ ): ICommandResult {
35
+ const position = Number(args.trim().split(/\s+/)[1]);
36
+ const denial = retryCommandPermissionDenial(context, position);
37
+ if (denial === undefined) {
38
+ return {
39
+ message:
40
+ 'Usage: /permissions retry <n>, where <n> numbers a call blocked by the auto-mode ' +
41
+ 'classifier in /permissions.',
42
+ success: false,
43
+ };
44
+ }
45
+ const call =
46
+ denial.argument !== undefined ? `${denial.toolName}(${denial.argument})` : denial.toolName;
47
+ return {
48
+ message: `${call} will run once, without the classifier, when the model tries it again.`,
49
+ success: true,
50
+ };
51
+ }
12
52
 
13
53
  export function executePermissionsCommand(
14
- context: ICommandHostContext,
54
+ context: ICommandHostAdapterAccess & ICommandHostSessionAccess,
15
55
  args: string,
16
56
  ): ICommandResult {
17
57
  const arg = parsePermissionModeArgument(args);
58
+ if (arg === 'retry') return executeRetry(context, args);
18
59
  if (arg !== undefined) {
19
60
  if (!isPermissionMode(arg)) {
20
61
  return {
@@ -23,7 +64,8 @@ export function executePermissionsCommand(
23
64
  };
24
65
  }
25
66
 
26
- writeCommandPermissionMode(context, arg);
67
+ const refused = tryWritePermissionMode(context, arg);
68
+ if (refused !== undefined) return refused;
27
69
  const state = readCommandPermissionsState(context);
28
70
  return {
29
71
  message: `Permission mode set to: ${arg}\n${formatCommandPermissionsMessage(state)}`,
@@ -0,0 +1,129 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { describe, it, expect, vi } from 'vitest';
5
+
6
+ import { executePlanCommand } from '../plan-command.js';
7
+
8
+ import type { IPlanArtifact } from '@robota-sdk/agent-interface-session';
9
+ import {
10
+ createTestCommandHost,
11
+ type ICreateTestCommandHostOptions,
12
+ } from '@robota-sdk/agent-framework/testing';
13
+
14
+ function plan(overrides: Partial<IPlanArtifact> = {}): IPlanArtifact {
15
+ return {
16
+ id: 'p1',
17
+ objective: 'ship the feature',
18
+ steps: [{ id: 'p1_0', description: 'draft the change', status: 'pending' }],
19
+ phase: 'planning',
20
+ createdAt: '2026-07-17T00:00:00.000Z',
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function host(overrides: ICreateTestCommandHostOptions['overrides'] = {}) {
26
+ // ARCH-029: the partial is now an OVERRIDE over a conformant host, not a cast that turns the
27
+ // check off. A fixture that names three members no longer claims to satisfy 46.
28
+ return createTestCommandHost({ overrides });
29
+ }
30
+
31
+ describe('executePlanCommand (SELFHOST-002 /plan)', () => {
32
+ it('drafts a plan from the objective text', async () => {
33
+ const setPlan = vi.fn().mockResolvedValue(plan());
34
+ const result = await executePlanCommand(host({ setPlan }), 'ship the feature');
35
+ expect(setPlan).toHaveBeenCalledWith('ship the feature');
36
+ expect(result.success).toBe(true);
37
+ expect(result.message).toContain('/plan approve');
38
+ expect(result.data).toMatchObject({ planId: 'p1' });
39
+ });
40
+
41
+ it('renders the current plan on "status"', async () => {
42
+ const result = await executePlanCommand(
43
+ host({ getPlanState: () => plan({ phase: 'awaiting-approval' }) }),
44
+ 'status',
45
+ );
46
+ expect(result.success).toBe(true);
47
+ expect(result.message).toContain('ship the feature');
48
+ expect(result.message).toContain('Phase: awaiting-approval');
49
+ expect(result.message).toContain('1. [pending] draft the change');
50
+ });
51
+
52
+ it('reports when no plan is active', async () => {
53
+ const result = await executePlanCommand(host({ getPlanState: () => null }), 'status');
54
+ expect(result.message).toBe('No plan is active.');
55
+ });
56
+
57
+ it('approves via approvePlan and reports the executing phase', async () => {
58
+ const approvePlan = vi.fn().mockReturnValue(plan({ phase: 'executing' }));
59
+ const result = await executePlanCommand(
60
+ host({ getPlanState: () => plan({ phase: 'awaiting-approval' }), approvePlan }),
61
+ 'approve',
62
+ );
63
+ expect(approvePlan).toHaveBeenCalled();
64
+ expect(result.success).toBe(true);
65
+ expect(result.message).toContain('edits will auto-apply');
66
+ expect(result.data).toMatchObject({ phase: 'executing' });
67
+ });
68
+
69
+ it('rejects approve when no plan exists', async () => {
70
+ const approvePlan = vi.fn();
71
+ const result = await executePlanCommand(
72
+ host({ getPlanState: () => null, approvePlan }),
73
+ 'approve',
74
+ );
75
+ expect(approvePlan).not.toHaveBeenCalled();
76
+ expect(result.success).toBe(false);
77
+ expect(result.message).toContain('No plan to approve');
78
+ });
79
+
80
+ it('surfaces an out-of-phase approval error as a failed result', async () => {
81
+ const approvePlan = vi.fn(() => {
82
+ throw new Error('approve requires phase "awaiting-approval", got "executing".');
83
+ });
84
+ const result = await executePlanCommand(
85
+ host({ getPlanState: () => plan({ phase: 'executing' }), approvePlan }),
86
+ 'approve',
87
+ );
88
+ expect(result.success).toBe(false);
89
+ expect(result.message).toContain('awaiting-approval');
90
+ });
91
+
92
+ it('reverts via revertPlan', async () => {
93
+ const revertPlan = vi.fn().mockReturnValue(plan({ phase: 'planning' }));
94
+ const result = await executePlanCommand(
95
+ host({ getPlanState: () => plan({ phase: 'executing' }), revertPlan }),
96
+ 'revert',
97
+ );
98
+ expect(revertPlan).toHaveBeenCalled();
99
+ expect(result.message).toContain('edits are blocked again');
100
+ });
101
+
102
+ it('shows usage on empty args or help', async () => {
103
+ const result = await executePlanCommand(host({}), '');
104
+ expect(result.message).toContain('Usage:');
105
+ expect(result.message).toContain('/plan approve');
106
+ });
107
+
108
+ it('ARCH-029 TC-09: the availability guards are gone from the source', () => {
109
+ // Review caught the first attempt at this case: it re-asserted "No plan is active." for a
110
+ // null-returning host, which `reports when no plan is active` above already pins with a
111
+ // STRONGER matcher, over an override that is the double's own default. It would have passed
112
+ // unchanged on the pre-fix code — a check that cannot fail on the thing it names.
113
+ //
114
+ // Stated plainly instead: there is NO behavioural test for this deletion, because there was no
115
+ // behaviour. `if (!context.setPlan) return 'Plan mode is not available in this session.'` was
116
+ // already unreachable — the members are unconditionally implemented on `InteractiveSession`
117
+ // over always-constructed controllers, and it is the only host (`implements` is compiler-
118
+ // checked, casts are ratcheted at 0, and heritage-clause aliases are counted since this
119
+ // review). Required-ness makes the branch unreachable for the type checker too. So what can be
120
+ // pinned is that the branch is not in the file, and that is what this asserts.
121
+ const source = readFileSync(
122
+ fileURLToPath(new URL('../plan-command.js', import.meta.url)).replace(/\.js$/, '.ts'),
123
+ 'utf8',
124
+ );
125
+
126
+ expect(source).not.toContain('is not available in this session');
127
+ expect(source).not.toMatch(/if \(!context\.[a-zA-Z]+\)/);
128
+ });
129
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SELFHOST-002: `/plan` explicit plan-mode command.
3
+ *
4
+ * Only the module factory is a package-entry export (matching the other command modules — sources,
5
+ * command handlers, and descriptions stay internal, imported by direct path). This keeps the
6
+ * package's public surface minimal and its SPEC Public API table in sync.
7
+ */
8
+
9
+ export { createPlanCommandModule } from './plan-command-module.js';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * SELFHOST-002: `/plan` command module registration.
3
+ */
4
+
5
+ import { executePlanCommand, PLAN_COMMAND_DESCRIPTION } from './plan-command.js';
6
+
7
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
8
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
9
+
10
+ export function createPlanCommandEntry(): ICommand {
11
+ return {
12
+ name: 'plan',
13
+ displayName: 'Plan Mode',
14
+ description: PLAN_COMMAND_DESCRIPTION,
15
+ source: 'plan',
16
+ // User-only: approving a plan unblocks edits; that permission change is the user's.
17
+ modelInvocable: false,
18
+ };
19
+ }
20
+
21
+ function createPlanSystemCommand(): ISystemCommand {
22
+ const entry = createPlanCommandEntry();
23
+ return {
24
+ name: entry.name,
25
+ displayName: entry.displayName,
26
+ description: entry.description,
27
+ requiresPermission: false,
28
+ userInvocable: true,
29
+ modelInvocable: false,
30
+ lifecycle: 'inline',
31
+ execute: executePlanCommand,
32
+ };
33
+ }
34
+
35
+ export class PlanCommandSource implements ICommandSource {
36
+ readonly name = 'plan';
37
+
38
+ getCommands(): ICommand[] {
39
+ return [createPlanCommandEntry()];
40
+ }
41
+ }
42
+
43
+ export function createPlanCommandModule(): ICommandModule {
44
+ return {
45
+ name: 'agent-command-plan',
46
+ commandSources: [new PlanCommandSource()],
47
+ systemCommands: [createPlanSystemCommand()],
48
+ };
49
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SELFHOST-002: the `/plan` slash command — explicit plan-mode (plan → review → approve → act).
3
+ * Delegates to the framework-owned plan controller via the command host context; the phase machine
4
+ * and the `setPermissionMode` application both live in agent-framework. The mutation block stays the
5
+ * existing `plan` permission mode; approving flips to `acceptEdits` (edits auto-apply, shell stays
6
+ * per-call confirmed).
7
+ */
8
+
9
+ import type { ICommandHostPlan } from '@robota-sdk/agent-framework';
10
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
11
+ import type { IPlanArtifact } from '@robota-sdk/agent-interface-session';
12
+
13
+ export const PLAN_COMMAND_DESCRIPTION =
14
+ 'Draft a reviewable plan, then approve it to unblock edits (plan → review → approve → act).';
15
+
16
+ const PLAN_COMMAND_USAGE =
17
+ 'Usage:\n /plan <objective> draft a plan for review (stays read-only until approved)\n' +
18
+ ' /plan status show the current plan and phase\n' +
19
+ ' /plan approve approve the plan (edits auto-apply; shell stays confirmed)\n' +
20
+ ' /plan revert return to drafting (re-blocks edits)';
21
+
22
+ function formatPlan(plan: IPlanArtifact): string {
23
+ const lines = [
24
+ `Plan: ${plan.objective}`,
25
+ `Phase: ${plan.phase}${plan.approvedAt ? ` (approved ${plan.approvedAt})` : ''}`,
26
+ ];
27
+ plan.steps.forEach((step, index) => {
28
+ lines.push(` ${index + 1}. [${step.status}] ${step.description}`);
29
+ });
30
+ return lines.join('\n');
31
+ }
32
+
33
+ export async function executePlanCommand(
34
+ context: ICommandHostPlan,
35
+ args: string,
36
+ ): Promise<ICommandResult> {
37
+ const trimmed = args.trim();
38
+ const verb = trimmed.split(/\s+/)[0]?.toLowerCase() ?? '';
39
+
40
+ if (trimmed.length === 0 || verb === 'help') {
41
+ return { message: PLAN_COMMAND_USAGE, success: true };
42
+ }
43
+
44
+ if (verb === 'status') {
45
+ const plan = context.getPlanState();
46
+ return plan
47
+ ? { message: formatPlan(plan), success: true, data: { phase: plan.phase } }
48
+ : { message: 'No plan is active.', success: true };
49
+ }
50
+
51
+ if (verb === 'approve') {
52
+ if (!context.getPlanState()) {
53
+ return { message: 'No plan to approve. Start one with /plan <objective>.', success: false };
54
+ }
55
+ try {
56
+ const plan = context.approvePlan();
57
+ return {
58
+ message: `Plan approved — edits will auto-apply; shell steps stay confirmed.\nPhase: ${plan.phase}`,
59
+ success: true,
60
+ data: { phase: plan.phase },
61
+ };
62
+ } catch (error) {
63
+ // allow-fallback: an out-of-phase approval surfaces as a failed command, not a crash
64
+ return { message: error instanceof Error ? error.message : String(error), success: false };
65
+ }
66
+ }
67
+
68
+ if (verb === 'revert') {
69
+ if (!context.getPlanState()) {
70
+ return { message: 'No plan to revert.', success: false };
71
+ }
72
+ const plan = context.revertPlan();
73
+ return {
74
+ message: `Reverted to drafting — edits are blocked again (plan mode).\nPhase: ${plan.phase}`,
75
+ success: true,
76
+ data: { phase: plan.phase },
77
+ };
78
+ }
79
+
80
+ let plan: IPlanArtifact;
81
+ try {
82
+ plan = await context.setPlan(trimmed);
83
+ } catch (error) {
84
+ // allow-fallback: surface an invalid-objective error as a failed command result, not a crash
85
+ return { message: error instanceof Error ? error.message : String(error), success: false };
86
+ }
87
+ return {
88
+ message: `Plan drafted (read-only until approved). Review it, then run /plan approve:\n${plan.objective}`,
89
+ success: true,
90
+ data: { planId: plan.id },
91
+ };
92
+ }