/** * pi-cron — Task Scheduling & Reminders * Cross-platform. Persistent schedules in ~/.pi/cron/ * * /cron add "backup" "0 2 * * *" "npm run backup" * /cron list * /cron remove * /cron run * /cron due → show overdue tasks */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; const DIR = join(homedir(), ".pi", "cron"); const FILE = join(DIR, "jobs.json"); const RST = "\x1b[0m", B = "\x1b[1m", D = "\x1b[2m"; const G = "\x1b[32m", R = "\x1b[31m", Y = "\x1b[33m", C = "\x1b[36m"; interface CronJob { id: string; name: string; schedule: string; // cron expression or "daily", "hourly", "weekly", interval like "every 30m" command: string; enabled: boolean; created: string; lastRun?: string; nextDue?: string; runCount: number; } function ensureDir() { if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true }); } function loadJobs(): CronJob[] { ensureDir(); if (!existsSync(FILE)) { writeFileSync(FILE, "[]"); return []; } try { return JSON.parse(readFileSync(FILE, "utf-8")); } catch { return []; } } function saveJobs(jobs: CronJob[]) { ensureDir(); writeFileSync(FILE, JSON.stringify(jobs, null, 2)); } function nextId(jobs: CronJob[]): string { const max = jobs.reduce((m, j) => Math.max(m, parseInt(j.id.replace("cron-", "")) || 0), 0); return `cron-${max + 1}`; } function parseSchedule(schedule: string): { intervalMs: number; desc: string } { const s = schedule.toLowerCase().trim(); if (s === "hourly" || s === "every 1h") return { intervalMs: 3600000, desc: "every hour" }; if (s === "daily" || s === "every 1d") return { intervalMs: 86400000, desc: "every day" }; if (s === "weekly" || s === "every 1w") return { intervalMs: 604800000, desc: "every week" }; const match = s.match(/every\s+(\d+)\s*(m|min|h|hr|d|day|w|week|s|sec)/); if (match) { const n = parseInt(match[1]); const unit = match[2][0]; const mult = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : unit === "d" ? 86400000 : 604800000; return { intervalMs: n * mult, desc: `every ${n}${unit}` }; } // Cron expression — store as-is, compute next due simply return { intervalMs: 86400000, desc: schedule }; } function computeNextDue(job: CronJob): string { const { intervalMs } = parseSchedule(job.schedule); const base = job.lastRun ? new Date(job.lastRun).getTime() : new Date(job.created).getTime(); return new Date(base + intervalMs).toISOString(); } function isDue(job: CronJob): boolean { if (!job.enabled) return false; const next = job.nextDue || computeNextDue(job); return new Date(next).getTime() <= Date.now(); } function formatRelative(iso: string): string { const diff = new Date(iso).getTime() - Date.now(); const abs = Math.abs(diff); if (abs < 60000) return diff < 0 ? "overdue" : "< 1min"; if (abs < 3600000) return `${Math.round(abs / 60000)}min ${diff < 0 ? "ago" : ""}`; if (abs < 86400000) return `${Math.round(abs / 3600000)}h ${diff < 0 ? "ago" : ""}`; return `${Math.round(abs / 86400000)}d ${diff < 0 ? "ago" : ""}`; } export default function (pi: ExtensionAPI) { ensureDir(); pi.registerCommand("cron", { description: "Scheduling: /cron add|list|remove|run|due|enable|disable", handler: async (args, ctx) => { const parts = (args || "").trim().match(/(?:[^\s"]+|"[^"]*")+/g) || []; const cmd = (parts[0] || "list").toLowerCase(); if (cmd === "add") { const name = (parts[1] || "").replace(/"/g, ""); const schedule = (parts[2] || "").replace(/"/g, ""); const command = (parts[3] || "").replace(/"/g, ""); if (!name || !schedule || !command) return `${Y}Usage:${RST} /cron add "name" "schedule" "command"\n Schedules: hourly, daily, weekly, "every 30m", "every 2h", "0 * * * *"`; const jobs = loadJobs(); const job: CronJob = { id: nextId(jobs), name, schedule, command, enabled: true, created: new Date().toISOString(), runCount: 0, }; job.nextDue = computeNextDue(job); jobs.push(job); saveJobs(jobs); return `${G}✅ Added ${job.id}:${RST} "${name}" — ${parseSchedule(schedule).desc}\n Command: ${command}\n Next due: ${job.nextDue.slice(0, 19)}`; } if (cmd === "list") { const jobs = loadJobs(); if (!jobs.length) return `${Y}No scheduled jobs.${RST} Use /cron add to create one.`; let out = `${B}${C}⏰ Scheduled Jobs${RST} (${jobs.length})\n\n`; for (const j of jobs) { const status = j.enabled ? G + "●" : R + "○"; const due = j.nextDue ? formatRelative(j.nextDue) : "—"; const dueColor = isDue(j) ? R : D; out += ` ${status}${RST} ${B}${j.id}${RST} ${j.name.padEnd(20)} ${D}${parseSchedule(j.schedule).desc}${RST}\n`; out += ` cmd: ${D}${j.command}${RST} runs: ${j.runCount} next: ${dueColor}${due}${RST}\n`; } return out; } if (cmd === "due") { const jobs = loadJobs().filter(isDue); if (!jobs.length) return `${G}No overdue jobs.${RST}`; let out = `${B}${R}⚠ Overdue Jobs${RST} (${jobs.length})\n\n`; for (const j of jobs) { out += ` ${R}${j.id}${RST} ${j.name} — ${D}${j.command}${RST}\n`; out += ` Due: ${formatRelative(j.nextDue || "")}\n`; } return out; } if (cmd === "run") { const id = parts[1]; if (!id) return `${Y}Usage:${RST} /cron run `; const jobs = loadJobs(); const job = jobs.find(j => j.id === id); if (!job) return `${R}Job not found:${RST} ${id}`; job.lastRun = new Date().toISOString(); job.runCount++; job.nextDue = computeNextDue(job); saveJobs(jobs); return `${G}✅ Marked ${id} as run.${RST} Next due: ${job.nextDue.slice(0, 19)}\n To execute: ${B}${job.command}${RST}`; } if (cmd === "remove") { const id = parts[1]; if (!id) return `${Y}Usage:${RST} /cron remove `; let jobs = loadJobs(); const before = jobs.length; jobs = jobs.filter(j => j.id !== id); if (jobs.length === before) return `${R}Not found:${RST} ${id}`; saveJobs(jobs); return `${G}Removed ${id}.${RST}`; } if (cmd === "enable" || cmd === "disable") { const id = parts[1]; if (!id) return `${Y}Usage:${RST} /cron ${cmd} `; const jobs = loadJobs(); const job = jobs.find(j => j.id === id); if (!job) return `${R}Not found:${RST} ${id}`; job.enabled = cmd === "enable"; saveJobs(jobs); return `${G}${job.id} ${cmd}d.${RST}`; } return `${B}${C}⏰ Cron${RST}\n /cron add "name" "schedule" "cmd"\n /cron list | due | run | remove \n /cron enable | disable `; } }); pi.registerTool({ name: "cron_add", description: "Schedule a recurring task. Schedules: 'hourly', 'daily', 'weekly', 'every 30m', 'every 2h', cron expressions.", parameters: Type.Object({ name: Type.String({ description: "Job name" }), schedule: Type.String({ description: "Schedule: hourly, daily, weekly, 'every 30m', cron expression" }), command: Type.String({ description: "Shell command to run" }), }), execute: async (p) => { const jobs = loadJobs(); const job: CronJob = { id: nextId(jobs), name: p.name, schedule: p.schedule, command: p.command, enabled: true, created: new Date().toISOString(), runCount: 0 }; job.nextDue = computeNextDue(job); jobs.push(job); saveJobs(jobs); return JSON.stringify(job, null, 2); } }); pi.registerTool({ name: "cron_list", description: "List all scheduled jobs with status and next due time.", parameters: Type.Object({}), execute: async () => JSON.stringify(loadJobs(), null, 2), }); pi.registerTool({ name: "cron_remove", description: "Remove a scheduled job by ID.", parameters: Type.Object({ id: Type.String({ description: "Job ID (e.g., cron-1)" }) }), execute: async (p) => { let jobs = loadJobs(); const before = jobs.length; jobs = jobs.filter(j => j.id !== p.id); if (jobs.length === before) return `Not found: ${p.id}`; saveJobs(jobs); return `Removed ${p.id}`; } }); }