@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,136 @@
1
+ import { MODEL_EFFORT_VALUES, selectAction } from '@robota-sdk/agent-core';
2
+ import { EFFORT_LEVEL_LABELS, parseModelEffort, resolveModelEffort } from '@robota-sdk/agent-framework';
3
+
4
+ import type {
5
+ ICommandHostAdapterAccess,
6
+ ICommandHostAdapters,
7
+ ICommandHostSessionAccess,
8
+ ICommandHostUserInteraction,
9
+ ICommandHostWorkspace,
10
+ IModelEffortResolution,
11
+ TEffortSelection,
12
+ } from '@robota-sdk/agent-framework';
13
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
14
+
15
+ const EFFORT_SELECTIONS: readonly TEffortSelection[] = ['auto', ...MODEL_EFFORT_VALUES];
16
+
17
+ /**
18
+ * #3282 §2 — re-exported for callers already importing it from here; the canonical definition moved
19
+ * to `agent-framework` (`effort/effort-resolution.ts`) so headless `/goal` output (in that same
20
+ * package) can use the same labels without a circular dependency on `agent-command`.
21
+ */
22
+ export { EFFORT_LEVEL_LABELS };
23
+
24
+ /**
25
+ * Plain outcome text (#3282 §2), replacing the earlier `Model effort: requested=…, effective=…,
26
+ * source=…, disposition=…` internal-field dump. `not-applied` means the model has no matching native
27
+ * control at all — the earlier fields carried no information a person could act on for that case, so
28
+ * it gets its own plain sentence instead of a label.
29
+ */
30
+ function formatEffortMessage(resolution: {
31
+ requested: TEffortSelection;
32
+ disposition: string;
33
+ }): string {
34
+ if (resolution.disposition === 'not-applied') {
35
+ return "This model doesn't support effort levels, so it will use its default.";
36
+ }
37
+ return `Effort: ${EFFORT_LEVEL_LABELS[resolution.requested]}`;
38
+ }
39
+
40
+ async function askForEffort(
41
+ context: ICommandHostUserInteraction,
42
+ ): Promise<TEffortSelection | undefined> {
43
+ const interaction = context.getUserInteraction();
44
+ if (interaction === undefined) return undefined;
45
+ const response = await interaction.ask(
46
+ selectAction(
47
+ 'effort',
48
+ 'Select model effort',
49
+ EFFORT_SELECTIONS.map((value) => ({ value, label: EFFORT_LEVEL_LABELS[value] })),
50
+ ),
51
+ );
52
+ const selected = response.type === 'answer' ? response.values[0] : undefined;
53
+ return parseModelEffort(selected, '/effort');
54
+ }
55
+
56
+ function readCurrentResolution(
57
+ context: ICommandHostAdapterAccess & ICommandHostSessionAccess,
58
+ ): IModelEffortResolution {
59
+ const adapter = context.getCommandHostAdapters?.().effort;
60
+ if (adapter !== undefined) return adapter.getResolution();
61
+ const selection = context.getSession().getModelEffort();
62
+ return resolveModelEffort({
63
+ command: selection,
64
+ modelDefault: selection === 'auto' ? 'high' : selection,
65
+ });
66
+ }
67
+
68
+ function readAdapters(context: ICommandHostAdapterAccess): ICommandHostAdapters {
69
+ return context.getCommandHostAdapters?.() ?? {};
70
+ }
71
+
72
+ function persistEffortSelection(
73
+ context: ICommandHostAdapterAccess,
74
+ selection: TEffortSelection,
75
+ ): void {
76
+ const settings = readAdapters(context).settings;
77
+ if (settings === undefined || selection === 'max') return;
78
+ const current = settings.read();
79
+ if (selection === 'auto') {
80
+ const { effort: _effort, ...withoutEffort } = current;
81
+ settings.write(withoutEffort);
82
+ return;
83
+ }
84
+ settings.write({ ...current, effort: selection });
85
+ }
86
+
87
+ export async function executeEffortCommand(
88
+ context: ICommandHostAdapterAccess &
89
+ ICommandHostSessionAccess &
90
+ ICommandHostUserInteraction &
91
+ ICommandHostWorkspace,
92
+ args: string,
93
+ ): Promise<ICommandResult> {
94
+ let selection: TEffortSelection | undefined;
95
+ try {
96
+ selection = parseModelEffort(args.trim() || undefined, '/effort');
97
+ if (selection === undefined) selection = await askForEffort(context);
98
+ } catch (error) {
99
+ return { message: error instanceof Error ? error.message : String(error), success: false };
100
+ }
101
+
102
+ const current = readCurrentResolution(context);
103
+ if (selection === undefined) {
104
+ return {
105
+ message: formatEffortMessage(current),
106
+ success: true,
107
+ data: { effort: current },
108
+ };
109
+ }
110
+
111
+ const adapter = readAdapters(context).effort;
112
+ let resolution;
113
+ try {
114
+ resolution =
115
+ adapter === undefined
116
+ ? resolveModelEffort({ command: selection, modelDefault: current.effective })
117
+ : await adapter.apply(selection, context.getSession());
118
+ if (adapter === undefined) {
119
+ await context.getSession().applyModelOptions({ effort: selection });
120
+ }
121
+ if (context.getCommandInvocationSource() === 'user') {
122
+ persistEffortSelection(context, selection);
123
+ }
124
+ } catch (error) {
125
+ return {
126
+ message: `Model effort was not applied: ${error instanceof Error ? error.message : String(error)}`,
127
+ success: false,
128
+ };
129
+ }
130
+
131
+ return {
132
+ message: formatEffortMessage(resolution),
133
+ success: true,
134
+ data: { effort: resolution },
135
+ };
136
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createEffortCommandEntry,
3
+ createEffortCommandModule,
4
+ EffortCommandSource,
5
+ } from './effort-command-module.js';
6
+ export { executeEffortCommand } from './effort-command.js';
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { executeEventsCommand } from '../events-command.js';
4
+ import { createEventsCommandModule } from '../events-command-module.js';
5
+
6
+ import type { ICommandExternalEventsAdapter } from '@robota-sdk/agent-framework';
7
+
8
+ function adapter(): ICommandExternalEventsAdapter & { revoke: ReturnType<typeof vi.fn> } {
9
+ return {
10
+ list: () => [
11
+ {
12
+ grantId: 'ci',
13
+ principal: 'client',
14
+ state: 'open',
15
+ counters: { accepted: 2, refused: { expired: 1 }, settled: { completed: 2 } },
16
+ },
17
+ ],
18
+ revoke: vi.fn((grantId: string) => (grantId === 'ci' ? 'revoked' : 'unknown-grant')),
19
+ };
20
+ }
21
+
22
+ const context = (externalEvents?: ICommandExternalEventsAdapter) => ({
23
+ getCommandHostAdapters: () => (externalEvents ? { externalEvents } : {}),
24
+ });
25
+
26
+ describe('/events command module', () => {
27
+ it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
28
+ const module = createEventsCommandModule();
29
+ const palette = module.commandSources?.[0]?.getCommands()[0];
30
+ const executable = module.systemCommands?.[0];
31
+ expect(palette?.name).toBe('events');
32
+ expect(executable?.modelInvocable).toBe(false);
33
+ expect(palette?.modelInvocable).toBe(false);
34
+ expect(executable?.userInvocable).toBe(true);
35
+ expect(executable?.description).toBe(palette?.description);
36
+ });
37
+
38
+ it('tells the model what it does, what it returns, that it is user-only, and what to suggest', () => {
39
+ const description =
40
+ createEventsCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
41
+ expect(description).toMatch(/external event grants/i);
42
+ expect(description).toMatch(/returns/i);
43
+ expect(description).toMatch(/user-only/i);
44
+ expect(description).toContain('`/events revoke <grant-id>`');
45
+ });
46
+ });
47
+
48
+ describe('/events', () => {
49
+ it('lists grants with their principal kind and counts', async () => {
50
+ const result = await executeEventsCommand(context(adapter()), '');
51
+ expect(result.success).toBe(true);
52
+ expect(result.message).toContain(
53
+ 'ci client open accepted 2; refused: expired 1; settled: completed 2',
54
+ );
55
+ });
56
+
57
+ it('revokes one grant by label and says when the label is unknown', async () => {
58
+ const events = adapter();
59
+ expect((await executeEventsCommand(context(events), 'revoke ci')).message).toMatch(
60
+ /Revoked external event grant ci/,
61
+ );
62
+ expect(events.revoke).toHaveBeenCalledWith('ci');
63
+ const unknown = await executeEventsCommand(context(events), 'revoke nope');
64
+ expect(unknown.success).toBe(false);
65
+ expect(unknown.message).toMatch(/no external event grant nope/);
66
+ expect((await executeEventsCommand(context(events), 'revoke')).success).toBe(false);
67
+ });
68
+
69
+ it('says how grants are given when the session holds none', async () => {
70
+ const result = await executeEventsCommand(context(), '');
71
+ expect(result.message).toMatch(/only when a session starts/);
72
+ });
73
+ });
@@ -0,0 +1,49 @@
1
+ import { executeEventsCommand } from './events-command.js';
2
+
3
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
4
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
5
+
6
+ export function createEventsCommandEntry(): ICommand {
7
+ return {
8
+ name: 'events',
9
+ displayName: 'Events',
10
+ description:
11
+ "List this session's external event grants (each grant's label, whether it pins a subject or a client, whether it is open or revoked, and its counts of accepted, refused and settled events), or revoke one with `/events revoke <grant-id>`. Returns the grants, or whether the revocation took effect. User-only: the model cannot run it; when the user wants to stop events from outside, suggest `/events revoke <grant-id>`.",
12
+ source: 'events',
13
+ argumentHint: '[revoke <grant-id>]',
14
+ // User-only: a grant is the owner's standing decision that someone outside may put turns into
15
+ // this session. Withdrawing it is the owner's call too, and listing it lives on the same command.
16
+ modelInvocable: false,
17
+ };
18
+ }
19
+
20
+ function createEventsSystemCommand(): ISystemCommand {
21
+ const entry = createEventsCommandEntry();
22
+ return {
23
+ name: entry.name,
24
+ displayName: entry.displayName,
25
+ description: entry.description,
26
+ requiresPermission: false,
27
+ userInvocable: true,
28
+ modelInvocable: false,
29
+ ...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
30
+ lifecycle: 'inline',
31
+ execute: (context, args) => executeEventsCommand(context, args),
32
+ };
33
+ }
34
+
35
+ export class EventsCommandSource implements ICommandSource {
36
+ readonly name = 'events';
37
+
38
+ getCommands(): ICommand[] {
39
+ return [createEventsCommandEntry()];
40
+ }
41
+ }
42
+
43
+ export function createEventsCommandModule(): ICommandModule {
44
+ return {
45
+ name: 'agent-command-events',
46
+ commandSources: [new EventsCommandSource()],
47
+ systemCommands: [createEventsSystemCommand()],
48
+ };
49
+ }
@@ -0,0 +1,69 @@
1
+ import type {
2
+ ICommandExternalEventGrant,
3
+ ICommandHostAdapterAccess,
4
+ } from '@robota-sdk/agent-framework';
5
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
6
+
7
+ const GRANT_ID = /^[a-zA-Z0-9_-]{1,64}$/u;
8
+
9
+ function counts(values: Readonly<Partial<Record<string, number>>>): string {
10
+ const entries = Object.entries(values).filter(([, count]) => (count ?? 0) > 0);
11
+ return entries.length === 0
12
+ ? 'none'
13
+ : entries.map(([key, count]) => `${key} ${count}`).join(', ');
14
+ }
15
+
16
+ /** Label, principal kind, state and counts. The principal itself is never shown. */
17
+ function describe(grant: ICommandExternalEventGrant): string {
18
+ const { counters } = grant;
19
+ return (
20
+ ` ${grant.grantId} ${grant.principal} ${grant.state} accepted ${counters.accepted}; ` +
21
+ `refused: ${counts(counters.refused)}; settled: ${counts(counters.settled)}`
22
+ );
23
+ }
24
+
25
+ /**
26
+ * `/events` — this session's external-event grants, and `/events revoke <grant-id>` to withdraw one.
27
+ * Grants are given only when the session starts; nothing here creates one.
28
+ */
29
+ export async function executeEventsCommand(
30
+ context: ICommandHostAdapterAccess,
31
+ args = '',
32
+ ): Promise<ICommandResult> {
33
+ const adapter = context.getCommandHostAdapters?.().externalEvents;
34
+ if (!adapter) {
35
+ return {
36
+ message:
37
+ 'This session holds no external event grant. Grants are given only when a session starts.',
38
+ success: true,
39
+ };
40
+ }
41
+ const [verb, grantId, extra] = args
42
+ .trim()
43
+ .split(/\s+/u)
44
+ .filter((word) => word !== '');
45
+ if (verb === undefined) {
46
+ const grants = adapter.list();
47
+ return {
48
+ message:
49
+ grants.length === 0
50
+ ? 'This session holds no external event grant.'
51
+ : `External event grants:\n${grants.map(describe).join('\n')}\n\nWithdraw one: /events revoke <grant-id>`,
52
+ success: true,
53
+ };
54
+ }
55
+ if (
56
+ verb !== 'revoke' ||
57
+ grantId === undefined ||
58
+ extra !== undefined ||
59
+ !GRANT_ID.test(grantId)
60
+ ) {
61
+ return { message: 'Usage: /events | /events revoke <grant-id>', success: false };
62
+ }
63
+ return adapter.revoke(grantId) === 'revoked'
64
+ ? {
65
+ message: `Revoked external event grant ${grantId}. Later events for it are refused.`,
66
+ success: true,
67
+ }
68
+ : { message: `This session holds no external event grant ${grantId}.`, success: false };
69
+ }
@@ -0,0 +1,2 @@
1
+ /** Only the module factory leaves this directory; the command is registered by default. */
2
+ export { createEventsCommandModule } from './events-command-module.js';
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
3
+ import {
4
+ ExitCommandSource,
5
+ createExitCommandEntry,
6
+ createExitCommandModule,
7
+ executeExitCommand,
8
+ } from '../index.js';
9
+
10
+ function contextWithAnswer(value: string) {
11
+ // The double answers "no capability of that kind" by default; this fixture states the one
12
+ // capability it exercises, so the precondition is declared rather than inherited.
13
+ return createTestCommandHost({
14
+ overrides: {
15
+ getUserInteraction: () => ({ ask: async () => ({ type: 'answer', values: [value] }) }),
16
+ },
17
+ });
18
+ }
19
+
20
+ describe('exit command module', () => {
21
+ it('provides command metadata and executable registration from one module', () => {
22
+ const entry = createExitCommandEntry();
23
+ const module = createExitCommandModule();
24
+
25
+ expect(entry).toEqual({
26
+ name: 'exit',
27
+ displayName: 'Exit Session',
28
+ description: 'Exit CLI',
29
+ source: 'exit',
30
+ modelInvocable: false,
31
+ });
32
+ expect(new ExitCommandSource().getCommands()).toEqual([entry]);
33
+ expect(module.systemCommands?.map((command) => command.name)).toEqual(['exit']);
34
+ expect(module.commandSources?.flatMap((source) => source.getCommands())).toEqual([entry]);
35
+ });
36
+
37
+ it('proceeds to exit with no renderer attached (no human to confirm)', async () => {
38
+ // ARCH-029 TC-09: "no renderer attached" is a VALUE — `getUserInteraction()` returns undefined —
39
+ // not an absent member. `{} as never` expressed it as absence, which the required member no
40
+ // longer permits, and the double's default answers exactly this case.
41
+ const result = await executeExitCommand(createTestCommandHost(), '');
42
+
43
+ expect(result).toEqual({
44
+ success: true,
45
+ message: 'Exit requested.',
46
+ hostActions: [{ type: 'session-exit' }],
47
+ });
48
+ });
49
+
50
+ it('confirms before exiting and proceeds on yes (CMD-004)', async () => {
51
+ const result = await executeExitCommand(contextWithAnswer('yes'), '');
52
+ expect(result.hostActions).toEqual([{ type: 'session-exit' }]);
53
+ });
54
+
55
+ it('cancels the exit when the user declines (CMD-004)', async () => {
56
+ const result = await executeExitCommand(contextWithAnswer('no'), '');
57
+ expect(result.success).toBe(true);
58
+ expect(result.message).toBe('Exit cancelled.');
59
+ expect(result.hostActions).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,47 @@
1
+ import { EXIT_COMMAND_DESCRIPTION } from '@robota-sdk/agent-framework';
2
+
3
+ import { executeExitCommand } from './exit-command.js';
4
+
5
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
6
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
7
+
8
+ export function createExitCommandEntry(): ICommand {
9
+ return {
10
+ name: 'exit',
11
+ displayName: 'Exit Session',
12
+ description: EXIT_COMMAND_DESCRIPTION,
13
+ source: 'exit',
14
+ // User-only: ending the session is the user's decision.
15
+ modelInvocable: false,
16
+ };
17
+ }
18
+
19
+ function createExitSystemCommand(): ISystemCommand {
20
+ const entry = createExitCommandEntry();
21
+ return {
22
+ name: entry.name,
23
+ displayName: entry.displayName,
24
+ description: entry.description,
25
+ requiresPermission: true,
26
+ userInvocable: true,
27
+ modelInvocable: false,
28
+ lifecycle: 'inline',
29
+ execute: executeExitCommand,
30
+ };
31
+ }
32
+
33
+ export class ExitCommandSource implements ICommandSource {
34
+ readonly name = 'exit';
35
+
36
+ getCommands(): ICommand[] {
37
+ return [createExitCommandEntry()];
38
+ }
39
+ }
40
+
41
+ export function createExitCommandModule(): ICommandModule {
42
+ return {
43
+ name: 'agent-command-exit',
44
+ commandSources: [new ExitCommandSource()],
45
+ systemCommands: [createExitSystemCommand()],
46
+ };
47
+ }
@@ -0,0 +1,26 @@
1
+ import { confirmAction, isConfirmed } from '@robota-sdk/agent-core';
2
+ import { createSessionExitHostAction } from '@robota-sdk/agent-framework';
3
+
4
+ import type { ICommandHostUserInteraction } from '@robota-sdk/agent-framework';
5
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
6
+
7
+ export async function executeExitCommand(
8
+ context: ICommandHostUserInteraction,
9
+ _args: string,
10
+ ): Promise<ICommandResult> {
11
+ // Confirm only when an interactive renderer is attached. With no human (headless/automation) the
12
+ // user explicitly invoked /exit, so proceed — the confirm is an interactive safety prompt, not a gate.
13
+ const ui = context.getUserInteraction();
14
+ if (ui) {
15
+ const response = await ui.ask(confirmAction('exit', 'Exit the session?'));
16
+ if (!isConfirmed(response)) {
17
+ return { success: true, message: 'Exit cancelled.' };
18
+ }
19
+ }
20
+
21
+ return {
22
+ success: true,
23
+ message: 'Exit requested.',
24
+ hostActions: [createSessionExitHostAction()],
25
+ };
26
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ ExitCommandSource,
3
+ createExitCommandEntry,
4
+ createExitCommandModule,
5
+ } from './exit-command-module.js';
6
+ export { executeExitCommand } from './exit-command.js';
@@ -0,0 +1,184 @@
1
+ /**
2
+ * CLI-1994 TC-07 — what `/fork` actually does to its host.
3
+ *
4
+ * The command is the only place the two halves of a fork meet: the session writes the COPY, and the
5
+ * job that resumes it carries the copy's id and nothing else. What these cases pin is that pairing —
6
+ * the copy is written exactly once, the job that follows names it, and a failure to write the copy
7
+ * stops there rather than spawning a child that will look for a record nobody wrote.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ import { executeForkCommand } from '../fork-command.js';
13
+
14
+ import type { ICommandHostAgentJobs, ICommandHostSessionAccess } from '@robota-sdk/agent-framework';
15
+ import type { ISubagentJobState } from '@robota-sdk/agent-interface-execution';
16
+
17
+ const FORK_SESSION_ID = 'session_cli-1994-fork';
18
+ const DEFAULT_FORK_NAME = 'parent-work (fork)';
19
+ const TASK_ID = 'agent_1';
20
+
21
+ const JOB_STATE = {
22
+ id: TASK_ID,
23
+ type: 'general-purpose',
24
+ label: DEFAULT_FORK_NAME,
25
+ parentSessionId: 'session_parent',
26
+ status: 'running',
27
+ mode: 'background',
28
+ depth: 1,
29
+ cwd: '/workspace',
30
+ promptPreview: 'forked',
31
+ updatedAt: '2026-09-07T00:00:00.000Z',
32
+ } as unknown as ISubagentJobState;
33
+
34
+ interface IForkHost {
35
+ readonly context: ICommandHostSessionAccess & ICommandHostAgentJobs;
36
+ readonly forkSession: ReturnType<typeof vi.fn>;
37
+ readonly spawnAgentJob: ReturnType<typeof vi.fn>;
38
+ }
39
+
40
+ /** The two capabilities `/fork` reads, and nothing else the command never touches. */
41
+ function createForkHost(options?: {
42
+ readonly forkSession?: ReturnType<typeof vi.fn>;
43
+ readonly spawnAgentJob?: ReturnType<typeof vi.fn>;
44
+ readonly withoutAgentJobs?: boolean;
45
+ }): IForkHost {
46
+ const forkSession =
47
+ options?.forkSession ??
48
+ vi.fn().mockResolvedValue({ sessionId: FORK_SESSION_ID, name: DEFAULT_FORK_NAME });
49
+ const spawnAgentJob = options?.spawnAgentJob ?? vi.fn().mockResolvedValue(JOB_STATE);
50
+ const context = {
51
+ forkSession,
52
+ getAgentJobCapability: () =>
53
+ options?.withoutAgentJobs === true ? undefined : { spawnAgentJob },
54
+ } as unknown as ICommandHostSessionAccess & ICommandHostAgentJobs;
55
+ return { context, forkSession, spawnAgentJob };
56
+ }
57
+
58
+ describe('/fork writes the copy and starts the job that resumes it (CLI-1994 TC-07)', () => {
59
+ it('forks once, then spawns a background job carrying the returned id and a worktree', async () => {
60
+ const host = createForkHost();
61
+
62
+ const result = await executeForkCommand(host.context, '');
63
+
64
+ expect(host.forkSession).toHaveBeenCalledTimes(1);
65
+ expect(host.forkSession).toHaveBeenCalledWith({});
66
+ expect(host.spawnAgentJob).toHaveBeenCalledTimes(1);
67
+ expect(host.spawnAgentJob).toHaveBeenCalledWith(
68
+ expect.objectContaining({
69
+ mode: 'background',
70
+ isolation: 'worktree',
71
+ resumeSessionId: FORK_SESSION_ID,
72
+ label: DEFAULT_FORK_NAME,
73
+ }),
74
+ );
75
+ // The conversation is NOT on the request — only the id it was written under.
76
+ const request = host.spawnAgentJob.mock.calls[0]?.[0] as Record<string, unknown>;
77
+ expect(Object.keys(request)).not.toContain('messages');
78
+ expect(Object.keys(request)).not.toContain('seedMessages');
79
+ // The operator is told both names they will need: the new session and the task.
80
+ expect(result.success).toBe(true);
81
+ expect(result.message).toContain(DEFAULT_FORK_NAME);
82
+ expect(result.message).toContain(TASK_ID);
83
+ expect(result.data).toEqual({
84
+ sessionId: FORK_SESSION_ID,
85
+ name: DEFAULT_FORK_NAME,
86
+ taskId: TASK_ID,
87
+ });
88
+ });
89
+
90
+ it('`/fork my-branch` passes the operator name through to the copy', async () => {
91
+ const host = createForkHost({
92
+ forkSession: vi.fn().mockResolvedValue({ sessionId: FORK_SESSION_ID, name: 'my-branch' }),
93
+ });
94
+
95
+ const result = await executeForkCommand(host.context, 'my-branch');
96
+
97
+ expect(host.forkSession).toHaveBeenCalledWith({ name: 'my-branch' });
98
+ expect(host.spawnAgentJob).toHaveBeenCalledWith(
99
+ expect.objectContaining({ label: 'my-branch', resumeSessionId: FORK_SESSION_ID }),
100
+ );
101
+ expect(result.message).toContain('my-branch');
102
+ });
103
+
104
+ it('`/fork --same-dir` opts out of the worktree, and still names the copy by default', async () => {
105
+ const host = createForkHost();
106
+
107
+ const result = await executeForkCommand(host.context, '--same-dir');
108
+
109
+ expect(host.forkSession).toHaveBeenCalledWith({});
110
+ expect(host.spawnAgentJob).toHaveBeenCalledWith(expect.objectContaining({ isolation: 'none' }));
111
+ expect(result.message).toContain('in this directory');
112
+ });
113
+
114
+ it('`/fork name --same-dir` reads as both — the flag is not part of the name', async () => {
115
+ const host = createForkHost({
116
+ forkSession: vi.fn().mockResolvedValue({ sessionId: FORK_SESSION_ID, name: 'experiment' }),
117
+ });
118
+
119
+ await executeForkCommand(host.context, 'experiment --same-dir');
120
+
121
+ expect(host.forkSession).toHaveBeenCalledWith({ name: 'experiment' });
122
+ expect(host.spawnAgentJob).toHaveBeenCalledWith(expect.objectContaining({ isolation: 'none' }));
123
+ });
124
+
125
+ it('a forkSession rejection is reported and NO job is spawned', async () => {
126
+ const host = createForkHost({
127
+ forkSession: vi.fn().mockRejectedValue(new Error('this session has no session store')),
128
+ });
129
+
130
+ const result = await executeForkCommand(host.context, '');
131
+
132
+ expect(result.success).toBe(false);
133
+ expect(result.message).toContain('this session has no session store');
134
+ expect(host.spawnAgentJob).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('a spawn failure still names the written copy, so the operator can resume it by hand', async () => {
138
+ const host = createForkHost({
139
+ spawnAgentJob: vi.fn().mockRejectedValue(new Error('depth limit reached')),
140
+ });
141
+
142
+ const result = await executeForkCommand(host.context, '');
143
+
144
+ expect(result.success).toBe(false);
145
+ expect(result.message).toContain('depth limit reached');
146
+ expect(result.message).toContain(FORK_SESSION_ID);
147
+ expect(result.message).not.toContain('fixture-agent');
148
+ });
149
+
150
+ it('renders a host-selected resume command after a fork spawn failure', async () => {
151
+ const host = createForkHost({
152
+ spawnAgentJob: vi.fn().mockRejectedValue(new Error('depth limit reached')),
153
+ });
154
+
155
+ const result = await executeForkCommand(
156
+ host.context,
157
+ '',
158
+ (sessionId) => `atlas --resume ${sessionId}`,
159
+ );
160
+
161
+ expect(result.message).toContain(`atlas --resume ${FORK_SESSION_ID}`);
162
+ });
163
+
164
+ it('a host with no agent runtime refuses before writing anything', async () => {
165
+ const host = createForkHost({ withoutAgentJobs: true });
166
+
167
+ const result = await executeForkCommand(host.context, '');
168
+
169
+ expect(result.success).toBe(false);
170
+ expect(result.message).toContain('cannot start background jobs');
171
+ expect(host.forkSession).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('a mistyped flag is refused rather than becoming the session name', async () => {
175
+ const host = createForkHost();
176
+
177
+ const result = await executeForkCommand(host.context, '--same-dr');
178
+
179
+ expect(result.success).toBe(false);
180
+ expect(result.message).toContain('Unknown option: --same-dr');
181
+ expect(host.forkSession).not.toHaveBeenCalled();
182
+ expect(host.spawnAgentJob).not.toHaveBeenCalled();
183
+ });
184
+ });