macroclaw 0.17.0 → 0.19.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/README.md +41 -2
- package/package.json +1 -1
- package/src/cli.test.ts +19 -1
- package/src/cli.ts +11 -2
- package/src/cron.test.ts +104 -34
- package/src/cron.ts +16 -7
- package/src/prompts.ts +1 -1
- package/src/setup.test.ts +25 -0
- package/src/setup.ts +31 -18
- package/workspace-template/.claude/skills/schedule/SKILL.md +102 -0
- package/workspace-template/CLAUDE.md +4 -4
- package/workspace-template/.claude/skills/add-cronjob/SKILL.md +0 -77
- /package/workspace-template/{.macroclaw/cron.json → data/schedule.json} +0 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A lightweight bridge that turns a Telegram chat into a personal AI assistant —
|
|
|
9
9
|
Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
|
|
10
10
|
is designed to run in an isolated environment (container or VM) where the workspace is
|
|
11
11
|
the entire world. The single authorized chat ID ensures only one user can interact with
|
|
12
|
-
the bot.
|
|
12
|
+
the bot. See [Docker](#docker) for the recommended containerized setup.
|
|
13
13
|
|
|
14
14
|
## Requirements
|
|
15
15
|
|
|
@@ -55,6 +55,8 @@ Session state (Claude session IDs) is stored separately in `~/.macroclaw/session
|
|
|
55
55
|
|
|
56
56
|
## Commands
|
|
57
57
|
|
|
58
|
+
Run `macroclaw --help` or `macroclaw <command> --help` for the complete reference.
|
|
59
|
+
|
|
58
60
|
| Command | Description |
|
|
59
61
|
|---------|-------------|
|
|
60
62
|
| `macroclaw start` | Start the bridge |
|
|
@@ -80,6 +82,43 @@ Both paths install macroclaw globally via `bun install -g`, register it as a **l
|
|
|
80
82
|
|
|
81
83
|
On Linux, the command runs as a normal user. Only the privileged operations (writing to `/etc/systemd/system/`, systemctl commands) are elevated via `sudo`, which prompts for a password when needed. Package installation and path resolution stay in the user's environment.
|
|
82
84
|
|
|
85
|
+
## Docker
|
|
86
|
+
|
|
87
|
+
Run macroclaw in a Docker container. The workspace is bind-mounted from the host; everything else (settings, Claude auth, sessions) lives in a named Docker volume.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# 1. Login to Claude Code (one-time, interactive — use /login inside the session)
|
|
91
|
+
docker compose run --rm -w /workspace --entrypoint claude macroclaw
|
|
92
|
+
|
|
93
|
+
# 2. Setup macroclaw (one-time, interactive — bot token, chat ID, model)
|
|
94
|
+
docker compose run --rm macroclaw setup --skip-service
|
|
95
|
+
|
|
96
|
+
# 3. Start the bridge
|
|
97
|
+
docker compose up -d
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The `WORKSPACE` env var is pre-set in `docker-compose.yml` so you don't need to configure the workspace path during setup. The `--skip-service` flag skips the service installation prompt (not applicable in Docker).
|
|
101
|
+
|
|
102
|
+
To build a specific version:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
docker compose build --build-arg VERSION=0.18.0
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To reset everything (remove containers, volumes, and images):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
docker compose down -v --rmi all
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Development with Docker
|
|
115
|
+
|
|
116
|
+
Use `Dockerfile.dev` to build from local source instead of the published npm package:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
docker compose -f docker-compose.dev.yml up -d
|
|
120
|
+
```
|
|
121
|
+
|
|
83
122
|
## Development
|
|
84
123
|
|
|
85
124
|
```bash
|
|
@@ -118,7 +157,7 @@ Macroclaw follows a **thin platform, rich workspace** design:
|
|
|
118
157
|
**Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
|
|
119
158
|
- [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
|
|
120
159
|
- [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
|
|
121
|
-
- [
|
|
160
|
+
- [`data/schedule.json`](workspace-template/data/schedule.json) — scheduled event definitions
|
|
122
161
|
- [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
|
|
123
162
|
|
|
124
163
|
### Where does a new feature belong?
|
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -18,10 +18,11 @@ mock.module("node:child_process", () => ({
|
|
|
18
18
|
|
|
19
19
|
const { main } = await import("./cli");
|
|
20
20
|
|
|
21
|
-
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void> }) {
|
|
21
|
+
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void>; forceInstallService?: () => Promise<void> }) {
|
|
22
22
|
return {
|
|
23
23
|
collectSettings: overrides?.collectSettings ?? mock(async () => ({ botToken: "tok", chatId: "123" })),
|
|
24
24
|
installService: overrides?.installService ?? mock(async () => {}),
|
|
25
|
+
forceInstallService: overrides?.forceInstallService ?? mock(async () => {}),
|
|
25
26
|
} as unknown as SetupWizard;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -94,6 +95,23 @@ describe("Cli.setup", () => {
|
|
|
94
95
|
expect((wizard.installService as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
95
96
|
});
|
|
96
97
|
|
|
98
|
+
it("skips service install when --skip-service is set", async () => {
|
|
99
|
+
const wizard = createMockWizard();
|
|
100
|
+
const cli = new Cli({ wizard, settings: createMockSettings() });
|
|
101
|
+
await cli.setup({ skipService: true });
|
|
102
|
+
expect((wizard.collectSettings as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
103
|
+
expect((wizard.installService as ReturnType<typeof mock>)).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("force installs service when --install-service is set", async () => {
|
|
107
|
+
const forceInstallService = mock(async () => {});
|
|
108
|
+
const wizard = { ...createMockWizard(), forceInstallService } as unknown as SetupWizard;
|
|
109
|
+
const cli = new Cli({ wizard, settings: createMockSettings() });
|
|
110
|
+
await cli.setup({ installService: true });
|
|
111
|
+
expect(forceInstallService).toHaveBeenCalled();
|
|
112
|
+
expect((wizard.installService as ReturnType<typeof mock>)).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
97
115
|
it("creates default wizard when none provided", () => {
|
|
98
116
|
const cli = new Cli({ settings: createMockSettings() });
|
|
99
117
|
expect(cli).toBeDefined();
|
package/src/cli.ts
CHANGED
|
@@ -18,10 +18,15 @@ export class Cli {
|
|
|
18
18
|
this.#serviceManager = opts?.systemService ?? new SystemServiceManager();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
async setup(): Promise<void> {
|
|
21
|
+
async setup(opts?: { skipService?: boolean; installService?: boolean }): Promise<void> {
|
|
22
22
|
const defaults = this.#settingsManager.loadRaw() ?? undefined;
|
|
23
23
|
const settings = await this.#setupWizard.collectSettings(defaults);
|
|
24
24
|
this.#settingsManager.save(settings);
|
|
25
|
+
if (opts?.skipService) return;
|
|
26
|
+
if (opts?.installService) {
|
|
27
|
+
await this.#setupWizard.forceInstallService();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
25
30
|
await this.#setupWizard.installService();
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -123,7 +128,11 @@ const startCommand = defineCommand({
|
|
|
123
128
|
|
|
124
129
|
const setupCommand = defineCommand({
|
|
125
130
|
meta: { name: "setup", description: "Run the interactive setup wizard" },
|
|
126
|
-
|
|
131
|
+
args: {
|
|
132
|
+
"skip-service": { type: "boolean", description: "Skip the service installation prompt" },
|
|
133
|
+
"install-service": { type: "boolean", description: "Install as a system service without prompting" },
|
|
134
|
+
},
|
|
135
|
+
run: ({ args }) => defaultCli.setup({ skipService: args["skip-service"], installService: args["install-service"] }).catch(handleError),
|
|
127
136
|
});
|
|
128
137
|
|
|
129
138
|
const claudeCommand = defineCommand({
|
package/src/cron.test.ts
CHANGED
|
@@ -4,16 +4,16 @@ import { join } from "node:path";
|
|
|
4
4
|
import { CronScheduler } from "./cron";
|
|
5
5
|
|
|
6
6
|
const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-cron");
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const SCHEDULE_DIR = join(TEST_DIR, "data");
|
|
8
|
+
const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
|
|
9
9
|
|
|
10
|
-
function
|
|
11
|
-
mkdirSync(
|
|
12
|
-
writeFileSync(
|
|
10
|
+
function writeScheduleConfig(config: any) {
|
|
11
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
12
|
+
writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function
|
|
16
|
-
return JSON.parse(readFileSync(
|
|
15
|
+
function readScheduleConfig() {
|
|
16
|
+
return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function makeOnJob() {
|
|
@@ -33,8 +33,14 @@ function nonMatchingCron(): string {
|
|
|
33
33
|
return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
|
|
34
34
|
}
|
|
35
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
|
+
|
|
36
42
|
beforeEach(() => {
|
|
37
|
-
mkdirSync(
|
|
43
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
38
44
|
});
|
|
39
45
|
|
|
40
46
|
afterEach(() => {
|
|
@@ -43,7 +49,7 @@ afterEach(() => {
|
|
|
43
49
|
|
|
44
50
|
describe("CronScheduler", () => {
|
|
45
51
|
it("calls onJob for matching cron job", () => {
|
|
46
|
-
|
|
52
|
+
writeScheduleConfig({
|
|
47
53
|
jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
|
|
48
54
|
});
|
|
49
55
|
|
|
@@ -56,7 +62,7 @@ describe("CronScheduler", () => {
|
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
it("does not call onJob for non-matching jobs", () => {
|
|
59
|
-
|
|
65
|
+
writeScheduleConfig({
|
|
60
66
|
jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
|
|
61
67
|
});
|
|
62
68
|
|
|
@@ -68,8 +74,8 @@ describe("CronScheduler", () => {
|
|
|
68
74
|
expect(onJob).not.toHaveBeenCalled();
|
|
69
75
|
});
|
|
70
76
|
|
|
71
|
-
it("silently skips when
|
|
72
|
-
rmSync(
|
|
77
|
+
it("silently skips when schedule.json does not exist", () => {
|
|
78
|
+
rmSync(SCHEDULE_FILE, { force: true });
|
|
73
79
|
|
|
74
80
|
const onJob = makeOnJob();
|
|
75
81
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -80,7 +86,7 @@ describe("CronScheduler", () => {
|
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
it("does not call onJob on malformed JSON", () => {
|
|
83
|
-
writeFileSync(
|
|
89
|
+
writeFileSync(SCHEDULE_FILE, "not json{{{");
|
|
84
90
|
|
|
85
91
|
const onJob = makeOnJob();
|
|
86
92
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -91,7 +97,7 @@ describe("CronScheduler", () => {
|
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
it("does not call onJob when jobs is not an array", () => {
|
|
94
|
-
|
|
100
|
+
writeScheduleConfig({ jobs: "not-array" });
|
|
95
101
|
|
|
96
102
|
const onJob = makeOnJob();
|
|
97
103
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -102,7 +108,7 @@ describe("CronScheduler", () => {
|
|
|
102
108
|
});
|
|
103
109
|
|
|
104
110
|
it("skips invalid cron expression and processes valid jobs", () => {
|
|
105
|
-
|
|
111
|
+
writeScheduleConfig({
|
|
106
112
|
jobs: [
|
|
107
113
|
{ name: "bad", cron: "invalid cron", prompt: "bad" },
|
|
108
114
|
{ name: "good", cron: currentMinuteCron(), prompt: "good" },
|
|
@@ -119,7 +125,7 @@ describe("CronScheduler", () => {
|
|
|
119
125
|
});
|
|
120
126
|
|
|
121
127
|
it("stop clears the interval", () => {
|
|
122
|
-
|
|
128
|
+
writeScheduleConfig({ jobs: [] });
|
|
123
129
|
|
|
124
130
|
const onJob = makeOnJob();
|
|
125
131
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -128,7 +134,7 @@ describe("CronScheduler", () => {
|
|
|
128
134
|
});
|
|
129
135
|
|
|
130
136
|
it("only evaluates once per minute", () => {
|
|
131
|
-
|
|
137
|
+
writeScheduleConfig({
|
|
132
138
|
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
|
|
133
139
|
});
|
|
134
140
|
|
|
@@ -149,7 +155,7 @@ describe("CronScheduler", () => {
|
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
it("handles multiple matching jobs", () => {
|
|
152
|
-
|
|
158
|
+
writeScheduleConfig({
|
|
153
159
|
jobs: [
|
|
154
160
|
{ name: "first", cron: currentMinuteCron(), prompt: "first" },
|
|
155
161
|
{ name: "second", cron: currentMinuteCron(), prompt: "second" },
|
|
@@ -167,7 +173,7 @@ describe("CronScheduler", () => {
|
|
|
167
173
|
});
|
|
168
174
|
|
|
169
175
|
it("passes model override to onJob", () => {
|
|
170
|
-
|
|
176
|
+
writeScheduleConfig({
|
|
171
177
|
jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
|
|
172
178
|
});
|
|
173
179
|
|
|
@@ -180,7 +186,7 @@ describe("CronScheduler", () => {
|
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
it("removes non-recurring job after it fires", () => {
|
|
183
|
-
|
|
189
|
+
writeScheduleConfig({
|
|
184
190
|
jobs: [
|
|
185
191
|
{ name: "once", cron: currentMinuteCron(), prompt: "one-time", recurring: false },
|
|
186
192
|
{ name: "always", cron: currentMinuteCron(), prompt: "forever" },
|
|
@@ -194,13 +200,13 @@ describe("CronScheduler", () => {
|
|
|
194
200
|
|
|
195
201
|
expect(onJob).toHaveBeenCalledTimes(2);
|
|
196
202
|
|
|
197
|
-
const updated =
|
|
203
|
+
const updated = readScheduleConfig();
|
|
198
204
|
expect(updated.jobs).toHaveLength(1);
|
|
199
205
|
expect(updated.jobs[0].name).toBe("always");
|
|
200
206
|
});
|
|
201
207
|
|
|
202
208
|
it("keeps recurring jobs (default behavior)", () => {
|
|
203
|
-
|
|
209
|
+
writeScheduleConfig({
|
|
204
210
|
jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
|
|
205
211
|
});
|
|
206
212
|
|
|
@@ -209,13 +215,13 @@ describe("CronScheduler", () => {
|
|
|
209
215
|
cron.start();
|
|
210
216
|
cron.stop();
|
|
211
217
|
|
|
212
|
-
const updated =
|
|
218
|
+
const updated = readScheduleConfig();
|
|
213
219
|
expect(updated.jobs).toHaveLength(1);
|
|
214
220
|
expect(updated.jobs[0].name).toBe("keeper");
|
|
215
221
|
});
|
|
216
222
|
|
|
217
223
|
it("keeps jobs with recurring: true", () => {
|
|
218
|
-
|
|
224
|
+
writeScheduleConfig({
|
|
219
225
|
jobs: [{ name: "explicit", cron: currentMinuteCron(), prompt: "stay", recurring: true }],
|
|
220
226
|
});
|
|
221
227
|
|
|
@@ -224,32 +230,30 @@ describe("CronScheduler", () => {
|
|
|
224
230
|
cron.start();
|
|
225
231
|
cron.stop();
|
|
226
232
|
|
|
227
|
-
const updated =
|
|
233
|
+
const updated = readScheduleConfig();
|
|
228
234
|
expect(updated.jobs).toHaveLength(1);
|
|
229
235
|
});
|
|
230
236
|
|
|
231
|
-
it("still fires job when write-back of
|
|
232
|
-
|
|
233
|
-
writeCronConfig({
|
|
237
|
+
it("still fires job when write-back of schedule.json fails", () => {
|
|
238
|
+
writeScheduleConfig({
|
|
234
239
|
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "fire", recurring: false }],
|
|
235
240
|
});
|
|
236
241
|
|
|
237
|
-
// Make cron.json read-only so writeFileSync fails
|
|
238
242
|
const { chmodSync } = require("node:fs");
|
|
239
|
-
chmodSync(
|
|
243
|
+
chmodSync(SCHEDULE_FILE, 0o444);
|
|
240
244
|
|
|
241
245
|
const onJob = makeOnJob();
|
|
242
246
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
243
247
|
cron.start();
|
|
244
248
|
cron.stop();
|
|
245
249
|
|
|
246
|
-
chmodSync(
|
|
250
|
+
chmodSync(SCHEDULE_FILE, 0o644);
|
|
247
251
|
|
|
248
252
|
expect(onJob).toHaveBeenCalledTimes(1);
|
|
249
253
|
});
|
|
250
254
|
|
|
251
255
|
it("does not write file when no non-recurring jobs fired", () => {
|
|
252
|
-
|
|
256
|
+
writeScheduleConfig({
|
|
253
257
|
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope", recurring: false }],
|
|
254
258
|
});
|
|
255
259
|
|
|
@@ -258,8 +262,74 @@ describe("CronScheduler", () => {
|
|
|
258
262
|
cron.start();
|
|
259
263
|
cron.stop();
|
|
260
264
|
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const updated = readScheduleConfig();
|
|
266
|
+
expect(updated.jobs).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("missed non-recurring events", () => {
|
|
271
|
+
it("fires missed non-recurring job with missed prefix", () => {
|
|
272
|
+
writeScheduleConfig({
|
|
273
|
+
jobs: [{ name: "reminder", cron: minutesAgoCron(3), prompt: "buy milk", recurring: false }],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const onJob = makeOnJob();
|
|
277
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
278
|
+
cron.start();
|
|
279
|
+
cron.stop();
|
|
280
|
+
|
|
281
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
282
|
+
const call = onJob.mock.calls[0];
|
|
283
|
+
expect(call[0]).toBe("reminder");
|
|
284
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
285
|
+
expect(call[1]).toContain("min ago at");
|
|
286
|
+
expect(call[1]).toContain("buy milk");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("removes missed non-recurring job after firing", () => {
|
|
290
|
+
writeScheduleConfig({
|
|
291
|
+
jobs: [
|
|
292
|
+
{ name: "missed", cron: minutesAgoCron(3), prompt: "do it", recurring: false },
|
|
293
|
+
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const onJob = makeOnJob();
|
|
298
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
299
|
+
cron.start();
|
|
300
|
+
cron.stop();
|
|
301
|
+
|
|
302
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
303
|
+
const updated = readScheduleConfig();
|
|
304
|
+
expect(updated.jobs).toHaveLength(1);
|
|
305
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("does not fire missed recurring jobs", () => {
|
|
309
|
+
writeScheduleConfig({
|
|
310
|
+
jobs: [{ name: "recurring", cron: minutesAgoCron(3), prompt: "repeat" }],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const onJob = makeOnJob();
|
|
314
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
315
|
+
cron.start();
|
|
316
|
+
cron.stop();
|
|
317
|
+
|
|
318
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does not fire non-recurring jobs older than threshold", () => {
|
|
322
|
+
writeScheduleConfig({
|
|
323
|
+
jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "too late", recurring: false }],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const onJob = makeOnJob();
|
|
327
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
328
|
+
cron.start();
|
|
329
|
+
cron.stop();
|
|
330
|
+
|
|
331
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
332
|
+
const updated = readScheduleConfig();
|
|
263
333
|
expect(updated.jobs).toHaveLength(1);
|
|
264
334
|
});
|
|
265
335
|
});
|
package/src/cron.ts
CHANGED
|
@@ -25,15 +25,16 @@ export interface CronSchedulerConfig {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
28
|
+
const MISSED_THRESHOLD = 300_000; // 5 minutes
|
|
28
29
|
|
|
29
30
|
export class CronScheduler {
|
|
30
31
|
#lastMinute = -1;
|
|
31
|
-
#
|
|
32
|
+
#schedulePath: string;
|
|
32
33
|
#config: CronSchedulerConfig;
|
|
33
34
|
#timer: Timer | null = null;
|
|
34
35
|
|
|
35
36
|
constructor(workspace: string, config: CronSchedulerConfig) {
|
|
36
|
-
this.#
|
|
37
|
+
this.#schedulePath = join(workspace, "data", "schedule.json");
|
|
37
38
|
this.#config = config;
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -59,16 +60,16 @@ export class CronScheduler {
|
|
|
59
60
|
|
|
60
61
|
let config: CronConfig;
|
|
61
62
|
try {
|
|
62
|
-
const raw = readFileSync(this.#
|
|
63
|
+
const raw = readFileSync(this.#schedulePath, "utf-8");
|
|
63
64
|
const parsed = cronConfigSchema.safeParse(JSON.parse(raw));
|
|
64
65
|
if (!parsed.success) {
|
|
65
|
-
log.warn("
|
|
66
|
+
log.warn("schedule.json: 'jobs' is not an array");
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
68
69
|
config = parsed.data;
|
|
69
70
|
} catch (err) {
|
|
70
71
|
if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
|
|
71
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read
|
|
72
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
|
|
72
73
|
return;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -87,6 +88,14 @@ export class CronScheduler {
|
|
|
87
88
|
if (job.recurring === false) {
|
|
88
89
|
firedNonRecurring.push(i);
|
|
89
90
|
}
|
|
91
|
+
} else if (job.recurring === false && diff < MISSED_THRESHOLD) {
|
|
92
|
+
// Non-recurring job missed (service was down) — fire with missed prefix
|
|
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);
|
|
90
99
|
}
|
|
91
100
|
} catch (err) {
|
|
92
101
|
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
@@ -99,9 +108,9 @@ export class CronScheduler {
|
|
|
99
108
|
config.jobs.splice(firedNonRecurring[i], 1);
|
|
100
109
|
}
|
|
101
110
|
try {
|
|
102
|
-
writeFileSync(this.#
|
|
111
|
+
writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
103
112
|
} catch (err) {
|
|
104
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write
|
|
113
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
|
|
105
114
|
}
|
|
106
115
|
}
|
|
107
116
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -42,7 +42,7 @@ Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp)
|
|
|
42
42
|
Timeouts: user=${fmtMin(MAIN_TIMEOUT)}, cron=${fmtMin(CRON_TIMEOUT)}, background=${fmtMin(BG_TIMEOUT)}. \
|
|
43
43
|
On timeout, task continues in background automatically. Spawn background agents proactively for long tasks.
|
|
44
44
|
|
|
45
|
-
Cron: jobs in
|
|
45
|
+
Cron: jobs in data/schedule.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
|
|
46
46
|
|
|
47
47
|
MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
|
|
48
48
|
Each button gets its own row. Max 27 characters per label — if options need more detail, describe them in the message and use short labels on buttons.`;
|
package/src/setup.test.ts
CHANGED
|
@@ -383,6 +383,31 @@ it("installs service when user answers yes", async () => {
|
|
|
383
383
|
expect(io.written).toContainEqual(expect.stringContaining("Service installation failed: Permission denied"));
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
it("forceInstallService installs without prompting", async () => {
|
|
387
|
+
mockInstall.mockClear();
|
|
388
|
+
const installer = createMockServiceInstaller();
|
|
389
|
+
const io = createMockIO([
|
|
390
|
+
"sk-test-token", // oauth token (macOS)
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
const wizard = new SetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
394
|
+
await wizard.forceInstallService();
|
|
395
|
+
|
|
396
|
+
expect(mockInstall).toHaveBeenCalled();
|
|
397
|
+
expect(io.written).toContainEqual(expect.stringContaining("Service installed and started."));
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("forceInstallService skips on Linux without prompting", async () => {
|
|
401
|
+
mockInstall.mockClear();
|
|
402
|
+
const installer = createMockServiceInstaller();
|
|
403
|
+
const io = createMockIO([]);
|
|
404
|
+
|
|
405
|
+
const wizard = new SetupWizard(io, { serviceInstaller: installer, platform: "linux" });
|
|
406
|
+
await wizard.forceInstallService();
|
|
407
|
+
|
|
408
|
+
expect(mockInstall).toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
|
|
386
411
|
it("fails fast when claude CLI is not found", async () => {
|
|
387
412
|
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
388
413
|
const io = createMockIO([]);
|
package/src/setup.ts
CHANGED
|
@@ -80,7 +80,7 @@ export class SetupWizard {
|
|
|
80
80
|
|
|
81
81
|
// Workspace
|
|
82
82
|
this.#io.write("\nThe workspace directory where Claude Code runs — instructions, skills,\n");
|
|
83
|
-
this.#io.write("memory, and
|
|
83
|
+
this.#io.write("memory, and scheduled events all live here.\n\n");
|
|
84
84
|
const defaultWorkspace = this.#default("workspace", "~/.macroclaw-workspace");
|
|
85
85
|
const workspace = await this.#askValidated("workspace", `Workspace [${defaultWorkspace}]: `, defaultWorkspace);
|
|
86
86
|
|
|
@@ -116,29 +116,42 @@ export class SetupWizard {
|
|
|
116
116
|
const installAnswer = await this.#io.ask("Install as a system service? [Y/n]: ");
|
|
117
117
|
if (installAnswer.toLowerCase() === "n" || installAnswer.toLowerCase() === "no") return;
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!oauthToken) {
|
|
125
|
-
this.#io.write("No token provided. Skipping service installation.\n");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
119
|
+
await this.#doInstallService();
|
|
120
|
+
} finally {
|
|
121
|
+
this.#io.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
129
124
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} catch (err) {
|
|
135
|
-
this.#io.write(`Service installation failed: ${(err as Error).message}\n`);
|
|
136
|
-
}
|
|
125
|
+
async forceInstallService(): Promise<void> {
|
|
126
|
+
this.#io.open();
|
|
127
|
+
try {
|
|
128
|
+
await this.#doInstallService();
|
|
137
129
|
} finally {
|
|
138
130
|
this.#io.close();
|
|
139
131
|
}
|
|
140
132
|
}
|
|
141
133
|
|
|
134
|
+
async #doInstallService(): Promise<void> {
|
|
135
|
+
let oauthToken: string | undefined;
|
|
136
|
+
if (this.#platform === "darwin") {
|
|
137
|
+
this.#io.write("\nmacOS requires a long-lived OAuth token for the service.\n");
|
|
138
|
+
this.#io.write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
|
|
139
|
+
oauthToken = await this.#io.ask("OAuth token: ");
|
|
140
|
+
if (!oauthToken) {
|
|
141
|
+
this.#io.write("No token provided. Skipping service installation.\n");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const svc = this.#serviceInstaller ?? new (await import("./system-service")).SystemServiceManager();
|
|
148
|
+
const logCmd = svc.install(oauthToken);
|
|
149
|
+
this.#io.write(`Service installed and started. Check logs:\n ${logCmd}\n`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.#io.write(`Service installation failed: ${(err as Error).message}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
async #askValidated(field: SetupField, prompt: string, fallback: string): Promise<string> {
|
|
143
156
|
const schema = settingsSchema.shape[field];
|
|
144
157
|
let value = await this.#io.ask(prompt) || fallback;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: schedule
|
|
3
|
+
description: >
|
|
4
|
+
Schedule events, reminders, and recurring tasks. Use when the user wants to:
|
|
5
|
+
set a reminder, schedule something for later, create a recurring task,
|
|
6
|
+
set up a periodic check, automate a prompt on a schedule, or plan a
|
|
7
|
+
one-time or repeating event at a specific time.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Schedule a new event by adding it to `data/schedule.json`.
|
|
11
|
+
|
|
12
|
+
## When to use this
|
|
13
|
+
|
|
14
|
+
- **Reminders**: "remind me to call the dentist tomorrow at 10"
|
|
15
|
+
- **One-time events**: "send the weekly report this Friday at 5pm"
|
|
16
|
+
- **Recurring tasks**: "check my email every 30 minutes"
|
|
17
|
+
- **Periodic prompts**: "give me a morning summary every weekday at 9"
|
|
18
|
+
- **Scheduled checks**: "monitor the deploy status every 5 minutes for the next hour"
|
|
19
|
+
- **Future actions**: "next Monday, ask me how the presentation went"
|
|
20
|
+
|
|
21
|
+
## How to schedule
|
|
22
|
+
|
|
23
|
+
1. Read `data/schedule.json` (create with `{"jobs": []}` if missing)
|
|
24
|
+
2. Convert the user's request to a cron expression (see reference below)
|
|
25
|
+
3. **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)
|
|
26
|
+
4. Append the new job to the `jobs` array
|
|
27
|
+
5. Write the updated file
|
|
28
|
+
6. Confirm: what was scheduled, when it will fire, and offer to adjust
|
|
29
|
+
|
|
30
|
+
## Natural language → cron
|
|
31
|
+
|
|
32
|
+
Convert user intent to cron expressions. The user's timezone is in their profile (CLAUDE.md/USER.md) — convert to UTC for cron.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
- "in 5 minutes" → compute the exact minute/hour, use a one-shot with `recurring: false`
|
|
36
|
+
- "tomorrow at 10am" → `0 8 14 3 *` (if user is UTC+2, March 13), `recurring: false`
|
|
37
|
+
- "every morning" → `0 7 * * *` (adjust for timezone)
|
|
38
|
+
- "every weekday at 9" → `0 7 * * 1-5` (UTC)
|
|
39
|
+
- "next Monday" → specific date cron, `recurring: false`
|
|
40
|
+
- "every 30 minutes" → `*/30 * * * *`
|
|
41
|
+
|
|
42
|
+
## schedule.json format
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"jobs": [
|
|
47
|
+
{
|
|
48
|
+
"name": "morning-summary",
|
|
49
|
+
"cron": "0 7 * * 1-5",
|
|
50
|
+
"prompt": "Give me a morning summary of my tasks"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "email-check",
|
|
54
|
+
"cron": "*/30 * * * *",
|
|
55
|
+
"prompt": "Check if any important emails arrived",
|
|
56
|
+
"model": "haiku"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "dentist-reminder",
|
|
60
|
+
"cron": "0 8 15 3 *",
|
|
61
|
+
"prompt": "Reminder: call the dentist to reschedule your appointment",
|
|
62
|
+
"recurring": false
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Job fields
|
|
69
|
+
|
|
70
|
+
| Field | Required | Description |
|
|
71
|
+
|-------|----------|-------------|
|
|
72
|
+
| `name` | yes | Short kebab-case identifier (e.g. `dentist-reminder`). Appears in the `[Context: cron/<name>]` prefix when fired. |
|
|
73
|
+
| `cron` | yes | Standard cron expression (UTC). See reference below. |
|
|
74
|
+
| `prompt` | yes | The message sent to the agent when the event fires. Write it as a natural instruction. |
|
|
75
|
+
| `recurring` | no | Defaults to `true`. Set to `false` for one-time events — they're automatically removed after firing. |
|
|
76
|
+
| `model` | no | Override the model. Use `haiku` for cheap checks, `opus` for complex reasoning. Omit for default. |
|
|
77
|
+
|
|
78
|
+
## Cron expression reference (UTC)
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
┌───────── minute (0-59)
|
|
82
|
+
│ ┌─────── hour (0-23)
|
|
83
|
+
│ │ ┌───── day of month (1-31)
|
|
84
|
+
│ │ │ ┌─── month (1-12)
|
|
85
|
+
│ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday)
|
|
86
|
+
│ │ │ │ │
|
|
87
|
+
* * * * *
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Common patterns:
|
|
91
|
+
- `0 9 * * *` — daily at 9:00 UTC
|
|
92
|
+
- `0 7 * * 1-5` — weekdays at 7:00 UTC
|
|
93
|
+
- `*/30 * * * *` — every 30 minutes
|
|
94
|
+
- `0 */2 * * *` — every 2 hours
|
|
95
|
+
- `0 9,18 * * *` — at 9:00 and 18:00 UTC
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- Changes are hot-reloaded — no restart needed
|
|
100
|
+
- File location: `<workspace>/data/schedule.json`
|
|
101
|
+
- One-shot events (`recurring: false`) are cleaned up automatically after firing
|
|
102
|
+
- Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts
|
|
@@ -57,14 +57,14 @@ Don't ask permission. Just do it.
|
|
|
57
57
|
- bullet points (plain text bullet character)
|
|
58
58
|
- No markdown syntax. No # headings. No [links](url). No *stars*.
|
|
59
59
|
|
|
60
|
-
##
|
|
60
|
+
## Scheduled Events
|
|
61
61
|
|
|
62
|
-
Messages prefixed with `[
|
|
62
|
+
Scheduled events are created by the `schedule` skill and stored in `data/schedule.json`. Messages prefixed with `[Context: cron/<name>]` are automated scheduled events. The agent decides whether to respond:
|
|
63
63
|
|
|
64
64
|
- **action: "send"** — the response goes to Telegram
|
|
65
65
|
- **action: "silent"** — the response is logged but not sent
|
|
66
66
|
|
|
67
|
-
Use `silent` when a
|
|
67
|
+
Use `silent` when a scheduled check finds nothing new. Only send when there's something worth reading.
|
|
68
68
|
|
|
69
69
|
## Skills
|
|
70
70
|
|
|
@@ -86,7 +86,7 @@ When creating new skills, always put them in `.claude/skills/` within this works
|
|
|
86
86
|
Structure:
|
|
87
87
|
- `.claude/skills/` — local agent skills
|
|
88
88
|
- `memory/` — daily logs (YYYY-MM-DD.md)
|
|
89
|
-
-
|
|
89
|
+
- `data/schedule.json` — scheduled events and reminders (hot-reloaded, no restart needed) (use schedule skill to modify)
|
|
90
90
|
|
|
91
91
|
## Safety
|
|
92
92
|
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: add-cronjob
|
|
3
|
-
description: Add a new scheduled cron job. Use when the user wants to schedule a recurring prompt, add a periodic task, or set up automated messages.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Add a new cron job to `.macroclaw/cron.json` in this workspace.
|
|
7
|
-
|
|
8
|
-
## Steps
|
|
9
|
-
|
|
10
|
-
1. Read the current `.macroclaw/cron.json` file (create it if missing with `{"jobs": []}`)
|
|
11
|
-
2. Ask the user what prompt to run and when (if not already specified)
|
|
12
|
-
3. Build a standard cron expression for the schedule
|
|
13
|
-
4. Append the new job to the `jobs` array
|
|
14
|
-
5. Write the updated file
|
|
15
|
-
6. Confirm what was added and when it will next run
|
|
16
|
-
|
|
17
|
-
## cron.json format
|
|
18
|
-
|
|
19
|
-
```json
|
|
20
|
-
{
|
|
21
|
-
"jobs": [
|
|
22
|
-
{
|
|
23
|
-
"name": "morning-summary",
|
|
24
|
-
"cron": "0 9 * * *",
|
|
25
|
-
"prompt": "Give me a morning summary of my tasks"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "email-check",
|
|
29
|
-
"cron": "*/30 * * * *",
|
|
30
|
-
"prompt": "Check if any important emails arrived",
|
|
31
|
-
"model": "haiku"
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"name": "weekly-report",
|
|
35
|
-
"cron": "0 17 * * 5",
|
|
36
|
-
"prompt": "Generate a weekly report of completed tasks",
|
|
37
|
-
"recurring": false
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Job fields
|
|
44
|
-
|
|
45
|
-
| Field | Required | Description |
|
|
46
|
-
|-------|----------|-------------|
|
|
47
|
-
| `name` | yes | Short identifier for the job. Appears in the `[Tool: cron/<name>]` prefix so the agent knows which job triggered the prompt. Use kebab-case (e.g. `morning-summary`). |
|
|
48
|
-
| `cron` | yes | Standard cron expression defining when the job runs. See reference below. |
|
|
49
|
-
| `prompt` | yes | The message sent to Claude when the job fires. Write it as if you're typing a message in Telegram — the agent will receive and act on it. |
|
|
50
|
-
| `recurring` | no | Whether the job repeats. Defaults to `true`. Set to `false` for one-shot jobs that should fire once and be automatically removed from cron.json. Good for reminders ("remind me to call the dentist tomorrow at 10") or one-time scheduled events ("send the weekly report this Friday"). |
|
|
51
|
-
| `model` | no | Override the model for this specific job. Omit to use the default model (set via `MODEL` in `.env`), which is best for normal interactive tasks. Use `haiku` for cheap/fast routine checks (email polling, status pings). Use `opus` only when the task genuinely needs deeper reasoning. |
|
|
52
|
-
|
|
53
|
-
## Cron expression reference
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
┌───────── minute (0-59)
|
|
57
|
-
│ ┌─────── hour (0-23)
|
|
58
|
-
│ │ ┌───── day of month (1-31)
|
|
59
|
-
│ │ │ ┌─── month (1-12)
|
|
60
|
-
│ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday)
|
|
61
|
-
│ │ │ │ │
|
|
62
|
-
* * * * *
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Common patterns:
|
|
66
|
-
- `0 9 * * *` — daily at 9:00
|
|
67
|
-
- `0 9 * * 1-5` — weekdays at 9:00
|
|
68
|
-
- `*/30 * * * *` — every 30 minutes
|
|
69
|
-
- `0 */2 * * *` — every 2 hours
|
|
70
|
-
- `0 9,18 * * *` — at 9:00 and 18:00
|
|
71
|
-
|
|
72
|
-
## Notes
|
|
73
|
-
|
|
74
|
-
- Changes are hot-reloaded — no restart needed
|
|
75
|
-
- File location: `<workspace>/.macroclaw/cron.json`
|
|
76
|
-
- Prompts are injected into the conversation with a `[Tool: cron/<name>]` prefix
|
|
77
|
-
- One-shot jobs (`recurring: false`) are cleaned up automatically after firing
|
|
File without changes
|