macroclaw 0.24.0 → 0.25.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Bot } from "grammy";
2
- import { CronScheduler } from "./cron";
3
2
  import { createLogger } from "./logger";
4
3
  import { type Claude, Orchestrator, type OrchestratorResponse } from "./orchestrator";
4
+ import { Scheduler } from "./scheduler";
5
5
  import type { SpeechToText } from "./speech-to-text";
6
6
  import { createBot, downloadFile, sendFile, sendResponse } from "./telegram";
7
7
 
@@ -42,10 +42,10 @@ export class App {
42
42
 
43
43
  start() {
44
44
  log.info("Starting macroclaw...");
45
- const cron = new CronScheduler(this.#config.workspace, {
45
+ const scheduler = new Scheduler(this.#config.workspace, {
46
46
  onJob: (name, prompt, model) => this.#orchestrator.handleCron(name, prompt, model),
47
47
  });
48
- cron.start();
48
+ scheduler.start();
49
49
  this.#bot.api.setMyCommands([
50
50
  { command: "chatid", description: "Show current chat ID" },
51
51
  { command: "session", description: "Show current session ID" },
@@ -0,0 +1,423 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Scheduler } from "./scheduler";
5
+
6
+ const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-scheduler");
7
+ const SCHEDULE_DIR = join(TEST_DIR, "data");
8
+ const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
9
+
10
+ function writeScheduleConfig(config: any) {
11
+ mkdirSync(SCHEDULE_DIR, { recursive: true });
12
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
13
+ }
14
+
15
+ function readScheduleConfig() {
16
+ return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
17
+ }
18
+
19
+ function makeOnJob() {
20
+ return mock((_name: string, _prompt: string, _model?: string) => {});
21
+ }
22
+
23
+ // Build a cron expression that matches the current minute
24
+ function currentMinuteCron(): string {
25
+ const now = new Date();
26
+ return `${now.getMinutes()} ${now.getHours()} * * *`;
27
+ }
28
+
29
+ // Build a cron expression that never matches now
30
+ function nonMatchingCron(): string {
31
+ const now = new Date();
32
+ const otherMinute = (now.getMinutes() + 30) % 60;
33
+ return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
34
+ }
35
+
36
+ // Build an ISO fireAt string N minutes in the past with timezone offset
37
+ function minutesAgoFireAt(minutesAgo: number): string {
38
+ return toIsoWithOffset(new Date(Date.now() - minutesAgo * 60_000));
39
+ }
40
+
41
+ // Build an ISO fireAt string N days in the past with timezone offset
42
+ function daysAgoFireAt(daysAgo: number): string {
43
+ return toIsoWithOffset(new Date(Date.now() - daysAgo * 24 * 60 * 60_000));
44
+ }
45
+
46
+ // Build an ISO fireAt string N minutes in the future with timezone offset
47
+ function minutesFromNowFireAt(minutes: number): string {
48
+ return toIsoWithOffset(new Date(Date.now() + minutes * 60_000));
49
+ }
50
+
51
+ // Format a Date as ISO 8601 with +00:00 offset (not Z)
52
+ function toIsoWithOffset(date: Date): string {
53
+ return date.toISOString().replace("Z", "+00:00");
54
+ }
55
+
56
+ // Build a fireAt string for ~30 seconds ago (within 60s window)
57
+ function justNowFireAt(): string {
58
+ return toIsoWithOffset(new Date(Date.now() - 30_000));
59
+ }
60
+
61
+ beforeEach(() => {
62
+ mkdirSync(SCHEDULE_DIR, { recursive: true });
63
+ });
64
+
65
+ afterEach(() => {
66
+ rmSync(TEST_DIR, { recursive: true, force: true });
67
+ });
68
+
69
+ describe("Scheduler — cron jobs", () => {
70
+ it("calls onJob for matching cron job", () => {
71
+ writeScheduleConfig({
72
+ jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
73
+ });
74
+
75
+ const onJob = makeOnJob();
76
+ const s = new Scheduler(TEST_DIR, { onJob });
77
+ s.start();
78
+ s.stop();
79
+
80
+ expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
81
+ });
82
+
83
+ it("does not call onJob for non-matching jobs", () => {
84
+ writeScheduleConfig({
85
+ jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
86
+ });
87
+
88
+ const onJob = makeOnJob();
89
+ const s = new Scheduler(TEST_DIR, { onJob });
90
+ s.start();
91
+ s.stop();
92
+
93
+ expect(onJob).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("skips invalid cron expression and processes valid jobs", () => {
97
+ writeScheduleConfig({
98
+ jobs: [
99
+ { name: "bad", cron: "invalid cron", prompt: "bad" },
100
+ { name: "good", cron: currentMinuteCron(), prompt: "good" },
101
+ ],
102
+ });
103
+
104
+ const onJob = makeOnJob();
105
+ const s = new Scheduler(TEST_DIR, { onJob });
106
+ s.start();
107
+ s.stop();
108
+
109
+ expect(onJob).toHaveBeenCalledTimes(1);
110
+ expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
111
+ });
112
+
113
+ it("handles multiple matching jobs", () => {
114
+ writeScheduleConfig({
115
+ jobs: [
116
+ { name: "first", cron: currentMinuteCron(), prompt: "first" },
117
+ { name: "second", cron: currentMinuteCron(), prompt: "second" },
118
+ ],
119
+ });
120
+
121
+ const onJob = makeOnJob();
122
+ const s = new Scheduler(TEST_DIR, { onJob });
123
+ s.start();
124
+ s.stop();
125
+
126
+ expect(onJob).toHaveBeenCalledTimes(2);
127
+ expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
128
+ expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
129
+ });
130
+
131
+ it("passes model override to onJob", () => {
132
+ writeScheduleConfig({
133
+ jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
134
+ });
135
+
136
+ const onJob = makeOnJob();
137
+ const s = new Scheduler(TEST_DIR, { onJob });
138
+ s.start();
139
+ s.stop();
140
+
141
+ expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
142
+ });
143
+
144
+ it("never removes cron jobs after firing", () => {
145
+ writeScheduleConfig({
146
+ jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
147
+ });
148
+
149
+ const onJob = makeOnJob();
150
+ const s = new Scheduler(TEST_DIR, { onJob });
151
+ s.start();
152
+ s.stop();
153
+
154
+ const updated = readScheduleConfig();
155
+ expect(updated.jobs).toHaveLength(1);
156
+ expect(updated.jobs[0].name).toBe("keeper");
157
+ });
158
+ });
159
+
160
+ describe("Scheduler — fireAt jobs", () => {
161
+ it("fires one-shot job when fireAt is within 60s of now", () => {
162
+ writeScheduleConfig({
163
+ jobs: [{ name: "now", fireAt: justNowFireAt(), prompt: "do it" }],
164
+ });
165
+
166
+ const onJob = makeOnJob();
167
+ const s = new Scheduler(TEST_DIR, { onJob });
168
+ s.start();
169
+ s.stop();
170
+
171
+ expect(onJob).toHaveBeenCalledWith("now", "do it", undefined);
172
+ });
173
+
174
+ it("removes one-shot job after firing", () => {
175
+ writeScheduleConfig({
176
+ jobs: [
177
+ { name: "once", fireAt: justNowFireAt(), prompt: "one-time" },
178
+ { name: "recurring", cron: currentMinuteCron(), prompt: "forever" },
179
+ ],
180
+ });
181
+
182
+ const onJob = makeOnJob();
183
+ const s = new Scheduler(TEST_DIR, { onJob });
184
+ s.start();
185
+ s.stop();
186
+
187
+ expect(onJob).toHaveBeenCalledTimes(2);
188
+ const updated = readScheduleConfig();
189
+ expect(updated.jobs).toHaveLength(1);
190
+ expect(updated.jobs[0].name).toBe("recurring");
191
+ });
192
+
193
+ it("skips upcoming fireAt job (in the future)", () => {
194
+ writeScheduleConfig({
195
+ jobs: [{ name: "later", fireAt: minutesFromNowFireAt(60), prompt: "not yet" }],
196
+ });
197
+
198
+ const onJob = makeOnJob();
199
+ const s = new Scheduler(TEST_DIR, { onJob });
200
+ s.start();
201
+ s.stop();
202
+
203
+ expect(onJob).not.toHaveBeenCalled();
204
+
205
+ const updated = readScheduleConfig();
206
+ expect(updated.jobs).toHaveLength(1);
207
+ expect(updated.jobs[0].name).toBe("later");
208
+ });
209
+
210
+ it("fires missed one-shot job with missed prefix", () => {
211
+ writeScheduleConfig({
212
+ jobs: [{ name: "reminder", fireAt: minutesAgoFireAt(3), prompt: "buy milk" }],
213
+ });
214
+
215
+ const onJob = makeOnJob();
216
+ const s = new Scheduler(TEST_DIR, { onJob });
217
+ s.start();
218
+ s.stop();
219
+
220
+ expect(onJob).toHaveBeenCalledTimes(1);
221
+ const call = onJob.mock.calls[0];
222
+ expect(call[0]).toBe("reminder");
223
+ expect(call[1]).toContain("[missed event, should have fired");
224
+ expect(call[1]).toContain("min ago at");
225
+ expect(call[1]).toContain("buy milk");
226
+ });
227
+
228
+ it("removes missed one-shot job after firing", () => {
229
+ writeScheduleConfig({
230
+ jobs: [
231
+ { name: "missed", fireAt: minutesAgoFireAt(3), prompt: "do it" },
232
+ { name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
233
+ ],
234
+ });
235
+
236
+ const onJob = makeOnJob();
237
+ const s = new Scheduler(TEST_DIR, { onJob });
238
+ s.start();
239
+ s.stop();
240
+
241
+ expect(onJob).toHaveBeenCalledTimes(1);
242
+ const updated = readScheduleConfig();
243
+ expect(updated.jobs).toHaveLength(1);
244
+ expect(updated.jobs[0].name).toBe("keeper");
245
+ });
246
+
247
+ it("fires one-shot job missed by 6 days (within week window)", () => {
248
+ writeScheduleConfig({
249
+ jobs: [{ name: "recent", fireAt: daysAgoFireAt(6), prompt: "still valid" }],
250
+ });
251
+
252
+ const onJob = makeOnJob();
253
+ const s = new Scheduler(TEST_DIR, { onJob });
254
+ s.start();
255
+ s.stop();
256
+
257
+ expect(onJob).toHaveBeenCalledTimes(1);
258
+ const call = onJob.mock.calls[0];
259
+ expect(call[0]).toBe("recent");
260
+ expect(call[1]).toContain("[missed event, should have fired");
261
+ expect(call[1]).toContain("still valid");
262
+ });
263
+
264
+ it("discards stale one-shot job (older than a week) without firing", () => {
265
+ writeScheduleConfig({
266
+ jobs: [
267
+ { name: "stale", fireAt: daysAgoFireAt(10), prompt: "too old" },
268
+ { name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
269
+ ],
270
+ });
271
+
272
+ const onJob = makeOnJob();
273
+ const s = new Scheduler(TEST_DIR, { onJob });
274
+ s.start();
275
+ s.stop();
276
+
277
+ expect(onJob).not.toHaveBeenCalled();
278
+
279
+ const updated = readScheduleConfig();
280
+ expect(updated.jobs).toHaveLength(1);
281
+ expect(updated.jobs[0].name).toBe("keeper");
282
+ });
283
+
284
+ it("passes model override for fireAt jobs", () => {
285
+ writeScheduleConfig({
286
+ jobs: [{ name: "smart", fireAt: justNowFireAt(), prompt: "think", model: "opus" }],
287
+ });
288
+
289
+ const onJob = makeOnJob();
290
+ const s = new Scheduler(TEST_DIR, { onJob });
291
+ s.start();
292
+ s.stop();
293
+
294
+ expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
295
+ });
296
+
297
+ it("still fires when write-back of schedule.json fails", () => {
298
+ writeScheduleConfig({
299
+ jobs: [{ name: "once", fireAt: justNowFireAt(), prompt: "fire" }],
300
+ });
301
+
302
+ const { chmodSync } = require("node:fs");
303
+ chmodSync(SCHEDULE_FILE, 0o444);
304
+
305
+ const onJob = makeOnJob();
306
+ const s = new Scheduler(TEST_DIR, { onJob });
307
+ s.start();
308
+ s.stop();
309
+
310
+ chmodSync(SCHEDULE_FILE, 0o644);
311
+
312
+ expect(onJob).toHaveBeenCalledTimes(1);
313
+ });
314
+ });
315
+
316
+ describe("Scheduler — validation and edge cases", () => {
317
+ it("silently skips when schedule.json does not exist", () => {
318
+ rmSync(SCHEDULE_FILE, { force: true });
319
+
320
+ const onJob = makeOnJob();
321
+ const s = new Scheduler(TEST_DIR, { onJob });
322
+ s.start();
323
+ s.stop();
324
+
325
+ expect(onJob).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("does not call onJob on malformed JSON", () => {
329
+ writeFileSync(SCHEDULE_FILE, "not json{{{");
330
+
331
+ const onJob = makeOnJob();
332
+ const s = new Scheduler(TEST_DIR, { onJob });
333
+ s.start();
334
+ s.stop();
335
+
336
+ expect(onJob).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it("does not call onJob when jobs is not an array", () => {
340
+ writeScheduleConfig({ jobs: "not-array" });
341
+
342
+ const onJob = makeOnJob();
343
+ const s = new Scheduler(TEST_DIR, { onJob });
344
+ s.start();
345
+ s.stop();
346
+
347
+ expect(onJob).not.toHaveBeenCalled();
348
+ });
349
+
350
+ it("skips job with both cron and fireAt", () => {
351
+ writeScheduleConfig({
352
+ jobs: [{ name: "bad", cron: currentMinuteCron(), fireAt: justNowFireAt(), prompt: "oops" }],
353
+ });
354
+
355
+ const onJob = makeOnJob();
356
+ const s = new Scheduler(TEST_DIR, { onJob });
357
+ s.start();
358
+ s.stop();
359
+
360
+ expect(onJob).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it("skips fireAt with unparseable date", () => {
364
+ writeScheduleConfig({
365
+ jobs: [{ name: "bad", fireAt: "not-a-date", prompt: "invalid" }],
366
+ });
367
+
368
+ const onJob = makeOnJob();
369
+ const s = new Scheduler(TEST_DIR, { onJob });
370
+ s.start();
371
+ s.stop();
372
+
373
+ expect(onJob).not.toHaveBeenCalled();
374
+
375
+ // Job should remain (not removed, just skipped)
376
+ const updated = readScheduleConfig();
377
+ expect(updated.jobs).toHaveLength(1);
378
+ });
379
+
380
+ it("stop clears the interval", () => {
381
+ writeScheduleConfig({ jobs: [] });
382
+
383
+ const onJob = makeOnJob();
384
+ const s = new Scheduler(TEST_DIR, { onJob });
385
+ s.start();
386
+ s.stop(); // should not throw
387
+ });
388
+
389
+ it("only evaluates once per minute", () => {
390
+ writeScheduleConfig({
391
+ jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
392
+ });
393
+
394
+ const onJob = makeOnJob();
395
+ const s = new Scheduler(TEST_DIR, { onJob });
396
+ s.start();
397
+ s.stop();
398
+
399
+ expect(onJob).toHaveBeenCalledTimes(1);
400
+
401
+ // Start again with a new instance — the lastMinute tracker is per-instance
402
+ const onJob2 = makeOnJob();
403
+ const s2 = new Scheduler(TEST_DIR, { onJob: onJob2 });
404
+ s2.start();
405
+ s2.stop();
406
+
407
+ expect(onJob2).toHaveBeenCalledTimes(1);
408
+ });
409
+
410
+ it("does not write file when no one-shot jobs were processed", () => {
411
+ writeScheduleConfig({
412
+ jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope" }],
413
+ });
414
+
415
+ const onJob = makeOnJob();
416
+ const s = new Scheduler(TEST_DIR, { onJob });
417
+ s.start();
418
+ s.stop();
419
+
420
+ const updated = readScheduleConfig();
421
+ expect(updated.jobs).toHaveLength(1);
422
+ });
423
+ });
@@ -0,0 +1,160 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CronExpressionParser } from "cron-parser";
4
+ import { z } from "zod/v4";
5
+ import { createLogger } from "./logger";
6
+
7
+ const log = createLogger("scheduler");
8
+
9
+ const jobSchema = z.object({
10
+ name: z.string(),
11
+ prompt: z.string(),
12
+ model: z.string().optional(),
13
+ cron: z.string().optional(),
14
+ fireAt: z.string().optional(),
15
+ });
16
+
17
+ const scheduleConfigSchema = z.object({
18
+ jobs: z.array(jobSchema),
19
+ });
20
+
21
+ type ScheduleConfig = z.infer<typeof scheduleConfigSchema>;
22
+ type Job = z.infer<typeof jobSchema>;
23
+
24
+ export interface SchedulerConfig {
25
+ onJob: (name: string, prompt: string, model?: string) => void;
26
+ }
27
+
28
+ const TICK_INTERVAL = 10_000; // 10 seconds
29
+ const MAX_MISSED_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
30
+
31
+ export class Scheduler {
32
+ #lastMinute = -1;
33
+ #schedulePath: string;
34
+ #config: SchedulerConfig;
35
+ #timer: Timer | null = null;
36
+
37
+ constructor(workspace: string, config: SchedulerConfig) {
38
+ this.#schedulePath = join(workspace, "data", "schedule.json");
39
+ this.#config = config;
40
+ }
41
+
42
+ start(): void {
43
+ this.#tick();
44
+ this.#timer = setInterval(() => this.#tick(), TICK_INTERVAL);
45
+ }
46
+
47
+ stop(): void {
48
+ if (this.#timer) {
49
+ clearInterval(this.#timer);
50
+ this.#timer = null;
51
+ }
52
+ }
53
+
54
+ #tick(): void {
55
+ const now = new Date();
56
+ const currentMinute =
57
+ now.getMinutes() + now.getHours() * 60 + now.getDate() * 1440 + now.getMonth() * 43200;
58
+
59
+ // Only evaluate once per minute
60
+ if (currentMinute === this.#lastMinute) return;
61
+ this.#lastMinute = currentMinute;
62
+
63
+ let config: ScheduleConfig;
64
+ try {
65
+ const raw = readFileSync(this.#schedulePath, "utf-8");
66
+ const parsed = scheduleConfigSchema.safeParse(JSON.parse(raw));
67
+ if (!parsed.success) {
68
+ log.warn("schedule.json validation failed");
69
+ return;
70
+ }
71
+ config = parsed.data;
72
+ } catch (err) {
73
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
74
+ log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
75
+ return;
76
+ }
77
+
78
+ const removedIndices: number[] = [];
79
+
80
+ for (let i = 0; i < config.jobs.length; i++) {
81
+ const job = config.jobs[i];
82
+ if (job.cron && job.fireAt) {
83
+ log.warn({ name: job.name }, "Job has both cron and fireAt, skipping");
84
+ continue;
85
+ }
86
+ if (job.cron) {
87
+ this.#evaluateCronJob(job as Job & { cron: string }, now);
88
+ } else if (job.fireAt) {
89
+ try {
90
+ const result = this.#evaluateFireAtJob(job as Job & { fireAt: string }, now);
91
+ if (result === "remove") removedIndices.push(i);
92
+ } catch (err) {
93
+ log.warn({ name: job.name, fireAt: job.fireAt, err: err instanceof Error ? err.message : err }, "Failed to evaluate fireAt job");
94
+ }
95
+ } else {
96
+ log.warn({ name: job.name }, "Job has neither cron nor fireAt, skipping");
97
+ }
98
+ }
99
+
100
+ if (removedIndices.length > 0) {
101
+ for (let i = removedIndices.length - 1; i >= 0; i--) {
102
+ config.jobs.splice(removedIndices[i], 1);
103
+ }
104
+ try {
105
+ writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
106
+ } catch (err) {
107
+ log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
108
+ }
109
+ }
110
+ }
111
+
112
+ #evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
113
+ try {
114
+ const interval = CronExpressionParser.parse(job.cron);
115
+ const prev = interval.prev();
116
+ const diff = Math.abs(now.getTime() - prev.getTime());
117
+ if (diff < 60_000) {
118
+ log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
119
+ this.#config.onJob(job.name, job.prompt, job.model);
120
+ }
121
+ } catch (err) {
122
+ log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
123
+ }
124
+ }
125
+
126
+ #evaluateFireAtJob(
127
+ job: { name: string; fireAt: string; prompt: string; model?: string },
128
+ now: Date,
129
+ ): "remove" | "keep" {
130
+ const fireAt = new Date(job.fireAt);
131
+ if (Number.isNaN(fireAt.getTime())) {
132
+ log.warn({ name: job.name, fireAt: job.fireAt }, "Invalid fireAt date");
133
+ return "keep";
134
+ }
135
+
136
+ const diff = now.getTime() - fireAt.getTime();
137
+
138
+ if (diff < 0) {
139
+ // Upcoming — not yet due
140
+ return "keep";
141
+ }
142
+
143
+ if (diff < 60_000) {
144
+ log.debug({ name: job.name, fireAt: job.fireAt }, "One-shot job triggered");
145
+ this.#config.onJob(job.name, job.prompt, job.model);
146
+ return "remove";
147
+ }
148
+
149
+ if (diff <= MAX_MISSED_MS) {
150
+ const missedMinutes = Math.round(diff / 60_000);
151
+ const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${job.fireAt}] ${job.prompt}`;
152
+ log.info({ name: job.name, missedMinutes, fireAt: job.fireAt }, "Firing missed one-shot job");
153
+ this.#config.onJob(job.name, missedPrompt, job.model);
154
+ return "remove";
155
+ }
156
+
157
+ log.warn({ name: job.name, missedMinutes: Math.round(diff / 60_000) }, "Discarding stale one-shot job");
158
+ return "remove";
159
+ }
160
+ }
@@ -18,26 +18,32 @@ Schedule a new event by adding it to `data/schedule.json`.
18
18
 
19
19
  1. Run `date` to get the current date and time
20
20
  2. Read `data/schedule.json` (create with `{"jobs": []}` if missing)
21
- 3. Convert the user's request to a cron expression (see reference below)
21
+ 3. Determine job type:
22
+ - **Recurring** → convert to a cron expression (UTC). See reference below.
23
+ - **One-time** → compute an ISO 8601 timestamp with the user's timezone offset for `fireAt`
22
24
  4. **Be proactive about timing**: if the user says "next week" or "tomorrow" without a specific time, pick the best time based on what you know (their routine, calendar, context)
23
25
  5. Append the new job to the `jobs` array
24
26
  6. Write the updated file
25
27
  7. Confirm: what was scheduled, when it will fire, and offer to adjust
26
28
 
27
- ## Natural language → cron
29
+ ## Natural language → job format
28
30
 
29
- Convert user intent to cron expressions. The user's timezone is in their profile (CLAUDE.md/USER.md) — convert to UTC for cron.
31
+ The user's timezone is in their profile (CLAUDE.md/USER.md).
30
32
 
31
- Examples:
32
- - "in 5 minutes" → compute the exact minute/hour, use a one-shot with `recurring: false`
33
- - "tomorrow at 10am" → `0 8 14 3 *` (if user is UTC+2, March 13), `recurring: false`
34
- - "every morning" → `0 7 * * *` (adjust for timezone)
35
- - "every weekday at 9" → `0 7 * * 1-5` (UTC)
36
- - "next Monday" specific date cron, `recurring: false`
37
- - "every 30 minutes" → `*/30 * * * *`
33
+ **One-time events** use `fireAt` with the user's timezone offset:
34
+ - "in 5 minutes" → compute exact time, e.g. `"fireAt": "2026-03-16T10:05:00+01:00"`
35
+ - "tomorrow at 10am" → `"fireAt": "2026-03-14T10:00:00+01:00"`
36
+ - "next Monday" → `"fireAt": "2026-03-17T09:00:00+01:00"` (pick a sensible time)
37
+
38
+ **Recurring events** use `cron` in UTC:
39
+ - "every morning" → `"cron": "0 7 * * *"` (adjust for timezone)
40
+ - "every weekday at 9" → `"cron": "0 7 * * 1-5"` (UTC)
41
+ - "every 30 minutes" → `"cron": "*/30 * * * *"`
38
42
 
39
43
  ## schedule.json format
40
44
 
45
+ Two job types, discriminated by field:
46
+
41
47
  ```json
42
48
  {
43
49
  "jobs": [
@@ -54,9 +60,8 @@ Examples:
54
60
  },
55
61
  {
56
62
  "name": "dentist-reminder",
57
- "cron": "0 8 15 3 *",
58
- "prompt": "Reminder: call the dentist to reschedule your appointment",
59
- "recurring": false
63
+ "fireAt": "2026-03-15T08:00:00+01:00",
64
+ "prompt": "Reminder: call the dentist to reschedule your appointment"
60
65
  }
61
66
  ]
62
67
  }
@@ -67,11 +72,13 @@ Examples:
67
72
  | Field | Required | Description |
68
73
  |-------|----------|-------------|
69
74
  | `name` | yes | Short kebab-case identifier (e.g. `dentist-reminder`). Appears in the `[Context: cron/<name>]` prefix when fired. |
70
- | `cron` | yes | Standard cron expression (UTC). See reference below. |
75
+ | `cron` | for recurring | Standard cron expression (UTC). See reference below. |
76
+ | `fireAt` | for one-time | ISO 8601 timestamp, preferably with timezone offset (e.g. `2026-03-15T08:00:00+01:00`). Any format parseable by JavaScript `Date` works. |
71
77
  | `prompt` | yes | The message sent to the agent when the event fires. Write it as a natural instruction. |
72
- | `recurring` | no | Defaults to `true`. Set to `false` for one-time events — they're automatically removed after firing. |
73
78
  | `model` | no | Override the model. Use `haiku` for cheap checks, `opus` for complex reasoning. Omit for default. |
74
79
 
80
+ Each job must have exactly one of `cron` or `fireAt` (not both).
81
+
75
82
  ## Cron expression reference (UTC)
76
83
 
77
84
  ```
@@ -95,5 +102,5 @@ Common patterns:
95
102
 
96
103
  - Changes are hot-reloaded — no restart needed
97
104
  - File location: `<workspace>/data/schedule.json`
98
- - One-shot events (`recurring: false`) are cleaned up automatically after firing
99
- - Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts
105
+ - One-shot events (`fireAt`) are cleaned up automatically after firing
106
+ - Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts (up to 7 days late)
package/src/cron.test.ts DELETED
@@ -1,383 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { CronScheduler } from "./cron";
5
-
6
- const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-cron");
7
- const SCHEDULE_DIR = join(TEST_DIR, "data");
8
- const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
9
-
10
- function writeScheduleConfig(config: any) {
11
- mkdirSync(SCHEDULE_DIR, { recursive: true });
12
- writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
13
- }
14
-
15
- function readScheduleConfig() {
16
- return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
17
- }
18
-
19
- function makeOnJob() {
20
- return mock((_name: string, _prompt: string, _model?: string) => {});
21
- }
22
-
23
- // Build a cron expression that matches the current minute
24
- function currentMinuteCron(): string {
25
- const now = new Date();
26
- return `${now.getMinutes()} ${now.getHours()} * * *`;
27
- }
28
-
29
- // Build a cron expression that never matches now
30
- function nonMatchingCron(): string {
31
- const now = new Date();
32
- const otherMinute = (now.getMinutes() + 30) % 60;
33
- return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
34
- }
35
-
36
- // Build a cron expression that matched N minutes ago
37
- function minutesAgoCron(minutesAgo: number): string {
38
- const past = new Date(Date.now() - minutesAgo * 60_000);
39
- return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
40
- }
41
-
42
- // Build a cron expression that matched N days ago
43
- function daysAgoCron(daysAgo: number): string {
44
- const past = new Date(Date.now() - daysAgo * 24 * 60 * 60_000);
45
- return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
46
- }
47
-
48
- beforeEach(() => {
49
- mkdirSync(SCHEDULE_DIR, { recursive: true });
50
- });
51
-
52
- afterEach(() => {
53
- rmSync(TEST_DIR, { recursive: true, force: true });
54
- });
55
-
56
- describe("CronScheduler", () => {
57
- it("calls onJob for matching cron job", () => {
58
- writeScheduleConfig({
59
- jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
60
- });
61
-
62
- const onJob = makeOnJob();
63
- const cron = new CronScheduler(TEST_DIR, { onJob });
64
- cron.start();
65
- cron.stop();
66
-
67
- expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
68
- });
69
-
70
- it("does not call onJob for non-matching jobs", () => {
71
- writeScheduleConfig({
72
- jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
73
- });
74
-
75
- const onJob = makeOnJob();
76
- const cron = new CronScheduler(TEST_DIR, { onJob });
77
- cron.start();
78
- cron.stop();
79
-
80
- expect(onJob).not.toHaveBeenCalled();
81
- });
82
-
83
- it("silently skips when schedule.json does not exist", () => {
84
- rmSync(SCHEDULE_FILE, { force: true });
85
-
86
- const onJob = makeOnJob();
87
- const cron = new CronScheduler(TEST_DIR, { onJob });
88
- cron.start();
89
- cron.stop();
90
-
91
- expect(onJob).not.toHaveBeenCalled();
92
- });
93
-
94
- it("does not call onJob on malformed JSON", () => {
95
- writeFileSync(SCHEDULE_FILE, "not json{{{");
96
-
97
- const onJob = makeOnJob();
98
- const cron = new CronScheduler(TEST_DIR, { onJob });
99
- cron.start();
100
- cron.stop();
101
-
102
- expect(onJob).not.toHaveBeenCalled();
103
- });
104
-
105
- it("does not call onJob when jobs is not an array", () => {
106
- writeScheduleConfig({ jobs: "not-array" });
107
-
108
- const onJob = makeOnJob();
109
- const cron = new CronScheduler(TEST_DIR, { onJob });
110
- cron.start();
111
- cron.stop();
112
-
113
- expect(onJob).not.toHaveBeenCalled();
114
- });
115
-
116
- it("skips invalid cron expression and processes valid jobs", () => {
117
- writeScheduleConfig({
118
- jobs: [
119
- { name: "bad", cron: "invalid cron", prompt: "bad" },
120
- { name: "good", cron: currentMinuteCron(), prompt: "good" },
121
- ],
122
- });
123
-
124
- const onJob = makeOnJob();
125
- const cron = new CronScheduler(TEST_DIR, { onJob });
126
- cron.start();
127
- cron.stop();
128
-
129
- expect(onJob).toHaveBeenCalledTimes(1);
130
- expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
131
- });
132
-
133
- it("stop clears the interval", () => {
134
- writeScheduleConfig({ jobs: [] });
135
-
136
- const onJob = makeOnJob();
137
- const cron = new CronScheduler(TEST_DIR, { onJob });
138
- cron.start();
139
- cron.stop(); // should not throw
140
- });
141
-
142
- it("only evaluates once per minute", () => {
143
- writeScheduleConfig({
144
- jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
145
- });
146
-
147
- const onJob = makeOnJob();
148
- const cron = new CronScheduler(TEST_DIR, { onJob });
149
- cron.start();
150
- cron.stop();
151
-
152
- expect(onJob).toHaveBeenCalledTimes(1);
153
-
154
- // Start again with a new instance — the lastMinute tracker is per-instance
155
- const onJob2 = makeOnJob();
156
- const cron2 = new CronScheduler(TEST_DIR, { onJob: onJob2 });
157
- cron2.start();
158
- cron2.stop();
159
-
160
- expect(onJob2).toHaveBeenCalledTimes(1);
161
- });
162
-
163
- it("handles multiple matching jobs", () => {
164
- writeScheduleConfig({
165
- jobs: [
166
- { name: "first", cron: currentMinuteCron(), prompt: "first" },
167
- { name: "second", cron: currentMinuteCron(), prompt: "second" },
168
- ],
169
- });
170
-
171
- const onJob = makeOnJob();
172
- const cron = new CronScheduler(TEST_DIR, { onJob });
173
- cron.start();
174
- cron.stop();
175
-
176
- expect(onJob).toHaveBeenCalledTimes(2);
177
- expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
178
- expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
179
- });
180
-
181
- it("passes model override to onJob", () => {
182
- writeScheduleConfig({
183
- jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
184
- });
185
-
186
- const onJob = makeOnJob();
187
- const cron = new CronScheduler(TEST_DIR, { onJob });
188
- cron.start();
189
- cron.stop();
190
-
191
- expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
192
- });
193
-
194
- it("removes non-recurring job after it fires", () => {
195
- writeScheduleConfig({
196
- jobs: [
197
- { name: "once", cron: currentMinuteCron(), prompt: "one-time", recurring: false },
198
- { name: "always", cron: currentMinuteCron(), prompt: "forever" },
199
- ],
200
- });
201
-
202
- const onJob = makeOnJob();
203
- const cron = new CronScheduler(TEST_DIR, { onJob });
204
- cron.start();
205
- cron.stop();
206
-
207
- expect(onJob).toHaveBeenCalledTimes(2);
208
-
209
- const updated = readScheduleConfig();
210
- expect(updated.jobs).toHaveLength(1);
211
- expect(updated.jobs[0].name).toBe("always");
212
- });
213
-
214
- it("keeps recurring jobs (default behavior)", () => {
215
- writeScheduleConfig({
216
- jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
217
- });
218
-
219
- const onJob = makeOnJob();
220
- const cron = new CronScheduler(TEST_DIR, { onJob });
221
- cron.start();
222
- cron.stop();
223
-
224
- const updated = readScheduleConfig();
225
- expect(updated.jobs).toHaveLength(1);
226
- expect(updated.jobs[0].name).toBe("keeper");
227
- });
228
-
229
- it("keeps jobs with recurring: true", () => {
230
- writeScheduleConfig({
231
- jobs: [{ name: "explicit", cron: currentMinuteCron(), prompt: "stay", recurring: true }],
232
- });
233
-
234
- const onJob = makeOnJob();
235
- const cron = new CronScheduler(TEST_DIR, { onJob });
236
- cron.start();
237
- cron.stop();
238
-
239
- const updated = readScheduleConfig();
240
- expect(updated.jobs).toHaveLength(1);
241
- });
242
-
243
- it("still fires job when write-back of schedule.json fails", () => {
244
- writeScheduleConfig({
245
- jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "fire", recurring: false }],
246
- });
247
-
248
- const { chmodSync } = require("node:fs");
249
- chmodSync(SCHEDULE_FILE, 0o444);
250
-
251
- const onJob = makeOnJob();
252
- const cron = new CronScheduler(TEST_DIR, { onJob });
253
- cron.start();
254
- cron.stop();
255
-
256
- chmodSync(SCHEDULE_FILE, 0o644);
257
-
258
- expect(onJob).toHaveBeenCalledTimes(1);
259
- });
260
-
261
- it("does not write file when no non-recurring jobs fired", () => {
262
- writeScheduleConfig({
263
- jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope" }],
264
- });
265
-
266
- const onJob = makeOnJob();
267
- const cron = new CronScheduler(TEST_DIR, { onJob });
268
- cron.start();
269
- cron.stop();
270
-
271
- const updated = readScheduleConfig();
272
- expect(updated.jobs).toHaveLength(1);
273
- });
274
- });
275
-
276
- describe("missed non-recurring events", () => {
277
- it("fires missed non-recurring job with missed prefix", () => {
278
- writeScheduleConfig({
279
- jobs: [{ name: "reminder", cron: minutesAgoCron(3), prompt: "buy milk", recurring: false }],
280
- });
281
-
282
- const onJob = makeOnJob();
283
- const cron = new CronScheduler(TEST_DIR, { onJob });
284
- cron.start();
285
- cron.stop();
286
-
287
- expect(onJob).toHaveBeenCalledTimes(1);
288
- const call = onJob.mock.calls[0];
289
- expect(call[0]).toBe("reminder");
290
- expect(call[1]).toContain("[missed event, should have fired");
291
- expect(call[1]).toContain("min ago at");
292
- expect(call[1]).toContain("buy milk");
293
- });
294
-
295
- it("removes missed non-recurring job after firing", () => {
296
- writeScheduleConfig({
297
- jobs: [
298
- { name: "missed", cron: minutesAgoCron(3), prompt: "do it", recurring: false },
299
- { name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
300
- ],
301
- });
302
-
303
- const onJob = makeOnJob();
304
- const cron = new CronScheduler(TEST_DIR, { onJob });
305
- cron.start();
306
- cron.stop();
307
-
308
- expect(onJob).toHaveBeenCalledTimes(1);
309
- const updated = readScheduleConfig();
310
- expect(updated.jobs).toHaveLength(1);
311
- expect(updated.jobs[0].name).toBe("keeper");
312
- });
313
-
314
- it("does not fire missed recurring jobs", () => {
315
- writeScheduleConfig({
316
- jobs: [{ name: "recurring", cron: minutesAgoCron(3), prompt: "repeat" }],
317
- });
318
-
319
- const onJob = makeOnJob();
320
- const cron = new CronScheduler(TEST_DIR, { onJob });
321
- cron.start();
322
- cron.stop();
323
-
324
- expect(onJob).not.toHaveBeenCalled();
325
- });
326
-
327
- it("fires non-recurring job missed by more than 5 minutes", () => {
328
- writeScheduleConfig({
329
- jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "still fires", recurring: false }],
330
- });
331
-
332
- const onJob = makeOnJob();
333
- const cron = new CronScheduler(TEST_DIR, { onJob });
334
- cron.start();
335
- cron.stop();
336
-
337
- expect(onJob).toHaveBeenCalledTimes(1);
338
- const call = onJob.mock.calls[0];
339
- expect(call[0]).toBe("old");
340
- expect(call[1]).toContain("[missed event, should have fired");
341
- expect(call[1]).toContain("still fires");
342
-
343
- const updated = readScheduleConfig();
344
- expect(updated.jobs).toHaveLength(0);
345
- });
346
-
347
- it("discards non-recurring job missed by more than a week without firing", () => {
348
- writeScheduleConfig({
349
- jobs: [
350
- { name: "stale", cron: daysAgoCron(10), prompt: "too old", recurring: false },
351
- { name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
352
- ],
353
- });
354
-
355
- const onJob = makeOnJob();
356
- const cron = new CronScheduler(TEST_DIR, { onJob });
357
- cron.start();
358
- cron.stop();
359
-
360
- expect(onJob).not.toHaveBeenCalled();
361
-
362
- const updated = readScheduleConfig();
363
- expect(updated.jobs).toHaveLength(1);
364
- expect(updated.jobs[0].name).toBe("keeper");
365
- });
366
-
367
- it("fires non-recurring job missed by 6 days (within week window)", () => {
368
- writeScheduleConfig({
369
- jobs: [{ name: "recent", cron: daysAgoCron(6), prompt: "still valid", recurring: false }],
370
- });
371
-
372
- const onJob = makeOnJob();
373
- const cron = new CronScheduler(TEST_DIR, { onJob });
374
- cron.start();
375
- cron.stop();
376
-
377
- expect(onJob).toHaveBeenCalledTimes(1);
378
- const call = onJob.mock.calls[0];
379
- expect(call[0]).toBe("recent");
380
- expect(call[1]).toContain("[missed event, should have fired");
381
- expect(call[1]).toContain("still valid");
382
- });
383
- });
package/src/cron.ts DELETED
@@ -1,121 +0,0 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { CronExpressionParser } from "cron-parser";
4
- import { z } from "zod/v4";
5
- import { createLogger } from "./logger";
6
-
7
- const log = createLogger("cron");
8
-
9
- const cronJobSchema = z.object({
10
- name: z.string(),
11
- cron: z.string(),
12
- prompt: z.string(),
13
- recurring: z.boolean().optional(),
14
- model: z.string().optional(),
15
- });
16
-
17
- const cronConfigSchema = z.object({
18
- jobs: z.array(cronJobSchema),
19
- });
20
-
21
- type CronConfig = z.infer<typeof cronConfigSchema>;
22
-
23
- export interface CronSchedulerConfig {
24
- onJob: (name: string, prompt: string, model?: string) => void;
25
- }
26
-
27
- const TICK_INTERVAL = 10_000; // 10 seconds
28
- const MAX_MISSED_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
29
-
30
- export class CronScheduler {
31
- #lastMinute = -1;
32
- #schedulePath: string;
33
- #config: CronSchedulerConfig;
34
- #timer: Timer | null = null;
35
-
36
- constructor(workspace: string, config: CronSchedulerConfig) {
37
- this.#schedulePath = join(workspace, "data", "schedule.json");
38
- this.#config = config;
39
- }
40
-
41
- start(): void {
42
- this.#tick();
43
- this.#timer = setInterval(() => this.#tick(), TICK_INTERVAL);
44
- }
45
-
46
- stop(): void {
47
- if (this.#timer) {
48
- clearInterval(this.#timer);
49
- this.#timer = null;
50
- }
51
- }
52
-
53
- #tick(): void {
54
- const now = new Date();
55
- const currentMinute = now.getMinutes() + now.getHours() * 60 + now.getDate() * 1440 + now.getMonth() * 43200;
56
-
57
- // Only evaluate once per minute
58
- if (currentMinute === this.#lastMinute) return;
59
- this.#lastMinute = currentMinute;
60
-
61
- let config: CronConfig;
62
- try {
63
- const raw = readFileSync(this.#schedulePath, "utf-8");
64
- const parsed = cronConfigSchema.safeParse(JSON.parse(raw));
65
- if (!parsed.success) {
66
- log.warn("schedule.json: 'jobs' is not an array");
67
- return;
68
- }
69
- config = parsed.data;
70
- } catch (err) {
71
- if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
72
- log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
73
- return;
74
- }
75
-
76
- const firedNonRecurring: number[] = [];
77
-
78
- for (let i = 0; i < config.jobs.length; i++) {
79
- const job = config.jobs[i];
80
- try {
81
- const interval = CronExpressionParser.parse(job.cron);
82
- const prev = interval.prev();
83
- const diff = Math.abs(now.getTime() - prev.getTime());
84
- // Match if the previous occurrence is within the current minute
85
- if (diff < 60_000) {
86
- log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
87
- this.#config.onJob(job.name, job.prompt, job.model);
88
- if (job.recurring === false) {
89
- firedNonRecurring.push(i);
90
- }
91
- } else if (job.recurring === false && diff >= 60_000 && diff <= MAX_MISSED_MS) {
92
- // Non-recurring job in the past — fire if missed within the last week
93
- const missedMinutes = Math.round(diff / 60_000);
94
- const firedAt = prev.toISOString();
95
- const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
96
- log.info({ name: job.name, missedMinutes, firedAt }, "Firing missed non-recurring job");
97
- this.#config.onJob(job.name, missedPrompt, job.model);
98
- firedNonRecurring.push(i);
99
- } else if (job.recurring === false && diff > MAX_MISSED_MS) {
100
- // Non-recurring job missed by more than a week — discard without firing
101
- log.warn({ name: job.name, missedMinutes: Math.round(diff / 60_000) }, "Discarding stale non-recurring job");
102
- firedNonRecurring.push(i);
103
- }
104
- } catch (err) {
105
- log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
106
- }
107
- }
108
-
109
- // Remove fired non-recurring jobs (iterate in reverse to preserve indices)
110
- if (firedNonRecurring.length > 0) {
111
- for (let i = firedNonRecurring.length - 1; i >= 0; i--) {
112
- config.jobs.splice(firedNonRecurring[i], 1);
113
- }
114
- try {
115
- writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
116
- } catch (err) {
117
- log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
118
- }
119
- }
120
- }
121
- }