@hellcoder/companion 0.96.0

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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,957 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import type { CronJob } from "./cron-types.js";
5
+
6
+ // Mock homedir so cron-store writes to a temp directory
7
+ const mockHomedir = vi.hoisted(() => {
8
+ let dir = "";
9
+ return {
10
+ get: () => dir,
11
+ set: (d: string) => { dir = d; },
12
+ };
13
+ });
14
+
15
+ vi.mock("node:os", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("node:os")>();
17
+ return { ...actual, homedir: () => mockHomedir.get() };
18
+ });
19
+
20
+ // Mock session-names to avoid side effects
21
+ vi.mock("./session-names.js", () => ({
22
+ setName: vi.fn(),
23
+ getName: vi.fn(),
24
+ }));
25
+
26
+ let tempDir: string;
27
+ let cronStore: typeof import("./cron-store.js");
28
+ let CronSchedulerClass: typeof import("./cron-scheduler.js").CronScheduler;
29
+
30
+ // Minimal mock launcher
31
+ function createMockLauncher() {
32
+ const sessions = new Map<string, { sessionId: string; state: string; exitCode?: number | null; cronJobId?: string; cronJobName?: string }>();
33
+ let launchCount = 0;
34
+ return {
35
+ launch: vi.fn((options: Record<string, unknown>) => {
36
+ const sessionId = `mock-session-${++launchCount}`;
37
+ const info = {
38
+ sessionId,
39
+ state: "connected", // immediately connected for testing
40
+ model: options.model as string,
41
+ permissionMode: options.permissionMode as string,
42
+ cwd: (options.cwd as string) || "/tmp",
43
+ createdAt: Date.now(),
44
+ backendType: options.backendType as string,
45
+ };
46
+ sessions.set(sessionId, info);
47
+ return info;
48
+ }),
49
+ getSession: vi.fn((id: string) => sessions.get(id)),
50
+ isAlive: vi.fn((id: string) => {
51
+ const s = sessions.get(id);
52
+ return !!s && s.state !== "exited";
53
+ }),
54
+ sessions,
55
+ };
56
+ }
57
+
58
+ // Minimal mock wsBridge
59
+ function createMockBridge() {
60
+ return {
61
+ injectUserMessage: vi.fn(),
62
+ };
63
+ }
64
+
65
+ function makeJob(overrides: Partial<CronJob> = {}): CronJob {
66
+ return {
67
+ id: "test-job",
68
+ name: "Test Job",
69
+ prompt: "Do something",
70
+ schedule: "0 8 * * *",
71
+ recurring: true,
72
+ backendType: "claude",
73
+ model: "claude-sonnet-4-6",
74
+ cwd: "/tmp/test",
75
+ enabled: true,
76
+ permissionMode: "bypassPermissions",
77
+ createdAt: Date.now(),
78
+ updatedAt: Date.now(),
79
+ consecutiveFailures: 0,
80
+ totalRuns: 0,
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ beforeEach(async () => {
86
+ tempDir = mkdtempSync(join(tmpdir(), "cron-sched-test-"));
87
+ mockHomedir.set(tempDir);
88
+ vi.resetModules();
89
+ cronStore = await import("./cron-store.js");
90
+ const mod = await import("./cron-scheduler.js");
91
+ CronSchedulerClass = mod.CronScheduler;
92
+ });
93
+
94
+ afterEach(() => {
95
+ rmSync(tempDir, { recursive: true, force: true });
96
+ });
97
+
98
+ // ===========================================================================
99
+ // Scheduling
100
+ // ===========================================================================
101
+ describe("scheduling", () => {
102
+ it("schedules a recurring job and tracks the timer", () => {
103
+ const launcher = createMockLauncher();
104
+ const bridge = createMockBridge();
105
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
106
+
107
+ const job = makeJob();
108
+ scheduler.scheduleJob(job);
109
+
110
+ // Should have a next run time
111
+ const nextRun = scheduler.getNextRunTime("test-job");
112
+ expect(nextRun).toBeInstanceOf(Date);
113
+ expect(nextRun!.getTime()).toBeGreaterThan(Date.now());
114
+
115
+ scheduler.destroy();
116
+ });
117
+
118
+ it("stops a job timer", () => {
119
+ const launcher = createMockLauncher();
120
+ const bridge = createMockBridge();
121
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
122
+
123
+ const job = makeJob();
124
+ scheduler.scheduleJob(job);
125
+ expect(scheduler.getNextRunTime("test-job")).not.toBeNull();
126
+
127
+ scheduler.stopJob("test-job");
128
+ expect(scheduler.getNextRunTime("test-job")).toBeNull();
129
+
130
+ scheduler.destroy();
131
+ });
132
+
133
+ it("skips disabled jobs", () => {
134
+ const launcher = createMockLauncher();
135
+ const bridge = createMockBridge();
136
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
137
+
138
+ const job = makeJob({ enabled: false });
139
+ scheduler.scheduleJob(job);
140
+
141
+ expect(scheduler.getNextRunTime("test-job")).toBeNull();
142
+ scheduler.destroy();
143
+ });
144
+
145
+ it("skips one-shot jobs in the past", () => {
146
+ const launcher = createMockLauncher();
147
+ const bridge = createMockBridge();
148
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
149
+
150
+ const pastDate = new Date(Date.now() - 60_000).toISOString();
151
+ const job = makeJob({ recurring: false, schedule: pastDate });
152
+ scheduler.scheduleJob(job);
153
+
154
+ expect(scheduler.getNextRunTime("test-job")).toBeNull();
155
+ scheduler.destroy();
156
+ });
157
+
158
+ it("startAll loads and schedules enabled jobs from store", () => {
159
+ // Create jobs in store
160
+ cronStore.createJob({
161
+ name: "Enabled Job",
162
+ prompt: "Do it",
163
+ schedule: "0 9 * * *",
164
+ recurring: true,
165
+ backendType: "claude",
166
+ model: "claude-sonnet-4-6",
167
+ cwd: "/tmp",
168
+ enabled: true,
169
+ permissionMode: "bypassPermissions",
170
+ });
171
+ cronStore.createJob({
172
+ name: "Disabled Job",
173
+ prompt: "Skip me",
174
+ schedule: "0 10 * * *",
175
+ recurring: true,
176
+ backendType: "codex",
177
+ model: "o3",
178
+ cwd: "/tmp",
179
+ enabled: false,
180
+ permissionMode: "bypassPermissions",
181
+ });
182
+
183
+ const launcher = createMockLauncher();
184
+ const bridge = createMockBridge();
185
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
186
+ scheduler.startAll();
187
+
188
+ expect(scheduler.getNextRunTime("enabled-job")).not.toBeNull();
189
+ expect(scheduler.getNextRunTime("disabled-job")).toBeNull();
190
+
191
+ scheduler.destroy();
192
+ });
193
+ });
194
+
195
+ // ===========================================================================
196
+ // Execution
197
+ // ===========================================================================
198
+ describe("execution", () => {
199
+ it("launches a session and injects the prompt", async () => {
200
+ const launcher = createMockLauncher();
201
+ const bridge = createMockBridge();
202
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
203
+
204
+ // Create job in store so executeJob can read it
205
+ cronStore.createJob({
206
+ name: "Run Me",
207
+ prompt: "Check PRs",
208
+ schedule: "0 8 * * *",
209
+ recurring: true,
210
+ backendType: "claude",
211
+ model: "claude-sonnet-4-6",
212
+ cwd: "/tmp/repo",
213
+ enabled: true,
214
+ permissionMode: "bypassPermissions",
215
+ });
216
+
217
+ await scheduler.executeJob("run-me");
218
+
219
+ // Verify launcher was called with correct params
220
+ expect(launcher.launch).toHaveBeenCalledWith(
221
+ expect.objectContaining({
222
+ model: "claude-sonnet-4-6",
223
+ permissionMode: "bypassPermissions",
224
+ cwd: "/tmp/repo",
225
+ backendType: "claude",
226
+ }),
227
+ );
228
+
229
+ // Verify prompt was injected with cron prefix
230
+ expect(bridge.injectUserMessage).toHaveBeenCalledWith(
231
+ expect.stringMatching(/^mock-session-/),
232
+ expect.stringContaining("[cron:run-me Run Me]"),
233
+ );
234
+ expect(bridge.injectUserMessage).toHaveBeenCalledWith(
235
+ expect.any(String),
236
+ expect.stringContaining("Check PRs"),
237
+ );
238
+
239
+ // Verify job tracking was updated
240
+ const updated = cronStore.getJob("run-me");
241
+ expect(updated!.totalRuns).toBe(1);
242
+ expect(updated!.consecutiveFailures).toBe(0);
243
+ expect(updated!.lastRunAt).toBeGreaterThan(0);
244
+ expect(updated!.lastSessionId).toMatch(/^mock-session-/);
245
+
246
+ // Verify execution history
247
+ const execs = scheduler.getExecutions("run-me");
248
+ expect(execs).toHaveLength(1);
249
+ expect(execs[0].success).toBe(true);
250
+
251
+ scheduler.destroy();
252
+ });
253
+
254
+ it("skips execution when previous run is still alive", async () => {
255
+ const launcher = createMockLauncher();
256
+ const bridge = createMockBridge();
257
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
258
+
259
+ // Create job and set lastSessionId to a still-alive session
260
+ cronStore.createJob({
261
+ name: "Overlap Test",
262
+ prompt: "Do it",
263
+ schedule: "* * * * *",
264
+ recurring: true,
265
+ backendType: "claude",
266
+ model: "claude-sonnet-4-6",
267
+ cwd: "/tmp",
268
+ enabled: true,
269
+ permissionMode: "bypassPermissions",
270
+ });
271
+
272
+ // Run once to get a lastSessionId
273
+ await scheduler.executeJob("overlap-test");
274
+ const firstSessionId = cronStore.getJob("overlap-test")!.lastSessionId!;
275
+ expect(launcher.isAlive(firstSessionId)).toBe(true);
276
+
277
+ // Try to run again — should skip
278
+ launcher.launch.mockClear();
279
+ await scheduler.executeJob("overlap-test");
280
+ expect(launcher.launch).not.toHaveBeenCalled();
281
+
282
+ scheduler.destroy();
283
+ });
284
+
285
+ it("tracks failures and auto-disables after 5 consecutive failures", async () => {
286
+ const launcher = createMockLauncher();
287
+ const bridge = createMockBridge();
288
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
289
+
290
+ // Make launcher return a session that immediately exits
291
+ launcher.launch.mockImplementation((options: Record<string, unknown>) => {
292
+ const info = {
293
+ sessionId: `fail-${Date.now()}`,
294
+ state: "exited",
295
+ exitCode: 1,
296
+ model: (options.model as string) || "",
297
+ permissionMode: (options.permissionMode as string) || "",
298
+ cwd: (options.cwd as string) || "/tmp",
299
+ createdAt: Date.now(),
300
+ backendType: (options.backendType as string) || "claude",
301
+ };
302
+ launcher.sessions.set(info.sessionId, info);
303
+ return info;
304
+ });
305
+
306
+ cronStore.createJob({
307
+ name: "Failing Job",
308
+ prompt: "Will fail",
309
+ schedule: "0 8 * * *",
310
+ recurring: true,
311
+ backendType: "claude",
312
+ model: "claude-sonnet-4-6",
313
+ cwd: "/tmp",
314
+ enabled: true,
315
+ permissionMode: "bypassPermissions",
316
+ });
317
+
318
+ // Execute 5 times (each should fail because the CLI exits immediately)
319
+ for (let i = 0; i < 5; i++) {
320
+ await scheduler.executeJob("failing-job");
321
+ }
322
+
323
+ const job = cronStore.getJob("failing-job");
324
+ expect(job!.enabled).toBe(false);
325
+ expect(job!.consecutiveFailures).toBe(5);
326
+
327
+ scheduler.destroy();
328
+ });
329
+
330
+ it("skips disabled jobs during execution", async () => {
331
+ const launcher = createMockLauncher();
332
+ const bridge = createMockBridge();
333
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
334
+
335
+ cronStore.createJob({
336
+ name: "Disabled",
337
+ prompt: "Skip",
338
+ schedule: "0 8 * * *",
339
+ recurring: true,
340
+ backendType: "claude",
341
+ model: "claude-sonnet-4-6",
342
+ cwd: "/tmp",
343
+ enabled: false,
344
+ permissionMode: "bypassPermissions",
345
+ });
346
+
347
+ await scheduler.executeJob("disabled");
348
+ expect(launcher.launch).not.toHaveBeenCalled();
349
+
350
+ scheduler.destroy();
351
+ });
352
+
353
+ it("passes codexSandbox=danger-full-access for Codex jobs with bypassPermissions", async () => {
354
+ // Codex cron jobs must launch with explicit full autonomy params:
355
+ // codexSandbox="danger-full-access" and codexInternetAccess=true
356
+ const launcher = createMockLauncher();
357
+ const bridge = createMockBridge();
358
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
359
+
360
+ cronStore.createJob({
361
+ name: "Codex Auto",
362
+ prompt: "Check PRs",
363
+ schedule: "0 8 * * *",
364
+ recurring: true,
365
+ backendType: "codex",
366
+ model: "gpt-5.3-codex",
367
+ cwd: "/tmp",
368
+ enabled: true,
369
+ permissionMode: "bypassPermissions",
370
+ });
371
+
372
+ await scheduler.executeJob("codex-auto");
373
+
374
+ expect(launcher.launch).toHaveBeenCalledWith(
375
+ expect.objectContaining({
376
+ backendType: "codex",
377
+ permissionMode: "bypassPermissions",
378
+ codexSandbox: "danger-full-access",
379
+ codexInternetAccess: true,
380
+ }),
381
+ );
382
+
383
+ scheduler.destroy();
384
+ });
385
+
386
+ it("defaults codexInternetAccess to true for Codex cron jobs when not explicitly set", async () => {
387
+ // When codexInternetAccess is not set on the job, it should default to true
388
+ // for Codex autonomous sessions (no point running autonomous without internet)
389
+ const launcher = createMockLauncher();
390
+ const bridge = createMockBridge();
391
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
392
+
393
+ cronStore.createJob({
394
+ name: "Codex Default Internet",
395
+ prompt: "Fetch latest",
396
+ schedule: "0 9 * * *",
397
+ recurring: true,
398
+ backendType: "codex",
399
+ model: "gpt-5.3-codex",
400
+ cwd: "/tmp",
401
+ enabled: true,
402
+ permissionMode: "bypassPermissions",
403
+ // codexInternetAccess NOT set
404
+ });
405
+
406
+ await scheduler.executeJob("codex-default-internet");
407
+
408
+ expect(launcher.launch).toHaveBeenCalledWith(
409
+ expect.objectContaining({
410
+ codexInternetAccess: true,
411
+ }),
412
+ );
413
+
414
+ scheduler.destroy();
415
+ });
416
+
417
+ it("does not pass codexSandbox for Claude jobs", async () => {
418
+ // Claude Code doesn't use codexSandbox — it uses --permission-mode directly
419
+ const launcher = createMockLauncher();
420
+ const bridge = createMockBridge();
421
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
422
+
423
+ cronStore.createJob({
424
+ name: "Claude Job",
425
+ prompt: "Run tests",
426
+ schedule: "0 8 * * *",
427
+ recurring: true,
428
+ backendType: "claude",
429
+ model: "claude-sonnet-4-6",
430
+ cwd: "/tmp",
431
+ enabled: true,
432
+ permissionMode: "bypassPermissions",
433
+ });
434
+
435
+ await scheduler.executeJob("claude-job");
436
+
437
+ const launchArgs = launcher.launch.mock.calls[0][0];
438
+ expect(launchArgs.codexSandbox).toBeUndefined();
439
+ expect(launchArgs.codexInternetAccess).toBeUndefined();
440
+
441
+ scheduler.destroy();
442
+ });
443
+ });
444
+
445
+ // ===========================================================================
446
+ // Execution history
447
+ // ===========================================================================
448
+ describe("execution history", () => {
449
+ it("tracks multiple executions per job", async () => {
450
+ const launcher = createMockLauncher();
451
+ const bridge = createMockBridge();
452
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
453
+
454
+ cronStore.createJob({
455
+ name: "Multi Run",
456
+ prompt: "Go",
457
+ schedule: "0 8 * * *",
458
+ recurring: true,
459
+ backendType: "claude",
460
+ model: "claude-sonnet-4-6",
461
+ cwd: "/tmp",
462
+ enabled: true,
463
+ permissionMode: "bypassPermissions",
464
+ });
465
+
466
+ // Run 3 times — need to mark previous sessions as exited to avoid overlap skip
467
+ await scheduler.executeJob("multi-run");
468
+ const sess1 = cronStore.getJob("multi-run")!.lastSessionId!;
469
+ launcher.sessions.get(sess1)!.state = "exited";
470
+
471
+ await scheduler.executeJob("multi-run");
472
+ const sess2 = cronStore.getJob("multi-run")!.lastSessionId!;
473
+ launcher.sessions.get(sess2)!.state = "exited";
474
+
475
+ await scheduler.executeJob("multi-run");
476
+
477
+ const execs = scheduler.getExecutions("multi-run");
478
+ expect(execs).toHaveLength(3);
479
+ // All should be successful
480
+ expect(execs.every((e) => e.success === true)).toBe(true);
481
+ // Each should have a unique session ID
482
+ const sessionIds = new Set(execs.map((e) => e.sessionId));
483
+ expect(sessionIds.size).toBe(3);
484
+
485
+ scheduler.destroy();
486
+ });
487
+
488
+ it("returns empty array for unknown job", () => {
489
+ const launcher = createMockLauncher();
490
+ const bridge = createMockBridge();
491
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
492
+
493
+ expect(scheduler.getExecutions("nonexistent")).toEqual([]);
494
+
495
+ scheduler.destroy();
496
+ });
497
+
498
+ it("records error details on failed executions", async () => {
499
+ const launcher = createMockLauncher();
500
+ const bridge = createMockBridge();
501
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
502
+
503
+ // Make launcher return sessions that immediately exit
504
+ launcher.launch.mockImplementation((options: Record<string, unknown>) => {
505
+ const info = {
506
+ sessionId: `fail-${Date.now()}-${Math.random()}`,
507
+ state: "exited",
508
+ exitCode: 1,
509
+ model: (options.model as string) || "",
510
+ permissionMode: (options.permissionMode as string) || "",
511
+ cwd: (options.cwd as string) || "/tmp",
512
+ createdAt: Date.now(),
513
+ backendType: (options.backendType as string) || "claude",
514
+ };
515
+ launcher.sessions.set(info.sessionId, info);
516
+ return info;
517
+ });
518
+
519
+ cronStore.createJob({
520
+ name: "Error Detail",
521
+ prompt: "Will fail",
522
+ schedule: "0 8 * * *",
523
+ recurring: true,
524
+ backendType: "claude",
525
+ model: "claude-sonnet-4-6",
526
+ cwd: "/tmp",
527
+ enabled: true,
528
+ permissionMode: "bypassPermissions",
529
+ });
530
+
531
+ await scheduler.executeJob("error-detail");
532
+
533
+ const execs = scheduler.getExecutions("error-detail");
534
+ expect(execs).toHaveLength(1);
535
+ expect(execs[0].error).toContain("CLI process exited before connecting");
536
+ expect(execs[0].completedAt).toBeGreaterThan(0);
537
+
538
+ scheduler.destroy();
539
+ });
540
+ });
541
+
542
+ // ===========================================================================
543
+ // Prompt formatting
544
+ // ===========================================================================
545
+ describe("prompt formatting", () => {
546
+ it("prefixes prompt with [cron:<id> <name>] tag", async () => {
547
+ const launcher = createMockLauncher();
548
+ const bridge = createMockBridge();
549
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
550
+
551
+ cronStore.createJob({
552
+ name: "Email Digest",
553
+ prompt: "Read my emails and summarize them",
554
+ schedule: "0 8 * * *",
555
+ recurring: true,
556
+ backendType: "claude",
557
+ model: "claude-sonnet-4-6",
558
+ cwd: "/tmp",
559
+ enabled: true,
560
+ permissionMode: "bypassPermissions",
561
+ });
562
+
563
+ await scheduler.executeJob("email-digest");
564
+
565
+ const injectedPrompt = bridge.injectUserMessage.mock.calls[0][1];
566
+ expect(injectedPrompt).toBe("[cron:email-digest Email Digest]\n\nRead my emails and summarize them");
567
+
568
+ scheduler.destroy();
569
+ });
570
+
571
+ it("sets session name with clock emoji prefix", async () => {
572
+ const launcher = createMockLauncher();
573
+ const bridge = createMockBridge();
574
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
575
+
576
+ const { setName } = await import("./session-names.js");
577
+
578
+ cronStore.createJob({
579
+ name: "PR Check",
580
+ prompt: "Check PRs",
581
+ schedule: "0 8 * * *",
582
+ recurring: true,
583
+ backendType: "claude",
584
+ model: "claude-sonnet-4-6",
585
+ cwd: "/tmp",
586
+ enabled: true,
587
+ permissionMode: "bypassPermissions",
588
+ });
589
+
590
+ await scheduler.executeJob("pr-check");
591
+
592
+ expect(setName).toHaveBeenCalledWith(
593
+ expect.stringMatching(/^mock-session-/),
594
+ "⏰ PR Check",
595
+ );
596
+
597
+ scheduler.destroy();
598
+ });
599
+ });
600
+
601
+ // ===========================================================================
602
+ // Session tagging
603
+ // ===========================================================================
604
+ describe("session tagging", () => {
605
+ it("tags the session with cronJobId and cronJobName", async () => {
606
+ const launcher = createMockLauncher();
607
+ const bridge = createMockBridge();
608
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
609
+
610
+ cronStore.createJob({
611
+ name: "Tagged Session",
612
+ prompt: "Do work",
613
+ schedule: "0 8 * * *",
614
+ recurring: true,
615
+ backendType: "claude",
616
+ model: "claude-sonnet-4-6",
617
+ cwd: "/tmp",
618
+ enabled: true,
619
+ permissionMode: "bypassPermissions",
620
+ });
621
+
622
+ await scheduler.executeJob("tagged-session");
623
+
624
+ // The session should be tagged after launch
625
+ const sessionId = cronStore.getJob("tagged-session")!.lastSessionId!;
626
+ const session = launcher.sessions.get(sessionId);
627
+ expect(session!.cronJobId).toBe("tagged-session");
628
+ expect(session!.cronJobName).toBe("Tagged Session");
629
+
630
+ scheduler.destroy();
631
+ });
632
+ });
633
+
634
+ // ===========================================================================
635
+ // Failure recovery
636
+ // ===========================================================================
637
+ describe("failure recovery", () => {
638
+ it("resets consecutiveFailures to 0 on successful execution", async () => {
639
+ const launcher = createMockLauncher();
640
+ const bridge = createMockBridge();
641
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
642
+
643
+ // Create a job that has had previous failures
644
+ cronStore.createJob({
645
+ name: "Recovering",
646
+ prompt: "Try again",
647
+ schedule: "0 8 * * *",
648
+ recurring: true,
649
+ backendType: "claude",
650
+ model: "claude-sonnet-4-6",
651
+ cwd: "/tmp",
652
+ enabled: true,
653
+ permissionMode: "bypassPermissions",
654
+ });
655
+ // Manually set some failures
656
+ cronStore.updateJob("recovering", { consecutiveFailures: 3 });
657
+
658
+ // Execute successfully
659
+ await scheduler.executeJob("recovering");
660
+
661
+ const job = cronStore.getJob("recovering");
662
+ expect(job!.consecutiveFailures).toBe(0);
663
+ expect(job!.totalRuns).toBe(1);
664
+
665
+ scheduler.destroy();
666
+ });
667
+
668
+ it("increments totalRuns on each successful execution", async () => {
669
+ const launcher = createMockLauncher();
670
+ const bridge = createMockBridge();
671
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
672
+
673
+ cronStore.createJob({
674
+ name: "Counter",
675
+ prompt: "Go",
676
+ schedule: "0 8 * * *",
677
+ recurring: true,
678
+ backendType: "claude",
679
+ model: "claude-sonnet-4-6",
680
+ cwd: "/tmp",
681
+ enabled: true,
682
+ permissionMode: "bypassPermissions",
683
+ });
684
+
685
+ // Run 3 times, marking previous sessions as exited
686
+ for (let i = 0; i < 3; i++) {
687
+ await scheduler.executeJob("counter");
688
+ const sid = cronStore.getJob("counter")!.lastSessionId!;
689
+ launcher.sessions.get(sid)!.state = "exited";
690
+ }
691
+
692
+ const job = cronStore.getJob("counter");
693
+ expect(job!.totalRuns).toBe(3);
694
+
695
+ scheduler.destroy();
696
+ });
697
+
698
+ it("does not auto-disable after fewer than 5 failures", async () => {
699
+ const launcher = createMockLauncher();
700
+ const bridge = createMockBridge();
701
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
702
+
703
+ // Make launcher return sessions that immediately exit
704
+ launcher.launch.mockImplementation((options: Record<string, unknown>) => {
705
+ const info = {
706
+ sessionId: `fail-${Date.now()}-${Math.random()}`,
707
+ state: "exited",
708
+ exitCode: 1,
709
+ model: (options.model as string) || "",
710
+ permissionMode: (options.permissionMode as string) || "",
711
+ cwd: (options.cwd as string) || "/tmp",
712
+ createdAt: Date.now(),
713
+ backendType: (options.backendType as string) || "claude",
714
+ };
715
+ launcher.sessions.set(info.sessionId, info);
716
+ return info;
717
+ });
718
+
719
+ cronStore.createJob({
720
+ name: "Resilient",
721
+ prompt: "Try hard",
722
+ schedule: "0 8 * * *",
723
+ recurring: true,
724
+ backendType: "claude",
725
+ model: "claude-sonnet-4-6",
726
+ cwd: "/tmp",
727
+ enabled: true,
728
+ permissionMode: "bypassPermissions",
729
+ });
730
+
731
+ // Execute 4 times (below threshold)
732
+ for (let i = 0; i < 4; i++) {
733
+ await scheduler.executeJob("resilient");
734
+ }
735
+
736
+ const job = cronStore.getJob("resilient");
737
+ expect(job!.enabled).toBe(true); // Still enabled
738
+ expect(job!.consecutiveFailures).toBe(4);
739
+
740
+ scheduler.destroy();
741
+ });
742
+ });
743
+
744
+ // ===========================================================================
745
+ // Non-existent / invalid job handling
746
+ // ===========================================================================
747
+ describe("invalid job handling", () => {
748
+ it("silently skips execution when job does not exist in store", async () => {
749
+ const launcher = createMockLauncher();
750
+ const bridge = createMockBridge();
751
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
752
+
753
+ // Should not throw
754
+ await scheduler.executeJob("ghost-job");
755
+ expect(launcher.launch).not.toHaveBeenCalled();
756
+
757
+ scheduler.destroy();
758
+ });
759
+
760
+ it("getNextRunTime returns null for unknown job id", () => {
761
+ const launcher = createMockLauncher();
762
+ const bridge = createMockBridge();
763
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
764
+
765
+ expect(scheduler.getNextRunTime("nonexistent")).toBeNull();
766
+
767
+ scheduler.destroy();
768
+ });
769
+
770
+ it("stopJob is a no-op for unknown job id", () => {
771
+ const launcher = createMockLauncher();
772
+ const bridge = createMockBridge();
773
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
774
+
775
+ // Should not throw
776
+ scheduler.stopJob("unknown");
777
+
778
+ scheduler.destroy();
779
+ });
780
+ });
781
+
782
+ // ===========================================================================
783
+ // scheduleJob replaces existing timer
784
+ // ===========================================================================
785
+ describe("reschedule", () => {
786
+ it("replaces existing timer when scheduleJob is called again", () => {
787
+ const launcher = createMockLauncher();
788
+ const bridge = createMockBridge();
789
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
790
+
791
+ const job = makeJob({ schedule: "0 8 * * *" });
792
+ scheduler.scheduleJob(job);
793
+ const firstNextRun = scheduler.getNextRunTime("test-job");
794
+
795
+ // Reschedule with different cron
796
+ scheduler.scheduleJob({ ...job, schedule: "0 20 * * *" });
797
+ const secondNextRun = scheduler.getNextRunTime("test-job");
798
+
799
+ expect(firstNextRun).not.toBeNull();
800
+ expect(secondNextRun).not.toBeNull();
801
+ // Different schedules should produce different next run times
802
+ expect(firstNextRun!.getHours()).not.toBe(secondNextRun!.getHours());
803
+
804
+ scheduler.destroy();
805
+ });
806
+ });
807
+
808
+ // ===========================================================================
809
+ // One-shot scheduling
810
+ // ===========================================================================
811
+ describe("one-shot scheduling", () => {
812
+ it("schedules a future one-shot and has a next run time", () => {
813
+ const launcher = createMockLauncher();
814
+ const bridge = createMockBridge();
815
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
816
+
817
+ const futureDate = new Date(Date.now() + 3_600_000).toISOString(); // +1 hour
818
+ const job = makeJob({ recurring: false, schedule: futureDate });
819
+ scheduler.scheduleJob(job);
820
+
821
+ const nextRun = scheduler.getNextRunTime("test-job");
822
+ expect(nextRun).toBeInstanceOf(Date);
823
+ // Should be roughly 1 hour from now
824
+ expect(nextRun!.getTime()).toBeGreaterThan(Date.now());
825
+ expect(nextRun!.getTime()).toBeLessThanOrEqual(Date.now() + 3_600_000 + 1000);
826
+
827
+ scheduler.destroy();
828
+ });
829
+ });
830
+
831
+ // ===========================================================================
832
+ // Codex sandbox with non-bypass permission mode
833
+ // ===========================================================================
834
+ describe("Codex sandbox modes", () => {
835
+ it("passes codexSandbox=workspace-write for Codex jobs with plan mode", async () => {
836
+ // Codex jobs NOT using bypassPermissions should get workspace-write sandbox
837
+ const launcher = createMockLauncher();
838
+ const bridge = createMockBridge();
839
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
840
+
841
+ cronStore.createJob({
842
+ name: "Codex Plan",
843
+ prompt: "Suggest changes",
844
+ schedule: "0 8 * * *",
845
+ recurring: true,
846
+ backendType: "codex",
847
+ model: "gpt-5.3-codex",
848
+ cwd: "/tmp",
849
+ enabled: true,
850
+ permissionMode: "plan",
851
+ });
852
+
853
+ await scheduler.executeJob("codex-plan");
854
+
855
+ expect(launcher.launch).toHaveBeenCalledWith(
856
+ expect.objectContaining({
857
+ codexSandbox: "workspace-write",
858
+ permissionMode: "plan",
859
+ }),
860
+ );
861
+
862
+ scheduler.destroy();
863
+ });
864
+
865
+ it("respects explicit codexInternetAccess=false", async () => {
866
+ // If user explicitly sets codexInternetAccess to false, it should be respected
867
+ const launcher = createMockLauncher();
868
+ const bridge = createMockBridge();
869
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
870
+
871
+ cronStore.createJob({
872
+ name: "Codex No Internet",
873
+ prompt: "Work offline",
874
+ schedule: "0 8 * * *",
875
+ recurring: true,
876
+ backendType: "codex",
877
+ model: "gpt-5.3-codex",
878
+ cwd: "/tmp",
879
+ enabled: true,
880
+ permissionMode: "bypassPermissions",
881
+ codexInternetAccess: false,
882
+ });
883
+
884
+ await scheduler.executeJob("codex-no-internet");
885
+
886
+ expect(launcher.launch).toHaveBeenCalledWith(
887
+ expect.objectContaining({
888
+ codexInternetAccess: false,
889
+ }),
890
+ );
891
+
892
+ scheduler.destroy();
893
+ });
894
+ });
895
+
896
+ // ===========================================================================
897
+ // Cleanup
898
+ // ===========================================================================
899
+ describe("destroy", () => {
900
+ it("stops all timers and clears state", () => {
901
+ const launcher = createMockLauncher();
902
+ const bridge = createMockBridge();
903
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
904
+
905
+ const job = makeJob();
906
+ scheduler.scheduleJob(job);
907
+ expect(scheduler.getNextRunTime("test-job")).not.toBeNull();
908
+
909
+ scheduler.destroy();
910
+ expect(scheduler.getNextRunTime("test-job")).toBeNull();
911
+ expect(scheduler.getExecutions("test-job")).toEqual([]);
912
+ });
913
+
914
+ it("clears execution history for all jobs", async () => {
915
+ const launcher = createMockLauncher();
916
+ const bridge = createMockBridge();
917
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
918
+
919
+ cronStore.createJob({
920
+ name: "History Clear",
921
+ prompt: "Go",
922
+ schedule: "0 8 * * *",
923
+ recurring: true,
924
+ backendType: "claude",
925
+ model: "claude-sonnet-4-6",
926
+ cwd: "/tmp",
927
+ enabled: true,
928
+ permissionMode: "bypassPermissions",
929
+ });
930
+
931
+ await scheduler.executeJob("history-clear");
932
+ expect(scheduler.getExecutions("history-clear")).toHaveLength(1);
933
+
934
+ scheduler.destroy();
935
+ expect(scheduler.getExecutions("history-clear")).toEqual([]);
936
+ });
937
+
938
+ it("stops multiple timers", () => {
939
+ const launcher = createMockLauncher();
940
+ const bridge = createMockBridge();
941
+ const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
942
+
943
+ scheduler.scheduleJob(makeJob({ id: "job-1", name: "Job 1", schedule: "0 8 * * *" }));
944
+ scheduler.scheduleJob(makeJob({ id: "job-2", name: "Job 2", schedule: "0 12 * * *" }));
945
+ scheduler.scheduleJob(makeJob({ id: "job-3", name: "Job 3", schedule: "0 18 * * *" }));
946
+
947
+ expect(scheduler.getNextRunTime("job-1")).not.toBeNull();
948
+ expect(scheduler.getNextRunTime("job-2")).not.toBeNull();
949
+ expect(scheduler.getNextRunTime("job-3")).not.toBeNull();
950
+
951
+ scheduler.destroy();
952
+
953
+ expect(scheduler.getNextRunTime("job-1")).toBeNull();
954
+ expect(scheduler.getNextRunTime("job-2")).toBeNull();
955
+ expect(scheduler.getNextRunTime("job-3")).toBeNull();
956
+ });
957
+ });