opencode-scheduled-tasks 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.
package/dist/plugin.js ADDED
@@ -0,0 +1,882 @@
1
+ // src/plugin.ts
2
+ import { tool } from "@opencode-ai/plugin";
3
+
4
+ // src/lib/db.ts
5
+ import { randomUUID } from "crypto";
6
+ import { mkdirSync } from "fs";
7
+ import { dirname } from "path";
8
+
9
+ // src/lib/sqlite.ts
10
+ import { createRequire } from "module";
11
+ var isBun = typeof globalThis.Bun !== "undefined";
12
+ var require2 = createRequire(import.meta.url);
13
+ function openDatabase(path) {
14
+ if (isBun) {
15
+ return openBunDatabase(path);
16
+ }
17
+ return openNodeDatabase(path);
18
+ }
19
+ function openBunDatabase(path) {
20
+ const { Database: BunDatabase } = require2("bun:sqlite");
21
+ const db = new BunDatabase(path);
22
+ return {
23
+ exec(sql) {
24
+ db.exec(sql);
25
+ },
26
+ prepare(sql) {
27
+ const stmt = db.prepare(sql);
28
+ return {
29
+ run(...params) {
30
+ const result = stmt.run(...params);
31
+ return { changes: result.changes ?? 0 };
32
+ },
33
+ get(...params) {
34
+ return stmt.get(...params);
35
+ },
36
+ all(...params) {
37
+ return stmt.all(...params);
38
+ }
39
+ };
40
+ },
41
+ pragma(pragma) {
42
+ return db.exec(`PRAGMA ${pragma}`);
43
+ },
44
+ close() {
45
+ db.close();
46
+ }
47
+ };
48
+ }
49
+ function openNodeDatabase(path) {
50
+ const BetterSqlite3 = require2("better-sqlite3");
51
+ const db = new BetterSqlite3(path);
52
+ return {
53
+ exec(sql) {
54
+ db.exec(sql);
55
+ },
56
+ prepare(sql) {
57
+ const stmt = db.prepare(sql);
58
+ return {
59
+ run(...params) {
60
+ const result = stmt.run(...params);
61
+ return { changes: result.changes ?? 0 };
62
+ },
63
+ get(...params) {
64
+ return stmt.get(...params);
65
+ },
66
+ all(...params) {
67
+ return stmt.all(...params);
68
+ }
69
+ };
70
+ },
71
+ pragma(pragma) {
72
+ return db.pragma(pragma);
73
+ },
74
+ close() {
75
+ db.close();
76
+ }
77
+ };
78
+ }
79
+
80
+ // src/lib/db.ts
81
+ var SCHEMA_VERSION = 2;
82
+ var SCHEMA_V1 = `
83
+ CREATE TABLE IF NOT EXISTS schema_version (
84
+ version INTEGER PRIMARY KEY
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS oneoff_tasks (
88
+ id TEXT PRIMARY KEY,
89
+ description TEXT NOT NULL,
90
+ prompt TEXT NOT NULL,
91
+ cwd TEXT NOT NULL,
92
+ scheduled_at TEXT NOT NULL,
93
+ session_mode TEXT NOT NULL DEFAULT 'new',
94
+ session_name TEXT,
95
+ model TEXT,
96
+ agent TEXT,
97
+ permission TEXT,
98
+ status TEXT NOT NULL DEFAULT 'pending',
99
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
100
+ executed_at TEXT,
101
+ session_id TEXT,
102
+ error TEXT,
103
+ created_by_session TEXT
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS task_runs (
107
+ id TEXT PRIMARY KEY,
108
+ task_name TEXT NOT NULL,
109
+ started_at TEXT NOT NULL,
110
+ completed_at TEXT,
111
+ status TEXT NOT NULL DEFAULT 'running',
112
+ session_id TEXT,
113
+ error TEXT
114
+ );
115
+
116
+ CREATE TABLE IF NOT EXISTS session_map (
117
+ session_name TEXT PRIMARY KEY,
118
+ session_id TEXT NOT NULL,
119
+ task_name TEXT,
120
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
121
+ );
122
+ `;
123
+ var MIGRATION_V2 = `
124
+ ALTER TABLE task_runs ADD COLUMN pid INTEGER;
125
+ ALTER TABLE oneoff_tasks ADD COLUMN pid INTEGER;
126
+ `;
127
+ var TaskDatabase = class {
128
+ db;
129
+ constructor(dbPath) {
130
+ mkdirSync(dirname(dbPath), { recursive: true });
131
+ this.db = openDatabase(dbPath);
132
+ this.db.pragma("journal_mode = WAL");
133
+ this.db.pragma("foreign_keys = ON");
134
+ this.initialize();
135
+ }
136
+ initialize() {
137
+ const versionExists = this.db.prepare(
138
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
139
+ ).get();
140
+ if (!versionExists) {
141
+ this.db.exec(SCHEMA_V1);
142
+ this.db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(1);
143
+ }
144
+ const row = this.db.prepare("SELECT MAX(version) as version FROM schema_version").get();
145
+ const currentVersion = row?.version ?? 0;
146
+ if (currentVersion < SCHEMA_VERSION) {
147
+ this.migrate(currentVersion);
148
+ }
149
+ }
150
+ migrate(fromVersion) {
151
+ if (fromVersion < 2) {
152
+ const statements = MIGRATION_V2.trim().split(";").filter(Boolean);
153
+ for (const stmt of statements) {
154
+ try {
155
+ this.db.exec(stmt.trim() + ";");
156
+ } catch {
157
+ }
158
+ }
159
+ this.db.prepare(
160
+ "INSERT OR REPLACE INTO schema_version (version) VALUES (?)"
161
+ ).run(2);
162
+ }
163
+ }
164
+ // --- One-off tasks ---
165
+ createOneoffTask(task) {
166
+ const id = randomUUID();
167
+ this.db.prepare(
168
+ `INSERT INTO oneoff_tasks (id, description, prompt, cwd, scheduled_at, session_name, model, agent, permission, created_by_session)
169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
170
+ ).run(
171
+ id,
172
+ task.description,
173
+ task.prompt,
174
+ task.cwd,
175
+ task.scheduledAt,
176
+ task.sessionName ?? null,
177
+ task.model ?? null,
178
+ task.agent ?? null,
179
+ task.permission ? JSON.stringify(task.permission) : null,
180
+ task.createdBySession ?? null
181
+ );
182
+ return this.getOneoffTask(id);
183
+ }
184
+ getOneoffTask(id) {
185
+ const row = this.db.prepare("SELECT * FROM oneoff_tasks WHERE id = ?").get(id);
186
+ return row ? this.mapOneoffRow(row) : void 0;
187
+ }
188
+ listOneoffTasks(options) {
189
+ const status = options?.status ?? "all";
190
+ let rows;
191
+ if (status === "all") {
192
+ rows = this.db.prepare("SELECT * FROM oneoff_tasks ORDER BY scheduled_at ASC").all();
193
+ } else {
194
+ rows = this.db.prepare(
195
+ "SELECT * FROM oneoff_tasks WHERE status = ? ORDER BY scheduled_at ASC"
196
+ ).all(status);
197
+ }
198
+ return rows.map((r) => this.mapOneoffRow(r));
199
+ }
200
+ getDueOneoffTasks() {
201
+ return this.db.prepare(
202
+ "SELECT * FROM oneoff_tasks WHERE status = 'pending' AND scheduled_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ORDER BY scheduled_at ASC"
203
+ ).all().map((r) => this.mapOneoffRow(r));
204
+ }
205
+ updateOneoffTaskStatus(id, status, extra) {
206
+ const executedAt = status === "running" ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
207
+ this.db.prepare(
208
+ `UPDATE oneoff_tasks SET status = ?, executed_at = COALESCE(?, executed_at), session_id = COALESCE(?, session_id), error = ?, pid = COALESCE(?, pid) WHERE id = ?`
209
+ ).run(
210
+ status,
211
+ executedAt ?? null,
212
+ extra?.sessionId ?? null,
213
+ extra?.error ?? null,
214
+ extra?.pid ?? null,
215
+ id
216
+ );
217
+ }
218
+ setTaskRunPid(id, pid) {
219
+ this.db.prepare("UPDATE task_runs SET pid = ? WHERE id = ?").run(pid, id);
220
+ }
221
+ cancelOneoffTask(id) {
222
+ const result = this.db.prepare(
223
+ "UPDATE oneoff_tasks SET status = 'cancelled' WHERE id = ? AND status = 'pending'"
224
+ ).run(id);
225
+ return result.changes > 0;
226
+ }
227
+ // --- Task runs (recurring) ---
228
+ createTaskRun(taskName, pid) {
229
+ const id = randomUUID();
230
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
231
+ this.db.prepare(
232
+ "INSERT INTO task_runs (id, task_name, started_at, pid) VALUES (?, ?, ?, ?)"
233
+ ).run(id, taskName, startedAt, pid ?? null);
234
+ return { id, taskName, startedAt, status: "running", pid };
235
+ }
236
+ completeTaskRun(id, status, extra) {
237
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
238
+ this.db.prepare(
239
+ `UPDATE task_runs SET status = ?, completed_at = ?, session_id = COALESCE(?, session_id), error = ? WHERE id = ?`
240
+ ).run(
241
+ status,
242
+ completedAt,
243
+ extra?.sessionId ?? null,
244
+ extra?.error ?? null,
245
+ id
246
+ );
247
+ }
248
+ getLastTaskRun(taskName) {
249
+ const row = this.db.prepare(
250
+ "SELECT * FROM task_runs WHERE task_name = ? ORDER BY started_at DESC LIMIT 1"
251
+ ).get(taskName);
252
+ return row ? this.mapTaskRunRow(row) : void 0;
253
+ }
254
+ getLastSuccessfulTaskRun(taskName) {
255
+ const row = this.db.prepare(
256
+ "SELECT * FROM task_runs WHERE task_name = ? AND status = 'completed' ORDER BY started_at DESC LIMIT 1"
257
+ ).get(taskName);
258
+ return row ? this.mapTaskRunRow(row) : void 0;
259
+ }
260
+ getTaskRunHistory(taskName, limit = 10) {
261
+ return this.db.prepare(
262
+ "SELECT * FROM task_runs WHERE task_name = ? ORDER BY started_at DESC LIMIT ?"
263
+ ).all(taskName, limit).map((r) => this.mapTaskRunRow(r));
264
+ }
265
+ hasRunningTask(taskName) {
266
+ const row = this.db.prepare(
267
+ "SELECT id FROM task_runs WHERE task_name = ? AND status = 'running' LIMIT 1"
268
+ ).get(taskName);
269
+ return !!row;
270
+ }
271
+ hasRunningOneoffTask(id) {
272
+ const row = this.db.prepare(
273
+ "SELECT id FROM oneoff_tasks WHERE id = ? AND status = 'running' LIMIT 1"
274
+ ).get(id);
275
+ return !!row;
276
+ }
277
+ /**
278
+ * Get all running task runs (for PID-based reaping).
279
+ */
280
+ getRunningTaskRuns() {
281
+ return this.db.prepare("SELECT * FROM task_runs WHERE status = 'running'").all().map((r) => this.mapTaskRunRow(r));
282
+ }
283
+ /**
284
+ * Get all running one-off tasks (for PID-based reaping).
285
+ */
286
+ getRunningOneoffTasks() {
287
+ return this.db.prepare("SELECT * FROM oneoff_tasks WHERE status = 'running'").all().map((r) => this.mapOneoffRow(r));
288
+ }
289
+ /**
290
+ * Mark stale running records as failed.
291
+ * A record is stale if it has a PID set and that process is no longer alive,
292
+ * or if it has no PID and is older than maxAgeMs (fallback for records
293
+ * created before async execution was added).
294
+ */
295
+ cleanupStaleRuns(maxAgeMs = 2 * 60 * 60 * 1e3) {
296
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
297
+ const taskRunResult = this.db.prepare(
298
+ "UPDATE task_runs SET status = 'failed', completed_at = datetime('now'), error = 'Timed out (stale running record)' WHERE status = 'running' AND pid IS NULL AND started_at < ?"
299
+ ).run(cutoff);
300
+ const oneoffResult = this.db.prepare(
301
+ "UPDATE oneoff_tasks SET status = 'failed', error = 'Timed out (stale running record)' WHERE status = 'running' AND pid IS NULL AND executed_at < ?"
302
+ ).run(cutoff);
303
+ return taskRunResult.changes + oneoffResult.changes;
304
+ }
305
+ // --- Session map ---
306
+ getSessionMapping(sessionName) {
307
+ const row = this.db.prepare("SELECT * FROM session_map WHERE session_name = ?").get(sessionName);
308
+ return row ? this.mapSessionMapRow(row) : void 0;
309
+ }
310
+ upsertSessionMapping(sessionName, sessionId, taskName) {
311
+ this.db.prepare(
312
+ `INSERT INTO session_map (session_name, session_id, task_name, updated_at)
313
+ VALUES (?, ?, ?, datetime('now'))
314
+ ON CONFLICT(session_name) DO UPDATE SET
315
+ session_id = excluded.session_id,
316
+ task_name = COALESCE(excluded.task_name, session_map.task_name),
317
+ updated_at = datetime('now')`
318
+ ).run(sessionName, sessionId, taskName ?? null);
319
+ }
320
+ // --- Row mappers ---
321
+ mapOneoffRow(row) {
322
+ return {
323
+ id: row.id,
324
+ description: row.description,
325
+ prompt: row.prompt,
326
+ cwd: row.cwd,
327
+ scheduledAt: row.scheduled_at,
328
+ sessionName: row.session_name ?? void 0,
329
+ model: row.model ?? void 0,
330
+ agent: row.agent ?? void 0,
331
+ permission: row.permission ? JSON.parse(row.permission) : void 0,
332
+ status: row.status,
333
+ createdAt: row.created_at,
334
+ executedAt: row.executed_at ?? void 0,
335
+ sessionId: row.session_id ?? void 0,
336
+ error: row.error ?? void 0,
337
+ createdBySession: row.created_by_session ?? void 0,
338
+ pid: row.pid ?? void 0
339
+ };
340
+ }
341
+ mapTaskRunRow(row) {
342
+ return {
343
+ id: row.id,
344
+ taskName: row.task_name,
345
+ startedAt: row.started_at,
346
+ completedAt: row.completed_at ?? void 0,
347
+ status: row.status,
348
+ sessionId: row.session_id ?? void 0,
349
+ error: row.error ?? void 0,
350
+ pid: row.pid ?? void 0
351
+ };
352
+ }
353
+ mapSessionMapRow(row) {
354
+ return {
355
+ sessionName: row.session_name,
356
+ sessionId: row.session_id,
357
+ taskName: row.task_name ?? void 0,
358
+ updatedAt: row.updated_at
359
+ };
360
+ }
361
+ close() {
362
+ this.db.close();
363
+ }
364
+ };
365
+ function getDefaultDbPath() {
366
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
367
+ return `${home}/.config/opencode/.tasks.db`;
368
+ }
369
+
370
+ // src/lib/tasks.ts
371
+ import matter from "gray-matter";
372
+ import { readdirSync, readFileSync, writeFileSync, existsSync } from "fs";
373
+ import { join, basename } from "path";
374
+ function getTasksDir() {
375
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
376
+ return join(home, ".config", "opencode", "tasks");
377
+ }
378
+ function expandPath(p) {
379
+ if (p.startsWith("~/") || p === "~") {
380
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
381
+ return join(home, p.slice(2));
382
+ }
383
+ return p;
384
+ }
385
+ function validateFrontmatter(data, fileName) {
386
+ const errors = [];
387
+ if (data.description !== void 0 && typeof data.description !== "string") {
388
+ errors.push("Invalid 'description' field (must be a string)");
389
+ }
390
+ if (!data.schedule || typeof data.schedule !== "string") {
391
+ errors.push("Missing or invalid 'schedule' field");
392
+ }
393
+ if (!data.cwd || typeof data.cwd !== "string") {
394
+ errors.push("Missing or invalid 'cwd' field");
395
+ }
396
+ if (data.session_name !== void 0 && typeof data.session_name !== "string") {
397
+ errors.push("Invalid 'session_name' field (must be a string)");
398
+ }
399
+ if (data.model !== void 0 && typeof data.model !== "string") {
400
+ errors.push("Invalid 'model' field (must be a string)");
401
+ }
402
+ if (data.agent !== void 0 && typeof data.agent !== "string") {
403
+ errors.push("Invalid 'agent' field (must be a string)");
404
+ }
405
+ if (data.enabled !== void 0 && typeof data.enabled !== "boolean") {
406
+ errors.push("Invalid 'enabled' field (must be a boolean)");
407
+ }
408
+ return errors;
409
+ }
410
+ function parseTaskFile(filePath) {
411
+ const content = readFileSync(filePath, "utf-8");
412
+ const fileName = basename(filePath);
413
+ const { data, content: body } = matter(content);
414
+ const fm = data;
415
+ const errors = validateFrontmatter(data, fileName);
416
+ if (errors.length > 0) {
417
+ throw new Error(
418
+ `Invalid task file "${fileName}":
419
+ - ${errors.join("\n - ")}`
420
+ );
421
+ }
422
+ const name = fileName.replace(/\.md$/, "");
423
+ return {
424
+ name,
425
+ description: fm.description,
426
+ schedule: fm.schedule,
427
+ cwd: fm.cwd,
428
+ sessionName: fm.session_name,
429
+ model: fm.model,
430
+ agent: fm.agent,
431
+ permission: fm.permission,
432
+ enabled: fm.enabled ?? true,
433
+ prompt: body.trim(),
434
+ filePath
435
+ };
436
+ }
437
+ function readAllTasks(tasksDir) {
438
+ const dir = tasksDir ?? getTasksDir();
439
+ const tasks = [];
440
+ const errors = [];
441
+ if (!existsSync(dir)) {
442
+ return { tasks, errors };
443
+ }
444
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
445
+ for (const file of files) {
446
+ const filePath = join(dir, file);
447
+ try {
448
+ const task = parseTaskFile(filePath);
449
+ tasks.push(task);
450
+ } catch (err) {
451
+ errors.push({ file, error: err.message });
452
+ }
453
+ }
454
+ return { tasks, errors };
455
+ }
456
+ function setTaskEnabled(filePath, enabled) {
457
+ const content = readFileSync(filePath, "utf-8");
458
+ const { data, content: body } = matter(content);
459
+ data.enabled = enabled;
460
+ const updated = matter.stringify(body, data);
461
+ writeFileSync(filePath, updated);
462
+ }
463
+
464
+ // src/lib/cron.ts
465
+ import cronParser from "cron-parser";
466
+ var CronExpressionParser = cronParser.CronExpressionParser ?? cronParser;
467
+ function getNextRunTime(expression, after) {
468
+ const expr = CronExpressionParser.parse(expression, {
469
+ currentDate: after ?? /* @__PURE__ */ new Date()
470
+ });
471
+ const next = expr.next().toISOString();
472
+ if (!next) throw new Error(`No next run time for expression "${expression}"`);
473
+ return next;
474
+ }
475
+
476
+ // src/lib/installer.ts
477
+ import { execFileSync } from "child_process";
478
+ import {
479
+ existsSync as existsSync2,
480
+ mkdirSync as mkdirSync2,
481
+ unlinkSync,
482
+ writeFileSync as writeFileSync2
483
+ } from "fs";
484
+ import { basename as basename2, dirname as dirname2, join as join2, resolve } from "path";
485
+ import { fileURLToPath } from "url";
486
+ var LAUNCHD_LABEL = "ai.opencode.scheduled-tasks";
487
+ var SYSTEMD_TIMER = "opencode-scheduler.timer";
488
+ function detectPlatform() {
489
+ if (process.platform === "darwin") return "macos-launchd";
490
+ if (process.platform === "linux") {
491
+ try {
492
+ execFileSync("systemctl", ["--version"], { stdio: "ignore" });
493
+ return "linux-systemd";
494
+ } catch {
495
+ }
496
+ }
497
+ return "unsupported";
498
+ }
499
+ function getHome() {
500
+ return process.env.HOME ?? process.env.USERPROFILE ?? "";
501
+ }
502
+ function getLaunchdPlistPath() {
503
+ return join2(
504
+ getHome(),
505
+ "Library",
506
+ "LaunchAgents",
507
+ `${LAUNCHD_LABEL}.plist`
508
+ );
509
+ }
510
+ function isLaunchdInstalled() {
511
+ return existsSync2(getLaunchdPlistPath());
512
+ }
513
+ function getSystemdDir() {
514
+ return join2(getHome(), ".config", "systemd", "user");
515
+ }
516
+ function isSystemdInstalled() {
517
+ const systemdDir = getSystemdDir();
518
+ return existsSync2(join2(systemdDir, SYSTEMD_TIMER));
519
+ }
520
+ function isInstalled() {
521
+ const platform = detectPlatform();
522
+ switch (platform) {
523
+ case "macos-launchd":
524
+ return isLaunchdInstalled();
525
+ case "linux-systemd":
526
+ return isSystemdInstalled();
527
+ default:
528
+ return false;
529
+ }
530
+ }
531
+
532
+ // src/plugin.ts
533
+ function getDb() {
534
+ return new TaskDatabase(getDefaultDbPath());
535
+ }
536
+ function schedulerWarning() {
537
+ if (!isInstalled()) {
538
+ return "\n\nNote: The opencode-scheduler daemon is not installed. Tasks will only execute when the scheduler is run manually. Install it with: npx opencode-scheduler --install";
539
+ }
540
+ return "";
541
+ }
542
+ var ScheduledTasksPlugin = async (ctx) => {
543
+ return {
544
+ tool: {
545
+ schedule_task: tool({
546
+ description: "Schedule a one-off task to run at a specific time. The task will execute an opencode prompt in the specified working directory. Requires the opencode-scheduler daemon to be installed for reliable execution.",
547
+ args: {
548
+ prompt: tool.schema.string(
549
+ "The prompt to send to opencode when the task runs"
550
+ ),
551
+ description: tool.schema.string(
552
+ "Human-readable description of what this task does"
553
+ ),
554
+ cwd: tool.schema.string(
555
+ "Working directory for the task (absolute path or ~ for home)"
556
+ ),
557
+ scheduled_at: tool.schema.string(
558
+ "ISO 8601 timestamp for when to run (e.g. '2026-03-31T09:00:00')"
559
+ ),
560
+ session_name: tool.schema.string().optional().describe("Session name. If set, reuses the same session across runs. If omitted, creates a fresh session each run."),
561
+ model: tool.schema.string().optional().describe("Model in provider/model format"),
562
+ agent: tool.schema.string().optional().describe("Agent to use for execution"),
563
+ permission: tool.schema.string().optional().describe(
564
+ `Permission config as a JSON string (same schema as opencode.json permissions). Example: '{"bash":{"*":"allow"},"edit":"allow"}'`
565
+ )
566
+ },
567
+ async execute(args) {
568
+ const scheduledDate = new Date(args.scheduled_at);
569
+ if (isNaN(scheduledDate.getTime())) {
570
+ return `Error: Invalid date format "${args.scheduled_at}". Use ISO 8601 format (e.g. '2026-03-31T09:00:00').`;
571
+ }
572
+ if (scheduledDate <= /* @__PURE__ */ new Date()) {
573
+ return `Error: Scheduled time "${args.scheduled_at}" is in the past.`;
574
+ }
575
+ const cwd = expandPath(args.cwd);
576
+ let permission;
577
+ if (args.permission) {
578
+ try {
579
+ permission = JSON.parse(args.permission);
580
+ } catch {
581
+ return `Error: Invalid permission JSON: ${args.permission}`;
582
+ }
583
+ }
584
+ const db = getDb();
585
+ try {
586
+ const task = db.createOneoffTask({
587
+ description: args.description,
588
+ prompt: args.prompt,
589
+ cwd,
590
+ scheduledAt: scheduledDate.toISOString(),
591
+ sessionName: args.session_name,
592
+ model: args.model,
593
+ agent: args.agent,
594
+ permission
595
+ });
596
+ return `Task scheduled successfully!
597
+ ID: ${task.id}
598
+ Description: ${task.description}
599
+ Scheduled for: ${task.scheduledAt}
600
+ Working directory: ${task.cwd}
601
+ Session: ${task.sessionName ? `named (${task.sessionName})` : "new (fresh each run)"}` + schedulerWarning();
602
+ } finally {
603
+ db.close();
604
+ }
605
+ }
606
+ }),
607
+ list_tasks: tool({
608
+ description: "List all scheduled tasks. Shows recurring tasks from markdown files and pending one-off tasks. Includes next run time for recurring tasks and scheduled time for one-offs.",
609
+ args: {
610
+ status: tool.schema.enum(["all", "pending", "completed", "failed"]).optional().describe("Filter by status (default: all)"),
611
+ type: tool.schema.enum(["all", "recurring", "oneoff"]).optional().describe("Filter by type (default: all)")
612
+ },
613
+ async execute(args) {
614
+ const status = args.status ?? "all";
615
+ const type = args.type ?? "all";
616
+ const db = getDb();
617
+ const lines = [];
618
+ try {
619
+ if (type === "all" || type === "recurring") {
620
+ const { tasks, errors } = readAllTasks();
621
+ if (tasks.length > 0) {
622
+ lines.push("## Recurring Tasks\n");
623
+ for (const task of tasks) {
624
+ const lastRun = db.getLastTaskRun(task.name);
625
+ const statusStr = task.enabled ? "enabled" : "disabled";
626
+ let nextStr = "N/A";
627
+ if (task.enabled) {
628
+ try {
629
+ nextStr = getNextRunTime(task.schedule);
630
+ } catch {
631
+ nextStr = "invalid cron expression";
632
+ }
633
+ }
634
+ lines.push(`- **${task.name}** (${statusStr})`);
635
+ lines.push(` Schedule: \`${task.schedule}\``);
636
+ lines.push(` CWD: ${task.cwd}`);
637
+ lines.push(` Next run: ${nextStr}`);
638
+ if (lastRun) {
639
+ lines.push(
640
+ ` Last run: ${lastRun.status} at ${lastRun.startedAt}`
641
+ );
642
+ } else {
643
+ lines.push(` Last run: never`);
644
+ }
645
+ lines.push("");
646
+ }
647
+ } else {
648
+ lines.push("No recurring tasks found.\n");
649
+ }
650
+ if (errors.length > 0) {
651
+ lines.push("### Task file errors:\n");
652
+ for (const { file, error } of errors) {
653
+ lines.push(`- ${file}: ${error}`);
654
+ }
655
+ lines.push("");
656
+ }
657
+ }
658
+ if (type === "all" || type === "oneoff") {
659
+ const oneoffs = db.listOneoffTasks({
660
+ status: status === "all" ? "all" : status
661
+ });
662
+ if (oneoffs.length > 0) {
663
+ lines.push("## One-off Tasks\n");
664
+ for (const task of oneoffs) {
665
+ lines.push(`- **${task.description}** [${task.status}]`);
666
+ lines.push(` ID: ${task.id}`);
667
+ lines.push(` Scheduled: ${task.scheduledAt}`);
668
+ lines.push(` CWD: ${task.cwd}`);
669
+ if (task.sessionId) {
670
+ lines.push(` Session: ${task.sessionId}`);
671
+ }
672
+ if (task.error) {
673
+ lines.push(` Error: ${task.error}`);
674
+ }
675
+ lines.push("");
676
+ }
677
+ } else {
678
+ lines.push(
679
+ `No one-off tasks found${status !== "all" ? ` with status "${status}"` : ""}.
680
+ `
681
+ );
682
+ }
683
+ }
684
+ return lines.join("\n") + schedulerWarning();
685
+ } finally {
686
+ db.close();
687
+ }
688
+ }
689
+ }),
690
+ cancel_task: tool({
691
+ description: "Cancel a pending one-off task by ID, or disable a recurring task by name.",
692
+ args: {
693
+ id: tool.schema.string(
694
+ "Task ID (for one-off, a UUID) or task name (for recurring)"
695
+ )
696
+ },
697
+ async execute(args) {
698
+ const db = getDb();
699
+ try {
700
+ if (args.id.includes("-")) {
701
+ const task = db.getOneoffTask(args.id);
702
+ if (task) {
703
+ if (task.status !== "pending") {
704
+ return `Cannot cancel task: status is "${task.status}" (must be "pending")`;
705
+ }
706
+ db.cancelOneoffTask(args.id);
707
+ return `Cancelled one-off task: ${task.description} (${task.id})`;
708
+ }
709
+ }
710
+ const { tasks } = readAllTasks();
711
+ const recurringTask = tasks.find((t) => t.name === args.id);
712
+ if (recurringTask) {
713
+ setTaskEnabled(recurringTask.filePath, false);
714
+ return `Disabled recurring task: ${recurringTask.name}
715
+ File updated: ${recurringTask.filePath}`;
716
+ }
717
+ return `No task found with ID or name "${args.id}"`;
718
+ } finally {
719
+ db.close();
720
+ }
721
+ }
722
+ }),
723
+ task_history: tool({
724
+ description: "Get the execution history for a scheduled task. Shows recent runs with status, timing, and any errors.",
725
+ args: {
726
+ task_name: tool.schema.string(
727
+ "Task name (for recurring) or task ID (for one-off)"
728
+ ),
729
+ limit: tool.schema.number().optional().describe("Maximum number of history entries to show (default: 10)")
730
+ },
731
+ async execute(args) {
732
+ const limit = args.limit ?? 10;
733
+ const db = getDb();
734
+ try {
735
+ if (args.task_name.includes("-")) {
736
+ const task = db.getOneoffTask(args.task_name);
737
+ if (task) {
738
+ const lines2 = [
739
+ `## One-off Task: ${task.description}
740
+ `,
741
+ `- ID: ${task.id}`,
742
+ `- Status: ${task.status}`,
743
+ `- Scheduled: ${task.scheduledAt}`,
744
+ `- Created: ${task.createdAt}`,
745
+ `- CWD: ${task.cwd}`
746
+ ];
747
+ if (task.executedAt) lines2.push(`- Executed: ${task.executedAt}`);
748
+ if (task.sessionId) lines2.push(`- Session: ${task.sessionId}`);
749
+ if (task.error) lines2.push(`- Error: ${task.error}`);
750
+ return lines2.join("\n");
751
+ }
752
+ }
753
+ const runs = db.getTaskRunHistory(args.task_name, limit);
754
+ if (runs.length === 0) {
755
+ return `No history found for task "${args.task_name}"`;
756
+ }
757
+ const lines = [`## History for "${args.task_name}"
758
+ `];
759
+ for (const run of runs) {
760
+ lines.push(`- **${run.status}** at ${run.startedAt}`);
761
+ if (run.completedAt) {
762
+ const duration = new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime();
763
+ lines.push(` Duration: ${Math.round(duration / 1e3)}s`);
764
+ }
765
+ if (run.sessionId) lines.push(` Session: ${run.sessionId}`);
766
+ if (run.error) lines.push(` Error: ${run.error}`);
767
+ }
768
+ return lines.join("\n");
769
+ } finally {
770
+ db.close();
771
+ }
772
+ }
773
+ }),
774
+ get_task_instructions: tool({
775
+ description: "Get instructions and the frontmatter format for creating or editing recurring scheduled task markdown files. Use this when the user wants to set up a new recurring task or modify an existing one. After getting instructions, use file tools to create/edit the task file.",
776
+ args: {},
777
+ async execute() {
778
+ const tasksDir = getTasksDir();
779
+ return `## Creating a Recurring Scheduled Task
780
+
781
+ Recurring tasks are defined as markdown files in:
782
+ ${tasksDir}
783
+
784
+ The filename (without \`.md\`) is used as the task name. Each file contains YAML frontmatter followed by the prompt.
785
+
786
+ ### Frontmatter Format
787
+
788
+ \`\`\`yaml
789
+ ---
790
+ description: Clean up old branches # Optional. Human-readable description
791
+ schedule: "0 9 * * *" # Required. 5-field cron expression
792
+ cwd: ~/projects/my-app # Required. Working directory (~ is expanded)
793
+ session_name: daily-cleanup # Optional. Reuses the same session across runs. Omit for fresh session each run.
794
+ model: anthropic/claude-sonnet-4-6 # Optional. Model to use
795
+ agent: build # Optional. Agent to use
796
+ permission: # Optional. Same format as opencode.json permissions
797
+ bash:
798
+ "*": "allow"
799
+ "rm -rf *": "deny"
800
+ edit: "allow"
801
+ external_directory: # IMPORTANT for accessing files outside cwd
802
+ "/tmp/*": "allow"
803
+ enabled: true # Optional. Default: true
804
+ ---
805
+
806
+ The prompt goes here. This is what will be sent to the opencode agent when the task runs.
807
+ \`\`\`
808
+
809
+ ### Permissions - IMPORTANT
810
+
811
+ Since scheduled tasks run in the background with no user present, any permission set to \`"ask"\` will effectively be **denied**. You must explicitly allow any operations the task needs.
812
+
813
+ **Most commonly missed: \`external_directory\`** - This defaults to \`"ask"\` and controls access to files outside the task's \`cwd\`. If your task writes to \`/tmp\`, reads from another project, or accesses any path outside \`cwd\`, you MUST add an \`external_directory\` rule:
814
+
815
+ \`\`\`yaml
816
+ permission:
817
+ external_directory:
818
+ "/tmp/*": "allow"
819
+ "~/other-project/*": "allow"
820
+ \`\`\`
821
+
822
+ Other permissions like \`bash\` and \`edit\` default to \`"allow"\` and usually don't need explicit rules unless you want to restrict them.
823
+
824
+ ### Cron Expression Reference
825
+
826
+ \`\`\`
827
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0-59)
828
+ \u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0-23)
829
+ \u2502 \u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1-31)
830
+ \u2502 \u2502 \u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 month (1-12)
831
+ \u2502 \u2502 \u2502 \u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of week (0-7, 0 and 7 are Sunday)
832
+ \u2502 \u2502 \u2502 \u2502 \u2502
833
+ * * * * *
834
+ \`\`\`
835
+
836
+ Common examples:
837
+ - \`0 9 * * *\` - Every day at 9:00 AM
838
+ - \`0 9 * * 1-5\` - Every weekday at 9:00 AM
839
+ - \`*/30 * * * *\` - Every 30 minutes
840
+ - \`0 0 * * 0\` - Every Sunday at midnight
841
+ - \`0 9 1 * *\` - First day of every month at 9:00 AM
842
+
843
+ ### Notes
844
+
845
+ - The scheduler daemon must be installed for tasks to run automatically:
846
+ \`npx opencode-scheduler --install\`
847
+ - Tasks use your system's local timezone
848
+ - Tasks with \`session_name\` set will reuse the same session across runs
849
+ - Use \`enabled: false\` to temporarily disable a task without deleting it${schedulerWarning()}`;
850
+ }
851
+ })
852
+ },
853
+ event: async ({ event }) => {
854
+ if (event.type === "session.created") {
855
+ try {
856
+ const db = getDb();
857
+ try {
858
+ const overdueTasks = db.getDueOneoffTasks();
859
+ if (overdueTasks.length > 0 && !isInstalled()) {
860
+ await ctx.client.app.log({
861
+ body: {
862
+ service: "opencode-scheduled-tasks",
863
+ level: "warn",
864
+ message: `${overdueTasks.length} overdue task(s) found but scheduler daemon is not installed. Run: npx opencode-scheduler --install`
865
+ }
866
+ });
867
+ }
868
+ } finally {
869
+ db.close();
870
+ }
871
+ } catch {
872
+ }
873
+ }
874
+ }
875
+ };
876
+ };
877
+ var plugin_default = ScheduledTasksPlugin;
878
+ export {
879
+ ScheduledTasksPlugin,
880
+ plugin_default as default
881
+ };
882
+ //# sourceMappingURL=plugin.js.map