@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.82

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 (201) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +77 -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 +77 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +39 -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-module.test.ts +24 -0
  87. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  88. package/src/handoff/handoff-command-module.ts +49 -0
  89. package/src/handoff/handoff-command.ts +187 -0
  90. package/src/handoff/index.ts +6 -0
  91. package/src/help/__tests__/help-command-module.test.ts +63 -40
  92. package/src/help/__tests__/help-command.test.ts +46 -37
  93. package/src/help/help-command-module.ts +2 -1
  94. package/src/help/help-command.ts +3 -3
  95. package/src/index.ts +124 -2
  96. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  97. package/src/keybindings/index.ts +6 -0
  98. package/src/keybindings/keybindings-command-module.ts +74 -0
  99. package/src/language/__tests__/language-command-module.test.ts +33 -28
  100. package/src/language/language-command-module.ts +2 -1
  101. package/src/language/language-command.ts +8 -6
  102. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  103. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  104. package/src/mcp-activation/index.ts +12 -0
  105. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  106. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  107. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  108. package/src/mcp-activation/mcp-model-status.ts +99 -0
  109. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  110. package/src/memory/memory-command-module.ts +18 -1
  111. package/src/memory/memory-command.ts +38 -38
  112. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  113. package/src/mode/mode-command-module.ts +2 -1
  114. package/src/mode/mode-command.ts +15 -7
  115. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  116. package/src/output-style/index.ts +6 -0
  117. package/src/output-style/output-style-command-module.ts +143 -0
  118. package/src/peers/__tests__/peers-command.test.ts +321 -0
  119. package/src/peers/index.ts +9 -0
  120. package/src/peers/peers-command-module.ts +49 -0
  121. package/src/peers/peers-command.ts +191 -0
  122. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  123. package/src/permissions/permissions-command-module.ts +2 -1
  124. package/src/permissions/permissions-command.ts +46 -4
  125. package/src/plan/__tests__/plan-command.test.ts +129 -0
  126. package/src/plan/index.ts +9 -0
  127. package/src/plan/plan-command-module.ts +49 -0
  128. package/src/plan/plan-command.ts +92 -0
  129. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  130. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  131. package/src/plugin/plugin-command-module.ts +3 -1
  132. package/src/plugin/plugin-command.ts +17 -16
  133. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  134. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  135. package/src/preset/preset-command-module.ts +26 -8
  136. package/src/preset/preset-command.ts +85 -27
  137. package/src/provider/__tests__/org-policy.test.ts +4 -5
  138. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  139. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  140. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  141. package/src/provider/provider-command-execution.ts +4 -4
  142. package/src/provider/provider-command-module.ts +2 -1
  143. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  144. package/src/provider/provider-command-profile-operations.ts +3 -3
  145. package/src/provider/provider-command-profile.ts +1 -1
  146. package/src/provider/provider-command-setup.ts +4 -4
  147. package/src/provider/provider-startup.ts +62 -35
  148. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  149. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  150. package/src/remote-control/index.ts +8 -0
  151. package/src/remote-control/remote-control-command-module.ts +61 -0
  152. package/src/remote-control/remote-control-command.ts +138 -0
  153. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  154. package/src/reset/reset-command-module.ts +2 -1
  155. package/src/reset/reset-command.ts +3 -4
  156. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  157. package/src/rewind/rewind-command-module.ts +2 -1
  158. package/src/rewind/rewind-command.ts +73 -6
  159. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  160. package/src/sandbox/index.ts +2 -0
  161. package/src/sandbox/sandbox-command-module.ts +56 -0
  162. package/src/sandbox/sandbox-command.ts +94 -0
  163. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  164. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  165. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  166. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  167. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  168. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  169. package/src/schedule/index.ts +3 -0
  170. package/src/schedule/loop-command.ts +333 -0
  171. package/src/schedule/loop-jitter.ts +41 -0
  172. package/src/schedule/schedule-command-module.ts +92 -14
  173. package/src/schedule/schedule-command.ts +98 -9
  174. package/src/schedule/schedule-spec-parser.ts +5 -1
  175. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  176. package/src/session/__tests__/session-command-module.test.ts +210 -45
  177. package/src/session/session-command-module.ts +68 -37
  178. package/src/session/session-command.ts +147 -80
  179. package/src/settings/settings-command-module.ts +3 -2
  180. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  181. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  182. package/src/shell/resolve-shell.ts +2 -2
  183. package/src/shell/shell-command-module.ts +6 -5
  184. package/src/shell/shell-command.ts +9 -5
  185. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  186. package/src/skills/index.ts +1 -0
  187. package/src/skills/skills-command-module.ts +22 -5
  188. package/src/skills/skills-command.ts +14 -13
  189. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  190. package/src/statusline/statusline-command-module.ts +2 -1
  191. package/src/statusline/statusline-command.ts +4 -4
  192. package/src/theme/__tests__/theme-command.test.ts +204 -0
  193. package/src/theme/index.ts +12 -0
  194. package/src/theme/theme-command-module.ts +211 -0
  195. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  196. package/src/user-local/user-local-command-constants.ts +1 -1
  197. package/src/user-local/user-local-command-module.ts +6 -5
  198. package/src/user-local/user-local-command.ts +12 -6
  199. package/src/user-local/user-local-memory-command.ts +17 -7
  200. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  201. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import type { ICommandHostContext } from '@robota-sdk/agent-framework';
2
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
3
3
  import {
4
4
  ExitCommandSource,
5
5
  createExitCommandEntry,
@@ -7,10 +7,14 @@ import {
7
7
  executeExitCommand,
8
8
  } from '../index.js';
9
9
 
10
- function contextWithAnswer(value: string): ICommandHostContext {
11
- return {
12
- getUserInteraction: () => ({ ask: async () => ({ type: 'answer', values: [value] }) }),
13
- } as unknown as ICommandHostContext;
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
+ });
14
18
  }
15
19
 
16
20
  describe('exit command module', () => {
@@ -31,24 +35,27 @@ describe('exit command module', () => {
31
35
  });
32
36
 
33
37
  it('proceeds to exit with no renderer attached (no human to confirm)', async () => {
34
- const result = await executeExitCommand({} as never, '');
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(), '');
35
42
 
36
43
  expect(result).toEqual({
37
44
  success: true,
38
45
  message: 'Exit requested.',
39
- effects: [{ type: 'session-exit-requested' }],
46
+ hostActions: [{ type: 'session-exit' }],
40
47
  });
41
48
  });
42
49
 
43
50
  it('confirms before exiting and proceeds on yes (CMD-004)', async () => {
44
51
  const result = await executeExitCommand(contextWithAnswer('yes'), '');
45
- expect(result.effects).toEqual([{ type: 'session-exit-requested' }]);
52
+ expect(result.hostActions).toEqual([{ type: 'session-exit' }]);
46
53
  });
47
54
 
48
55
  it('cancels the exit when the user declines (CMD-004)', async () => {
49
56
  const result = await executeExitCommand(contextWithAnswer('no'), '');
50
57
  expect(result.success).toBe(true);
51
58
  expect(result.message).toBe('Exit cancelled.');
52
- expect(result.effects).toBeUndefined();
59
+ expect(result.hostActions).toBeUndefined();
53
60
  });
54
61
  });
@@ -3,7 +3,7 @@ import { EXIT_COMMAND_DESCRIPTION } from '@robota-sdk/agent-framework';
3
3
  import { executeExitCommand } from './exit-command.js';
4
4
 
5
5
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
6
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
6
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
7
7
 
8
8
  export function createExitCommandEntry(): ICommand {
9
9
  return {
@@ -11,6 +11,7 @@ export function createExitCommandEntry(): ICommand {
11
11
  displayName: 'Exit Session',
12
12
  description: EXIT_COMMAND_DESCRIPTION,
13
13
  source: 'exit',
14
+ // User-only: ending the session is the user's decision.
14
15
  modelInvocable: false,
15
16
  };
16
17
  }
@@ -1,16 +1,16 @@
1
1
  import { confirmAction, isConfirmed } from '@robota-sdk/agent-core';
2
- import { createSessionExitRequestedEffect } from '@robota-sdk/agent-framework';
2
+ import { createSessionExitHostAction } from '@robota-sdk/agent-framework';
3
3
 
4
- import type { ICommandHostContext } from '@robota-sdk/agent-framework';
5
- import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
4
+ import type { ICommandHostUserInteraction } from '@robota-sdk/agent-framework';
5
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
6
6
 
7
7
  export async function executeExitCommand(
8
- context: ICommandHostContext,
8
+ context: ICommandHostUserInteraction,
9
9
  _args: string,
10
10
  ): Promise<ICommandResult> {
11
11
  // Confirm only when an interactive renderer is attached. With no human (headless/automation) the
12
12
  // user explicitly invoked /exit, so proceed — the confirm is an interactive safety prompt, not a gate.
13
- const ui = context.getUserInteraction?.();
13
+ const ui = context.getUserInteraction();
14
14
  if (ui) {
15
15
  const response = await ui.ask(confirmAction('exit', 'Exit the session?'));
16
16
  if (!isConfirmed(response)) {
@@ -21,6 +21,6 @@ export async function executeExitCommand(
21
21
  return {
22
22
  success: true,
23
23
  message: 'Exit requested.',
24
- effects: [createSessionExitRequestedEffect()],
24
+ hostActions: [createSessionExitHostAction()],
25
25
  };
26
26
  }
@@ -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('robota');
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
+ });
@@ -0,0 +1,61 @@
1
+ import { executeForkCommand } from './fork-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 createForkCommandEntry(): ICommand {
7
+ return {
8
+ name: 'fork',
9
+ displayName: 'Fork Session',
10
+ description:
11
+ 'Copy this conversation into a new background session and keep working here. ' +
12
+ 'The fork inherits the whole conversation and the assembled system prompt; it is a copy, ' +
13
+ 'so nothing it does reaches this session and it never merges back.',
14
+ source: 'fork',
15
+ // The model does not decide to duplicate the operator's conversation. Forking spends a second
16
+ // session's budget and creates a second worktree, both of which are the operator's call.
17
+ // User-only: starts a separate session the user owns and attaches to.
18
+ modelInvocable: false,
19
+ argumentHint: '[name] [--same-dir]',
20
+ };
21
+ }
22
+
23
+ function createForkSystemCommand(
24
+ formatResumeCommand?: (sessionId: string) => string,
25
+ ): ISystemCommand {
26
+ const entry = createForkCommandEntry();
27
+ return {
28
+ name: entry.name,
29
+ displayName: entry.displayName,
30
+ description: entry.description,
31
+ requiresPermission: false,
32
+ userInvocable: true,
33
+ modelInvocable: false,
34
+ lifecycle: 'inline',
35
+ ...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
36
+ // `ICommandHostContext` aggregates both role ports `/fork` declares, so the narrowing is the
37
+ // parameter's own type — no assertion, and a role the command stops satisfying is a build error.
38
+ execute: (context, args) => executeForkCommand(context, args, formatResumeCommand),
39
+ };
40
+ }
41
+
42
+ export class ForkCommandSource implements ICommandSource {
43
+ readonly name = 'fork';
44
+
45
+ getCommands(): ICommand[] {
46
+ return [createForkCommandEntry()];
47
+ }
48
+ }
49
+
50
+ export function createForkCommandModule(
51
+ formatResumeCommand?: (sessionId: string) => string,
52
+ ): ICommandModule {
53
+ return {
54
+ name: 'agent-command-fork',
55
+ commandSources: [new ForkCommandSource()],
56
+ systemCommands: [createForkSystemCommand(formatResumeCommand)],
57
+ // A fork is a background agent job resuming a session record; a host with no agent runtime has
58
+ // neither half, so the command is not offered rather than offered and refused.
59
+ sessionRequirements: ['agent-runtime'],
60
+ };
61
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `/fork [name] [--same-dir]` (CLI-1994, issue #1994) — copy this conversation into a background
3
+ * session and keep working here.
4
+ *
5
+ * The two halves are deliberately separate calls to two different capabilities, because they are two
6
+ * different facts. `forkSession` writes a COPY of the live conversation as a new session record: the
7
+ * messages, the parent's assembled system message, the tool schemas and the transcript, under a
8
+ * fresh id and a name that is not the parent's. Spawning then starts a background agent job that
9
+ * carries ONLY that id — the conversation never rides on the request, so it never crosses the
10
+ * child-process wire (ARCH-044); the child reads the record from the session store, exactly as the
11
+ * startup `--fork-session` does.
12
+ *
13
+ * A fork is a copy, not a branch of one live thing: from the moment the record is written, work in
14
+ * the fork and work here are two histories that never rejoin. Attaching to the fork later (the
15
+ * background panel's `attach` control) switches the terminal's VIEW to that session; it does not
16
+ * merge anything back.
17
+ */
18
+
19
+ import type { ICommandHostAgentJobs, ICommandHostSessionAccess } from '@robota-sdk/agent-framework';
20
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
21
+ import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
22
+
23
+ /** What `/fork` needs from its host: the copy-writer and the job dispatcher. */
24
+ export type TForkCommandContext = ICommandHostSessionAccess & ICommandHostAgentJobs;
25
+
26
+ /**
27
+ * The fork runs in its own worktree by DEFAULT.
28
+ *
29
+ * A spawned job inherits the parent's cwd, so a fork that keeps working in the same directory writes
30
+ * over the files the parent is still editing — the "work in it does not affect the original"
31
+ * property lost through the filesystem rather than through the conversation. `--same-dir` is the
32
+ * explicit opt-out for a fork that is meant to read and think rather than to edit.
33
+ */
34
+ const DEFAULT_ISOLATION: TBackgroundTaskIsolation = 'worktree';
35
+ const SAME_DIR_ISOLATION: TBackgroundTaskIsolation = 'none';
36
+ const SAME_DIR_FLAG = '--same-dir';
37
+
38
+ /** The agent a forked conversation continues under — the general one, since the fork IS the context. */
39
+ const FORK_AGENT_TYPE = 'general-purpose';
40
+
41
+ /**
42
+ * The fork's first turn input.
43
+ *
44
+ * A fork inherits the parent's whole conversation and its assembled system message, so there is
45
+ * nothing left to brief it about — this is a minimal continuation cue, deliberately NOT a prompt.
46
+ * § Solution 5 asked for an empty string; a turn with no user content is rejected by providers, and
47
+ * one word is the smallest thing that is not — the divergence is recorded in the spec beside it. Anything longer would be this neutral command layer
48
+ * deciding how a forked agent should behave, which is the product layer's call
49
+ * (`scan-prompt-prose` is the mechanical form of that rule).
50
+ */
51
+ const FORK_FIRST_TURN = 'Continue.';
52
+
53
+ export interface IParsedForkArgs {
54
+ readonly name: string | undefined;
55
+ readonly isolation: TBackgroundTaskIsolation;
56
+ }
57
+
58
+ /** A rejected argument string, kept distinct from a parse so neither can be read as the other. */
59
+ export interface IForkArgsError {
60
+ readonly error: string;
61
+ }
62
+
63
+ /**
64
+ * `--same-dir` may appear anywhere; everything else is the name.
65
+ *
66
+ * An unrecognised `--flag` is REFUSED rather than folded into the name: a mistyped `--same-dr` that
67
+ * became a session called "--same-dr" would silently give the operator a worktree they asked not to
68
+ * have, and the name is the one field they will later use to find the fork.
69
+ */
70
+ export function parseForkArgs(args: string): IParsedForkArgs | IForkArgsError {
71
+ const tokens = args
72
+ .trim()
73
+ .split(/\s+/)
74
+ .filter((token) => token.length > 0);
75
+ const unknownFlag = tokens.find((token) => token.startsWith('--') && token !== SAME_DIR_FLAG);
76
+ if (unknownFlag !== undefined) {
77
+ return { error: `Unknown option: ${unknownFlag}\nUsage: /fork [name] [${SAME_DIR_FLAG}]` };
78
+ }
79
+ const nameTokens = tokens.filter((token) => token !== SAME_DIR_FLAG);
80
+ return {
81
+ name: nameTokens.length > 0 ? nameTokens.join(' ') : undefined,
82
+ isolation: tokens.includes(SAME_DIR_FLAG) ? SAME_DIR_ISOLATION : DEFAULT_ISOLATION,
83
+ };
84
+ }
85
+
86
+ function formatError<TError>(error: TError): string {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
89
+
90
+ function describeFork(name: string, taskId: string, isolation: TBackgroundTaskIsolation): string {
91
+ const where = isolation === SAME_DIR_ISOLATION ? 'in this directory' : 'in its own worktree';
92
+ return [
93
+ `Forked this conversation into "${name}" (${taskId}), running ${where}.`,
94
+ 'The fork is a copy: this session keeps going, and the two are separate records.',
95
+ 'See its progress with /background list. Attaching from the background panel points this',
96
+ "terminal at the copy as it was forked; the fork's own turns stay in its task transcript.",
97
+ ].join('\n');
98
+ }
99
+
100
+ /**
101
+ * The copy is on disk and the job is not. Both facts are reported, because the record is still
102
+ * resumable by name and an operator told only "failed" would not know that.
103
+ */
104
+ function describeSpawnFailure(
105
+ fork: { sessionId: string; name: string },
106
+ reason: string,
107
+ formatResumeCommand?: (sessionId: string) => string,
108
+ ): string {
109
+ const resumeHint = formatResumeCommand?.(fork.sessionId);
110
+ return (
111
+ `The fork "${fork.name}" was written (${fork.sessionId}) but its background job could not ` +
112
+ `start: ${reason}\n${resumeHint === undefined ? 'Resume the saved session through your host.' : `Resume it in a new terminal with: ${resumeHint}`}`
113
+ );
114
+ }
115
+
116
+ export async function executeForkCommand(
117
+ context: TForkCommandContext,
118
+ args = '',
119
+ formatResumeCommand?: (sessionId: string) => string,
120
+ ): Promise<ICommandResult> {
121
+ const parsed = parseForkArgs(args);
122
+ if ('error' in parsed) return { message: parsed.error, success: false };
123
+
124
+ const jobs = context.getAgentJobCapability();
125
+ if (!jobs) {
126
+ return {
127
+ message: 'This session cannot start background jobs, so it cannot run a fork.',
128
+ success: false,
129
+ };
130
+ }
131
+
132
+ // The copy is written FIRST, and a failure here is reported without spawning: a job that resumes a
133
+ // record which was never written is a child that fails on its own far side, out of the operator's
134
+ // sight.
135
+ let fork: { sessionId: string; name: string };
136
+ try {
137
+ fork = await context.forkSession(parsed.name !== undefined ? { name: parsed.name } : {});
138
+ } catch (error) {
139
+ return { message: `Could not fork this session: ${formatError(error)}`, success: false };
140
+ }
141
+
142
+ try {
143
+ const state = await jobs.spawnAgentJob({
144
+ agentType: FORK_AGENT_TYPE,
145
+ label: fork.name,
146
+ mode: 'background',
147
+ prompt: FORK_FIRST_TURN,
148
+ isolation: parsed.isolation,
149
+ resumeSessionId: fork.sessionId,
150
+ });
151
+ return {
152
+ message: describeFork(fork.name, state.id, parsed.isolation),
153
+ success: true,
154
+ data: { sessionId: fork.sessionId, name: fork.name, taskId: state.id },
155
+ };
156
+ } catch (error) {
157
+ return {
158
+ message: describeSpawnFailure(fork, formatError(error), formatResumeCommand),
159
+ success: false,
160
+ };
161
+ }
162
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Only the module factory leaves this directory — the same rule the sibling `src/peers/` follows.
3
+ *
4
+ * `executeForkCommand`, `parseForkArgs` and `ForkCommandSource` are how the module is BUILT, not how
5
+ * it is used: `/fork` is registered by default, so nothing outside constructs one. Exporting them
6
+ * would add entries to this package's frozen undocumented-surface count for a reach nobody asked for.
7
+ */
8
+ export { createForkCommandModule } from './fork-command-module.js';
@@ -0,0 +1,28 @@
1
+ /** A scripted `IGitProcessPort`: answers by argv, records every call, throws on an unscripted one. */
2
+ import type { IGitProcessPort, TGitProcessOutcome } from '../git-process.js';
3
+
4
+ export interface IFakeGitPort extends IGitProcessPort {
5
+ readonly calls: readonly (readonly string[])[];
6
+ }
7
+
8
+ export function exited(stdout: string, exitCode = 0, stderr = ''): TGitProcessOutcome {
9
+ return { kind: 'exited', stdout, stderr, exitCode };
10
+ }
11
+
12
+ export function fakeGitPort(
13
+ script: Readonly<
14
+ Record<string, TGitProcessOutcome | ((args: readonly string[]) => TGitProcessOutcome)>
15
+ >,
16
+ ): IFakeGitPort {
17
+ const calls: (readonly string[])[] = [];
18
+ return {
19
+ calls,
20
+ async run(args) {
21
+ calls.push([...args]);
22
+ const key = args.join(' ');
23
+ const entry = script[key];
24
+ if (entry === undefined) throw new Error(`unscripted git call: ${key}`);
25
+ return typeof entry === 'function' ? entry(args) : entry;
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,103 @@
1
+ /** BEHAVIOR-2437 TC-05 — registration, the descriptor, and the verb parse through the real session. */
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+
4
+ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
5
+
6
+ import { createDefaultCommandModules } from '../../default/default-command-modules.js';
7
+ import { createHelpCommandModule } from '../../help/help-command-module.js';
8
+ import {
9
+ createGitCommandEntry,
10
+ createGitCommandModule,
11
+ executeGitCommand,
12
+ } from '../git-command-module.js';
13
+
14
+ import { exited, fakeGitPort } from './fake-git-port.js';
15
+
16
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
17
+ import type {
18
+ IProviderCommandSettingsAdapter,
19
+ TProviderSettingsDocument,
20
+ } from '@robota-sdk/agent-framework';
21
+
22
+ const providerDefinitions: readonly IProviderDefinition[] = [
23
+ {
24
+ type: 'anthropic',
25
+ defaults: { model: 'claude-sonnet-4-6', apiKey: '$ENV:ANTHROPIC_API_KEY' },
26
+ setupSteps: [{ key: 'apiKey', title: 'anthropic API key', masked: true }],
27
+ requiresApiKey: true,
28
+ createProvider: () => {
29
+ throw new Error('not used');
30
+ },
31
+ },
32
+ ];
33
+ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
34
+ readMergedSettings: () => ({}) as TProviderSettingsDocument,
35
+ readTargetSettings: () => ({}) as TProviderSettingsDocument,
36
+ writeTargetSettings: () => undefined,
37
+ };
38
+ const BASE_OPTIONS = { cwd: '/tmp', userLocalStorageRoot: '/tmp/robota-test', providerDefinitions, providerSettingsAdapter } as const;
39
+
40
+ const STATUS_FIXTURE = '# branch.head main\0? scratch.log\0';
41
+
42
+ let h: ScriptedSessionHarness | undefined;
43
+ afterEach(async () => {
44
+ await h?.dispose();
45
+ h = undefined;
46
+ });
47
+
48
+ describe('the /git descriptor', () => {
49
+ it('is host-only, with the three verbs as descriptive subcommands', () => {
50
+ const entry = createGitCommandEntry();
51
+ expect(entry.name).toBe('git');
52
+ expect(entry.modelInvocable).toBe(false);
53
+ expect(entry.subcommands?.map((s) => s.name)).toEqual(['status', 'diff', 'commit']);
54
+ const module = createGitCommandModule({ port: fakeGitPort({}) });
55
+ expect(module.name).toBe('agent-command-git');
56
+ expect(module.systemCommands?.[0]).toMatchObject({
57
+ name: 'git',
58
+ requiresPermission: true,
59
+ modelInvocable: false,
60
+ lifecycle: 'blocking',
61
+ });
62
+ });
63
+
64
+ it('is in the base list, and the name `status` is registered nowhere', () => {
65
+ const { modules } = createDefaultCommandModules(BASE_OPTIONS);
66
+ expect(modules.map((m) => m.name)).toContain('agent-command-git');
67
+ const names = modules.flatMap((m) => (m.systemCommands ?? []).map((c) => c.name));
68
+ expect(names).toContain('git');
69
+ expect(names).not.toContain('status');
70
+ });
71
+ });
72
+
73
+ describe('executeGitCommand verb parse', () => {
74
+ const context = { getCwd: () => '/r', getUserInteraction: () => undefined };
75
+
76
+ it('routes status, refuses arguments to it, and refuses unknown or missing verbs', async () => {
77
+ const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(STATUS_FIXTURE) });
78
+ expect((await executeGitCommand(context, ' status ', port)).message).toContain(
79
+ 'On branch main',
80
+ );
81
+ expect((await executeGitCommand(context, 'status extra', port)).success).toBe(false);
82
+ const unknown = await executeGitCommand(context, 'push', port);
83
+ expect(unknown.success).toBe(false);
84
+ expect(unknown.message).toContain('push');
85
+ expect((await executeGitCommand(context, '', port)).message).toContain('Usage: /git');
86
+ expect(port.calls).toHaveLength(1);
87
+ });
88
+ });
89
+
90
+ describe('/git through the real session', () => {
91
+ it('/help lists /git, and /git status answers through the injected port', async () => {
92
+ const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(STATUS_FIXTURE) });
93
+ h = scriptedSession({
94
+ turns: [{ text: 'unused' }],
95
+ commandModules: [createHelpCommandModule(), createGitCommandModule({ port })],
96
+ });
97
+ const help = await h.command('help');
98
+ expect(help?.message).toContain('/git');
99
+ const status = await h.command('git', 'status');
100
+ expect(status?.success).toBe(true);
101
+ expect(status?.message).toContain('untracked (1): scratch.log');
102
+ });
103
+ });