heartbeat-opencode-plugin 0.1.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.
@@ -0,0 +1,966 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import {
4
+ execFileSync,
5
+ spawn,
6
+ } from "child_process";
7
+ import { homedir } from "os";
8
+ import { fileURLToPath } from "url";
9
+ import { type Plugin, tool } from "@opencode-ai/plugin";
10
+ import {
11
+ getBunPath,
12
+ getDefaultWorkdir,
13
+ getJobsDir,
14
+ getLaunchdLabel as getConfigLaunchdLabel,
15
+ getLogFilePath,
16
+ getLogsDir,
17
+ loadConfig,
18
+ } from "../config";
19
+ import { appendLog } from "../memory";
20
+
21
+ type JobKind = "opencode" | "command";
22
+ type RunStatus = "running" | "success" | "failed" | "skipped";
23
+
24
+ export interface Job {
25
+ id: string;
26
+ name: string;
27
+ kind: JobKind;
28
+ schedule: string;
29
+ prompt?: string;
30
+ command: string;
31
+ args: string[];
32
+ workdir: string;
33
+ enabled: boolean;
34
+ createdAt: string;
35
+ updatedAt?: string;
36
+ lastRunAt?: string;
37
+ lastRunStatus?: RunStatus;
38
+ lastRunExitCode?: number;
39
+ lastRunError?: string;
40
+ }
41
+
42
+ export interface CreateCommandJobInput {
43
+ name: string;
44
+ schedule: string;
45
+ command: string;
46
+ args?: string[];
47
+ workdir?: string;
48
+ enabled?: boolean;
49
+ }
50
+
51
+ export interface CreateOpencodeJobInput {
52
+ name: string;
53
+ schedule: string;
54
+ prompt: string;
55
+ workdir?: string;
56
+ enabled?: boolean;
57
+ }
58
+
59
+ export interface RunResult {
60
+ jobId: string;
61
+ status: RunStatus;
62
+ startedAt: string;
63
+ finishedAt: string;
64
+ durationMs: number;
65
+ exitCode: number | null;
66
+ signal: string | null;
67
+ error?: string;
68
+ }
69
+
70
+ const IS_MAC = process.platform === "darwin";
71
+ const LAUNCH_AGENTS_DIR = path.join(homedir(), "Library", "LaunchAgents");
72
+ const SCHEDULER_ENTRY = fileURLToPath(import.meta.url);
73
+
74
+ function ensureDir(dir: string): void {
75
+ if (!fs.existsSync(dir)) {
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ }
78
+ }
79
+
80
+ function isoNow(): string {
81
+ return new Date().toISOString();
82
+ }
83
+
84
+ function slugify(name: string): string {
85
+ const slug = name
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, "-")
88
+ .replace(/^-|-$/g, "");
89
+
90
+ if (slug) return slug;
91
+ return `job-${Date.now()}`;
92
+ }
93
+
94
+ function jobFilePath(jobId: string): string {
95
+ return path.join(getJobsDir(), `${jobId}.json`);
96
+ }
97
+
98
+ function sharedLogPath(): string {
99
+ return getLogFilePath();
100
+ }
101
+
102
+ function appendJobLog(job: Pick<Job, "id">, message: string): void {
103
+ appendLog(`[Scheduler][job:${job.id}] ${message}`);
104
+ }
105
+
106
+ function readJob(jobId: string): Job | null {
107
+ const file = jobFilePath(jobId);
108
+ if (!fs.existsSync(file)) return null;
109
+ try {
110
+ const raw = fs.readFileSync(file, "utf-8");
111
+ return JSON.parse(raw) as Job;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function writeJob(job: Job): void {
118
+ ensureDir(getJobsDir());
119
+ fs.writeFileSync(jobFilePath(job.id), JSON.stringify(job, null, 2));
120
+ }
121
+
122
+ function deleteJobFile(jobId: string): boolean {
123
+ const file = jobFilePath(jobId);
124
+ if (!fs.existsSync(file)) return false;
125
+ fs.unlinkSync(file);
126
+ return true;
127
+ }
128
+
129
+ function splitLines(chunk: string): { lines: string[]; remainder: string } {
130
+ const normalized = chunk.replace(/\r\n/g, "\n");
131
+ const parts = normalized.split("\n");
132
+ const remainder = parts.pop() ?? "";
133
+ return { lines: parts, remainder };
134
+ }
135
+
136
+ function buildHeartbeatPrompt(input: {
137
+ jobId: string;
138
+ jobName: string;
139
+ prompt: string;
140
+ }): string {
141
+ return [
142
+ "You are running inside Heartbeat scheduler as a human-created cron job.",
143
+ `Job ID: ${input.jobId}`,
144
+ `Job Name: ${input.jobName}`,
145
+ "",
146
+ "Scheduler intent:",
147
+ "- Scheduler is only the runner. Do not create or modify scheduler jobs from this run.",
148
+ "- Emit clear progress lines; all output is streamed into shared memory logs.",
149
+ "- If needed, read previous logs using memory tools (search_memory / memory_grep).",
150
+ "",
151
+ "Run instructions:",
152
+ input.prompt,
153
+ ].join("\n");
154
+ }
155
+
156
+ function validateCron(schedule: string): void {
157
+ parseCronToLaunchd(schedule);
158
+ }
159
+
160
+ export function initScheduler(projectDir?: string): void {
161
+ loadConfig(projectDir);
162
+ ensureDir(getJobsDir());
163
+ ensureDir(getLogsDir());
164
+ }
165
+
166
+ export function listJobs(): Job[] {
167
+ ensureDir(getJobsDir());
168
+ const files = fs.readdirSync(getJobsDir()).filter((entry) => entry.endsWith(".json"));
169
+ return files
170
+ .map((entry) => {
171
+ try {
172
+ const raw = fs.readFileSync(path.join(getJobsDir(), entry), "utf-8");
173
+ return JSON.parse(raw) as Job;
174
+ } catch {
175
+ return null;
176
+ }
177
+ })
178
+ .filter((job): job is Job => Boolean(job))
179
+ .sort((a, b) => a.name.localeCompare(b.name));
180
+ }
181
+
182
+ export function getJob(jobId: string): Job | null {
183
+ return readJob(jobId);
184
+ }
185
+
186
+ export function createCommandJob(input: CreateCommandJobInput): Job {
187
+ validateCron(input.schedule);
188
+ const id = slugify(input.name);
189
+
190
+ if (readJob(id)) {
191
+ throw new Error(`Job already exists: ${id}`);
192
+ }
193
+
194
+ const job: Job = {
195
+ id,
196
+ name: input.name,
197
+ kind: "command",
198
+ schedule: input.schedule,
199
+ command: input.command,
200
+ args: input.args ?? [],
201
+ workdir: input.workdir ?? getDefaultWorkdir(),
202
+ enabled: input.enabled ?? true,
203
+ createdAt: isoNow(),
204
+ };
205
+
206
+ writeJob(job);
207
+ appendJobLog(job, `Created command job "${job.name}" schedule=${job.schedule}`);
208
+ return job;
209
+ }
210
+
211
+ export function createOpencodeJob(input: CreateOpencodeJobInput): Job {
212
+ validateCron(input.schedule);
213
+ const id = slugify(input.name);
214
+
215
+ if (readJob(id)) {
216
+ throw new Error(`Job already exists: ${id}`);
217
+ }
218
+
219
+ const heartbeatPrompt = buildHeartbeatPrompt({
220
+ jobId: id,
221
+ jobName: input.name,
222
+ prompt: input.prompt,
223
+ });
224
+
225
+ const job: Job = {
226
+ id,
227
+ name: input.name,
228
+ kind: "opencode",
229
+ schedule: input.schedule,
230
+ prompt: input.prompt,
231
+ command: "opencode",
232
+ args: ["run", heartbeatPrompt],
233
+ workdir: input.workdir ?? getDefaultWorkdir(),
234
+ enabled: input.enabled ?? true,
235
+ createdAt: isoNow(),
236
+ };
237
+
238
+ writeJob(job);
239
+ appendJobLog(job, `Created opencode job "${job.name}" schedule=${job.schedule}`);
240
+ return job;
241
+ }
242
+
243
+ export function updateJob(
244
+ jobId: string,
245
+ updates: Partial<
246
+ Pick<Job, "name" | "schedule" | "prompt" | "workdir" | "enabled" | "command" | "args">
247
+ >,
248
+ ): Job | null {
249
+ const existing = readJob(jobId);
250
+ if (!existing) return null;
251
+
252
+ if (updates.schedule) {
253
+ validateCron(updates.schedule);
254
+ }
255
+
256
+ let nextCommand = updates.command ?? existing.command;
257
+ let nextArgs = updates.args ?? existing.args;
258
+ const nextPrompt = updates.prompt ?? existing.prompt;
259
+
260
+ if (existing.kind === "opencode" && (updates.prompt !== undefined || updates.name !== undefined)) {
261
+ if (!nextPrompt) {
262
+ throw new Error(`Opencode job ${existing.id} is missing prompt content.`);
263
+ }
264
+ nextCommand = "opencode";
265
+ nextArgs = [
266
+ "run",
267
+ buildHeartbeatPrompt({
268
+ jobId: existing.id,
269
+ jobName: updates.name ?? existing.name,
270
+ prompt: nextPrompt,
271
+ }),
272
+ ];
273
+ }
274
+
275
+ const updated: Job = {
276
+ ...existing,
277
+ ...updates,
278
+ prompt: nextPrompt,
279
+ command: nextCommand,
280
+ args: nextArgs,
281
+ updatedAt: isoNow(),
282
+ };
283
+
284
+ writeJob(updated);
285
+ appendJobLog(updated, `Updated job "${updated.name}"`);
286
+ return updated;
287
+ }
288
+
289
+ export function deleteJob(jobId: string): boolean {
290
+ const job = readJob(jobId);
291
+ if (!job) return false;
292
+
293
+ try {
294
+ uninstallLaunchdJob(job);
295
+ } catch {
296
+ // Ignore launchd teardown errors during delete.
297
+ }
298
+
299
+ const deleted = deleteJobFile(jobId);
300
+ if (deleted) {
301
+ appendJobLog(job, `Deleted job "${job.name}"`);
302
+ }
303
+ return deleted;
304
+ }
305
+
306
+ function createLineDrain(
307
+ onLine: (line: string) => void,
308
+ ): {
309
+ push: (chunk: Buffer) => void;
310
+ flush: () => void;
311
+ } {
312
+ let remainder = "";
313
+
314
+ return {
315
+ push(chunk: Buffer) {
316
+ const next = remainder + chunk.toString("utf-8");
317
+ const parsed = splitLines(next);
318
+ remainder = parsed.remainder;
319
+ for (const line of parsed.lines) {
320
+ if (line.trim().length > 0) {
321
+ onLine(line);
322
+ }
323
+ }
324
+ },
325
+ flush() {
326
+ if (remainder.trim().length > 0) {
327
+ onLine(remainder);
328
+ }
329
+ remainder = "";
330
+ },
331
+ };
332
+ }
333
+
334
+ export function runJob(jobId: string): Promise<RunResult> {
335
+ return new Promise((resolve, reject) => {
336
+ const job = readJob(jobId);
337
+ if (!job) {
338
+ reject(new Error(`Job not found: ${jobId}`));
339
+ return;
340
+ }
341
+
342
+ const startedAt = isoNow();
343
+ const startedMs = Date.now();
344
+
345
+ if (!job.enabled) {
346
+ const skipped: RunResult = {
347
+ jobId: job.id,
348
+ status: "skipped",
349
+ startedAt,
350
+ finishedAt: startedAt,
351
+ durationMs: 0,
352
+ exitCode: 0,
353
+ signal: null,
354
+ };
355
+ appendJobLog(job, `Skipped disabled job "${job.name}"`);
356
+ resolve(skipped);
357
+ return;
358
+ }
359
+
360
+ job.lastRunAt = startedAt;
361
+ job.lastRunStatus = "running";
362
+ delete job.lastRunExitCode;
363
+ delete job.lastRunError;
364
+ writeJob(job);
365
+
366
+ appendJobLog(job, `Starting job "${job.name}"`);
367
+ appendJobLog(job, `Command: ${job.command} ${job.args.join(" ")}`);
368
+ appendJobLog(job, `Workdir: ${job.workdir}`);
369
+
370
+ let child: ReturnType<typeof spawn>;
371
+ try {
372
+ child = spawn(job.command, job.args, {
373
+ cwd: job.workdir,
374
+ env: process.env,
375
+ shell: false,
376
+ stdio: ["ignore", "pipe", "pipe"],
377
+ });
378
+ } catch (error) {
379
+ const msg = error instanceof Error ? error.message : String(error);
380
+ const finishedAt = isoNow();
381
+ const failed: RunResult = {
382
+ jobId: job.id,
383
+ status: "failed",
384
+ startedAt,
385
+ finishedAt,
386
+ durationMs: Date.now() - startedMs,
387
+ exitCode: null,
388
+ signal: null,
389
+ error: msg,
390
+ };
391
+
392
+ job.lastRunStatus = "failed";
393
+ job.lastRunExitCode = undefined;
394
+ job.lastRunError = msg;
395
+ job.updatedAt = finishedAt;
396
+ writeJob(job);
397
+ appendJobLog(job, `[stderr] Failed to spawn: ${msg}`);
398
+ resolve(failed);
399
+ return;
400
+ }
401
+
402
+ const stdoutDrain = createLineDrain((line) => {
403
+ appendJobLog(job, `[stdout] ${line}`);
404
+ });
405
+ const stderrDrain = createLineDrain((line) => {
406
+ appendJobLog(job, `[stderr] ${line}`);
407
+ });
408
+
409
+ child.stdout?.on("data", (chunk: Buffer) => stdoutDrain.push(chunk));
410
+ child.stderr?.on("data", (chunk: Buffer) => stderrDrain.push(chunk));
411
+
412
+ let settled = false;
413
+ const finish = (result: RunResult): void => {
414
+ if (settled) return;
415
+ settled = true;
416
+
417
+ stdoutDrain.flush();
418
+ stderrDrain.flush();
419
+
420
+ job.lastRunStatus = result.status;
421
+ job.lastRunExitCode = result.exitCode ?? undefined;
422
+ job.lastRunError = result.error;
423
+ job.updatedAt = result.finishedAt;
424
+ writeJob(job);
425
+
426
+ appendJobLog(
427
+ job,
428
+ `Finished status=${result.status} exit=${result.exitCode ?? "null"} durationMs=${result.durationMs}`,
429
+ );
430
+
431
+ resolve(result);
432
+ };
433
+
434
+ child.on("error", (error) => {
435
+ const finishedAt = isoNow();
436
+ finish({
437
+ jobId: job.id,
438
+ status: "failed",
439
+ startedAt,
440
+ finishedAt,
441
+ durationMs: Date.now() - startedMs,
442
+ exitCode: null,
443
+ signal: null,
444
+ error: error.message,
445
+ });
446
+ });
447
+
448
+ child.on("close", (code, signal) => {
449
+ const finishedAt = isoNow();
450
+ finish({
451
+ jobId: job.id,
452
+ status: code === 0 ? "success" : "failed",
453
+ startedAt,
454
+ finishedAt,
455
+ durationMs: Date.now() - startedMs,
456
+ exitCode: code,
457
+ signal,
458
+ error: code === 0 ? undefined : `Exit code ${code}`,
459
+ });
460
+ });
461
+ });
462
+ }
463
+
464
+ type CalendarKey = "Minute" | "Hour" | "Day" | "Month" | "Weekday";
465
+ type CalendarEntry = Partial<Record<CalendarKey, number>>;
466
+
467
+ function parseCronNumber(
468
+ value: string,
469
+ min: number,
470
+ max: number,
471
+ label: string,
472
+ allowSundaySeven = false,
473
+ ): number {
474
+ const parsed = Number.parseInt(value, 10);
475
+ if (!Number.isFinite(parsed)) {
476
+ throw new Error(`Invalid cron ${label}: ${value}`);
477
+ }
478
+
479
+ const normalized = allowSundaySeven && parsed === 7 ? 0 : parsed;
480
+ if (normalized < min || normalized > max) {
481
+ throw new Error(`Cron ${label} out of range (${min}-${max}): ${value}`);
482
+ }
483
+ return normalized;
484
+ }
485
+
486
+ function parseCronField(
487
+ field: string,
488
+ min: number,
489
+ max: number,
490
+ label: string,
491
+ allowSundaySeven = false,
492
+ ): number[] | null {
493
+ if (field === "*") return null;
494
+
495
+ const values = new Set<number>();
496
+ const tokens = field.split(",");
497
+
498
+ for (const token of tokens) {
499
+ const stepMatch = token.match(/^\*\/(\d+)$/);
500
+ if (stepMatch) {
501
+ const step = Number.parseInt(stepMatch[1], 10);
502
+ if (!Number.isFinite(step) || step <= 0) {
503
+ throw new Error(`Invalid cron ${label} step: ${token}`);
504
+ }
505
+ for (let value = min; value <= max; value += step) {
506
+ values.add(value);
507
+ }
508
+ continue;
509
+ }
510
+
511
+ const rangeMatch = token.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
512
+ if (rangeMatch) {
513
+ const start = parseCronNumber(rangeMatch[1], min, max, label, allowSundaySeven);
514
+ const end = parseCronNumber(rangeMatch[2], min, max, label, allowSundaySeven);
515
+ const step = rangeMatch[3] ? Number.parseInt(rangeMatch[3], 10) : 1;
516
+ if (step <= 0) {
517
+ throw new Error(`Invalid cron ${label} range step: ${token}`);
518
+ }
519
+ if (end < start) {
520
+ throw new Error(`Invalid cron ${label} range: ${token}`);
521
+ }
522
+ for (let value = start; value <= end; value += step) {
523
+ values.add(value);
524
+ }
525
+ continue;
526
+ }
527
+
528
+ values.add(parseCronNumber(token, min, max, label, allowSundaySeven));
529
+ }
530
+
531
+ return Array.from(values).sort((a, b) => a - b);
532
+ }
533
+
534
+ function expandCalendar(
535
+ base: CalendarEntry[],
536
+ key: CalendarKey,
537
+ values: number[] | null,
538
+ ): CalendarEntry[] {
539
+ if (!values) return base;
540
+
541
+ const next: CalendarEntry[] = [];
542
+ for (const row of base) {
543
+ for (const value of values) {
544
+ next.push({
545
+ ...row,
546
+ [key]: value,
547
+ });
548
+ }
549
+ }
550
+ return next;
551
+ }
552
+
553
+ function parseCronToLaunchd(cron: string): { startInterval?: number; calendars?: CalendarEntry[] } {
554
+ const parts = cron.trim().split(/\s+/);
555
+ if (parts.length !== 5) {
556
+ throw new Error(`Invalid cron expression: ${cron}`);
557
+ }
558
+
559
+ const [minute, hour, day, month, weekday] = parts;
560
+
561
+ if (day !== "*" && weekday !== "*") {
562
+ throw new Error("Cron with both day-of-month and day-of-week is not supported for launchd.");
563
+ }
564
+
565
+ if (hour === "*" && day === "*" && month === "*" && weekday === "*") {
566
+ if (minute === "*") {
567
+ return { startInterval: 60 };
568
+ }
569
+ const everyMatch = minute.match(/^\*\/(\d+)$/);
570
+ if (everyMatch) {
571
+ const every = Number.parseInt(everyMatch[1], 10);
572
+ if (every <= 0) {
573
+ throw new Error(`Invalid minute step in cron: ${minute}`);
574
+ }
575
+ return { startInterval: every * 60 };
576
+ }
577
+ }
578
+
579
+ const minutes = parseCronField(minute, 0, 59, "minute");
580
+ const hours = parseCronField(hour, 0, 23, "hour");
581
+ const days = parseCronField(day, 1, 31, "day");
582
+ const months = parseCronField(month, 1, 12, "month");
583
+ const weekdays = parseCronField(weekday, 0, 6, "weekday", true);
584
+
585
+ let calendars: CalendarEntry[] = [{}];
586
+ calendars = expandCalendar(calendars, "Minute", minutes);
587
+ calendars = expandCalendar(calendars, "Hour", hours);
588
+ calendars = expandCalendar(calendars, "Day", days);
589
+ calendars = expandCalendar(calendars, "Month", months);
590
+ calendars = expandCalendar(calendars, "Weekday", weekdays);
591
+
592
+ if (calendars.length === 0) {
593
+ calendars = [{}];
594
+ }
595
+
596
+ if (calendars.length > 256) {
597
+ throw new Error("Cron expands to too many launchd schedules. Use a simpler schedule.");
598
+ }
599
+
600
+ return { calendars };
601
+ }
602
+
603
+ function escapeXml(value: string): string {
604
+ return value
605
+ .replace(/&/g, "&amp;")
606
+ .replace(/</g, "&lt;")
607
+ .replace(/>/g, "&gt;");
608
+ }
609
+
610
+ function launchdPlistPath(job: Job): string {
611
+ return path.join(LAUNCH_AGENTS_DIR, `${getConfigLaunchdLabel(job.id)}.plist`);
612
+ }
613
+
614
+ function buildCalendarXml(calendars: CalendarEntry[]): string {
615
+ return calendars
616
+ .map((entry) => {
617
+ const rows = Object.entries(entry)
618
+ .map(([key, value]) => ` <key>${key}</key>\n <integer>${value}</integer>`)
619
+ .join("\n");
620
+ return ` <dict>\n${rows}\n </dict>`;
621
+ })
622
+ .join("\n");
623
+ }
624
+
625
+ function createLaunchdPlist(job: Job): string {
626
+ const label = getConfigLaunchdLabel(job.id);
627
+ const parsed = parseCronToLaunchd(job.schedule);
628
+
629
+ const scheduleBlock = parsed.startInterval
630
+ ? ` <key>StartInterval</key>\n <integer>${parsed.startInterval}</integer>`
631
+ : ` <key>StartCalendarInterval</key>\n <array>\n${buildCalendarXml(parsed.calendars ?? [{}])}\n </array>`;
632
+
633
+ return `<?xml version="1.0" encoding="UTF-8"?>
634
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
635
+ <plist version="1.0">
636
+ <dict>
637
+ <key>Label</key>
638
+ <string>${escapeXml(label)}</string>
639
+ <key>WorkingDirectory</key>
640
+ <string>${escapeXml(job.workdir)}</string>
641
+ <key>ProgramArguments</key>
642
+ <array>
643
+ <string>${escapeXml(getBunPath())}</string>
644
+ <string>run</string>
645
+ <string>${escapeXml(SCHEDULER_ENTRY)}</string>
646
+ <string>run</string>
647
+ <string>${escapeXml(job.id)}</string>
648
+ </array>
649
+ ${scheduleBlock}
650
+ <key>StandardOutPath</key>
651
+ <string>${escapeXml(sharedLogPath())}</string>
652
+ <key>StandardErrorPath</key>
653
+ <string>${escapeXml(sharedLogPath())}</string>
654
+ <key>RunAtLoad</key>
655
+ <false/>
656
+ </dict>
657
+ </plist>`;
658
+ }
659
+
660
+ export function installLaunchdJob(job: Job): void {
661
+ if (!IS_MAC) {
662
+ throw new Error("launchd install is only supported on macOS");
663
+ }
664
+
665
+ ensureDir(LAUNCH_AGENTS_DIR);
666
+ ensureDir(path.dirname(sharedLogPath()));
667
+
668
+ const plistPath = launchdPlistPath(job);
669
+ const plist = createLaunchdPlist(job);
670
+
671
+ try {
672
+ execFileSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
673
+ } catch {
674
+ // Ignore unload failures.
675
+ }
676
+
677
+ fs.writeFileSync(plistPath, plist);
678
+ execFileSync("launchctl", ["load", plistPath], { stdio: "ignore" });
679
+ appendJobLog(job, `Installed launchd job ${plistPath}`);
680
+ }
681
+
682
+ export function uninstallLaunchdJob(job: Job): void {
683
+ if (!IS_MAC) return;
684
+
685
+ const plistPath = launchdPlistPath(job);
686
+ if (!fs.existsSync(plistPath)) return;
687
+
688
+ try {
689
+ execFileSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
690
+ } catch {
691
+ // Ignore unload failures.
692
+ }
693
+
694
+ try {
695
+ fs.unlinkSync(plistPath);
696
+ } catch {
697
+ // Ignore remove failures.
698
+ }
699
+
700
+ appendJobLog(job, `Uninstalled launchd job ${plistPath}`);
701
+ }
702
+
703
+ async function runCli(): Promise<void> {
704
+ initScheduler();
705
+ const [cmd, arg] = process.argv.slice(2);
706
+
707
+ if (cmd === "run" && arg) {
708
+ const result = await runJob(arg);
709
+ console.log(JSON.stringify(result, null, 2));
710
+ process.exit(result.status === "success" ? 0 : 1);
711
+ }
712
+
713
+ if (cmd === "list") {
714
+ console.log(JSON.stringify(listJobs(), null, 2));
715
+ return;
716
+ }
717
+
718
+ if (cmd === "install" && arg) {
719
+ const job = getJob(arg);
720
+ if (!job) throw new Error(`Job not found: ${arg}`);
721
+ installLaunchdJob(job);
722
+ console.log(`Installed ${job.id}`);
723
+ return;
724
+ }
725
+
726
+ if (cmd === "uninstall" && arg) {
727
+ const job = getJob(arg);
728
+ if (!job) throw new Error(`Job not found: ${arg}`);
729
+ uninstallLaunchdJob(job);
730
+ console.log(`Uninstalled ${job.id}`);
731
+ return;
732
+ }
733
+
734
+ console.log("Usage:");
735
+ console.log(" bun run scheduler/index.ts run <jobId>");
736
+ console.log(" bun run scheduler/index.ts list");
737
+ console.log(" bun run scheduler/index.ts install <jobId>");
738
+ console.log(" bun run scheduler/index.ts uninstall <jobId>");
739
+ }
740
+
741
+ if (import.meta.main) {
742
+ runCli().catch((error) => {
743
+ console.error(error);
744
+ process.exit(1);
745
+ });
746
+ }
747
+
748
+ export const SchedulerPlugin: Plugin = async (ctx) => {
749
+ initScheduler(ctx.directory);
750
+
751
+ return {
752
+ tool: {
753
+ scheduler_create_opencode_job: tool({
754
+ description:
755
+ "Create a human-managed cron job that runs `opencode run` with the provided prompt.",
756
+ args: {
757
+ name: tool.schema.string().describe("Job name"),
758
+ schedule: tool.schema.string().describe("Cron schedule (e.g. */5 * * * *)"),
759
+ prompt: tool.schema.string().describe("Task prompt for opencode"),
760
+ workdir: tool.schema
761
+ .string()
762
+ .optional()
763
+ .describe("Working directory for job execution"),
764
+ enabled: tool.schema
765
+ .boolean()
766
+ .optional()
767
+ .describe("Whether the job is enabled (default true)"),
768
+ install: tool.schema
769
+ .boolean()
770
+ .optional()
771
+ .describe("Install into launchd immediately (macOS only)"),
772
+ },
773
+ async execute(args) {
774
+ try {
775
+ const job = createOpencodeJob({
776
+ name: args.name,
777
+ schedule: args.schedule,
778
+ prompt: args.prompt,
779
+ workdir: args.workdir ?? ctx.directory,
780
+ enabled: args.enabled,
781
+ });
782
+
783
+ let installMsg = "";
784
+ if (args.install) {
785
+ installLaunchdJob(job);
786
+ installMsg = "\nInstalled to launchd.";
787
+ }
788
+
789
+ return [
790
+ `Created job "${job.name}" (id: ${job.id})`,
791
+ `Schedule: ${job.schedule}`,
792
+ `Workdir: ${job.workdir}`,
793
+ `Shared log: ${sharedLogPath()}`,
794
+ installMsg,
795
+ ]
796
+ .filter(Boolean)
797
+ .join("\n");
798
+ } catch (error) {
799
+ return `Failed to create opencode job: ${error instanceof Error ? error.message : String(error)}`;
800
+ }
801
+ },
802
+ }),
803
+
804
+ scheduler_create_command_job: tool({
805
+ description:
806
+ "Create a human-managed cron job for a generic command. Output streams to the shared heartbeat log.",
807
+ args: {
808
+ name: tool.schema.string().describe("Job name"),
809
+ schedule: tool.schema.string().describe("Cron schedule"),
810
+ command: tool.schema.string().describe("Command executable"),
811
+ args: tool.schema.array(tool.schema.string()).optional().describe("Command arguments"),
812
+ workdir: tool.schema.string().optional().describe("Working directory"),
813
+ enabled: tool.schema.boolean().optional().describe("Whether enabled"),
814
+ },
815
+ async execute(args) {
816
+ try {
817
+ const job = createCommandJob({
818
+ name: args.name,
819
+ schedule: args.schedule,
820
+ command: args.command,
821
+ args: args.args,
822
+ workdir: args.workdir ?? ctx.directory,
823
+ enabled: args.enabled,
824
+ });
825
+ return `Created command job "${job.name}" (id: ${job.id})`;
826
+ } catch (error) {
827
+ return `Failed to create command job: ${error instanceof Error ? error.message : String(error)}`;
828
+ }
829
+ },
830
+ }),
831
+
832
+ scheduler_list_jobs: tool({
833
+ description: "List all heartbeat scheduler jobs.",
834
+ args: {},
835
+ async execute() {
836
+ const jobs = listJobs();
837
+ if (jobs.length === 0) {
838
+ return "No jobs found.";
839
+ }
840
+ return JSON.stringify(jobs, null, 2);
841
+ },
842
+ }),
843
+
844
+ scheduler_get_job: tool({
845
+ description: "Get a scheduler job by id.",
846
+ args: {
847
+ jobId: tool.schema.string().describe("Job ID"),
848
+ },
849
+ async execute(args) {
850
+ const job = getJob(args.jobId);
851
+ if (!job) {
852
+ return `Job not found: ${args.jobId}`;
853
+ }
854
+ return JSON.stringify(job, null, 2);
855
+ },
856
+ }),
857
+
858
+ scheduler_update_job: tool({
859
+ description: "Update schedule, prompt, or metadata of an existing job.",
860
+ args: {
861
+ jobId: tool.schema.string().describe("Job ID"),
862
+ name: tool.schema.string().optional().describe("Updated name"),
863
+ schedule: tool.schema.string().optional().describe("Updated cron schedule"),
864
+ prompt: tool.schema.string().optional().describe("Updated prompt (opencode jobs)"),
865
+ workdir: tool.schema.string().optional().describe("Updated workdir"),
866
+ enabled: tool.schema.boolean().optional().describe("Updated enabled status"),
867
+ },
868
+ async execute(args) {
869
+ const job = updateJob(args.jobId, {
870
+ name: args.name,
871
+ schedule: args.schedule,
872
+ prompt: args.prompt,
873
+ workdir: args.workdir,
874
+ enabled: args.enabled,
875
+ });
876
+
877
+ if (!job) {
878
+ return `Job not found: ${args.jobId}`;
879
+ }
880
+
881
+ return `Updated job "${job.name}" (id: ${job.id})`;
882
+ },
883
+ }),
884
+
885
+ scheduler_run_job: tool({
886
+ description: "Run a job immediately. Output is appended to one shared log file.",
887
+ args: {
888
+ jobId: tool.schema.string().describe("Job ID"),
889
+ },
890
+ async execute(args) {
891
+ try {
892
+ const result = await runJob(args.jobId);
893
+ return JSON.stringify(result, null, 2);
894
+ } catch (error) {
895
+ return `Failed to run job: ${error instanceof Error ? error.message : String(error)}`;
896
+ }
897
+ },
898
+ }),
899
+
900
+ scheduler_install_job: tool({
901
+ description: "Install a job in macOS launchd.",
902
+ args: {
903
+ jobId: tool.schema.string().describe("Job ID"),
904
+ },
905
+ async execute(args) {
906
+ const job = getJob(args.jobId);
907
+ if (!job) return `Job not found: ${args.jobId}`;
908
+
909
+ try {
910
+ installLaunchdJob(job);
911
+ return `Installed "${job.name}" to launchd.`;
912
+ } catch (error) {
913
+ return `Failed to install launchd job: ${error instanceof Error ? error.message : String(error)}`;
914
+ }
915
+ },
916
+ }),
917
+
918
+ scheduler_uninstall_job: tool({
919
+ description: "Uninstall a job from macOS launchd.",
920
+ args: {
921
+ jobId: tool.schema.string().describe("Job ID"),
922
+ },
923
+ async execute(args) {
924
+ const job = getJob(args.jobId);
925
+ if (!job) return `Job not found: ${args.jobId}`;
926
+
927
+ try {
928
+ uninstallLaunchdJob(job);
929
+ return `Uninstalled "${job.name}" from launchd.`;
930
+ } catch (error) {
931
+ return `Failed to uninstall launchd job: ${error instanceof Error ? error.message : String(error)}`;
932
+ }
933
+ },
934
+ }),
935
+
936
+ scheduler_delete_job: tool({
937
+ description: "Delete a scheduler job and uninstall its launchd entry if present.",
938
+ args: {
939
+ jobId: tool.schema.string().describe("Job ID"),
940
+ },
941
+ async execute(args) {
942
+ const deleted = deleteJob(args.jobId);
943
+ return deleted ? `Deleted job ${args.jobId}` : `Job not found: ${args.jobId}`;
944
+ },
945
+ }),
946
+
947
+ scheduler_info: tool({
948
+ description: "Show scheduler storage paths and shared log path.",
949
+ args: {},
950
+ async execute() {
951
+ return JSON.stringify(
952
+ {
953
+ jobsDir: getJobsDir(),
954
+ logFile: sharedLogPath(),
955
+ launchAgentsDir: LAUNCH_AGENTS_DIR,
956
+ },
957
+ null,
958
+ 2,
959
+ );
960
+ },
961
+ }),
962
+ },
963
+ };
964
+ };
965
+
966
+ export default SchedulerPlugin;