@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,427 @@
1
+ /** Fixed or self-paced session-local repetition. */
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ import { selectAction } from '@robota-sdk/agent-core';
6
+ import { isLoopStopVerb } from '@robota-sdk/agent-framework';
7
+
8
+ import { jitterFixedLoop } from './loop-jitter.js';
9
+
10
+ import type { IUserInteraction } from '@robota-sdk/agent-core';
11
+ import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
12
+ import type { ICommandResult, TCommandInvocationSource } from '@robota-sdk/agent-interface-command';
13
+ import type { IBackgroundTaskState } from '@robota-sdk/agent-interface-execution';
14
+
15
+ const LOOP_LABEL = 'Loop: ';
16
+ const SECONDS_PER_MINUTE = 60;
17
+ const HOURS_PER_DAY = 24;
18
+ const MAX_LABEL_LENGTH = 48;
19
+ const MAX_ACTIVE_LOOPS = 3;
20
+ const LOOP_LIFETIME_MS = 7 * 24 * 60 * 60_000;
21
+ const LOOP_LIFETIME_DAYS = LOOP_LIFETIME_MS / (HOURS_PER_DAY * 60 * SECONDS_PER_MINUTE * 1_000);
22
+ const MAX_DEFAULT_PROMPT_LENGTH = 4_096;
23
+ const pendingCreates = new WeakMap<object, number>();
24
+ const TRAILING_UNITS = new Set([
25
+ 'second', 'seconds', 'minute', 'minutes', 'hour', 'hours', 'day', 'days', 's', 'm', 'h', 'd',
26
+ ]);
27
+ const USAGE =
28
+ 'Usage: /loop [<prompt>] | /loop <N><s|m|h|d> [<prompt>] | /loop <prompt> every <N> <unit> | /loop list | /loop stop <id>. Bare and interval-only forms require a host maintenance prompt.';
29
+
30
+ export interface ILoopCommandOptions {
31
+ /** Product-host-owned maintenance text, used only when the operator omits a prompt. */
32
+ defaultPrompt?: string;
33
+ /** Re-read the project/user default for each new loop; an explicit prompt never calls this. */
34
+ resolveDefaultPrompt?: () => string;
35
+ /** Host kill switch; listing and stopping existing loops remain available. */
36
+ disabled?: boolean;
37
+ }
38
+
39
+ /**
40
+ * #3288 §1: who invoked `/loop`, and how to ask them. `ui` is the injected "ask the user" port
41
+ * (absent for a headless/automation host — see `ICommandHostUserInteraction`); `source` is the
42
+ * command's own invocation source. Defaults to `{ ui: undefined, source: 'user' }` when the caller
43
+ * omits it, matching every pre-existing (headless) call site: proceed unconfirmed, the same as
44
+ * before this gate existed.
45
+ */
46
+ export interface ILoopInvocationContext {
47
+ readonly ui: IUserInteraction | undefined;
48
+ readonly source: TCommandInvocationSource;
49
+ }
50
+
51
+ const DEFAULT_INVOCATION: ILoopInvocationContext = { ui: undefined, source: 'user' };
52
+
53
+ const UNIT_MS: Record<string, number> = {
54
+ s: 1_000,
55
+ m: 60_000,
56
+ h: 3_600_000,
57
+ d: 86_400_000,
58
+ };
59
+
60
+ interface ILoopCadence {
61
+ cronExpression: string;
62
+ milliseconds: number;
63
+ description: string;
64
+ }
65
+
66
+ const CADENCES: ILoopCadence[] = [
67
+ ...divisors(SECONDS_PER_MINUTE).map((n) => ({
68
+ cronExpression: n === SECONDS_PER_MINUTE ? '0 * * * * *' : `*/${n} * * * * *`,
69
+ milliseconds: n * UNIT_MS.s!,
70
+ description: `${n}s`,
71
+ })),
72
+ ...divisors(SECONDS_PER_MINUTE)
73
+ .filter((n) => n > 1)
74
+ .map((n) => ({
75
+ cronExpression: n === SECONDS_PER_MINUTE ? '0 0 * * * *' : `0 */${n} * * * *`,
76
+ milliseconds: n * UNIT_MS.m!,
77
+ description: `${n}m`,
78
+ })),
79
+ ...divisors(HOURS_PER_DAY)
80
+ .filter((n) => n > 1)
81
+ .map((n) => ({
82
+ cronExpression: n === HOURS_PER_DAY ? '0 0 0 * * *' : `0 0 */${n} * * *`,
83
+ milliseconds: n * UNIT_MS.h!,
84
+ description: `${n}h`,
85
+ })),
86
+ ];
87
+
88
+ function divisors(maximum: number): number[] {
89
+ return Array.from({ length: maximum }, (_, index) => index + 1).filter(
90
+ (value) => maximum % value === 0,
91
+ );
92
+ }
93
+
94
+ function parseDuration(amountText: string, unit: string): number | undefined {
95
+ const amount = Number(amountText);
96
+ const multiplier = UNIT_MS[unit];
97
+ if (!Number.isSafeInteger(amount) || amount <= 0 || multiplier === undefined) return undefined;
98
+ const milliseconds = amount * multiplier;
99
+ return Number.isSafeInteger(milliseconds) && milliseconds <= UNIT_MS.d ? milliseconds : undefined;
100
+ }
101
+
102
+ function parseTrailingInterval(args: string): { instruction: string; amount: string; unit: string } | undefined {
103
+ let cursor = args.length;
104
+ while (cursor > 0 && /[a-z]/i.test(args[cursor - 1]!)) cursor -= 1;
105
+ const unit = args.slice(cursor).toLowerCase();
106
+ if (!TRAILING_UNITS.has(unit)) return undefined;
107
+ while (cursor > 0 && /\s/u.test(args[cursor - 1]!)) cursor -= 1;
108
+ const amountEnd = cursor;
109
+ while (cursor > 0 && args.charCodeAt(cursor - 1) >= 48 && args.charCodeAt(cursor - 1) <= 57) cursor -= 1;
110
+ if (cursor === amountEnd) return undefined;
111
+ const amount = args.slice(cursor, amountEnd);
112
+ const spaceAfterEvery = cursor;
113
+ while (cursor > 0 && /\s/u.test(args[cursor - 1]!)) cursor -= 1;
114
+ if (cursor === spaceAfterEvery || args.slice(cursor - 5, cursor).toLowerCase() !== 'every') return undefined;
115
+ cursor -= 5;
116
+ const spaceBeforeEvery = cursor;
117
+ while (cursor > 0 && /\s/u.test(args[cursor - 1]!)) cursor -= 1;
118
+ if (cursor === spaceBeforeEvery) return undefined;
119
+ const instruction = args.slice(0, cursor).trim();
120
+ return instruction ? { instruction, amount, unit } : undefined;
121
+ }
122
+
123
+ function parseCreate(
124
+ args: string,
125
+ defaultPrompt: string | undefined,
126
+ ): { instruction: string; requestedMs: number } | undefined {
127
+ const boundedDefault =
128
+ defaultPrompt && defaultPrompt.length <= MAX_DEFAULT_PROMPT_LENGTH
129
+ ? defaultPrompt.trim()
130
+ : undefined;
131
+ if (args === '') return undefined;
132
+ const intervalOnly = /^(\d+)(s|m|h|d)$/i.exec(args);
133
+ if (intervalOnly && boundedDefault) {
134
+ const requestedMs = parseDuration(intervalOnly[1]!, intervalOnly[2]!.toLowerCase());
135
+ return requestedMs === undefined ? undefined : { instruction: boundedDefault, requestedMs };
136
+ }
137
+ // One required space, then the rest verbatim (trimmed below): `\s+` before `[\s\S]+` split the
138
+ // same spaces two ways, which CodeQL reads as polynomial backtracking.
139
+ const leading = /^(\d+)(s|m|h|d)\s([\s\S]+)$/i.exec(args);
140
+ if (leading) {
141
+ const instruction = leading[3]!.trim();
142
+ const requestedMs = parseDuration(leading[1]!, leading[2]!.toLowerCase());
143
+ if (instruction && requestedMs !== undefined && !parseTrailingInterval(instruction)) {
144
+ return { instruction, requestedMs };
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ const trailing = parseTrailingInterval(args);
150
+ if (!trailing) return undefined;
151
+ const requestedMs = parseDuration(trailing.amount, trailing.unit[0]!);
152
+ return requestedMs !== undefined ? { instruction: trailing.instruction, requestedMs } : undefined;
153
+ }
154
+
155
+ function chooseCadence(requestedMs: number): ILoopCadence {
156
+ // Calendar-aligned cron steps cannot express every duration. Choose the first step that is
157
+ // never more frequent than requested, and report the normalization to the caller.
158
+ return CADENCES.find((cadence) => cadence.milliseconds >= requestedMs)!;
159
+ }
160
+
161
+ function activeLoops(host: Pick<IAgentJobHostContext, 'listSchedules'>): IBackgroundTaskState[] {
162
+ return host
163
+ .listSchedules()
164
+ .filter(
165
+ (task) =>
166
+ task.metadata?.['sessionLoop'] === true &&
167
+ task.metadata?.['sessionLoopSelfPaced'] !== true &&
168
+ task.status !== 'cancelled' &&
169
+ task.status !== 'completed' &&
170
+ task.status !== 'failed',
171
+ );
172
+ }
173
+
174
+ function activeSelfPacedLoops(host: Pick<IAgentJobHostContext, 'listSelfPacedLoops'>) {
175
+ return (host.listSelfPacedLoops?.() ?? []).filter(
176
+ (loop) => loop.phase !== 'stopped' && loop.phase !== 'expired',
177
+ );
178
+ }
179
+
180
+ function loopIdOf(task: IBackgroundTaskState): string {
181
+ const id = task.metadata?.['sessionLoopId'];
182
+ // Loops created before stable ids were introduced keep their runtime id as the stop handle.
183
+ return typeof id === 'string' && id.length > 0 ? id : task.id;
184
+ }
185
+
186
+ function describeActiveLoops(
187
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
188
+ ): string {
189
+ const loops = activeLoops(host);
190
+ const selfPaced = activeSelfPacedLoops(host);
191
+ if (loops.length + selfPaced.length === 0) return 'No other active loops.';
192
+ return [
193
+ ...loops.map((task) => `- ${loopIdOf(task)} [${task.status}] ${task.label}`),
194
+ ...selfPaced.map((loop) => `- ${loop.loopId} [${loop.phase}] Loop: ${loop.instruction}`),
195
+ ].join('\n');
196
+ }
197
+
198
+ /** The plain refusal for a default-prompt loop with no one to ask — never a silent, unconfirmed start. */
199
+ const HEADLESS_REFUSAL =
200
+ 'A loop with the default maintenance prompt needs someone to confirm it. Start it from an ' +
201
+ 'interactive session, or give the loop its own prompt: /loop <prompt>';
202
+
203
+ /**
204
+ * #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or an
205
+ * interval-only `/loop <N><s|m|h|d>` — keeps working on its own for up to 7 days: a standing
206
+ * commitment the operator never typed, gated the same way regardless of which form reached it.
207
+ * Returns the refusal/decline result to return in place of creating the loop, or `undefined` to
208
+ * proceed. A `source: 'model'` call is refused outright and never asks — the model must give its own
209
+ * prompt, or name `/loop` to the user (AGENTS.md: trust and permission-widening actions stay
210
+ * user-only, and a refusal names the command to suggest). A headless/automation host (no ask port —
211
+ * print mode, a scheduled run) is refused too, the same reason: a loop that can run for a week must
212
+ * not start without a person saying yes, and there is no one here to ask.
213
+ */
214
+ async function confirmDefaultLoopStart(
215
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
216
+ invocation: ILoopInvocationContext,
217
+ instruction: string,
218
+ ): Promise<ICommandResult | undefined> {
219
+ if (invocation.source === 'model') {
220
+ return {
221
+ success: false,
222
+ message:
223
+ 'Starting a loop with its built-in prompt needs a person to confirm it — ' +
224
+ 'ask the user to run /loop, or give your own prompt with /loop <prompt>.',
225
+ };
226
+ }
227
+ const { ui } = invocation;
228
+ if (!ui) return { success: false, message: HEADLESS_REFUSAL };
229
+ const firstLine = instruction.split('\n')[0]!.trim() || instruction;
230
+ const response = await ui.ask(
231
+ selectAction(
232
+ 'loop-start-default',
233
+ `Start a loop that keeps working on its own for up to ${LOOP_LIFETIME_DAYS} days? It will: ${firstLine}`,
234
+ [
235
+ { value: 'start', label: 'Start' },
236
+ { value: 'cancel', label: 'Cancel' },
237
+ ],
238
+ { description: describeActiveLoops(host) },
239
+ ),
240
+ );
241
+ if (response.type === 'answer' && response.values.includes('start')) return undefined;
242
+ return { success: true, message: 'Loop not started.' };
243
+ }
244
+
245
+ export async function executeLoopCommand(
246
+ host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops' | 'stopSelfPacedLoop'>,
247
+ cancelBackgroundTask: (taskId: string, reason: string) => Promise<void>,
248
+ args: string,
249
+ options: ILoopCommandOptions = {},
250
+ invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
251
+ ): Promise<ICommandResult> {
252
+ const trimmed = args.trim();
253
+ if (trimmed === 'list') return listLoops(host, options);
254
+ if (isLoopStopVerb(trimmed)) return stopLoop(host, cancelBackgroundTask, trimmed);
255
+
256
+ if (options.disabled) {
257
+ return { success: false, message: 'Session loops are disabled by the host.' };
258
+ }
259
+ return createLoop(host, trimmed, options, invocation);
260
+ }
261
+
262
+ function listLoops(
263
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
264
+ options: ILoopCommandOptions,
265
+ ): ICommandResult {
266
+ const loops = activeLoops(host);
267
+ const selfPaced = activeSelfPacedLoops(host);
268
+ const header = options.disabled ? 'Session loops are disabled by the host.\n' : '';
269
+ return {
270
+ success: true,
271
+ message:
272
+ header +
273
+ (loops.length + selfPaced.length === 0
274
+ ? 'No active loops.'
275
+ : [
276
+ ...loops.map((task) => `- ${loopIdOf(task)} [${task.status}] ${task.label}`),
277
+ ...selfPaced.map((loop) =>
278
+ `- ${loop.loopId} [${loop.phase}] Loop: ${loop.instruction}` +
279
+ (loop.phase === 'waiting' && loop.delaySeconds !== undefined && loop.reason
280
+ ? ` — next ${loop.delaySeconds}s: ${loop.reason}`
281
+ : ''),
282
+ ),
283
+ ].join('\n')),
284
+ data: { count: loops.length + selfPaced.length },
285
+ };
286
+ }
287
+
288
+ async function stopLoop(
289
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops' | 'stopSelfPacedLoop'>,
290
+ cancelBackgroundTask: (taskId: string, reason: string) => Promise<void>,
291
+ args: string,
292
+ ): Promise<ICommandResult> {
293
+ const match = /^stop\s+(\S+)$/.exec(args);
294
+ if (!match) return { success: false, message: USAGE };
295
+ const selfPaced = activeSelfPacedLoops(host).find((loop) => loop.loopId === match[1]);
296
+ if (selfPaced) {
297
+ if (!host.stopSelfPacedLoop) return { success: false, message: 'This host cannot stop self-paced loops.' };
298
+ await host.stopSelfPacedLoop(selfPaced.loopId, 'Loop stopped by user');
299
+ return {
300
+ success: true,
301
+ message: `Loop stopped: ${selfPaced.loopId}. An already-running turn may finish.`,
302
+ data: { loopId: selfPaced.loopId },
303
+ };
304
+ }
305
+ const task = activeLoops(host).find((candidate) => loopIdOf(candidate) === match[1]);
306
+ if (!task) return { success: false, message: `Active loop not found: ${match[1]}` };
307
+ await cancelBackgroundTask(task.id, 'Loop stopped by user');
308
+ return {
309
+ success: true,
310
+ message: `Loop stopped: ${loopIdOf(task)}. An already-running turn may finish.`,
311
+ data: { loopId: loopIdOf(task), taskId: task.id },
312
+ };
313
+ }
314
+
315
+ async function createLoop(
316
+ host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops'>,
317
+ args: string,
318
+ options: ILoopCommandOptions,
319
+ invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
320
+ ): Promise<ICommandResult> {
321
+ const useDefaultPrompt = args === '' || /^\d+(s|m|h|d)$/i.test(args);
322
+ let defaultPrompt = options.defaultPrompt;
323
+ if (useDefaultPrompt && options.resolveDefaultPrompt) {
324
+ try {
325
+ defaultPrompt = options.resolveDefaultPrompt();
326
+ } catch (error) {
327
+ return { success: false, message: `Default loop prompt could not be loaded: ${error instanceof Error ? error.message : String(error)}` };
328
+ }
329
+ }
330
+ const parsed = parseCreate(args, defaultPrompt);
331
+ if (!parsed) {
332
+ if (/^\d+[a-z](?:\s|$)/i.test(args) || parseTrailingInterval(args)) {
333
+ return { success: false, message: USAGE };
334
+ }
335
+ const instruction = (args || defaultPrompt || '').trim();
336
+ if (!instruction || (args === '' && instruction.length > MAX_DEFAULT_PROMPT_LENGTH)) {
337
+ return { success: false, message: USAGE };
338
+ }
339
+ if (!host.createSelfPacedLoop) {
340
+ return { success: false, message: 'Self-paced loops require a persistent interactive session.' };
341
+ }
342
+ if (useDefaultPrompt) {
343
+ const declined = await confirmDefaultLoopStart(host, invocation, instruction);
344
+ if (declined) return declined;
345
+ }
346
+ const pending = pendingCreates.get(host) ?? 0;
347
+ if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
348
+ return { success: false, message: `At most ${MAX_ACTIVE_LOOPS} active loops are allowed. Stop one before creating another.` };
349
+ }
350
+ pendingCreates.set(host, pending + 1);
351
+ try {
352
+ const loop = useDefaultPrompt
353
+ ? await host.createSelfPacedLoop(instruction, { useDefaultPrompt: true })
354
+ : await host.createSelfPacedLoop(instruction);
355
+ return {
356
+ success: true,
357
+ message: `Self-paced loop ${loop.loopId} started. It will choose a 1–60 minute delay after each iteration and expires ${loop.expiresAt}. Stop with /loop stop ${loop.loopId}.`,
358
+ data: { loopId: loop.loopId, expiresAt: loop.expiresAt },
359
+ };
360
+ } finally {
361
+ const remaining = (pendingCreates.get(host) ?? 1) - 1;
362
+ if (remaining === 0) pendingCreates.delete(host);
363
+ else pendingCreates.set(host, remaining);
364
+ }
365
+ }
366
+ // #3288 §1: an interval-only form (`/loop 5m`) that fell back to the host's default prompt is
367
+ // gated exactly like the bare form — it is the SAME standing commitment (up to 7 days, unattended),
368
+ // only on a fixed cadence instead of self-paced. An explicit prompt (`/loop 5m check the build`)
369
+ // never reaches here with `useDefaultPrompt` true, so it is never gated.
370
+ if (useDefaultPrompt) {
371
+ const declined = await confirmDefaultLoopStart(host, invocation, parsed.instruction);
372
+ if (declined) return declined;
373
+ }
374
+ const pending = pendingCreates.get(host) ?? 0;
375
+ if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
376
+ return {
377
+ success: false,
378
+ message: `At most ${MAX_ACTIVE_LOOPS} active loops are allowed. Stop one before creating another.`,
379
+ };
380
+ }
381
+ const cadence = chooseCadence(parsed.requestedMs);
382
+ const label = `${LOOP_LABEL}${parsed.instruction.slice(0, MAX_LABEL_LENGTH)}`;
383
+ const loopId = `loop_${randomUUID()}`;
384
+ const jitter = jitterFixedLoop(loopId, cadence);
385
+ const nowMs = Date.now();
386
+ const firstAllowedAt = new Date(nowMs + parsed.requestedMs).toISOString();
387
+ const expiresAt = new Date(nowMs + LOOP_LIFETIME_MS).toISOString();
388
+ pendingCreates.set(host, pending + 1);
389
+ let task: IBackgroundTaskState;
390
+ try {
391
+ task = await host.spawnScheduledWake({
392
+ label,
393
+ cronExpression: jitter.cronExpression,
394
+ agentInstruction: parsed.instruction,
395
+ sessionLoop: true,
396
+ sessionLoopId: loopId,
397
+ ...(useDefaultPrompt ? { sessionLoopDefaultPrompt: true } : {}),
398
+ sessionLoopFirstAllowedAt: firstAllowedAt,
399
+ sessionLoopExpiresAt: expiresAt,
400
+ });
401
+ } finally {
402
+ const remaining = (pendingCreates.get(host) ?? 1) - 1;
403
+ if (remaining === 0) pendingCreates.delete(host);
404
+ else pendingCreates.set(host, remaining);
405
+ }
406
+ const rounded = cadence.milliseconds !== parsed.requestedMs ? ' (rounded up)' : '';
407
+ // `spawnScheduledWake` always spawns a scheduled-kind task; the kind check lets TypeScript
408
+ // narrow to the member that carries `nextFireAt` (#2079).
409
+ const nextFire =
410
+ task.kind === 'scheduled' && task.nextFireAt && task.nextFireAt >= firstAllowedAt
411
+ ? ` Next fire: ${task.nextFireAt}.`
412
+ : '';
413
+ return {
414
+ success: true,
415
+ message: `Loop ${loopId} uses a ${cadence.description}${rounded} local-clock step with stable offset +${jitter.jitterSeconds}s; elapsed gaps can change with daylight saving. First eligible at or after ${firstAllowedAt}.${nextFire} Expires: ${expiresAt}. Stop with /loop stop ${loopId}.`,
416
+ data: {
417
+ loopId,
418
+ taskId: task.id,
419
+ requestedMs: parsed.requestedMs,
420
+ cadenceLabel: cadence.description,
421
+ cronExpression: jitter.cronExpression,
422
+ jitterSeconds: jitter.jitterSeconds,
423
+ firstAllowedAt,
424
+ expiresAt,
425
+ },
426
+ };
427
+ }
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /** Fixed loops are spread across a stable wall-clock slot; self-paced delays are not jittered. */
4
+ export function jitterFixedLoop(
5
+ loopId: string,
6
+ cadence: { description: string; milliseconds: number; cronExpression: string },
7
+ ): { cronExpression: string; jitterSeconds: number } {
8
+ const maxSeconds = Math.floor(Math.min(30 * 60, cadence.milliseconds / 2_000));
9
+ const hash = createHash('sha256').update(loopId).digest().readUInt32BE(0);
10
+ const jitterSeconds = hash % (maxSeconds + 1);
11
+ if (jitterSeconds === 0) return { cronExpression: cadence.cronExpression, jitterSeconds };
12
+
13
+ const match = /^(\d+)(s|m|h)$/.exec(cadence.description);
14
+ if (!match) throw new Error('Unsupported fixed-loop cadence for jitter.');
15
+ const step = Number(match[1]);
16
+ const unit = match[2];
17
+ const second = jitterSeconds % 60;
18
+ const minute = Math.floor(jitterSeconds / 60);
19
+ if (unit === 's') {
20
+ return {
21
+ cronExpression: step === 60
22
+ ? `${jitterSeconds} * * * * *`
23
+ : `${jitterSeconds}-59/${step} * * * * *`,
24
+ jitterSeconds,
25
+ };
26
+ }
27
+ if (unit === 'm') {
28
+ return {
29
+ cronExpression: step === 60
30
+ ? `${second} ${minute} * * * *`
31
+ : `${second} ${minute}-59/${step} * * * *`,
32
+ jitterSeconds,
33
+ };
34
+ }
35
+ return {
36
+ cronExpression: step === 24
37
+ ? `${second} ${minute} 0 * * *`
38
+ : `${second} ${minute} */${step} * * *`,
39
+ jitterSeconds,
40
+ };
41
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * FLOW-005: the `/schedule` + `/monitor` command module — surfaces agent-wake scheduling
3
+ * and process monitoring to users (and the model as tools).
4
+ */
5
+
6
+ import { executeLoopCommand } from './loop-command.js';
7
+ import { executeMonitorCommand, executeScheduleCommand } from './schedule-command.js';
8
+
9
+ import type { ILoopCommandOptions } from './loop-command.js';
10
+ import type {
11
+ IAgentJobHostContext,
12
+ ICommandHostAgentJobs,
13
+ ICommandModule,
14
+ ISystemCommand,
15
+ } from '@robota-sdk/agent-framework';
16
+ import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
17
+
18
+ function getAgentHostContext(context: ICommandHostAgentJobs): IAgentJobHostContext {
19
+ const cap = context.getAgentJobCapability();
20
+ if (!cap) throw new Error('Scheduling requires an active agent runtime.');
21
+ return cap;
22
+ }
23
+
24
+ const SCHEDULE_DESCRIPTION =
25
+ 'Schedule the agent to wake on a timer, and manage schedules (list / pause / resume / edit).';
26
+ const SCHEDULE_ARGUMENT_HINT =
27
+ 'in <N><s|m|h|d> <instruction> | cron "<expr>" <instruction> | list | pause <id> | resume <id> | edit <id> <spec>';
28
+ const MONITOR_DESCRIPTION =
29
+ 'Watch a process’s output and wake the agent when a line matches a pattern.';
30
+ const MONITOR_ARGUMENT_HINT = '"<command>" "<pattern>" <instruction>';
31
+
32
+ // Model-invocable, all three: waking itself later, watching a process and repeating a prompt are the
33
+ // model's own pacing. `/monitor` starts a process, so the model's command is decided by the shell
34
+ // tool's own gate (see the system command below) — never a way around a Bash/Shell rule.
35
+ const SCHEDULE_MODEL_DESCRIPTION =
36
+ 'Wake yourself later with an instruction. Use it when work must wait (a deploy, a long build, a ' +
37
+ 'reply) instead of polling: `in <N><s|m|h|d> <instruction>` or `cron "<expr>" <instruction>`; ' +
38
+ '`list`, `pause`, `resume` and `edit` manage existing schedules. Returns the schedule id, or the ' +
39
+ 'schedule list.';
40
+ const MONITOR_MODEL_DESCRIPTION =
41
+ 'Run a command in the background and wake yourself when a line of its output matches a pattern. ' +
42
+ 'Use it to wait for a server to start, a test to fail or a log line to appear instead of polling. ' +
43
+ 'The command is allowed, refused or asked about by the same permission rules as a shell tool ' +
44
+ 'call. Returns the monitor task id, or a refusal naming what the user must allow.';
45
+ const LOOP_MODEL_DESCRIPTION =
46
+ 'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> <prompt>`) or self-paced (`<prompt>`: each ' +
47
+ 'iteration chooses its next delay or stops). Use it when the user asks for recurring work in ' +
48
+ 'this session, and always give your own prompt: any form that would fall back to the host\'s ' +
49
+ 'default maintenance prompt — bare `/loop` or an interval alone (`<N><s|m|h|d>`) — needs a ' +
50
+ 'person to confirm it and is always refused for you, whether or not one is attached. `list` ' +
51
+ 'shows active loops; `stop <id>` ends one, even mid-turn. Returns the loop id and its next fire ' +
52
+ 'time, the loop list, or a refusal naming the command to suggest.';
53
+
54
+ export function createScheduleCommandEntry(): ICommand {
55
+ return {
56
+ name: 'schedule',
57
+ displayName: 'Schedule Wake',
58
+ description: SCHEDULE_DESCRIPTION,
59
+ modelDescription: SCHEDULE_MODEL_DESCRIPTION,
60
+ source: 'schedule',
61
+ argumentHint: SCHEDULE_ARGUMENT_HINT,
62
+ modelInvocable: true,
63
+ };
64
+ }
65
+
66
+ export function createMonitorCommandEntry(): ICommand {
67
+ return {
68
+ name: 'monitor',
69
+ displayName: 'Monitor Process',
70
+ description: MONITOR_DESCRIPTION,
71
+ modelDescription: MONITOR_MODEL_DESCRIPTION,
72
+ source: 'schedule',
73
+ argumentHint: MONITOR_ARGUMENT_HINT,
74
+ modelInvocable: true,
75
+ };
76
+ }
77
+
78
+ export function createLoopCommandEntry(): ICommand {
79
+ return {
80
+ name: 'loop',
81
+ displayName: 'Repeat Prompt',
82
+ description:
83
+ 'Repeat a prompt on a fixed cadence or let each iteration choose its next delay; list or stop active loops.',
84
+ modelDescription: LOOP_MODEL_DESCRIPTION,
85
+ source: 'schedule',
86
+ argumentHint: '[prompt] | <N><s|m|h|d> [prompt] | <prompt> every <N> <unit> | list | stop <id>',
87
+ modelInvocable: true,
88
+ subcommands: [
89
+ { name: 'list', description: 'List active loops', source: 'schedule' },
90
+ {
91
+ name: 'stop',
92
+ description: 'Stop an active loop',
93
+ source: 'schedule',
94
+ argumentHint: '<id>',
95
+ },
96
+ ],
97
+ };
98
+ }
99
+
100
+ function createScheduleSystemCommand(): ISystemCommand {
101
+ const entry = createScheduleCommandEntry();
102
+ return {
103
+ name: entry.name,
104
+ displayName: entry.displayName,
105
+ description: entry.description,
106
+ ...(entry.modelDescription !== undefined ? { modelDescription: entry.modelDescription } : {}),
107
+ requiresPermission: false,
108
+ userInvocable: true,
109
+ modelInvocable: true,
110
+ argumentHint: entry.argumentHint,
111
+ lifecycle: 'inline',
112
+ execute: (context, args): Promise<ICommandResult> =>
113
+ executeScheduleCommand(getAgentHostContext(context), args),
114
+ };
115
+ }
116
+
117
+ function createMonitorSystemCommand(): ISystemCommand {
118
+ const entry = createMonitorCommandEntry();
119
+ return {
120
+ name: entry.name,
121
+ displayName: entry.displayName,
122
+ description: entry.description,
123
+ ...(entry.modelDescription !== undefined ? { modelDescription: entry.modelDescription } : {}),
124
+ // It starts a process, so it is never treated as read-only (a remote read-only policy refuses
125
+ // it). The MODEL's call is not asked about by the command's name: the host decides the
126
+ // monitored command as a shell tool call (hooks, Bash/Shell rules, the mode and the prompt, never
127
+ // the sandbox's auto-approval), so one "always allow" of `/monitor` never approves another command.
128
+ requiresPermission: true,
129
+ modelRequiresPermission: false,
130
+ userInvocable: true,
131
+ modelInvocable: true,
132
+ argumentHint: entry.argumentHint,
133
+ lifecycle: 'inline',
134
+ execute: (context, args): Promise<ICommandResult> =>
135
+ executeMonitorCommand(getAgentHostContext(context), args),
136
+ };
137
+ }
138
+
139
+ function createLoopSystemCommand(options: ILoopCommandOptions): ISystemCommand {
140
+ const entry = createLoopCommandEntry();
141
+ return {
142
+ name: entry.name,
143
+ displayName: entry.displayName,
144
+ description: entry.description,
145
+ ...(entry.modelDescription !== undefined ? { modelDescription: entry.modelDescription } : {}),
146
+ // A static conservative classification keeps remote read-only policies from admitting
147
+ // create/stop through this mixed read/write command; list is gated too.
148
+ requiresPermission: true,
149
+ userInvocable: true,
150
+ modelInvocable: true,
151
+ argumentHint: entry.argumentHint,
152
+ subcommands: entry.subcommands,
153
+ lifecycle: 'inline',
154
+ execute: (context, args): Promise<ICommandResult> =>
155
+ executeLoopCommand(
156
+ getAgentHostContext(context),
157
+ (taskId, reason) => context.cancelBackgroundTask(taskId, reason),
158
+ args,
159
+ options,
160
+ { ui: context.getUserInteraction(), source: context.getCommandInvocationSource() },
161
+ ),
162
+ };
163
+ }
164
+
165
+ export class ScheduleCommandSource implements ICommandSource {
166
+ readonly name = 'schedule';
167
+
168
+ getCommands(): ICommand[] {
169
+ return [createScheduleCommandEntry(), createMonitorCommandEntry(), createLoopCommandEntry()];
170
+ }
171
+ }
172
+
173
+ export function createScheduleCommandModule(options: ILoopCommandOptions = {}): ICommandModule {
174
+ return {
175
+ name: 'agent-command-schedule',
176
+ commandSources: [new ScheduleCommandSource()],
177
+ systemCommands: [
178
+ createScheduleSystemCommand(),
179
+ createMonitorSystemCommand(),
180
+ createLoopSystemCommand(options),
181
+ ],
182
+ sessionRequirements: ['runtime'],
183
+ };
184
+ }