@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { InteractiveSession } from '@robota-sdk/agent-framework';
6
+ import type { IAIProvider, TUniversalMessage } from '@robota-sdk/agent-core';
7
+ import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
8
+ import { createPluginCommandModule } from '../plugin-command-module.js';
9
+
10
+ async function heldTurn(denyRemote = false, commandModules = [createPluginCommandModule()]) {
11
+ const root = mkdtempSync(join(tmpdir(), 'plugin-active-turn-'));
12
+ let release!: (value: TUniversalMessage) => void;
13
+ const pending = new Promise<TUniversalMessage>((resolve) => { release = resolve; });
14
+ const run = vi.fn(() => pending);
15
+ const adapter: ICommandPluginAdapter = {
16
+ listInstalled: async () => [], listAvailablePlugins: async () => [],
17
+ install: vi.fn(async () => {}), uninstall: vi.fn(async () => {}),
18
+ enable: vi.fn(async () => {}), disable: vi.fn(async () => {}),
19
+ marketplaceAdd: async () => 'fixture', marketplaceRemove: async () => {},
20
+ marketplaceUpdate: async () => {}, marketplaceList: async () => [],
21
+ reloadPlugins: async () => ({ loadedPluginCount: 0 }),
22
+ };
23
+ const session = new InteractiveSession({
24
+ cwd: root, bare: true, environment: { HOME: root },
25
+ provider: { name: 'held-provider', version: '1', chat: run } as unknown as IAIProvider,
26
+ config: {
27
+ provider: { name: 'held-provider', model: 'fixture-model', apiKey: undefined },
28
+ defaultTrustLevel: 'safe', permissions: { allow: [], deny: [] }, env: {},
29
+ },
30
+ commandModules,
31
+ commandHostAdapters: { plugin: adapter },
32
+ ...(denyRemote ? { remoteCommandPolicy: { isAllowed: () => false } } : {}),
33
+ });
34
+ const submission = session.submit('Hold this fixture turn until its owner releases it.');
35
+ await vi.waitFor(() => expect(run).toHaveBeenCalledOnce());
36
+ return {
37
+ session, adapter,
38
+ close: async () => {
39
+ release({ id: 'held-response', role: 'assistant', state: 'complete', content: 'Finished', timestamp: new Date() });
40
+ try { await (await submission).completed; } finally {
41
+ await session.shutdown();
42
+ rmSync(root, { recursive: true, force: true });
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ describe.each(['user', 'remote'] as const)('plugin withdrawal during a %s command', (source) => {
49
+ it.each(['disable', 'uninstall'] as const)('allows %s while an admitted turn remains active', async (operation) => {
50
+ const owned = await heldTurn();
51
+ try {
52
+ const result = await owned.session.executeCommand('plugin', `${operation} fixture@market`, source);
53
+ expect(result?.success).toBe(true);
54
+ expect(owned.adapter[operation]).toHaveBeenCalledExactlyOnceWith('fixture@market');
55
+ expect(owned.session.isExecuting()).toBe(true);
56
+ } finally { await owned.close(); }
57
+ });
58
+
59
+ it.each(['enable', 'install'] as const)('keeps %s behind the active-turn gate', async (operation) => {
60
+ const owned = await heldTurn();
61
+ try {
62
+ const result = await owned.session.executeCommand('plugin', `${operation} fixture@market`, source);
63
+ expect(result?.success).toBe(false);
64
+ expect(result?.message).toContain('already running');
65
+ expect(owned.adapter[operation]).not.toHaveBeenCalled();
66
+ } finally { await owned.close(); }
67
+ });
68
+ });
69
+
70
+ it('preserves remote policy and model refusal for active-turn withdrawal', async () => {
71
+ const owned = await heldTurn(true);
72
+ try {
73
+ const result = await owned.session.executeCommand('plugin', 'disable fixture@market', 'remote');
74
+ expect(result?.success).toBe(false);
75
+ expect(result?.message).toContain('configured remote-command policy');
76
+ for (const operation of ['disable', 'uninstall'] as const) {
77
+ expect(await owned.session.executeModelCommand('plugin', `${operation} fixture@market`)).toBeNull();
78
+ expect(owned.adapter[operation]).not.toHaveBeenCalled();
79
+ }
80
+ } finally { await owned.close(); }
81
+ });
82
+
83
+ it.each(['inline', 'blocking'] as const)('honors generic host control metadata only for %s lifecycle', async (lifecycle) => {
84
+ const execute = vi.fn(() => ({ success: true, message: 'Control applied' }));
85
+ const owned = await heldTurn(false, [{
86
+ name: 'fixture-control', systemCommands: [{
87
+ name: 'withdraw-fixture', description: 'Owner fixture control', lifecycle,
88
+ canRunDuringTurn: (args) => args === 'stop', modelInvocable: false, execute,
89
+ }],
90
+ }]);
91
+ try {
92
+ const refused = await owned.session.executeCommand('withdraw-fixture', 'start');
93
+ expect(refused?.success).toBe(false);
94
+ expect(execute).not.toHaveBeenCalled();
95
+ const result = await owned.session.executeCommand('withdraw-fixture', 'stop');
96
+ expect(result?.success).toBe(lifecycle === 'inline');
97
+ expect(execute).toHaveBeenCalledTimes(lifecycle === 'inline' ? 1 : 0);
98
+ expect(owned.session.isExecuting()).toBe(true);
99
+ } finally { await owned.close(); }
100
+ });
@@ -0,0 +1,300 @@
1
+ import type { ICommandHostContext, ICommandSessionRuntime } from '@robota-sdk/agent-framework';
2
+ import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { createPluginCommandModule } from '../plugin-command-module.js';
5
+ import { executePluginCommand, executeReloadPluginsCommand } from '../plugin-command.js';
6
+ import {
7
+ createTestCommandHost,
8
+ createTestSessionRuntime,
9
+ } from '@robota-sdk/agent-framework/testing';
10
+
11
+ function createPluginAdapter(overrides?: Partial<ICommandPluginAdapter>): ICommandPluginAdapter {
12
+ return {
13
+ listInstalled: vi.fn().mockResolvedValue([]),
14
+ listAvailablePlugins: vi.fn().mockResolvedValue([]),
15
+ install: vi.fn().mockResolvedValue(undefined),
16
+ uninstall: vi.fn().mockResolvedValue(undefined),
17
+ enable: vi.fn().mockResolvedValue(undefined),
18
+ disable: vi.fn().mockResolvedValue(undefined),
19
+ marketplaceAdd: vi.fn().mockResolvedValue('community'),
20
+ marketplaceRemove: vi.fn().mockResolvedValue(undefined),
21
+ marketplaceUpdate: vi.fn().mockResolvedValue(undefined),
22
+ marketplaceList: vi.fn().mockResolvedValue([]),
23
+ reloadPlugins: vi.fn().mockResolvedValue({ loadedPluginCount: 2 }),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function createCommandSessionRuntime() {
29
+ return createTestSessionRuntime({
30
+ clearHistory: () => undefined,
31
+ compact: async () => undefined,
32
+ getContextState: () => ({
33
+ maxTokens: 100,
34
+ usedTokens: 10,
35
+ usedPercentage: 10,
36
+ remainingPercentage: 90,
37
+ }),
38
+ getPermissionMode: () => 'default',
39
+ setPermissionMode: () => undefined,
40
+ getSessionId: () => 'session_1',
41
+ getMessageCount: () => 0,
42
+ getSessionAllowedTools: () => [],
43
+ getAutoCompactThreshold: () => false,
44
+ getFullHistory: () => [],
45
+ getHistory: () => [],
46
+ });
47
+ }
48
+
49
+ function createCommandHostContext(adapter?: ICommandPluginAdapter, locality?: 'local' | 'remote') {
50
+ const checkpoint = {
51
+ id: 'checkpoint_1',
52
+ sessionId: 'session_1',
53
+ sequence: 1,
54
+ prompt: 'prompt',
55
+ createdAt: '2026-05-03T00:00:00.000Z',
56
+ fileCount: 0,
57
+ };
58
+ return createTestCommandHost({
59
+ overrides: {
60
+ getSession: () => createCommandSessionRuntime(),
61
+ getContextState: () => ({
62
+ maxTokens: 100,
63
+ usedTokens: 10,
64
+ usedPercentage: 10,
65
+ remainingPercentage: 90,
66
+ }),
67
+ getAutoCompactThreshold: () => 0.8,
68
+ getCommandHostAdapters: () => (adapter === undefined ? {} : { plugin: adapter }),
69
+ ...(locality ? { getCommandSurfaceLocality: () => locality } : {}),
70
+ compactContext: async () => undefined,
71
+ getCwd: () => '/workspace',
72
+ listCommands: () => [],
73
+ listEditCheckpoints: () => [],
74
+ restoreEditCheckpoint: async () => ({
75
+ target: checkpoint,
76
+ restoredCheckpointCount: 0,
77
+ restoredFileCount: 0,
78
+ removedCheckpointCount: 0,
79
+ }),
80
+ rollbackEditCheckpoint: async () => ({
81
+ target: checkpoint,
82
+ restoredCheckpointCount: 0,
83
+ restoredFileCount: 0,
84
+ removedCheckpointCount: 0,
85
+ }),
86
+ getUsedMemoryReferences: () => [],
87
+ recordMemoryEvent: () => undefined,
88
+ listBackgroundTasks: () => [],
89
+ readBackgroundTaskLog: async (taskId) => ({ taskId, lines: [] }),
90
+ cancelBackgroundTask: async () => undefined,
91
+ closeBackgroundTask: async () => undefined,
92
+ },
93
+ });
94
+ }
95
+
96
+ describe('createPluginCommandModule', () => {
97
+ it('contributes plugin command metadata and executable commands', () => {
98
+ const module = createPluginCommandModule();
99
+
100
+ expect(module.name).toBe('agent-command-plugin');
101
+ expect(module.commandSources?.[0]?.getCommands()).toEqual([
102
+ expect.objectContaining({
103
+ name: 'plugin',
104
+ description: expect.stringContaining('returns operation status'),
105
+ source: 'plugin-manager',
106
+ modelInvocable: false,
107
+ argumentHint: expect.stringContaining('install'),
108
+ }),
109
+ expect.objectContaining({
110
+ name: 'reload-plugins',
111
+ description: expect.stringContaining(
112
+ 'refresh the command list, skill routing and model skill catalogue',
113
+ ),
114
+ source: 'plugin-manager',
115
+ modelInvocable: false,
116
+ }),
117
+ ]);
118
+ expect(module.systemCommands?.map((command) => command.name)).toEqual([
119
+ 'plugin',
120
+ 'reload-plugins',
121
+ ]);
122
+ expect(module.systemCommands?.[0]?.lifecycle).toBe('inline');
123
+ });
124
+ });
125
+
126
+ describe('executePluginCommand', () => {
127
+ it('opens the host plugin manager for empty args', async () => {
128
+ await expect(executePluginCommand(createCommandHostContext(), '')).resolves.toEqual({
129
+ success: true,
130
+ message: 'Opening plugin manager...',
131
+ uiIntents: [{ type: 'show-plugin-manager' }],
132
+ });
133
+ });
134
+
135
+ it('returns unavailable when an operation needs a missing adapter', async () => {
136
+ await expect(
137
+ executePluginCommand(createCommandHostContext(), 'install demo@community'),
138
+ ).resolves.toEqual({
139
+ success: false,
140
+ message: 'Plugin management is not available.',
141
+ });
142
+ });
143
+
144
+ it('installs plugins through the adapter', async () => {
145
+ const adapter = createPluginAdapter();
146
+
147
+ await expect(
148
+ executePluginCommand(createCommandHostContext(adapter), 'install demo@community'),
149
+ ).resolves.toEqual({
150
+ success: true,
151
+ message: 'Installed plugin: demo@community',
152
+ });
153
+ expect(adapter.install).toHaveBeenCalledWith('demo@community');
154
+ });
155
+
156
+ it('lists marketplaces through the adapter', async () => {
157
+ const adapter = createPluginAdapter({
158
+ marketplaceList: vi.fn().mockResolvedValue([{ name: 'community', type: 'git' }]),
159
+ });
160
+
161
+ const result = await executePluginCommand(
162
+ createCommandHostContext(adapter),
163
+ 'marketplace list',
164
+ );
165
+
166
+ expect(result).toEqual({
167
+ success: true,
168
+ message: 'Marketplace sources:\n community (git)',
169
+ });
170
+ expect(adapter.marketplaceList).toHaveBeenCalled();
171
+ });
172
+
173
+ it('reports adapter errors as command failures', async () => {
174
+ const adapter = createPluginAdapter({
175
+ enable: vi.fn().mockRejectedValue(new Error('not installed')),
176
+ });
177
+
178
+ await expect(
179
+ executePluginCommand(createCommandHostContext(adapter), 'enable missing@community'),
180
+ ).resolves.toEqual({
181
+ success: false,
182
+ message: 'Plugin error: not installed',
183
+ });
184
+ });
185
+
186
+ it('returns usage for incomplete subcommands', async () => {
187
+ await expect(executePluginCommand(createCommandHostContext(), 'disable')).resolves.toEqual({
188
+ success: false,
189
+ message: 'Usage: /plugin disable <name>@<marketplace>',
190
+ });
191
+ });
192
+
193
+ it('lists installed plugins through the adapter, for the Settings screen (#3282 §4 part b-2)', async () => {
194
+ const adapter = createPluginAdapter({
195
+ listInstalled: vi.fn().mockResolvedValue([
196
+ { name: 'formatter@fixture-agent', description: 'Formats code.', enabled: true },
197
+ { name: 'linter@fixture-agent', description: 'Lints code.', enabled: false },
198
+ ]),
199
+ });
200
+
201
+ const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
202
+
203
+ expect(result.success).toBe(true);
204
+ expect(result.data).toEqual({
205
+ plugins: [
206
+ { name: 'formatter@fixture-agent', description: 'Formats code.', enabled: true },
207
+ { name: 'linter@fixture-agent', description: 'Lints code.', enabled: false },
208
+ ],
209
+ });
210
+ expect(result.message).toContain('formatter@fixture-agent');
211
+ expect(result.message).toContain('linter@fixture-agent (disabled)');
212
+ });
213
+
214
+ it('no plugins installed reports a plain empty list', async () => {
215
+ const adapter = createPluginAdapter();
216
+ const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
217
+ expect(result).toEqual({
218
+ success: true,
219
+ message: 'No plugins are installed.',
220
+ data: { plugins: [] },
221
+ });
222
+ });
223
+
224
+ describe('install/uninstall stay local-surface-only (#3282 §4 part b-2)', () => {
225
+ it('runs install from a local surface', async () => {
226
+ const adapter = createPluginAdapter();
227
+ const result = await executePluginCommand(
228
+ createCommandHostContext(adapter, 'local'),
229
+ 'install demo@community',
230
+ );
231
+ expect(result.success).toBe(true);
232
+ expect(adapter.install).toHaveBeenCalledWith('demo@community');
233
+ });
234
+
235
+ it('refuses install from a remote surface with a plain message, writing nothing', async () => {
236
+ const adapter = createPluginAdapter();
237
+ const result = await executePluginCommand(
238
+ createCommandHostContext(adapter, 'remote'),
239
+ 'install demo@community',
240
+ );
241
+ expect(result.success).toBe(false);
242
+ expect(result.message).toMatch(/only works from the desktop app or the page opened here/);
243
+ expect(result.message).toMatch(/not from a remote device/);
244
+ expect(adapter.install).not.toHaveBeenCalled();
245
+ });
246
+
247
+ it('refuses uninstall from a remote surface with a plain message, writing nothing', async () => {
248
+ const adapter = createPluginAdapter();
249
+ const result = await executePluginCommand(
250
+ createCommandHostContext(adapter, 'remote'),
251
+ 'uninstall demo@community',
252
+ );
253
+ expect(result.success).toBe(false);
254
+ expect(result.message).toMatch(/not from a remote device/);
255
+ expect(adapter.uninstall).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it('never refuses enable/disable/reload from a remote surface — only install/uninstall run new code', async () => {
259
+ const adapter = createPluginAdapter();
260
+ const enableResult = await executePluginCommand(
261
+ createCommandHostContext(adapter, 'remote'),
262
+ 'enable demo@community',
263
+ );
264
+ expect(enableResult.success).toBe(true);
265
+ expect(adapter.enable).toHaveBeenCalledWith('demo@community');
266
+ });
267
+ });
268
+ });
269
+
270
+ describe('executeReloadPluginsCommand', () => {
271
+ it('reloads plugins through the adapter and requests registry refresh', async () => {
272
+ const adapter = createPluginAdapter({
273
+ reloadPlugins: vi.fn().mockResolvedValue({ loadedPluginCount: 3 }),
274
+ });
275
+
276
+ await expect(
277
+ executeReloadPluginsCommand(createCommandHostContext(adapter), ''),
278
+ ).resolves.toEqual({
279
+ success: true,
280
+ message:
281
+ 'Reloaded 3 plugin resources. Plugin commands and skills are active now; hook changes apply to new sessions.',
282
+ data: { pluginRegistryReloaded: true },
283
+ });
284
+ expect(adapter.reloadPlugins).toHaveBeenCalled();
285
+ });
286
+
287
+ it('reports reload failures instead of returning placeholder success', async () => {
288
+ const adapter = createPluginAdapter({
289
+ reloadPlugins: vi.fn().mockRejectedValue(new Error('manifest failed')),
290
+ });
291
+
292
+ await expect(
293
+ executeReloadPluginsCommand(createCommandHostContext(adapter), ''),
294
+ ).resolves.toEqual({
295
+ success: false,
296
+ message: 'Plugin error: manifest failed',
297
+ data: { pluginRegistryReloaded: true },
298
+ });
299
+ });
300
+ });