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 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
- - [`.macroclaw/cron.json`](workspace-template/.macroclaw/cron.json) — scheduled job definitions
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- run: () => defaultCli.setup().catch(handleError),
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 CRON_DIR = join(TEST_DIR, ".macroclaw");
8
- const CRON_FILE = join(CRON_DIR, "cron.json");
7
+ const SCHEDULE_DIR = join(TEST_DIR, "data");
8
+ const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
9
9
 
10
- function writeCronConfig(config: any) {
11
- mkdirSync(CRON_DIR, { recursive: true });
12
- writeFileSync(CRON_FILE, JSON.stringify(config));
10
+ function writeScheduleConfig(config: any) {
11
+ mkdirSync(SCHEDULE_DIR, { recursive: true });
12
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
13
13
  }
14
14
 
15
- function readCronConfig() {
16
- return JSON.parse(readFileSync(CRON_FILE, "utf-8"));
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(CRON_DIR, { recursive: true });
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
- writeCronConfig({
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
- writeCronConfig({
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 cron.json does not exist", () => {
72
- rmSync(CRON_FILE, { force: true });
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(CRON_FILE, "not json{{{");
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
- writeCronConfig({ jobs: "not-array" });
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
- writeCronConfig({
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
- writeCronConfig({ jobs: [] });
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
- writeCronConfig({
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
- writeCronConfig({
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
- writeCronConfig({
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
- writeCronConfig({
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 = readCronConfig();
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
- writeCronConfig({
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 = readCronConfig();
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
- writeCronConfig({
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 = readCronConfig();
233
+ const updated = readScheduleConfig();
228
234
  expect(updated.jobs).toHaveLength(1);
229
235
  });
230
236
 
231
- it("still fires job when write-back of cron.json fails", () => {
232
- // Write config to a path that will be read successfully
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(CRON_FILE, 0o444);
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(CRON_FILE, 0o644);
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
- writeCronConfig({
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
- // File should remain unchanged (job still present since it didn't fire)
262
- const updated = readCronConfig();
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
- #cronPath: string;
32
+ #schedulePath: string;
32
33
  #config: CronSchedulerConfig;
33
34
  #timer: Timer | null = null;
34
35
 
35
36
  constructor(workspace: string, config: CronSchedulerConfig) {
36
- this.#cronPath = join(workspace, ".macroclaw", "cron.json");
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.#cronPath, "utf-8");
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("cron.json: 'jobs' is not an array");
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 cron.json");
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.#cronPath, `${JSON.stringify(config, null, 2)}\n`);
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 cron.json");
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 .macroclaw/cron.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
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 cron definitions all live here.\n\n");
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
- let oauthToken: string | undefined;
120
- if (this.#platform === "darwin") {
121
- this.#io.write("\nmacOS requires a long-lived OAuth token for the service.\n");
122
- this.#io.write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
123
- oauthToken = await this.#io.ask("OAuth token: ");
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
- try {
131
- const svc = this.#serviceInstaller ?? new (await import("./system-service")).SystemServiceManager();
132
- const logCmd = svc.install(oauthToken);
133
- this.#io.write(`Service installed and started. Check logs:\n ${logCmd}\n`);
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
- ## Cron Jobs
60
+ ## Scheduled Events
61
61
 
62
- Messages prefixed with `[Tool: cron/<name>]` are automated. The agent decides whether to respond:
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 cron check finds nothing new. Only send when there's something worth reading.
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
- - `.macroclaw/cron.json` — scheduled jobs (hot-reloaded, no restart needed) (use add-cron skill to modify)
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