@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,218 @@
1
+ /**
2
+ * `/handoff` (HANDOFF-001, issue #1864) — the consent, and the sentence the operator needs.
3
+ *
4
+ * Two properties are asserted on nearly every path, because they are what the command is for:
5
+ *
6
+ * the prompt names what will be LOST before it asks, and
7
+ * every outcome says where the session is now.
8
+ *
9
+ * A hand-off is irreversible from the source's side. A confirmation that did not mention the
10
+ * uncommitted work is not consent to lose it, and an outcome that did not say where the session
11
+ * went leaves the operator not knowing which computer to walk to.
12
+ */
13
+
14
+ import type {
15
+ ICommandHostAdapters,
16
+ IHandoffProgress,
17
+ IHandoffStaysBehind,
18
+ } from '@robota-sdk/agent-framework';
19
+ import type { IActionRequest, TActionResponse } from '@robota-sdk/agent-core';
20
+ import { describe, expect, it, vi } from 'vitest';
21
+
22
+ import { executeHandoffCommand } from '../handoff-command.js';
23
+
24
+ const HERE: IHandoffProgress = { state: 'offered', stillMine: true };
25
+
26
+ interface IHarness {
27
+ readonly destinations?: readonly { readonly deviceId: string; readonly name?: string }[];
28
+ readonly staysBehind?: IHandoffStaysBehind;
29
+ readonly transfer?: (
30
+ deviceId: string,
31
+ onProgress?: (progress: IHandoffProgress) => void,
32
+ ) => Promise<IHandoffProgress>;
33
+ readonly answer?: TActionResponse;
34
+ /** Omitted entirely when the host has no interactive renderer — that is a case, not a default. */
35
+ readonly interactive?: boolean;
36
+ readonly adapter?: boolean;
37
+ }
38
+
39
+ function host(harness: IHarness = {}) {
40
+ const asked: IActionRequest[] = [];
41
+ const adapters: ICommandHostAdapters =
42
+ harness.adapter === false
43
+ ? {}
44
+ : {
45
+ handoff: {
46
+ destinations: async () => harness.destinations ?? [{ deviceId: 'laptop' }],
47
+ staysBehind: async () =>
48
+ harness.staysBehind ?? { uncommittedChanges: false, subprocesses: 0 },
49
+ transfer:
50
+ harness.transfer ??
51
+ (async (_id, onProgress) => {
52
+ onProgress?.({ state: 'sending', stillMine: true });
53
+ return { state: 'done', stillMine: false };
54
+ }),
55
+ status: () => HERE,
56
+ },
57
+ };
58
+ return {
59
+ asked,
60
+ context: {
61
+ getCommandHostAdapters: () => adapters,
62
+ getUserInteraction:
63
+ harness.interactive === false
64
+ ? () => undefined
65
+ : () => ({
66
+ ask: async (request: IActionRequest): Promise<TActionResponse> => {
67
+ asked.push(request);
68
+ return harness.answer ?? { type: 'answer', values: ['move'] };
69
+ },
70
+ }),
71
+ },
72
+ };
73
+ }
74
+
75
+ describe('the consent names what will be lost, before it asks', () => {
76
+ it('says the uncommitted work and the running processes stay behind', async () => {
77
+ const { asked, context } = host({
78
+ staysBehind: { uncommittedChanges: true, subprocesses: 3 },
79
+ });
80
+ await executeHandoffCommand(context, 'laptop');
81
+
82
+ expect(asked).toHaveLength(1);
83
+ const description = asked[0]?.description ?? '';
84
+ expect(description).toContain('uncommitted changes');
85
+ expect(description).toContain('3 running process');
86
+ // The credential line is unconditional: it is true on every hand-off, and it is the one that
87
+ // explains why the other machine may refuse at the last step.
88
+ expect(description).toContain('credentials are never transferred');
89
+ });
90
+
91
+ it('omits what does not apply, so the prompt is not a standing warning nobody reads', async () => {
92
+ const { asked, context } = host({
93
+ staysBehind: { uncommittedChanges: false, subprocesses: 0 },
94
+ });
95
+ await executeHandoffCommand(context, 'laptop');
96
+
97
+ const description = asked[0]?.description ?? '';
98
+ expect(description).not.toContain('uncommitted changes');
99
+ expect(description).not.toContain('running process');
100
+ expect(description).toContain('credentials are never transferred');
101
+ });
102
+
103
+ it('does not transfer when the operator declines', async () => {
104
+ const transfer = vi.fn();
105
+ const { context } = host({ answer: { type: 'answer', values: ['stay'] }, transfer });
106
+ const result = await executeHandoffCommand(context, 'laptop');
107
+
108
+ expect(transfer).not.toHaveBeenCalled();
109
+ expect(result.message).toContain('still on this machine');
110
+ });
111
+
112
+ it('treats a dismissed prompt as a decline, never as a yes', async () => {
113
+ const transfer = vi.fn();
114
+ const { context } = host({ answer: { type: 'cancelled' }, transfer });
115
+ const result = await executeHandoffCommand(context, 'laptop');
116
+
117
+ expect(transfer).not.toHaveBeenCalled();
118
+ expect(result.message).toContain('still on this machine');
119
+ });
120
+
121
+ it('refuses when no human is attached, rather than proceeding unasked', async () => {
122
+ const transfer = vi.fn();
123
+ const { context } = host({ interactive: false, transfer });
124
+ const result = await executeHandoffCommand(context, 'laptop');
125
+
126
+ expect(result.success).toBe(false);
127
+ expect(transfer).not.toHaveBeenCalled();
128
+ expect(result.message).toContain('needs a person to confirm');
129
+ expect(result.message).toContain('still on this machine');
130
+ });
131
+ });
132
+
133
+ describe('every outcome says where the session is now', () => {
134
+ it('says the destination owns it when the transfer completes', async () => {
135
+ const { context } = host({
136
+ transfer: async () => ({ state: 'done', stillMine: false }),
137
+ });
138
+ const result = await executeHandoffCommand(context, 'laptop');
139
+
140
+ expect(result.success).toBe(true);
141
+ expect(result.message).toContain('laptop saved this session and has not started it');
142
+ expect(result.message).toContain('this one ends');
143
+ });
144
+
145
+ it('says the session is still here when the transfer stops, and why', async () => {
146
+ const { context } = host({
147
+ transfer: async () => ({
148
+ state: 'stopped',
149
+ reason: 'the destination resolved no provider credential',
150
+ stillMine: true,
151
+ }),
152
+ });
153
+ const result = await executeHandoffCommand(context, 'laptop');
154
+
155
+ expect(result.success).toBe(false);
156
+ expect(result.message).toContain('no provider credential');
157
+ expect(result.message).toContain('still on this machine');
158
+ expect(result.message).not.toContain('this one ends');
159
+ });
160
+
161
+ it('names the waiting phase as safe to leave, since that is when it looks stuck', async () => {
162
+ const { context } = host({
163
+ transfer: async (_id, onProgress) => {
164
+ onProgress?.({ state: 'awaiting-confirmation', stillMine: true });
165
+ return { state: 'done', stillMine: false };
166
+ },
167
+ });
168
+ const result = await executeHandoffCommand(context, 'laptop');
169
+
170
+ expect(result.message).toContain('this machine keeps it');
171
+ });
172
+ });
173
+
174
+ describe('what it refuses before asking anything', () => {
175
+ it('says a host with no carrier cannot move a session', async () => {
176
+ const { asked, context } = host({ adapter: false });
177
+ const result = await executeHandoffCommand(context, 'laptop');
178
+
179
+ expect(result.success).toBe(false);
180
+ expect(asked).toHaveLength(0);
181
+ expect(result.message).toContain('no hand-off carrier');
182
+ expect(result.message).toContain('still here');
183
+ });
184
+
185
+ it('refuses a destination that is not reachable, and lists the ones that are', async () => {
186
+ const { asked, context } = host({
187
+ destinations: [{ deviceId: 'laptop', name: 'Work laptop' }],
188
+ });
189
+ const result = await executeHandoffCommand(context, 'tablet');
190
+
191
+ expect(result.success).toBe(false);
192
+ expect(asked).toHaveLength(0);
193
+ expect(result.message).toContain("No reachable machine is called 'tablet'");
194
+ expect(result.message).toContain('Work laptop');
195
+ expect(result.message).toContain('still on this machine');
196
+ });
197
+
198
+ it('lists the destinations when called with no argument', async () => {
199
+ const { asked, context } = host({
200
+ destinations: [{ deviceId: 'laptop' }, { deviceId: 'tablet' }],
201
+ });
202
+ const result = await executeHandoffCommand(context, '');
203
+
204
+ expect(asked).toHaveLength(0);
205
+ expect(result.success).toBe(true);
206
+ expect(result.message).toContain('laptop');
207
+ expect(result.message).toContain('tablet');
208
+ });
209
+
210
+ it('says so plainly when nothing is reachable', async () => {
211
+ const { context } = host({ destinations: [] });
212
+ const result = await executeHandoffCommand(context, '');
213
+
214
+ expect(result.success).toBe(true);
215
+ expect(result.message).toContain('No other machine is reachable');
216
+ expect(result.message).toContain('still on this machine');
217
+ });
218
+ });
@@ -0,0 +1,49 @@
1
+ import { executeHandoffCommand } from './handoff-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 createHandoffCommandEntry(): ICommand {
7
+ return {
8
+ name: 'handoff',
9
+ displayName: 'Hand off',
10
+ description:
11
+ "Push this conversation to another running session for the same user on this machine, or to another linked device, after the operator confirms what stays behind (uncommitted changes, running processes; credentials never travel). The receiving operator must also accept; the session arrives saved, not started, and this one ends once it is saved there. With no argument it lists the sessions and devices it could go to. User-only: the model cannot run it; when the user wants to continue this work in another session or on another device, suggest they run `/handoff <session-or-device-id>`.",
12
+ source: 'handoff',
13
+ // User-only: a hand-off moves authority over the operator's work to another place, a decision
14
+ // about where the person is, not about the task.
15
+ modelInvocable: false,
16
+ userInvocable: true,
17
+ argumentHint: '[session-id]',
18
+ };
19
+ }
20
+
21
+ function createHandoffSystemCommand(): ISystemCommand {
22
+ const entry = createHandoffCommandEntry();
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: (context, args) => executeHandoffCommand(context, args),
32
+ };
33
+ }
34
+
35
+ export class HandoffCommandSource implements ICommandSource {
36
+ readonly name = 'handoff';
37
+
38
+ getCommands(): ICommand[] {
39
+ return [createHandoffCommandEntry()];
40
+ }
41
+ }
42
+
43
+ export function createHandoffCommandModule(): ICommandModule {
44
+ return {
45
+ name: 'agent-command-handoff',
46
+ commandSources: [new HandoffCommandSource()],
47
+ systemCommands: [createHandoffSystemCommand()],
48
+ };
49
+ }
@@ -0,0 +1,187 @@
1
+ import type {
2
+ ICommandHostAdapterAccess,
3
+ ICommandHostAdapters,
4
+ ICommandHostUserInteraction,
5
+ IHandoffProgress,
6
+ } from '@robota-sdk/agent-framework';
7
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
8
+
9
+ /**
10
+ * `/handoff` (HANDOFF-001, issue #1864) — move this session to another machine.
11
+ *
12
+ * The command holds no protocol. It reads an injected adapter and touches no transport, for the same
13
+ * reason `/peers` does not: the carrier, the wire composition and the device identity are
14
+ * composition-root concerns and must have one owner.
15
+ *
16
+ * ## What it is FOR, which is the consent
17
+ *
18
+ * A hand-off loses things on purpose — uncommitted working-tree changes and running subprocesses
19
+ * stay on this machine, because moving them would make this a file-sync product and a process cannot
20
+ * migrate at all. Those are the operator's to lose, so they are named in the prompt BEFORE the
21
+ * question, not reported afterwards. A confirmation that did not mention them is not consent.
22
+ *
23
+ * ## The sentence it always ends on
24
+ *
25
+ * Every outcome that is not `done` says the session is still on this machine. That is not
26
+ * reassurance: it is the one invariant the whole design exists to preserve, and the operator is the
27
+ * person who has to know whether to keep typing here or walk to the other computer.
28
+ */
29
+
30
+ type THandoffHost = ICommandHostAdapterAccess & Partial<ICommandHostUserInteraction>;
31
+
32
+ /** The line that answers "where is my session?" — printed on every path, success included. */
33
+ function whereIsIt(progress: IHandoffProgress): string {
34
+ return progress.stillMine
35
+ ? 'This session is still on this machine, and still yours to use.'
36
+ : 'This session now belongs to the destination, and this one ends.';
37
+ }
38
+
39
+ function describeDestinations(
40
+ destinations: readonly { readonly deviceId: string; readonly name?: string }[],
41
+ ): string {
42
+ return destinations.map((d) => ` ${d.deviceId}${d.name ? ` ${d.name}` : ''}`).join('\n');
43
+ }
44
+
45
+ function usage(): ICommandResult {
46
+ return {
47
+ success: true,
48
+ message: [
49
+ 'Usage:',
50
+ ' /handoff list where this session could move to',
51
+ ' /handoff <session-or-device-id> move it there, after confirming what stays behind',
52
+ ].join('\n'),
53
+ };
54
+ }
55
+
56
+ export async function executeHandoffCommand(
57
+ context: THandoffHost,
58
+ args: string,
59
+ ): Promise<ICommandResult> {
60
+ const adapter: ICommandHostAdapters['handoff'] = context.getCommandHostAdapters?.().handoff;
61
+ if (adapter === undefined) {
62
+ // Said plainly rather than offered and then failed. A host with no carrier cannot move a
63
+ // session, and a command that took the request anyway would spend the operator's attention
64
+ // before telling them.
65
+ return {
66
+ success: false,
67
+ message:
68
+ 'This session has no hand-off carrier, so it cannot be moved to another machine. ' +
69
+ 'Nothing changed — the session is still here.',
70
+ };
71
+ }
72
+
73
+ const target = args.trim();
74
+ if (target === '' || target === 'help') {
75
+ const destinations = await adapter.destinations();
76
+ if (destinations.length === 0) {
77
+ return {
78
+ success: true,
79
+ message:
80
+ 'No other machine is reachable for a hand-off right now.\n' + whereIsIt(adapter.status()),
81
+ };
82
+ }
83
+ return {
84
+ success: true,
85
+ message: [
86
+ 'This session could move to:',
87
+ describeDestinations(destinations),
88
+ '',
89
+ usage().message ?? '',
90
+ ].join('\n'),
91
+ };
92
+ }
93
+
94
+ const destinations = await adapter.destinations();
95
+ if (!destinations.some((d) => d.deviceId === target)) {
96
+ return {
97
+ success: false,
98
+ message:
99
+ `No reachable machine is called '${target}'.\n` +
100
+ (destinations.length > 0
101
+ ? `Reachable now:\n${describeDestinations(destinations)}`
102
+ : 'None are reachable right now.') +
103
+ `\n${whereIsIt(adapter.status())}`,
104
+ };
105
+ }
106
+
107
+ const staying = await adapter.staysBehind();
108
+ const consequences = [
109
+ staying.uncommittedChanges
110
+ ? ' - uncommitted changes in the working tree stay on THIS machine'
111
+ : null,
112
+ staying.subprocesses > 0
113
+ ? ` - ${staying.subprocesses} running process(es) stay on THIS machine and will not be reachable there`
114
+ : null,
115
+ ' - the destination uses ITS OWN provider credential; credentials are never transferred',
116
+ ].filter((line): line is string => line !== null);
117
+
118
+ const ask = context.getUserInteraction?.();
119
+ if (ask === undefined) {
120
+ // No human is attached. Refused, never guessed: a hand-off is irreversible from the source's
121
+ // side, and "nobody was there to say no" is not a yes.
122
+ return {
123
+ success: false,
124
+ message:
125
+ 'A hand-off needs a person to confirm what stays behind, and no interactive session is ' +
126
+ `attached.\n${whereIsIt(adapter.status())}`,
127
+ };
128
+ }
129
+
130
+ const answer = await ask.ask({
131
+ id: `handoff:${target}`,
132
+ title: `Move this session to ${target}?`,
133
+ description: ['What will NOT travel:', ...consequences].join('\n'),
134
+ options: [
135
+ { value: 'move', label: `Move it to ${target}` },
136
+ { value: 'stay', label: 'Keep it here' },
137
+ ],
138
+ });
139
+ if (answer.type !== 'answer' || !answer.values.includes('move')) {
140
+ return {
141
+ success: true,
142
+ message: `Hand-off cancelled.\n${whereIsIt(adapter.status())}`,
143
+ };
144
+ }
145
+
146
+ const progressLines: string[] = [];
147
+ const final = await adapter.transfer(target, (progress) => {
148
+ progressLines.push(renderProgress(progress, target));
149
+ });
150
+
151
+ return {
152
+ success: final.state === 'done',
153
+ message: [
154
+ ...progressLines,
155
+ final.state === 'done'
156
+ ? `Hand-off complete. ${target} saved this session and has not started it; resume it there.`
157
+ : `Hand-off stopped: ${final.reason ?? 'no reason was reported'}.`,
158
+ whereIsIt(final),
159
+ ].join('\n'),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * One progress line, in the operator's vocabulary.
165
+ *
166
+ * `awaiting-confirmation` is spelled out rather than shortened because it is the phase the operator
167
+ * is most likely to interrupt: the payload is on the other machine and this one has not yet heard
168
+ * that it is on disk there. Quitting here is safe, and the wording is what says so.
169
+ */
170
+ function renderProgress(progress: IHandoffProgress, target: string): string {
171
+ // Exhaustive by construction: the default returns nothing rather than a plausible-looking line, so
172
+ // a new state added to the contract shows up as a blank line in a test instead of as a wrong one.
173
+ switch (progress.state) {
174
+ case 'offered':
175
+ return `Offering the session to ${target}...`;
176
+ case 'sending':
177
+ return 'Sending the session...';
178
+ case 'awaiting-confirmation':
179
+ return `Sent. Waiting for ${target} to confirm it has the session on disk — until it does, this machine keeps it.`;
180
+ case 'done':
181
+ return `${target} confirmed it has the session.`;
182
+ case 'stopped':
183
+ return `Stopped: ${progress.reason ?? 'no reason was reported'}.`;
184
+ default:
185
+ return '';
186
+ }
187
+ }
@@ -0,0 +1,6 @@
1
+ export { executeHandoffCommand } from './handoff-command.js';
2
+ export {
3
+ HandoffCommandSource,
4
+ createHandoffCommandEntry,
5
+ createHandoffCommandModule,
6
+ } from './handoff-command-module.js';
@@ -0,0 +1,134 @@
1
+ import type { ICommandHostContext, ICommandSessionRuntime } from '@robota-sdk/agent-framework';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createHelpCommandModule } from '../help-command-module.js';
4
+ import { executeHelpCommand } from '../help-command.js';
5
+ import {
6
+ createTestCommandHost,
7
+ createTestSessionRuntime,
8
+ } from '@robota-sdk/agent-framework/testing';
9
+
10
+ function createCommandSessionRuntime() {
11
+ return createTestSessionRuntime({
12
+ clearHistory: () => undefined,
13
+ compact: async () => undefined,
14
+ getContextState: () => ({
15
+ maxTokens: 100,
16
+ usedTokens: 10,
17
+ usedPercentage: 10,
18
+ remainingPercentage: 90,
19
+ }),
20
+ getPermissionMode: () => 'default',
21
+ setPermissionMode: () => undefined,
22
+ getSessionId: () => 'session_1',
23
+ getMessageCount: () => 0,
24
+ getSessionAllowedTools: () => [],
25
+ getAutoCompactThreshold: () => false,
26
+ getFullHistory: () => [],
27
+ getHistory: () => [],
28
+ });
29
+ }
30
+
31
+ function createCommandHostContext() {
32
+ const checkpoint = {
33
+ id: 'checkpoint_1',
34
+ sessionId: 'session_1',
35
+ sequence: 1,
36
+ prompt: 'prompt',
37
+ createdAt: '2026-05-03T00:00:00.000Z',
38
+ fileCount: 0,
39
+ };
40
+ return createTestCommandHost({
41
+ overrides: {
42
+ getSession: () => createCommandSessionRuntime(),
43
+ getContextState: () => ({
44
+ maxTokens: 100,
45
+ usedTokens: 10,
46
+ usedPercentage: 10,
47
+ remainingPercentage: 90,
48
+ }),
49
+ getAutoCompactThreshold: () => 0.8,
50
+ compactContext: async () => undefined,
51
+ getCwd: () => '/workspace',
52
+ listCommands: () => [
53
+ {
54
+ name: 'help',
55
+ displayName: 'Help',
56
+ description: 'Show available commands',
57
+ modelInvocable: true,
58
+ runner: 'runtime',
59
+ },
60
+ {
61
+ name: 'provider',
62
+ displayName: 'Provider Setup',
63
+ description: 'Manage provider profiles',
64
+ modelInvocable: true,
65
+ runner: 'runtime',
66
+ },
67
+ // SEC-008: `plugin` installs and enables code, so it is NOT model-invocable — the fixture says
68
+ // what the real command says rather than the value that happened to compile.
69
+ {
70
+ name: 'plugin',
71
+ displayName: 'Plugins',
72
+ description: 'Manage plugins',
73
+ modelInvocable: false,
74
+ runner: 'runtime',
75
+ },
76
+ ],
77
+ listEditCheckpoints: () => [],
78
+ restoreEditCheckpoint: async () => ({
79
+ target: checkpoint,
80
+ restoredCheckpointCount: 0,
81
+ restoredFileCount: 0,
82
+ removedCheckpointCount: 0,
83
+ }),
84
+ rollbackEditCheckpoint: async () => ({
85
+ target: checkpoint,
86
+ restoredCheckpointCount: 0,
87
+ restoredFileCount: 0,
88
+ removedCheckpointCount: 0,
89
+ }),
90
+ getUsedMemoryReferences: () => [],
91
+ recordMemoryEvent: () => undefined,
92
+ listBackgroundTasks: () => [],
93
+ readBackgroundTaskLog: async (taskId) => ({ taskId, lines: [] }),
94
+ cancelBackgroundTask: async () => undefined,
95
+ closeBackgroundTask: async () => undefined,
96
+ },
97
+ });
98
+ }
99
+
100
+ describe('createHelpCommandModule', () => {
101
+ it('contributes help command metadata and executable command', () => {
102
+ const module = createHelpCommandModule();
103
+
104
+ expect(module.name).toBe('agent-command-help');
105
+ expect(module.commandSources?.[0]?.getCommands()).toEqual([
106
+ {
107
+ name: 'help',
108
+ displayName: 'Help',
109
+ description: 'Show available commands',
110
+ source: 'help',
111
+ modelInvocable: false,
112
+ },
113
+ ]);
114
+ expect(module.systemCommands?.map((command) => command.name)).toEqual(['help']);
115
+ expect(module.systemCommands?.[0]?.lifecycle).toBe('inline');
116
+ expect(module.systemCommands?.[0]?.modelInvocable).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe('executeHelpCommand', () => {
121
+ it('renders the composed command list from the host context', () => {
122
+ const result = executeHelpCommand(createCommandHostContext(), '');
123
+
124
+ expect(result).toEqual({
125
+ success: true,
126
+ message: [
127
+ 'Available commands:',
128
+ ' Help (/help) — Show available commands',
129
+ ' Provider Setup (/provider) — Manage provider profiles',
130
+ ' Plugins (/plugin) — Manage plugins',
131
+ ].join('\n'),
132
+ });
133
+ });
134
+ });