openclaw-scheduler 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. package/v02-runtime.js +599 -0
package/setup.mjs ADDED
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenClaw Scheduler -- Interactive Setup Wizard
4
+ *
5
+ * Run from the scheduler directory:
6
+ * node setup.mjs
7
+ *
8
+ * What it does:
9
+ * 1. Runs DB migrations (creates/upgrades scheduler.db)
10
+ * 2. Appends scheduler queue/consumer entries to MEMORY.md + workspace-index.md
11
+ * 3. Creates Inbox Consumer + Stuck Run Detector scheduler jobs
12
+ * 4. Installs a macOS launchd service (LaunchAgent or LaunchDaemon, optional)
13
+ */
14
+
15
+ import readline from 'readline';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import os from 'os';
19
+ import { execSync } from 'child_process';
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import { ensureSchedulerDbParent, resolveSchedulerDbPath } from './paths.js';
23
+ import { createJob } from './jobs.js';
24
+ import { initDb } from './db.js';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const VALID_MAC_SERVICE_MODES = new Set(['agent', 'daemon', 'skip']);
28
+
29
+ function xmlEscape(value) {
30
+ return String(value)
31
+ .replaceAll('&', '&')
32
+ .replaceAll('<', '&lt;')
33
+ .replaceAll('>', '&gt;')
34
+ .replaceAll('"', '&quot;')
35
+ .replaceAll("'", '&apos;');
36
+ }
37
+
38
+ function printSetupUsage() {
39
+ process.stdout.write(`OpenClaw Scheduler setup
40
+
41
+ Usage:
42
+ node setup.mjs [--service-mode agent|daemon|skip]
43
+
44
+ Options:
45
+ --service-mode <mode> macOS only. Choose launchd install mode.
46
+ agent -> user LaunchAgent (best for auto-login workstation use)
47
+ daemon -> system LaunchDaemon (best for headless/pre-login startup)
48
+ skip -> do not install a macOS service
49
+ -h, --help Show this help
50
+ `);
51
+ }
52
+
53
+ function parseSetupArgs(argv) {
54
+ const options = { help: false, serviceMode: null };
55
+ for (let i = 0; i < argv.length; i += 1) {
56
+ const arg = argv[i];
57
+ if (arg === '--help' || arg === '-h') {
58
+ options.help = true;
59
+ continue;
60
+ }
61
+ if (arg === '--service-mode') {
62
+ const value = argv[i + 1];
63
+ if (!value) throw new Error('--service-mode requires a value: agent, daemon, or skip');
64
+ options.serviceMode = value;
65
+ i += 1;
66
+ continue;
67
+ }
68
+ if (arg.startsWith('--service-mode=')) {
69
+ options.serviceMode = arg.split('=')[1] || '';
70
+ continue;
71
+ }
72
+ throw new Error(`Unknown option: ${arg}`);
73
+ }
74
+ if (options.serviceMode && !VALID_MAC_SERVICE_MODES.has(options.serviceMode)) {
75
+ throw new Error(`Invalid --service-mode "${options.serviceMode}". Use agent, daemon, or skip.`);
76
+ }
77
+ return options;
78
+ }
79
+
80
+ const setupOptions = (() => {
81
+ try {
82
+ return parseSetupArgs(process.argv.slice(2));
83
+ } catch (err) {
84
+ process.stderr.write(`Error: ${err.message}\n`);
85
+ printSetupUsage();
86
+ process.exit(1);
87
+ }
88
+ })();
89
+
90
+ if (setupOptions.help) {
91
+ printSetupUsage();
92
+ process.exit(0);
93
+ }
94
+
95
+ // --- Helpers ------------------------------------------------------------------
96
+
97
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
98
+ const ask = (q, def) => new Promise(resolve => {
99
+ const hint = def ? ` (${def})` : '';
100
+ rl.question(`${q}${hint}: `, ans => resolve(ans.trim() || def || ''));
101
+ });
102
+ const confirm = async (q) => {
103
+ const ans = await ask(`${q} [y/N]`);
104
+ return /^y(es)?$/i.test(ans);
105
+ };
106
+ const print = (msg = '') => console.log(msg);
107
+ const ok = (msg) => console.log(` [ok] ${msg}`);
108
+ const warn = (msg) => console.log(` [WARN] ${msg}`);
109
+ const skip = (msg) => console.log(` [skip] ${msg}`);
110
+
111
+ function appendIfMissing(filePath, anchor, content) {
112
+ if (!fs.existsSync(filePath)) return false;
113
+ const existing = fs.readFileSync(filePath, 'utf8');
114
+ if (existing.includes(anchor)) return 'exists';
115
+ fs.appendFileSync(filePath, '\n' + content + '\n');
116
+ return true;
117
+ }
118
+
119
+ function getNpmConfigValue(key) {
120
+ try {
121
+ return execSync(`npm config get ${key}`, { encoding: 'utf8' }).trim();
122
+ } catch {
123
+ return '';
124
+ }
125
+ }
126
+
127
+ function getGatewayToken(homeDir) {
128
+ if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
129
+ try {
130
+ const cfgPath = path.join(homeDir, '.openclaw', 'openclaw.json');
131
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
132
+ return cfg?.gateway?.auth?.token || '';
133
+ } catch {
134
+ return '';
135
+ }
136
+ }
137
+
138
+ // --- Main ---------------------------------------------------------------------
139
+
140
+ print();
141
+ print('+======================================================+');
142
+ print('| OpenClaw Scheduler -- Setup Wizard |');
143
+ print('+======================================================+');
144
+ print();
145
+ print('This wizard will:');
146
+ print(' * Run DB migrations');
147
+ print(' * Add scheduler queue + consumer notes to agent memory files');
148
+ print(' * Create Inbox Consumer + Stuck Run Detector jobs');
149
+ print(' * Install a macOS LaunchAgent or LaunchDaemon (optional)');
150
+ print();
151
+
152
+ // --- Step 1: Paths ------------------------------------------------------------
153
+
154
+ print('-- Step 1: Paths ---------------------------------------');
155
+ const schedulerPath = __dirname;
156
+ const defaultWorkspace = path.join(os.homedir(), '.openclaw', 'workspace');
157
+ const workspacePath = await ask('Workspace path', defaultWorkspace);
158
+ const defaultGateway = 'http://127.0.0.1:18789';
159
+ const gatewayUrl = await ask('Gateway URL', defaultGateway);
160
+ const deliverTo = await ask('Telegram delivery ID for alerts (user or group ID, or blank to skip)');
161
+ const schedulerDbPath = resolveSchedulerDbPath({ env: process.env });
162
+ if (schedulerDbPath !== ':memory:') ensureSchedulerDbParent(schedulerDbPath);
163
+
164
+ print();
165
+ print(` Scheduler: ${schedulerPath}`);
166
+ print(` Workspace: ${workspacePath}`);
167
+ print(` Gateway: ${gatewayUrl}`);
168
+ print(` Deliver to: ${deliverTo || '(none -- skipping job creation)'}`);
169
+ print();
170
+
171
+ // --- Preflight: npm install behavior -----------------------------------------
172
+
173
+ print('-- Preflight: npm install behavior -------------------');
174
+ const ignoreScripts = getNpmConfigValue('ignore-scripts').toLowerCase();
175
+ if (ignoreScripts === 'true') {
176
+ warn('Detected npm config: ignore-scripts=true');
177
+ warn('better-sqlite3 requires install scripts to build/load native bindings.');
178
+ warn('Recommended fix:');
179
+ warn(' npm config set ignore-scripts false');
180
+ warn(' npm install --ignore-scripts=false');
181
+ const continueAnyway = await confirm('Continue setup anyway?');
182
+ if (!continueAnyway) {
183
+ print('Setup aborted. Fix npm config, then rerun: node setup.mjs');
184
+ rl.close();
185
+ process.exit(1);
186
+ }
187
+ } else {
188
+ ok('npm install scripts are enabled');
189
+ }
190
+ print();
191
+
192
+ // --- Step 2: DB migrations ----------------------------------------------------
193
+
194
+ print('-- Step 2: Database migrations -------------------------');
195
+ try {
196
+ const { setDbPath } = await import(path.join(schedulerPath, 'db.js'));
197
+ setDbPath(schedulerDbPath);
198
+ const migrate = (await import(path.join(schedulerPath, 'migrate-consolidate.js'))).default;
199
+ const ran = migrate();
200
+ if (ran) {
201
+ ok(`Migrations applied -> ${schedulerDbPath}`);
202
+ } else {
203
+ ok(`DB already up to date -> ${schedulerDbPath}`);
204
+ }
205
+ } catch (err) {
206
+ warn(`Migration failed: ${err.message}`);
207
+ warn('Continuing -- you can run migrations manually: node migrate-consolidate.js');
208
+ }
209
+ print();
210
+
211
+ // --- Step 3: Memory files ----------------------------------------------------
212
+
213
+ print('-- Step 3: Agent memory files --------------------------');
214
+
215
+ const memoryMd = path.join(workspacePath, 'MEMORY.md');
216
+ const memoryEntry = `- **Scheduler Queue Pattern:** Use \`node ${schedulerPath}/cli.js msg send <from> <to> "body"\` for signal-only queue entries.
217
+ Inbox Consumer (\`${schedulerPath}/scripts/inbox-consumer.mjs\`) drains pending queue messages to Telegram.
218
+ Stuck Run Detector (\`${schedulerPath}/scripts/stuck-run-detector.mjs\`) alerts on stale \`running\` runs.`;
219
+
220
+ const memResult = appendIfMissing(memoryMd, 'Scheduler Queue Pattern', memoryEntry);
221
+ if (memResult === true) ok('Appended scheduler queue entry -> MEMORY.md');
222
+ else if (memResult === 'exists') skip('Scheduler queue entry already in MEMORY.md');
223
+ else warn(`MEMORY.md not found at ${memoryMd} -- skipping`);
224
+
225
+ const workspaceIndex = path.join(workspacePath, 'memory', 'workspace-index.md');
226
+ const indexSection = `### Scheduler & Dispatch
227
+ > Covers: standalone scheduler, message queue, inbox consumer
228
+
229
+ | File | Covers | Load |
230
+ |------|--------|------|
231
+ | \`${schedulerPath}/\` | Standalone SQLite scheduler. CLI: \`node cli.js\`. launchd service: \`ai.openclaw.scheduler\`. | Any scheduler/cron work |
232
+ | \`${schedulerPath}/cli.js\` | Queue + run operations: \`msg send\`, \`msg inbox\`, \`runs running\`, \`runs stale\`. | Day-to-day scheduler operations |
233
+ | \`${schedulerPath}/scripts/inbox-consumer.mjs\` | Drains queue messages for one agent and delivers to Telegram. | Queue/inbox consumption |
234
+ | \`${schedulerPath}/scripts/stuck-run-detector.mjs\` | Detects stale \`running\` runs and exits non-zero for alerts. | Run health monitoring |`;
235
+
236
+ // Try inserting before a common section header, fall back to append.
237
+ // NOTE: the link emoji anchors must match the actual markdown heading in
238
+ // workspace index files -- do not replace with ASCII.
239
+ const idxAnchors = ['### Automation', '### Memory', '## \u{1F517}', '---\n\n## \u{1F517}'];
240
+ let idxResult = false;
241
+ if (fs.existsSync(workspaceIndex)) {
242
+ const existing = fs.readFileSync(workspaceIndex, 'utf8');
243
+ if (existing.includes('inbox-consumer.mjs') || existing.includes('stuck-run-detector.mjs')) {
244
+ idxResult = 'exists';
245
+ } else {
246
+ for (const anchor of idxAnchors) {
247
+ if (existing.includes(anchor)) {
248
+ fs.writeFileSync(workspaceIndex, existing.replace(anchor, indexSection + '\n\n' + anchor));
249
+ idxResult = true;
250
+ break;
251
+ }
252
+ }
253
+ if (!idxResult) {
254
+ fs.appendFileSync(workspaceIndex, '\n' + indexSection + '\n');
255
+ idxResult = true;
256
+ }
257
+ }
258
+ }
259
+
260
+ if (idxResult === true) ok(`Added Scheduler & Dispatch section -> workspace-index.md`);
261
+ else if (idxResult === 'exists') skip('Scheduler section already in workspace-index.md');
262
+ else warn(`workspace-index.md not found at ${workspaceIndex} -- skipping`);
263
+
264
+ print();
265
+
266
+ // --- Step 4: Scheduler jobs --------------------------------------------------
267
+
268
+ print('-- Step 4: Scheduler jobs ------------------------------');
269
+
270
+ if (!deliverTo) {
271
+ skip('No delivery ID provided -- skipping job creation');
272
+ skip('You can add jobs manually with: node cli.js jobs add \'{ ... }\'');
273
+ } else {
274
+ try {
275
+ await initDb();
276
+
277
+ const { listJobs } = await import('./jobs.js');
278
+ const existingNames = listJobs().map(r => r.name);
279
+
280
+ // Inbox Consumer
281
+ const icScript = path.join(schedulerPath, 'scripts', 'inbox-consumer.mjs');
282
+ const icName = 'Inbox Consumer';
283
+ if (existingNames.includes(icName)) {
284
+ skip(`"${icName}" job already exists`);
285
+ } else if (!fs.existsSync(icScript)) {
286
+ warn(`inbox-consumer.mjs not found at ${icScript}`);
287
+ warn('Install is incomplete. Re-clone scheduler repo or add the job manually.');
288
+ } else {
289
+ createJob({
290
+ name: icName,
291
+ schedule_cron: '*/5 * * * *',
292
+ session_target: 'shell',
293
+ payload_message: `node ${icScript} --to '${deliverTo.replace(/'/g, "'\\''")}'`,
294
+ payload_timeout_seconds: 60,
295
+ delivery_mode: 'announce',
296
+ delivery_channel: 'telegram',
297
+ delivery_to: deliverTo,
298
+ run_timeout_ms: 120000,
299
+ enabled: true,
300
+ origin: 'system',
301
+ });
302
+ ok(`Created "${icName}" job (*/5 * * * *)`);
303
+ }
304
+
305
+ // Stuck Run Detector
306
+ const srdName = 'Stuck Run Detector';
307
+ const srdScript = path.join(schedulerPath, 'scripts', 'stuck-run-detector.mjs');
308
+ const srdCmd = `node ${srdScript} --threshold-min 45`; // coding tasks regularly take 30m+
309
+ if (existingNames.includes(srdName)) {
310
+ skip(`"${srdName}" job already exists`);
311
+ } else if (!fs.existsSync(srdScript)) {
312
+ warn(`stuck-run-detector.mjs not found at ${srdScript}`);
313
+ warn('Install is incomplete. Re-clone scheduler repo or add the job manually.');
314
+ } else {
315
+ createJob({
316
+ name: srdName,
317
+ schedule_cron: '*/10 * * * *',
318
+ session_target: 'shell',
319
+ payload_message: srdCmd,
320
+ payload_timeout_seconds: 30,
321
+ delivery_mode: 'announce',
322
+ delivery_channel: 'telegram',
323
+ delivery_to: deliverTo,
324
+ run_timeout_ms: 120000,
325
+ enabled: true,
326
+ origin: 'system',
327
+ });
328
+ ok(`Created "${srdName}" job (*/10 * * * *)`);
329
+ }
330
+ } catch (err) {
331
+ warn(`Job creation failed: ${err.message}`);
332
+ }
333
+ }
334
+
335
+ print();
336
+
337
+ // --- Step 5: Service / auto-start --------------------------------------------
338
+
339
+ const platform = process.platform;
340
+ const nodePath = process.execPath;
341
+ const indexPath = path.join(schedulerPath, 'dispatcher.js');
342
+ const logPath = platform === 'win32'
343
+ ? path.join(os.tmpdir(), 'openclaw-scheduler.log')
344
+ : '/tmp/openclaw-scheduler.log';
345
+
346
+ // Detect WSL (WSL runs as linux; WSL_DISTRO_NAME is set by Microsoft)
347
+ const isWSL = platform === 'linux' && (
348
+ process.env.WSL_DISTRO_NAME ||
349
+ process.env.WSL_INTEROP ||
350
+ (() => { try { return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); } catch { return false; } })()
351
+ );
352
+ // WSL2 has systemd support; WSL1 does not
353
+ const wslVersion = isWSL && (() => {
354
+ try { return fs.readFileSync('/proc/version', 'utf8').includes('WSL2') ? 2 : 1; } catch { return null; }
355
+ })();
356
+ let macServiceSummary = null;
357
+
358
+ // -- macOS ------------------------------------------------------------------
359
+ if (platform === 'darwin') {
360
+ print('-- Step 5: Service (macOS launchd) ---------------------');
361
+ const serviceUser = os.userInfo().username;
362
+ const serviceUid = typeof process.getuid === 'function' ? process.getuid() : null;
363
+ const gatewayToken = getGatewayToken(os.homedir());
364
+ const envPath = process.env.PATH || '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin';
365
+ const serviceModes = {
366
+ agent: {
367
+ mode: 'agent',
368
+ title: 'LaunchAgent',
369
+ label: 'ai.openclaw.scheduler',
370
+ plistPath: path.join(os.homedir(), 'Library', 'LaunchAgents', 'ai.openclaw.scheduler.plist'),
371
+ domain: serviceUid == null ? null : `gui/${serviceUid}`,
372
+ installPrompt: 'Install LaunchAgent (recommended for a personal Mac with auto-login)?',
373
+ comment: 'OpenClaw Scheduler -- LaunchAgent (best for workstation/auto-login use)',
374
+ installMode: 'user',
375
+ },
376
+ daemon: {
377
+ mode: 'daemon',
378
+ title: 'LaunchDaemon',
379
+ label: 'ai.openclaw.scheduler',
380
+ plistPath: '/Library/LaunchDaemons/ai.openclaw.scheduler.plist',
381
+ domain: 'system',
382
+ installPrompt: 'Install LaunchDaemon (recommended for a headless Mac or startup before login)?',
383
+ comment: 'OpenClaw Scheduler -- LaunchDaemon (survives headless reboots)',
384
+ installMode: 'system',
385
+ },
386
+ };
387
+ const existingModes = Object.values(serviceModes).filter(cfg => fs.existsSync(cfg.plistPath));
388
+ let selectedServiceMode = setupOptions.serviceMode;
389
+ if (!selectedServiceMode) {
390
+ print(' Choose how the scheduler should start on macOS:');
391
+ print(' * agent = user LaunchAgent (best for personal Macs with auto-login)');
392
+ print(' * daemon = system LaunchDaemon (best for headless or pre-login startup)');
393
+ print(' * skip = do not install a service right now');
394
+ selectedServiceMode = (await ask('Service mode', 'agent')).toLowerCase();
395
+ while (!VALID_MAC_SERVICE_MODES.has(selectedServiceMode)) {
396
+ warn('Choose agent, daemon, or skip.');
397
+ selectedServiceMode = (await ask('Service mode', 'agent')).toLowerCase();
398
+ }
399
+ }
400
+
401
+ if (selectedServiceMode === 'skip') {
402
+ skip('Skipped macOS service install');
403
+ print(' Re-run later with: node setup.mjs --service-mode agent');
404
+ print(' or: node setup.mjs --service-mode daemon');
405
+ } else {
406
+ const service = serviceModes[selectedServiceMode];
407
+ const otherModes = existingModes.filter(cfg => cfg.mode !== service.mode);
408
+ if (otherModes.length) {
409
+ warn(`Detected existing ${otherModes.map(cfg => cfg.title).join(' + ')} install(s):`);
410
+ for (const cfg of otherModes) {
411
+ print(` * ${cfg.title}: ${cfg.plistPath}`);
412
+ }
413
+ const continueWithDuplicate = await confirm(`Install ${service.title} anyway? (This can run two schedulers if you leave both enabled)`);
414
+ if (!continueWithDuplicate) {
415
+ skip(`Skipped ${service.title} install`);
416
+ if (otherModes.length > 0) {
417
+ print(` Leaving existing ${otherModes[0].title} in place.`);
418
+ macServiceSummary = otherModes[0];
419
+ }
420
+ } else {
421
+ macServiceSummary = service;
422
+ }
423
+ } else {
424
+ macServiceSummary = service;
425
+ }
426
+
427
+ if (macServiceSummary && macServiceSummary !== service) {
428
+ // User declined new service and kept existing one -- skip install block
429
+ } else if (macServiceSummary && fs.existsSync(service.plistPath)) {
430
+ skip(`${service.title} already installed`);
431
+ print(` Path: ${service.plistPath}`);
432
+ if (service.domain) {
433
+ const restartPrefix = service.mode === 'daemon' ? 'sudo ' : '';
434
+ print(` To restart: ${restartPrefix}launchctl kickstart -k ${service.domain}/${service.label}`);
435
+ }
436
+ } else if (macServiceSummary) {
437
+ const install = await confirm(service.installPrompt);
438
+ if (install) {
439
+ const tokenXml = gatewayToken
440
+ ? ` <key>OPENCLAW_GATEWAY_TOKEN</key>\n <string>${xmlEscape(gatewayToken)}</string>\n`
441
+ : '';
442
+ const userXml = service.mode === 'daemon'
443
+ ? ` <key>UserName</key>\n <string>${xmlEscape(serviceUser)}</string>\n`
444
+ : '';
445
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
446
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
447
+ <plist version="1.0">
448
+ <dict>
449
+ <key>Comment</key>
450
+ <string>${xmlEscape(service.comment)}</string>
451
+ <key>Label</key>
452
+ <string>${service.label}</string>
453
+ <key>ProgramArguments</key>
454
+ <array>
455
+ <string>${xmlEscape(nodePath)}</string>
456
+ <string>--no-warnings</string>
457
+ <string>${xmlEscape(indexPath)}</string>
458
+ </array>
459
+ ${userXml} <key>WorkingDirectory</key>
460
+ <string>${xmlEscape(schedulerPath)}</string>
461
+ <key>EnvironmentVariables</key>
462
+ <dict>
463
+ <key>HOME</key>
464
+ <string>${xmlEscape(os.homedir())}</string>
465
+ <key>PATH</key>
466
+ <string>${xmlEscape(envPath)}</string>
467
+ <key>OPENCLAW_GATEWAY_URL</key>
468
+ <string>${xmlEscape(gatewayUrl)}</string>
469
+ <key>SCHEDULER_DB</key>
470
+ <string>${xmlEscape(schedulerDbPath)}</string>
471
+ ${tokenXml} </dict>
472
+ <key>RunAtLoad</key>
473
+ <true/>
474
+ <key>KeepAlive</key>
475
+ <true/>
476
+ <key>ThrottleInterval</key>
477
+ <integer>30</integer>
478
+ <key>StandardOutPath</key>
479
+ <string>${xmlEscape(logPath)}</string>
480
+ <key>StandardErrorPath</key>
481
+ <string>${xmlEscape(logPath)}</string>
482
+ </dict>
483
+ </plist>`;
484
+ if (service.mode === 'daemon') {
485
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-'));
486
+ const tmpPlistPath = path.join(tmpDir, 'ai.openclaw.scheduler.plist');
487
+ fs.writeFileSync(tmpPlistPath, plist, { mode: 0o600 });
488
+ try {
489
+ execSync(`sudo install -o root -g wheel -m 644 "${tmpPlistPath}" "${service.plistPath}"`, { stdio: 'inherit' });
490
+ execSync(`sudo launchctl bootstrap ${service.domain} "${service.plistPath}"`, { stdio: 'inherit' });
491
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
492
+ ok(`${service.title} installed and bootstrapped`);
493
+ } catch (err) {
494
+ ok(`${service.title} plist written -> ${tmpPlistPath}`);
495
+ warn(`Auto-bootstrap failed: ${err.message.trim()}`);
496
+ warn(`Run manually: sudo install -o root -g wheel -m 644 "${tmpPlistPath}" "${service.plistPath}"`);
497
+ warn(`Then: sudo launchctl bootstrap ${service.domain} "${service.plistPath}"`);
498
+ }
499
+ } else {
500
+ fs.mkdirSync(path.dirname(service.plistPath), { recursive: true });
501
+ fs.writeFileSync(service.plistPath, plist);
502
+ try {
503
+ execSync(`launchctl bootstrap ${service.domain} "${service.plistPath}"`, { stdio: 'inherit' });
504
+ ok(`${service.title} installed and bootstrapped`);
505
+ } catch (err) {
506
+ ok(`${service.title} plist written -> ${service.plistPath}`);
507
+ warn(`Auto-bootstrap failed: ${err.message.trim()}`);
508
+ warn(`Run manually: launchctl bootstrap ${service.domain} "${service.plistPath}"`);
509
+ }
510
+ }
511
+ print(` Logs: ${logPath}`);
512
+ } else {
513
+ skip(`Skipped ${service.title} install -- run again to install later`);
514
+ macServiceSummary = null;
515
+ }
516
+ }
517
+ }
518
+
519
+ // -- Linux ------------------------------------------------------------------
520
+ } else if (platform === 'linux') {
521
+ if (isWSL) {
522
+ const wslLabel = wslVersion ? `WSL${wslVersion}` : 'WSL';
523
+ print(`-- Step 5: Service (${wslLabel}) ------------------------------`);
524
+ if (wslVersion === 1) {
525
+ print(' WSL1 detected -- systemd not supported. Using PM2.');
526
+ } else {
527
+ print(' WSL2 detected. Systemd is supported if enabled in /etc/wsl.conf.');
528
+ print(' If not enabled: add [boot] systemd=true to /etc/wsl.conf, then wsl --shutdown.');
529
+ }
530
+ } else {
531
+ print('-- Step 5: Service (Linux) -----------------------------');
532
+ }
533
+
534
+ const gatewayToken = getGatewayToken(os.homedir());
535
+
536
+ // Detect whether systemd user session is available
537
+ let hasSystemd = false;
538
+ if (isWSL && wslVersion === 1) {
539
+ hasSystemd = false; // WSL1 never has systemd
540
+ } else {
541
+ try { execSync('systemctl --user status', { stdio: 'ignore' }); hasSystemd = true; } catch {}
542
+ if (!hasSystemd) {
543
+ try { execSync('systemctl --user list-units', { stdio: 'ignore' }); hasSystemd = true; } catch {}
544
+ }
545
+ }
546
+
547
+ // Check for PM2
548
+ let hasPm2 = false;
549
+ try { execSync('pm2 --version', { stdio: 'ignore' }); hasPm2 = true; } catch {}
550
+
551
+ if (hasSystemd) {
552
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
553
+ const unitPath = path.join(unitDir, 'openclaw-scheduler.service');
554
+
555
+ if (fs.existsSync(unitPath)) {
556
+ skip('systemd user service already installed');
557
+ print(` Path: ${unitPath}`);
558
+ print(' To restart: systemctl --user restart openclaw-scheduler');
559
+ } else {
560
+ const install = await confirm('Install systemd user service (auto-start on login)?');
561
+ if (install) {
562
+ const unit = `[Unit]
563
+ Description=OpenClaw Scheduler
564
+ After=network.target
565
+
566
+ [Service]
567
+ Type=simple
568
+ WorkingDirectory=${schedulerPath}
569
+ ExecStart=${nodePath} --no-warnings ${indexPath}
570
+ Environment=OPENCLAW_GATEWAY_URL=${gatewayUrl}${gatewayToken ? `\nEnvironment="OPENCLAW_GATEWAY_TOKEN=${gatewayToken.replace(/"/g, '\\"')}"` : ''}
571
+ Environment=SCHEDULER_DB=${schedulerDbPath}
572
+ Restart=always
573
+ RestartSec=5
574
+ StandardOutput=append:${logPath}
575
+ StandardError=append:${logPath}
576
+
577
+ [Install]
578
+ WantedBy=default.target
579
+ `;
580
+ fs.mkdirSync(unitDir, { recursive: true, mode: 0o700 });
581
+ fs.writeFileSync(unitPath, unit, { mode: 0o600 });
582
+ try {
583
+ execSync('systemctl --user daemon-reload');
584
+ execSync('systemctl --user enable --now openclaw-scheduler');
585
+ ok('systemd user service installed and started');
586
+ } catch (err) {
587
+ ok(`Unit file written -> ${unitPath}`);
588
+ warn(`Auto-start failed: ${err.message.trim()}`);
589
+ warn('Run manually:');
590
+ warn(' systemctl --user daemon-reload');
591
+ warn(' systemctl --user enable --now openclaw-scheduler');
592
+ }
593
+ print(` Logs: ${logPath} (or: journalctl --user -u openclaw-scheduler -f)`);
594
+ } else {
595
+ skip('Skipped -- run again to install later');
596
+ }
597
+ }
598
+ } else if (hasPm2) {
599
+ print(' systemd user session not available -- using PM2');
600
+ const pm2Name = 'openclaw-scheduler';
601
+ let pm2Running = false;
602
+ try {
603
+ const out = execSync('pm2 list --no-color', { encoding: 'utf8' });
604
+ pm2Running = out.includes(pm2Name);
605
+ } catch {}
606
+
607
+ if (pm2Running) {
608
+ skip(`PM2 process "${pm2Name}" already running`);
609
+ print(' To restart: pm2 restart openclaw-scheduler');
610
+ } else {
611
+ const install = await confirm('Register with PM2 (auto-start on login)?');
612
+ if (install) {
613
+ try {
614
+ execSync(
615
+ `pm2 start "${indexPath}" --name "${pm2Name}" --cwd "${schedulerPath}" ` +
616
+ `--log "${logPath}"`,
617
+ {
618
+ stdio: 'inherit',
619
+ env: {
620
+ ...process.env,
621
+ OPENCLAW_GATEWAY_URL: gatewayUrl,
622
+ SCHEDULER_DB: schedulerDbPath,
623
+ },
624
+ }
625
+ );
626
+ execSync('pm2 save');
627
+ ok('PM2 process started and saved');
628
+ print(' Run `pm2 startup` and follow the instructions to survive reboots.');
629
+ } catch (err) {
630
+ warn(`PM2 start failed: ${err.message.trim()}`);
631
+ }
632
+ } else {
633
+ skip('Skipped -- run again to install later');
634
+ }
635
+ }
636
+ } else {
637
+ warn('Neither systemd user session nor PM2 found');
638
+ print(' Options:');
639
+ print(' * Install PM2: npm install -g pm2');
640
+ print(' * Or run manually: node dispatcher.js &');
641
+ print(' * See INSTALL-LINUX.md for systemd setup without a user session');
642
+ }
643
+
644
+ // -- Windows (native) -------------------------------------------------------
645
+ } else if (platform === 'win32') {
646
+ print('-- Step 5: Service (Windows) ---------------------------');
647
+ print();
648
+ warn('Native Windows detected.');
649
+ print(' OpenClaw Scheduler is designed to run inside WSL (Windows Subsystem for Linux).');
650
+ print(' Running natively on Windows is not supported.');
651
+ print();
652
+ print(' Setup steps:');
653
+ print(' 1. Install WSL2: wsl --install (in PowerShell as Admin)');
654
+ print(' 2. Open your WSL terminal and run this wizard again from there:');
655
+ print(` cd ${schedulerPath.replace(/\\/g, '/')}`);
656
+ print(' node setup.mjs');
657
+ print();
658
+ print(' WSL2 with systemd enabled gives the best experience (auto-start on login).');
659
+ print(' See INSTALL-WINDOWS.md for the full WSL2 + systemd setup guide.');
660
+
661
+ // -- Unknown ----------------------------------------------------------------
662
+ } else {
663
+ skip(`Unsupported platform: ${platform}`);
664
+ print(' Start manually: node dispatcher.js');
665
+ }
666
+
667
+ print();
668
+
669
+ // --- Done ---------------------------------------------------------------------
670
+
671
+ print('-- Done! -----------------------------------------------');
672
+ print();
673
+ print('Next steps:');
674
+
675
+ if (platform === 'darwin') {
676
+ if (macServiceSummary?.domain) {
677
+ const prefix = macServiceSummary.mode === 'daemon' ? 'sudo ' : '';
678
+ print(` * Service mode: ${macServiceSummary.title}`);
679
+ print(` * Check service: ${prefix}launchctl print ${macServiceSummary.domain}/${macServiceSummary.label}`);
680
+ print(` * Restart: ${prefix}launchctl kickstart -k ${macServiceSummary.domain}/${macServiceSummary.label}`);
681
+ } else {
682
+ print(' * Install later: node setup.mjs --service-mode agent');
683
+ print(' node setup.mjs --service-mode daemon');
684
+ }
685
+ } else if (platform === 'linux') {
686
+ if (isWSL) {
687
+ print(' * Check service: systemctl --user status openclaw-scheduler (or: pm2 status)');
688
+ print(' * Logs: journalctl --user -u openclaw-scheduler -f (or: pm2 logs)');
689
+ print(' * Note: if WSL session closes, restart with: systemctl --user start openclaw-scheduler');
690
+ } else {
691
+ print(' * Check service: systemctl --user status openclaw-scheduler (or: pm2 status)');
692
+ print(' * Logs: journalctl --user -u openclaw-scheduler -f (or: pm2 logs)');
693
+ }
694
+ } else if (platform === 'win32') {
695
+ print(' * Run setup inside WSL -- see instructions above');
696
+ }
697
+
698
+ print(' * Scheduler CLI: node cli.js status');
699
+ print(' * List jobs: node cli.js jobs list');
700
+ print(' * Queue test: node cli.js msg send system main "setup smoke test"');
701
+ print(` * Logs: ${logPath}`);
702
+ print(' * Docs: README.md');
703
+ print();
704
+ print('-- [WARN] Important: activate memory changes -------------');
705
+ print();
706
+ print(' Memory file changes (MEMORY.md, workspace-index.md) only take');
707
+ print(' effect in NEW sessions. Your agent\'s current session won\'t see');
708
+ print(' them until it explicitly re-reads the files.');
709
+ print();
710
+ print(' Tell your agent now:');
711
+ print();
712
+ if (workspacePath) {
713
+ print(` "Read ${path.join(workspacePath, 'MEMORY.md')} and`);
714
+ print(` ${path.join(workspacePath, 'memory', 'workspace-index.md')} --`);
715
+ print(' scheduler queue pattern notes were added. Load them into your context."');
716
+ } else {
717
+ print(' "Read your MEMORY.md and memory/workspace-index.md --');
718
+ print(' scheduler queue pattern notes were added. Load them into your context."');
719
+ }
720
+ print();
721
+ print(' Future sessions will pick it up automatically via memory_search.');
722
+ print();
723
+
724
+ rl.close();