/** * OpenCode Scheduler Plugin * * Schedule recurring jobs using launchd (Mac), systemd (Linux), schtasks (Windows), or cron fallback. * Jobs are stored under ~/.config/opencode/scheduler/ (scoped by workdir). * * Features: * - Survives reboots * - Catches up on missed runs (if computer was asleep) * - Cross-platform (Mac + Linux + Windows) * - Working directory support for MCP configs * - Environment variable injection (PATH for node/npx) */ import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, unlinkSync } from "fs" import { basename, dirname, join, resolve as resolvePath } from "path" import { homedir, platform } from "os" import { execFileSync, execSync, spawn, type ChildProcess } from "child_process" import { fileURLToPath } from "url" // Storage location - shared with other opencode tools const OPENCODE_CONFIG = join(homedir(), ".config", "opencode") const LEGACY_JOBS_DIR = join(OPENCODE_CONFIG, "jobs") const LOGS_DIR = join(OPENCODE_CONFIG, "logs") const SCHEDULER_DIR = join(OPENCODE_CONFIG, "scheduler") const SCOPES_DIR = join(SCHEDULER_DIR, "scopes") const SUPERVISOR_PATH = join(SCHEDULER_DIR, "supervisor.pl") const SCHEDULER_CONFIG = join(OPENCODE_CONFIG, "opencode-scheduler.json") // Platform detection const IS_MAC = platform() === "darwin" const IS_LINUX = platform() === "linux" const IS_WINDOWS = platform() === "win32" // launchd paths (Mac) const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents") const LAUNCHD_PREFIX = "com.opencode.job" // systemd paths (Linux) const SYSTEMD_USER_DIR = join(homedir(), ".config", "systemd", "user") // Windows Task Scheduler const WINDOWS_TASK_ROOT = "\\OpenCode" const WINDOWS_TASK_PREFIX = "opencode-job" // cron backend const CRON_MANAGED_PREFIX = "opencode-scheduler" // Ensure directory exists function ensureDir(dir: string) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } } // Slugify a name function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") } function normalizeWorkdirPath(input: string): string { const trimmed = input.trim() if (!trimmed) return homedir() return resolvePath(trimmed) } function fnv1a64(input: string): bigint { // 64-bit FNV-1a let hash = 0xcbf29ce484222325n const prime = 0x100000001b3n const data = Buffer.from(input, "utf8") for (const byte of data) { hash ^= BigInt(byte) hash = (hash * prime) & 0xffffffffffffffffn } return hash } function fnv1a64Hex(input: string): string { return fnv1a64(input).toString(16).padStart(16, "0") } function deriveScopeId(workdir: string): string { const normalized = normalizeWorkdirPath(workdir) const base = slugify(basename(normalized)) || "workspace" // 48 bits of hash is enough here; keep label/unit names short. const suffix = fnv1a64Hex(normalized).slice(0, 12) return `${base}-${suffix}` } function scopeDir(scopeId: string): string { return join(SCOPES_DIR, scopeId) } function scopeJobsDir(scopeId: string): string { return join(scopeDir(scopeId), "jobs") } function scopeLocksDir(scopeId: string): string { return join(scopeDir(scopeId), "locks") } function scopeRunsDir(scopeId: string): string { return join(scopeDir(scopeId), "runs") } function scopeLogsDir(scopeId: string): string { return join(LOGS_DIR, "scheduler", scopeId) } function jobFilePath(scopeId: string, slug: string): string { return join(scopeJobsDir(scopeId), `${slug}.json`) } function scopedLogPath(scopeId: string, slug: string): string { return join(scopeLogsDir(scopeId), `${slug}.log`) } function currentScopeId(): string { return deriveScopeId(process.cwd()) } const SUPERVISOR_SCRIPT = `#!/usr/bin/perl use strict; use warnings; use JSON::PP; use File::Basename qw(dirname); use File::Path qw(make_path); use POSIX qw(setsid strftime); use Time::HiRes qw(time); # opencode-scheduler supervisor v1 sub iso_now { my @t = localtime(time()); return strftime("%Y-%m-%dT%H:%M:%S%z", @t); } sub read_json { my ($path) = @_; open my $fh, "<", $path or die "Failed to read $path: $!\n"; local $/; my $raw = <$fh>; close $fh; my $json = JSON::PP->new->utf8->relaxed; return $json->decode($raw); } sub write_json_atomic { my ($path, $data) = @_; my $tmp = "$path.tmp.$$"; my $json = JSON::PP->new->utf8->canonical; open my $fh, ">", $tmp or die "Failed to write $tmp: $!\n"; print $fh $json->encode($data); close $fh or die "Failed to close $tmp: $!\n"; rename $tmp, $path or die "Failed to rename $tmp -> $path: $!\n"; } sub append_jsonl { my ($path, $data) = @_; my $json = JSON::PP->new->utf8->canonical; open my $fh, ">>", $path or die "Failed to append $path: $!\n"; print $fh $json->encode($data) . "\n"; close $fh; } sub pid_alive { my ($pid) = @_; return 0 if !$pid; return kill 0, $pid; } sub random_id { my $n = int(rand(1_000_000_000)); return sprintf("%09d", $n); } my $job_path = shift @ARGV; if (!$job_path) { die "usage: supervisor.pl \n"; } my $job = read_json($job_path); my $scope_id = $job->{scopeId} || ""; my $slug = $job->{slug} || ""; if (!$scope_id || !$slug) { die "job missing scopeId/slug\n"; } my $home = $ENV{HOME} || ""; if (!$home) { die "HOME is not set\n"; } my $config_root = "$home/.config/opencode"; my $scheduler_root = "$config_root/scheduler/scopes/$scope_id"; my $locks_dir = "$scheduler_root/locks"; my $runs_dir = "$scheduler_root/runs"; my $logs_dir = "$config_root/logs/scheduler/$scope_id"; make_path($locks_dir); make_path($runs_dir); make_path($logs_dir); my $log_path = "$logs_dir/$slug.log"; open STDOUT, ">>", $log_path or die "Failed to open log $log_path: $!\n"; open STDERR, ">&STDOUT" or die "Failed to dup stderr: $!\n"; select STDOUT; $| = 1; select STDERR; $| = 1; my $lock_path = "$locks_dir/$slug.json"; if (-e $lock_path) { my $lock = eval { read_json($lock_path) }; my $pid = ($lock && ref($lock) eq 'HASH') ? ($lock->{pid} || 0) : 0; if (pid_alive($pid)) { my $now = iso_now(); print "\n=== Scheduled run skipped (already running pid=$pid) $now ===\n"; exit 0; } unlink $lock_path; } my $run_id = time() . "-" . random_id(); my $started_at = iso_now(); my $t0 = time(); write_json_atomic($lock_path, { pid => $$, startedAt => $started_at, runId => $run_id }); # Update job metadata: running $job->{lastRunAt} = $started_at; $job->{lastRunSource} = "scheduled"; $job->{lastRunStatus} = "running"; delete $job->{lastRunExitCode}; delete $job->{lastRunError}; $job->{updatedAt} = $started_at; write_json_atomic($job_path, $job); # Force non-interactive scheduled runs my $perm = { question => "deny" }; if ($ENV{OPENCODE_PERMISSION}) { my $existing = eval { JSON::PP->new->decode($ENV{OPENCODE_PERMISSION}) }; if ($existing && ref($existing) eq 'HASH') { $perm = { %$existing, %$perm }; } } $ENV{OPENCODE_PERMISSION} = JSON::PP->new->canonical->encode($perm); $ENV{OPENCODE_SCHEDULER_RUN_ID} = $run_id; print "\n=== Scheduled run $started_at runId=$run_id ===\n"; my $inv = $job->{invocation}; if (!$inv || ref($inv) ne 'HASH' || !$inv->{command} || ref($inv->{args}) ne 'ARRAY') { my $now = iso_now(); print "\n=== Supervisor error $now: job missing invocation.command/args ===\n"; $job->{lastRunStatus} = "failed"; $job->{lastRunError} = "job missing invocation"; $job->{updatedAt} = $now; write_json_atomic($job_path, $job); unlink $lock_path; exit 1; } my $command = $inv->{command}; my @args = @{ $inv->{args} }; my $workdir = $job->{workdir} || $home; my $timeout = $job->{timeoutSeconds}; $timeout = undef if defined($timeout) && $timeout !~ /^\\d+$/; my $timed_out = 0; my $child_pid = fork(); if (!defined $child_pid) { my $now = iso_now(); print "\n=== Supervisor error $now: fork failed: $! ===\n"; $job->{lastRunStatus} = "failed"; $job->{lastRunError} = "fork failed"; $job->{updatedAt} = $now; write_json_atomic($job_path, $job); unlink $lock_path; exit 1; } if ($child_pid == 0) { chdir $workdir or die "Failed to chdir to $workdir: $!\n"; eval { setsid(); }; exec { $command } $command, @args; die "Failed to exec $command: $!\n"; } if (defined($timeout) && $timeout > 0) { local $SIG{ALRM} = sub { $timed_out = 1; my $now = iso_now(); print "\n=== Timeout after $timeout seconds $now; sending SIGTERM ===\n"; kill 'TERM', -$child_pid; sleep 5; print "\n=== Forcing SIGKILL $now ===\n"; kill 'KILL', -$child_pid; }; alarm($timeout); } my $waited = waitpid($child_pid, 0); my $status = $?; alarm(0); my $finished_at = iso_now(); my $duration_ms = int((time() - $t0) * 1000); my $exit_code = ($status >> 8); if ($timed_out) { $exit_code = 124; } my $final_status = "failed"; my $final_error = undef; if ($timed_out) { $final_status = "failed"; $final_error = "timeout"; } elsif ($waited != $child_pid) { $final_status = "failed"; $final_error = "waitpid failed"; } elsif ($status == 0) { $final_status = "success"; } else { $final_status = "failed"; $final_error = "exit code $exit_code"; } $job->{lastRunStatus} = $final_status; $job->{lastRunExitCode} = $exit_code; $job->{lastRunError} = $final_error if defined $final_error; $job->{updatedAt} = $finished_at; write_json_atomic($job_path, $job); append_jsonl("$runs_dir/$slug.jsonl", { runId => $run_id, scopeId => $scope_id, slug => $slug, startedAt => $started_at, finishedAt => $finished_at, durationMs => $duration_ms, status => $final_status, exitCode => $exit_code, error => $final_error, pid => $child_pid, logPath => $log_path, }); unlink $lock_path; print "\n=== Finished $finished_at status=$final_status exitCode=$exit_code durationMs=$duration_ms ===\n"; exit($exit_code); ` function ensureSupervisorScript(): void { ensureDir(SCHEDULER_DIR) writeFileSync(SUPERVISOR_PATH, SUPERVISOR_SCRIPT) } // Job type type OpencodeRunFormat = "default" | "json" type SchedulerEnvConfig = { preserve?: string[] set?: Record preserveOpencodeEnv?: boolean } type SchedulerConfig = { env?: SchedulerEnvConfig } interface JobRunSpec { prompt?: string command?: string arguments?: string files?: string[] agent?: string model?: string variant?: string title?: string share?: boolean continue?: boolean session?: string runFormat?: OpencodeRunFormat attachUrl?: string port?: number } type JobInvocation = { command: string args: string[] } interface Job { // Scope isolates jobs per opencode "owner" (usually the workspace workdir). // Jobs scheduled from different workdirs should not collide. scopeId?: string slug: string name: string schedule: string // Legacy fields (kept for backward compatibility) prompt?: string attachUrl?: string // Preferred run specification (maps to `opencode run` flags) run?: JobRunSpec // Snapshot of the command line the OS scheduler should execute. // This keeps scheduled runs stable even if run/prompt is updated. invocation?: JobInvocation // Reliability knobs (optional) timeoutSeconds?: number source?: string workdir?: string createdAt: string updatedAt?: string lastRunAt?: string lastRunExitCode?: number lastRunError?: string lastRunSource?: "manual" | "scheduled" lastRunStatus?: "running" | "success" | "failed" } type OutputFormat = "text" | "json" interface ToolResult { success: boolean output: string shouldContinue: boolean data?: T } function normalizeFormat(format?: string): OutputFormat { return format === "json" ? "json" : "text" } function formatToolResult(format: OutputFormat, result: ToolResult): string { return format === "json" ? JSON.stringify(result, null, 2) : result.output } function okResult(format: OutputFormat, output: string, data?: T): string { return formatToolResult(format, { success: true, output, shouldContinue: false, data }) } function errorResult(format: OutputFormat, output: string, data?: T): string { return formatToolResult(format, { success: false, output, shouldContinue: true, data }) } // === Built-in Skills === interface BuiltinSkill { name: string description: string suggestedPath: string files: Record } const SCHEDULED_JOB_BEST_PRACTICES_SKILL: BuiltinSkill = { name: "scheduled-job-best-practices", description: "Patterns for resilient, non-interactive scheduled opencode jobs", suggestedPath: ".opencode/skill/scheduled-job-best-practices/SKILL.md", files: { "SKILL.md": `--- name: scheduled-job-best-practices description: Patterns for resilient, non-interactive scheduled opencode jobs --- ## Use This Skill Put this line at the very top of any scheduled job prompt: @scheduled-job-best-practices Then write your task below it. ## Core Principles 1. **No magic injection.** Do not assume placeholders like __TODAY__ exist. Compute runtime values using tools (bash) during the run. 2. **Non-interactive.** Scheduled jobs must not rely on QR codes, manual logins, or confirmation dialogs. 3. **Idempotent.** Make reruns safe (maintain a seen/state file; avoid duplicate messages). 4. **Observable.** Print a short summary at the end with status + outputs. 5. **Minimal side effects.** Write durable artifacts under outputs/ in the job workdir. ## Runtime Values: Dates If you need local dates, compute them at runtime. ### macOS ~~~bash TODAY="$(date +%F)" TOMORROW="$(date -v+1d +%F)" ~~~ ### Linux ~~~bash TODAY="$(date +%F)" TOMORROW="$(date -d 'tomorrow' +%F)" ~~~ ### Portable snippet ~~~bash if [ "$(uname)" = "Darwin" ]; then TODAY="$(date +%F)" TOMORROW="$(date -v+1d +%F)" else TODAY="$(date +%F)" TOMORROW="$(date -d 'tomorrow' +%F)" fi ~~~ If timezone matters, set TZ explicitly (example: TZ=America/Los_Angeles date +%F). ## Preflight Checklist Before doing any expensive work: - Confirm required tools are available (browser, network, etc). - Confirm required env vars exist (source .env only if needed). - If a dependency is missing/offline, stop early and emit a single concise reason. ## Notifications (Telegram) Prefer the Telegram Bot API (non-interactive) over web.telegram.org. ## Output Contract End every run with a compact summary: - Status: success | skipped | failed - Reason (1 line) - Outputs written (paths) - Notifications sent (message_id, chat_id) if applicable ## Idempotency Pattern When notifying about “new” items (deals, alerts, etc.): - Store a seen list in outputs//seen.json - Only notify on items not in seen.json - Update seen.json after sending `, }, } const BUILTIN_SKILLS: Record = { [SCHEDULED_JOB_BEST_PRACTICES_SKILL.name]: SCHEDULED_JOB_BEST_PRACTICES_SKILL, } const SKILL_ALIASES: Record = { "job-best-practices": SCHEDULED_JOB_BEST_PRACTICES_SKILL.name, "scheduled-jobs": SCHEDULED_JOB_BEST_PRACTICES_SKILL.name, scheduler: SCHEDULED_JOB_BEST_PRACTICES_SKILL.name, } function normalizeSkillName(name?: string): string { const trimmed = (name ?? "").trim() if (!trimmed) return SCHEDULED_JOB_BEST_PRACTICES_SKILL.name return SKILL_ALIASES[trimmed] ?? trimmed } function getBuiltinSkill(name?: string): BuiltinSkill | undefined { return BUILTIN_SKILLS[normalizeSkillName(name)] } function listBuiltinSkills(): BuiltinSkill[] { return Object.values(BUILTIN_SKILLS) } function installBuiltinSkill(skill: BuiltinSkill, rootDir: string, overwrite = false): { directory: string; files: string[] } { const installRoot = rootDir.trim() if (!installRoot) { throw new Error("Install directory cannot be empty.") } if (!existsSync(installRoot)) { throw new Error(`Directory not found: ${installRoot}`) } const relativeDir = dirname(skill.suggestedPath) const installDir = join(installRoot, relativeDir) ensureDir(installDir) const files: string[] = [] for (const [filename, content] of Object.entries(skill.files)) { const targetPath = join(installDir, filename) if (existsSync(targetPath) && !overwrite) { throw new Error(`File already exists: ${targetPath} (pass overwrite=true to replace)`) } writeFileSync(targetPath, `${content.trimEnd()}\n`) files.push(targetPath) } return { directory: installDir, files } } function loadPackageInfo(): { name: string; version: string } { const fallback = { name: "opencode-scheduler", version: "unknown" } try { const packagePath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json") const raw = readFileSync(packagePath, "utf-8") const parsed = JSON.parse(raw) as { name?: string; version?: string } return { name: typeof parsed.name === "string" ? parsed.name : fallback.name, version: typeof parsed.version === "string" ? parsed.version : fallback.version, } } catch { return fallback } } // Find opencode binary function findOpencode(): string { // Allow explicit override for edge cases (multiple installs, etc.) const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH?.trim() if (override) return override // Prefer PATH resolution so the scheduler uses the same `opencode` as the user. // This fixes cases where an old install exists at ~/.opencode/bin/opencode. try { const resolved = execSync("command -v opencode", { env: { ...process.env, PATH: getEnhancedPath() + ":" + (process.env.PATH ?? "") }, stdio: ["ignore", "pipe", "ignore"], }) .toString() .trim() if (resolved) { // If command -v returns a path, prefer it. if (resolved.includes("/")) return resolved // Fallback: let the OS resolve via PATH at runtime. return "opencode" } } catch { // ignore } // Fallbacks (common install locations) const paths = [ "/opt/homebrew/bin/opencode", "/usr/local/bin/opencode", join(homedir(), ".opencode", "bin", "opencode"), ] for (const p of paths) { if (existsSync(p)) { return p } } return "opencode" // hope it's in PATH } // Get PATH that includes common locations for node/npx function getEnhancedPath(): string { const paths = [ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", ] return paths.join(":") } function splitCronExpression(cron: string): [string, string, string, string, string] { const parts = cron.trim().split(/\s+/) if (parts.length !== 5) { throw new Error(`Invalid cron: ${cron}`) } return parts as [string, string, string, string, string] } function uniqueSorted(values: number[]): number[] { return Array.from(new Set(values)).sort((a, b) => a - b) } function parseCronField( field: string, min: number, max: number, label: string, allowSundaySeven = false ): number[] | null { if (field === "*") return null if (field.startsWith("*/")) { const step = parseInt(field.slice(2), 10) if (!Number.isFinite(step) || step <= 0) { throw new Error(`Invalid cron ${label} step: ${field}`) } const values: number[] = [] for (let value = min; value <= max; value += step) { values.push(value) } return values } const parts = field.split(",") if (parts.length > 1) { const values = parts.map((part) => parseCronNumber(part, min, max, label, allowSundaySeven)) return uniqueSorted(values) } if (/^\d+$/.test(field)) { return [parseCronNumber(field, min, max, label, allowSundaySeven)] } throw new Error(`Invalid cron ${label} field: ${field}`) } function parseCronNumber( value: string, min: number, max: number, label: string, allowSundaySeven: boolean ): number { const parsed = parseInt(value, 10) if (!Number.isFinite(parsed)) { throw new Error(`Invalid cron ${label} value: ${value}`) } const normalized = allowSundaySeven && parsed === 7 ? 0 : parsed if (normalized < min || normalized > max) { throw new Error(`Invalid cron ${label} value: ${value}`) } return normalized } function validateCronExpression(cron: string): void { const [minute, hour, dayOfMonth, month, dayOfWeek] = splitCronExpression(cron) parseCronField(minute, 0, 59, "minute") parseCronField(hour, 0, 23, "hour") parseCronField(dayOfMonth, 1, 31, "day of month") parseCronField(month, 1, 12, "month") parseCronField(dayOfWeek, 0, 7, "day of week", true) } function expandLaunchdEntries( entries: Record[], key: string, values: number[] | null ): Record[] { if (!values) return entries const expanded: Record[] = [] for (const entry of entries) { for (const value of values) { expanded.push({ ...entry, [key]: value }) } } return expanded } function buildLaunchdCalendars( minuteValues: number[] | null, hourValues: number[] | null, dayValues: number[] | null, monthValues: number[] | null, weekdayValues: number[] | null ): Record[] { let entries: Record[] = [{}] entries = expandLaunchdEntries(entries, "Minute", minuteValues) entries = expandLaunchdEntries(entries, "Hour", hourValues) entries = expandLaunchdEntries(entries, "Day", dayValues) entries = expandLaunchdEntries(entries, "Month", monthValues) entries = expandLaunchdEntries(entries, "Weekday", weekdayValues) return entries } function cronToLaunchdCalendars(cron: string): Record[] { const [minute, hour, dayOfMonth, month, dayOfWeek] = splitCronExpression(cron) const minuteValues = parseCronField(minute, 0, 59, "minute") const hourValues = parseCronField(hour, 0, 23, "hour") const dayValues = parseCronField(dayOfMonth, 1, 31, "day of month") const monthValues = parseCronField(month, 1, 12, "month") const weekdayValues = parseCronField(dayOfWeek, 0, 7, "day of week", true) if (dayValues && weekdayValues) { return [ ...buildLaunchdCalendars(minuteValues, hourValues, dayValues, monthValues, null), ...buildLaunchdCalendars(minuteValues, hourValues, null, monthValues, weekdayValues), ] } return buildLaunchdCalendars(minuteValues, hourValues, dayValues, monthValues, weekdayValues) } function escapePlistString(value: string): string { return value.replace(/&/g, "&").replace(//g, ">") } function escapeSystemdArg(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') } function renderLaunchdCalendar(calendar: Record): string { return Object.entries(calendar) .map(([key, value]) => ` ${key}\n ${value}`) .join("\n") } const SYSTEMD_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] function formatSystemdValue(value: number, size: number): string { return value.toString().padStart(size, "0") } function cronToSystemdCalendars(cron: string): string[] { const [minute, hour, dayOfMonth, month, dayOfWeek] = splitCronExpression(cron) const minuteValues = parseCronField(minute, 0, 59, "minute") const hourValues = parseCronField(hour, 0, 23, "hour") const dayValues = parseCronField(dayOfMonth, 1, 31, "day of month") const monthValues = parseCronField(month, 1, 12, "month") const weekdayValues = parseCronField(dayOfWeek, 0, 7, "day of week", true) const minutes = minuteValues ? minuteValues.map((value) => formatSystemdValue(value, 2)) : ["*"] const hours = hourValues ? hourValues.map((value) => formatSystemdValue(value, 2)) : ["*"] const days = dayValues ? dayValues.map((value) => formatSystemdValue(value, 2)) : ["*"] const months = monthValues ? monthValues.map((value) => formatSystemdValue(value, 2)) : ["*"] const weekdays = weekdayValues ? weekdayValues.map((value) => SYSTEMD_WEEKDAYS[value] ?? "*") : ["*"] const calendars: string[] = [] const buildCalendars = (domValues: string[], dowValues: string[]) => { for (const minuteValue of minutes) { for (const hourValue of hours) { for (const domValue of domValues) { for (const monthValue of months) { for (const dowValue of dowValues) { calendars.push(`${dowValue} *-${monthValue}-${domValue} ${hourValue}:${minuteValue}:00`) } } } } } } if (dayValues && weekdayValues) { buildCalendars(days, ["*"]) buildCalendars(["*"], weekdays) } else { buildCalendars(days, weekdays) } return calendars } const WINDOWS_WEEKDAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] const WINDOWS_MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] interface WindowsTaskDefinition { name: string args: string[] } interface WindowsCronPlan { schedule: "MINUTE" | "HOURLY" | "DAILY" | "WEEKLY" | "MONTHLY" modifier?: string weekdays?: string days?: string months?: string startTime: string } function pad2(value: number): string { return value.toString().padStart(2, "0") } function formatStartTime(hour: number, minute: number): string { return `${pad2(hour)}:${pad2(minute)}` } function windowsTaskBaseName(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) return `${WINDOWS_TASK_PREFIX}-${scopeId}-${job.slug}` } function windowsTaskName(baseName: string, index: number, total: number): string { const suffix = total > 1 ? `-${index + 1}` : "" return `${WINDOWS_TASK_ROOT}\\${baseName}${suffix}` } function quoteWindowsArg(value: string): string { if (!/[\s"]/u.test(value)) return value return `"${value.replace(/"/g, '""')}"` } function getWindowsInvocation(job: Job): JobInvocation { const invocation = job.invocation ?? buildOpencodeArgs(job) return invocation } function buildWindowsTaskCommand(job: Job): string { const invocation = getWindowsInvocation(job) return [invocation.command, ...invocation.args].map((arg) => quoteWindowsArg(arg)).join(" ") } function maybeStep(field: string): number | null { const match = field.match(/^\*\/(\d+)$/) if (!match) return null const step = parseInt(match[1], 10) return Number.isFinite(step) && step > 0 ? step : null } function ensureWindowsRepresentable(cron: string): WindowsCronPlan[] { const [minuteField, hourField, dayField, monthField, weekdayField] = splitCronExpression(cron) const minuteStep = maybeStep(minuteField) const hourStep = maybeStep(hourField) if (minuteField === "*" && hourField === "*" && dayField === "*" && monthField === "*" && weekdayField === "*") { return [{ schedule: "MINUTE", modifier: "1", startTime: "00:00" }] } if (minuteStep !== null && hourField === "*" && dayField === "*" && monthField === "*" && weekdayField === "*") { if (minuteStep > 1439) { throw new Error( `Windows Task Scheduler supports at most every 1439 minutes. Use ${minuteStep} with a smaller value or switch to an hourly/daily cron.` ) } return [{ schedule: "MINUTE", modifier: String(minuteStep), startTime: "00:00" }] } const minuteValues = parseCronField(minuteField, 0, 59, "minute") const hourValues = parseCronField(hourField, 0, 23, "hour") const dayValues = parseCronField(dayField, 1, 31, "day of month") const monthValues = parseCronField(monthField, 1, 12, "month") const weekdayValues = parseCronField(weekdayField, 0, 7, "day of week", true) if (hourStep !== null && minuteValues && minuteValues.length === 1 && dayField === "*" && monthField === "*" && weekdayField === "*") { return [{ schedule: "HOURLY", modifier: String(hourStep), startTime: formatStartTime(0, minuteValues[0]) }] } if (!minuteValues || minuteValues.length === 0 || !hourValues || hourValues.length === 0) { throw new Error( "Windows Task Scheduler requires explicit minute and hour values for this cron expression. Use formats like '0 9 * * *', '30 8 * * 1', '*/15 * * * *', or '0 */6 * * *'." ) } if (monthValues && weekdayValues) { throw new Error( "Windows Task Scheduler cannot combine specific months with day-of-week constraints in cron. Split this into multiple jobs (for example: one monthly job and one weekly job)." ) } if (monthValues && !dayValues) { throw new Error( "Windows Task Scheduler cannot represent 'every day in selected months'. Use explicit day-of-month values (for example '0 9 1,15 1,7 *') or create separate jobs." ) } const plans: WindowsCronPlan[] = [] for (const minute of minuteValues) { for (const hour of hourValues) { const startTime = formatStartTime(hour, minute) if (dayValues && weekdayValues) { plans.push({ schedule: "MONTHLY", days: dayValues.join(","), months: monthValues ? monthValues.map((value) => WINDOWS_MONTHS[value - 1]).join(",") : undefined, startTime, }) plans.push({ schedule: "WEEKLY", weekdays: weekdayValues.map((value) => WINDOWS_WEEKDAYS[value]).join(","), startTime, }) } else if (weekdayValues) { plans.push({ schedule: "WEEKLY", weekdays: weekdayValues.map((value) => WINDOWS_WEEKDAYS[value]).join(","), startTime, }) } else if (dayValues) { plans.push({ schedule: "MONTHLY", days: dayValues.join(","), months: monthValues ? monthValues.map((value) => WINDOWS_MONTHS[value - 1]).join(",") : undefined, startTime, }) } else if (monthValues) { throw new Error( "Windows Task Scheduler cannot represent month-only cron constraints without day-of-month. Use explicit days or create separate jobs." ) } else { plans.push({ schedule: "DAILY", startTime }) } } } return plans } function cronToWindowsTaskDefinitions(job: Job): WindowsTaskDefinition[] { const plans = ensureWindowsRepresentable(job.schedule) const baseName = windowsTaskBaseName(job) const command = buildWindowsTaskCommand(job) return plans.map((plan, index) => { const args = ["/Create", "/F", "/TN", windowsTaskName(baseName, index, plans.length), "/TR", command, "/SC", plan.schedule] if (plan.modifier) { args.push("/MO", plan.modifier) } if (plan.weekdays) { args.push("/D", plan.weekdays) } if (plan.days) { args.push("/D", plan.days) } if (plan.months) { args.push("/M", plan.months) } args.push("/ST", plan.startTime) return { name: windowsTaskName(baseName, index, plans.length), args } }) } // === LAUNCHD (Mac) === function createLaunchdPlist(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const label = `${LAUNCHD_PREFIX}.${scopeId}.${job.slug}` const logFilePath = scopedLogPath(scopeId, job.slug) const jobPath = jobFilePath(scopeId, job.slug) const calendars = cronToLaunchdCalendars(job.schedule) const calendarXml = calendars.length === 1 ? ` \n${renderLaunchdCalendar(calendars[0])}\n ` : ` \n${calendars .map((calendar) => ` \n${renderLaunchdCalendar(calendar)}\n `) .join("\n")}\n ` const programArgumentsXml = [ ` ${escapePlistString("/usr/bin/perl")}`, ` ${escapePlistString(SUPERVISOR_PATH)}`, ` ${escapePlistString(jobPath)}`, ].join("\n") // Use workdir if specified, otherwise default to home directory const workdir = job.workdir || homedir() const enhancedPath = getEnhancedPath() return ` Label ${label} WorkingDirectory ${escapePlistString(workdir)} EnvironmentVariables PATH ${enhancedPath} ProgramArguments ${programArgumentsXml} StartCalendarInterval ${calendarXml} StandardOutPath ${logFilePath} StandardErrorPath ${logFilePath} RunAtLoad ` } function installLaunchdJob(job: Job): void { ensureDir(LAUNCH_AGENTS_DIR) ensureDir(LOGS_DIR) const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) ensureDir(scopeLogsDir(scopeId)) ensureSupervisorScript() const legacyLabel = `${LAUNCHD_PREFIX}.${job.slug}` const legacyPlistPath = join(LAUNCH_AGENTS_DIR, `${legacyLabel}.plist`) const label = `${LAUNCHD_PREFIX}.${scopeId}.${job.slug}` const plistPath = join(LAUNCH_AGENTS_DIR, `${label}.plist`) // Unload if exists try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" }) } catch {} // Also unload legacy label (pre-scope) if (existsSync(legacyPlistPath)) { try { execSync(`launchctl unload "${legacyPlistPath}" 2>/dev/null`, { stdio: "ignore" }) } catch {} } // Write plist const plist = createLaunchdPlist(job) writeFileSync(plistPath, plist) // Load execSync(`launchctl load "${plistPath}"`) } function uninstallLaunchdJob(job: Job): void { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const scopedLabel = `${LAUNCHD_PREFIX}.${scopeId}.${job.slug}` const scopedPlistPath = join(LAUNCH_AGENTS_DIR, `${scopedLabel}.plist`) const legacyLabel = `${LAUNCHD_PREFIX}.${job.slug}` const legacyPlistPath = join(LAUNCH_AGENTS_DIR, `${legacyLabel}.plist`) for (const plistPath of [scopedPlistPath, legacyPlistPath]) { if (!existsSync(plistPath)) continue try { execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }) } catch {} try { unlinkSync(plistPath) } catch {} } } // === SYSTEMD (Linux) === function createSystemdService(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const logFilePath = scopedLogPath(scopeId, job.slug) const jobPath = jobFilePath(scopeId, job.slug) const workdir = job.workdir || homedir() const enhancedPath = getEnhancedPath() const execStart = ["/usr/bin/perl", SUPERVISOR_PATH, jobPath] .map((arg) => `"${escapeSystemdArg(arg)}"`) .join(" ") return `[Unit] Description=OpenCode Job: ${job.name} [Service] Type=oneshot WorkingDirectory=${workdir} Environment="PATH=${enhancedPath}" ExecStart=${execStart} StandardOutput=append:${logFilePath} StandardError=append:${logFilePath} [Install] WantedBy=default.target ` } function createSystemdTimer(job: Job): string { const calendars = cronToSystemdCalendars(job.schedule) const calendarLines = calendars.map((calendar) => `OnCalendar=${calendar}`).join("\n") return `[Unit] Description=Timer for OpenCode Job: ${job.name} [Timer] ${calendarLines} Persistent=true [Install] WantedBy=timers.target ` } function installSystemdJob(job: Job): void { ensureDir(SYSTEMD_USER_DIR) ensureDir(LOGS_DIR) const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) ensureDir(scopeLogsDir(scopeId)) ensureSupervisorScript() const servicePath = join(SYSTEMD_USER_DIR, `opencode-job-${scopeId}-${job.slug}.service`) const timerPath = join(SYSTEMD_USER_DIR, `opencode-job-${scopeId}-${job.slug}.timer`) // Also stop/disable legacy units (pre-scope) try { execSync(`systemctl --user stop opencode-job-${job.slug}.timer`, { stdio: "ignore" }) execSync(`systemctl --user disable opencode-job-${job.slug}.timer`, { stdio: "ignore" }) } catch {} // Write service and timer writeFileSync(servicePath, createSystemdService(job)) writeFileSync(timerPath, createSystemdTimer(job)) // Reload and enable execSync("systemctl --user daemon-reload") execSync(`systemctl --user enable opencode-job-${scopeId}-${job.slug}.timer`) execSync(`systemctl --user start opencode-job-${scopeId}-${job.slug}.timer`) } function uninstallSystemdJob(job: Job): void { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const scopedTimerUnit = `opencode-job-${scopeId}-${job.slug}.timer` const legacyTimerUnit = `opencode-job-${job.slug}.timer` for (const timerUnit of [scopedTimerUnit, legacyTimerUnit]) { try { execSync(`systemctl --user stop ${timerUnit}`, { stdio: "ignore" }) execSync(`systemctl --user disable ${timerUnit}`, { stdio: "ignore" }) } catch {} } const scopedServicePath = join(SYSTEMD_USER_DIR, `opencode-job-${scopeId}-${job.slug}.service`) const scopedTimerPath = join(SYSTEMD_USER_DIR, `opencode-job-${scopeId}-${job.slug}.timer`) const legacyServicePath = join(SYSTEMD_USER_DIR, `opencode-job-${job.slug}.service`) const legacyTimerPath = join(SYSTEMD_USER_DIR, `opencode-job-${job.slug}.timer`) for (const p of [scopedServicePath, scopedTimerPath, legacyServicePath, legacyTimerPath]) { if (existsSync(p)) { try { unlinkSync(p) } catch {} } } try { execSync("systemctl --user daemon-reload", { stdio: "ignore" }) } catch {} } // === WINDOWS TASK SCHEDULER === function installWindowsJob(job: Job): void { // Remove stale task variants before (re)creating current definitions. uninstallWindowsJob(job) const taskDefinitions = cronToWindowsTaskDefinitions(job) for (const task of taskDefinitions) { execFileSync("schtasks", task.args, { stdio: "ignore" }) } } function uninstallWindowsJob(job: Job): void { const candidates = new Set() const scopedBase = windowsTaskBaseName(job) const legacyBase = `${WINDOWS_TASK_PREFIX}-${job.slug}` for (let i = 0; i < 64; i += 1) { const suffix = i === 0 ? "" : `-${i + 1}` candidates.add(`${WINDOWS_TASK_ROOT}\\${scopedBase}${suffix}`) candidates.add(`${WINDOWS_TASK_ROOT}\\${legacyBase}${suffix}`) } for (const taskName of candidates) { try { execFileSync("schtasks", ["/Delete", "/TN", taskName, "/F"], { stdio: "ignore" }) } catch {} } } // === CROSS-PLATFORM === type SchedulerBackend = "launchd" | "systemd" | "schtasks" | "cron" function isCommandAvailable(command: string): boolean { try { execSync(`command -v ${command}`, { stdio: "ignore", env: buildRunEnvironment(), }) return true } catch { return false } } function isSystemdUserAvailable(): boolean { if (!IS_LINUX) return false if (!isCommandAvailable("systemctl")) return false try { execSync("systemctl --user show-environment", { stdio: "ignore", env: buildRunEnvironment(), }) return true } catch { return false } } function isCronAvailable(): boolean { if (IS_WINDOWS) return false return isCommandAvailable("crontab") } function cronBlockId(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) return `${scopeId}:${job.slug}` } function cronLegacyBlockId(job: Job): string { return `legacy:${job.slug}` } function cronBlockStart(id: string): string { return `# BEGIN ${CRON_MANAGED_PREFIX} ${id}` } function cronBlockEnd(id: string): string { return `# END ${CRON_MANAGED_PREFIX} ${id}` } function shellEscapeDoubleQuoted(value: string): string { return value.replace(/(["\\$`])/g, "\\$1") } function readUserCrontab(): string { try { return execFileSync("crontab", ["-l"], { encoding: "utf-8" }) as string } catch (error) { const status = typeof error === "object" && error !== null ? (error as { status?: number }).status : undefined const stderrValue = typeof error === "object" && error !== null && "stderr" in error ? (error as { stderr?: string | Buffer }).stderr : undefined const stderr = Buffer.isBuffer(stderrValue) ? stderrValue.toString("utf-8") : (stderrValue ?? "") const noCrontab = status === 1 && (!stderr.trim() || /no crontab/i.test(stderr)) if (noCrontab) return "" throw error } } function writeUserCrontab(content: string): void { const normalized = content.trim() const input = normalized ? `${normalized}\n` : "" execFileSync("crontab", ["-"], { input }) } function stripManagedCronBlocks(content: string, blockIds: Set): { content: string; removed: number } { const lines = content ? content.split(/\r?\n/) : [] const retained: string[] = [] const prefix = `# BEGIN ${CRON_MANAGED_PREFIX} ` const endPrefix = `# END ${CRON_MANAGED_PREFIX} ` let removed = 0 for (let index = 0; index < lines.length; ) { const line = lines[index] if (!line.startsWith(prefix)) { retained.push(line) index += 1 continue } const id = line.slice(prefix.length).trim() let endIndex = lines.length - 1 for (let probe = index + 1; probe < lines.length; probe += 1) { if (lines[probe] === `${endPrefix}${id}`) { endIndex = probe break } } if (blockIds.has(id)) { removed += 1 } else { for (let keep = index; keep <= endIndex; keep += 1) { retained.push(lines[keep]) } } index = endIndex + 1 } return { content: retained.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd(), removed, } } function createCronEntry(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const jobPath = jobFilePath(scopeId, job.slug) const logFilePath = scopedLogPath(scopeId, job.slug) const escapedSupervisor = shellEscapeDoubleQuoted(SUPERVISOR_PATH) const escapedJobPath = shellEscapeDoubleQuoted(jobPath) const escapedLogPath = shellEscapeDoubleQuoted(logFilePath) const escapedPath = shellEscapeDoubleQuoted(getEnhancedPath()) return `${job.schedule} PATH="${escapedPath}" /usr/bin/perl "${escapedSupervisor}" "${escapedJobPath}" >> "${escapedLogPath}" 2>&1` } function installCronJob(job: Job): void { if (!isCronAvailable()) { throw new Error("cron backend is unavailable: `crontab` command not found.") } ensureDir(LOGS_DIR) const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) ensureDir(scopeLogsDir(scopeId)) ensureSupervisorScript() const blockId = cronBlockId(job) const current = readUserCrontab() const stripped = stripManagedCronBlocks(current, new Set([blockId, cronLegacyBlockId(job)])) const block = [cronBlockStart(blockId), createCronEntry(job), cronBlockEnd(blockId)].join("\n") const next = [stripped.content.trim(), block].filter(Boolean).join("\n\n") writeUserCrontab(next) } function uninstallCronJob(job: Job): void { if (!isCronAvailable()) return const current = readUserCrontab() const stripped = stripManagedCronBlocks(current, new Set([cronBlockId(job), cronLegacyBlockId(job)])) if (stripped.removed === 0) return writeUserCrontab(stripped.content) } function resolveSchedulerBackend(): SchedulerBackend { if (IS_MAC) return "launchd" if (IS_WINDOWS) return "schtasks" if (isSystemdUserAvailable()) return "systemd" if (isCronAvailable()) return "cron" if (IS_LINUX) { throw new Error( "No supported scheduler backend found: systemd --user is unavailable and `crontab` is not installed." ) } throw new Error( `Unsupported platform: ${platform()}. Supported platforms: macOS (launchd), Linux (systemd or cron), Windows, and POSIX systems with cron.` ) } function installJob(job: Job): SchedulerBackend { const backend = resolveSchedulerBackend() if (backend === "launchd") { installLaunchdJob(job) } else if (backend === "systemd") { try { installSystemdJob(job) } catch (error) { if (!isCronAvailable()) { throw error } installCronJob(job) return "cron" } } else if (backend === "schtasks") { installWindowsJob(job) } else { installCronJob(job) } return backend } function uninstallJob(job: Job): void { if (IS_MAC) { uninstallLaunchdJob(job) return } if (IS_WINDOWS) { uninstallWindowsJob(job) return } if (IS_LINUX) { uninstallSystemdJob(job) uninstallCronJob(job) return } uninstallCronJob(job) } // === JOB STORAGE === function ensureScopeStorage(scopeId: string): void { ensureDir(SCHEDULER_DIR) ensureDir(SCOPES_DIR) ensureDir(scopeJobsDir(scopeId)) ensureDir(scopeLocksDir(scopeId)) ensureDir(scopeRunsDir(scopeId)) ensureDir(scopeLogsDir(scopeId)) } function loadScopedJob(scopeId: string, slug: string): Job | null { ensureScopeStorage(scopeId) const path = jobFilePath(scopeId, slug) if (!existsSync(path)) return null try { return normalizeJob(JSON.parse(readFileSync(path, "utf-8"))) } catch { return null } } function loadAllScopedJobs(scopeId: string): Job[] { ensureScopeStorage(scopeId) const files = readdirSync(scopeJobsDir(scopeId)).filter((f) => f.endsWith(".json")) return files .map((f) => { try { return normalizeJob(JSON.parse(readFileSync(join(scopeJobsDir(scopeId), f), "utf-8"))) } catch { return null } }) .filter(Boolean) as Job[] } function listScopeIds(): string[] { ensureDir(SCOPES_DIR) try { return readdirSync(SCOPES_DIR) .filter((name) => { try { return existsSync(scopeDir(name)) } catch { return false } }) .sort() } catch { return [] } } function loadAllJobsAcrossScopes(): Job[] { const scopeIds = listScopeIds() const out: Job[] = [] for (const scopeId of scopeIds) { out.push(...loadAllScopedJobs(scopeId)) } return out } function loadLegacyJob(slug: string): Job | null { ensureDir(LEGACY_JOBS_DIR) const path = join(LEGACY_JOBS_DIR, `${slug}.json`) if (!existsSync(path)) return null try { return normalizeJob(JSON.parse(readFileSync(path, "utf-8"))) } catch { return null } } function loadAllLegacyJobs(): Job[] { ensureDir(LEGACY_JOBS_DIR) const files = readdirSync(LEGACY_JOBS_DIR).filter((f) => f.endsWith(".json")) return files .map((f) => { try { return normalizeJob(JSON.parse(readFileSync(join(LEGACY_JOBS_DIR, f), "utf-8"))) } catch { return null } }) .filter(Boolean) as Job[] } function saveJob(job: Job): void { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const normalizedJob: Job = { ...job, scopeId } ensureScopeStorage(scopeId) const path = jobFilePath(scopeId, normalizedJob.slug) writeFileSync(path, JSON.stringify(sanitizeJob(normalizedJob), null, 2)) } function deleteJobFile(job: Job): void { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const path = jobFilePath(scopeId, job.slug) if (existsSync(path)) { unlinkSync(path) } } interface GlobalCleanupPlan { scopeIds: string[] jobsToUninstall: Job[] scopedJobDefinitionPaths: string[] legacyJobDefinitionPaths: string[] lockPaths: string[] runHistoryPaths: string[] logPaths: string[] launchdPaths: string[] systemdPaths: string[] } interface GlobalCleanupExecution { dryRun: boolean includeHistory: boolean removed: { scopedJobDefinitions: string[] legacyJobDefinitions: string[] locks: string[] runHistory: string[] logs: string[] launchdUnits: string[] systemdUnits: string[] } errors: string[] } function listDirectoryFiles( dir: string, options?: { prefix?: string; suffix?: string } ): string[] { if (!existsSync(dir)) return [] try { const entries = readdirSync(dir, { withFileTypes: true }) return entries .filter((entry) => entry.isFile()) .map((entry) => entry.name) .filter((name) => (options?.prefix ? name.startsWith(options.prefix) : true)) .filter((name) => (options?.suffix ? name.endsWith(options.suffix) : true)) .map((name) => join(dir, name)) .sort() } catch { return [] } } function listDirectoryNames(dir: string): string[] { if (!existsSync(dir)) return [] try { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() } catch { return [] } } function uniquePaths(paths: string[]): string[] { return Array.from(new Set(paths)).sort() } function buildGlobalCleanupPlan(includeHistory: boolean): GlobalCleanupPlan { const scopeIds = listScopeIds() const scopedJobDefinitionPaths = scopeIds.flatMap((scopeId) => listDirectoryFiles(scopeJobsDir(scopeId), { suffix: ".json" })) const lockPaths = scopeIds.flatMap((scopeId) => listDirectoryFiles(scopeLocksDir(scopeId), { suffix: ".json" })) const runHistoryPaths = includeHistory ? scopeIds.flatMap((scopeId) => listDirectoryFiles(scopeRunsDir(scopeId), { suffix: ".jsonl" })) : [] const schedulerLogsRoot = join(LOGS_DIR, "scheduler") const logScopeIds = listDirectoryNames(schedulerLogsRoot) const logPaths = includeHistory ? logScopeIds.flatMap((scopeId) => listDirectoryFiles(join(schedulerLogsRoot, scopeId), { suffix: ".log" })) : [] const launchdPaths = IS_MAC ? listDirectoryFiles(LAUNCH_AGENTS_DIR, { prefix: `${LAUNCHD_PREFIX}.`, suffix: ".plist" }) : [] const systemdPaths = IS_LINUX ? [ ...listDirectoryFiles(SYSTEMD_USER_DIR, { prefix: "opencode-job-", suffix: ".service" }), ...listDirectoryFiles(SYSTEMD_USER_DIR, { prefix: "opencode-job-", suffix: ".timer" }), ] : [] const jobsToUninstall = [...loadAllJobsAcrossScopes(), ...loadAllLegacyJobs()] return { scopeIds, jobsToUninstall, scopedJobDefinitionPaths: uniquePaths(scopedJobDefinitionPaths), legacyJobDefinitionPaths: listDirectoryFiles(LEGACY_JOBS_DIR, { suffix: ".json" }), lockPaths: uniquePaths(lockPaths), runHistoryPaths: uniquePaths(runHistoryPaths), logPaths: uniquePaths(logPaths), launchdPaths: uniquePaths(launchdPaths), systemdPaths: uniquePaths(systemdPaths), } } function removePaths(paths: string[], errors: string[]): string[] { const removed: string[] = [] for (const path of uniquePaths(paths)) { if (!existsSync(path)) continue try { rmSync(path, { recursive: true, force: true }) removed.push(path) } catch (error) { const msg = error instanceof Error ? error.message : String(error) errors.push(`Failed to remove ${path}: ${msg}`) } } return removed } function executeGlobalCleanup(plan: GlobalCleanupPlan, options: { dryRun: boolean; includeHistory: boolean }): GlobalCleanupExecution { const errors: string[] = [] const dryRun = options.dryRun if (!dryRun) { for (const job of plan.jobsToUninstall) { try { uninstallJob(job) } catch (error) { const msg = error instanceof Error ? error.message : String(error) errors.push(`Failed to uninstall scheduler entry for ${job.slug}: ${msg}`) } } } const removeOrPreview = (paths: string[]): string[] => { if (dryRun) return uniquePaths(paths).filter((path) => existsSync(path)) return removePaths(paths, errors) } const removed = { scopedJobDefinitions: removeOrPreview(plan.scopedJobDefinitionPaths), legacyJobDefinitions: removeOrPreview(plan.legacyJobDefinitionPaths), locks: removeOrPreview(plan.lockPaths), runHistory: options.includeHistory ? removeOrPreview(plan.runHistoryPaths) : [], logs: options.includeHistory ? removeOrPreview(plan.logPaths) : [], launchdUnits: removeOrPreview(plan.launchdPaths), systemdUnits: removeOrPreview(plan.systemdPaths), } return { dryRun, includeHistory: options.includeHistory, removed, errors, } } function formatCleanupLine(label: string, count: number, location: string): string { return `- ${label}: ${count} (${location})` } function formatGlobalCleanupOutput(execution: GlobalCleanupExecution): string { const mode = execution.dryRun ? "DRY RUN (no files deleted)" : "EXECUTED" const lines = [ `Global scheduler cleanup: ${mode}`, "", formatCleanupLine("Scoped job definitions", execution.removed.scopedJobDefinitions.length, `${SCOPES_DIR}/*/jobs`), formatCleanupLine("Legacy job definitions", execution.removed.legacyJobDefinitions.length, LEGACY_JOBS_DIR), formatCleanupLine("Lock files", execution.removed.locks.length, `${SCOPES_DIR}/*/locks`), ] if (execution.includeHistory) { lines.push(formatCleanupLine("Run history", execution.removed.runHistory.length, `${SCOPES_DIR}/*/runs`)) lines.push(formatCleanupLine("Logs", execution.removed.logs.length, `${LOGS_DIR}/scheduler/*`)) } else { lines.push("- Run history: skipped (set includeHistory=true)") lines.push("- Logs: skipped (set includeHistory=true)") } if (IS_MAC) { lines.push(formatCleanupLine("launchd plists", execution.removed.launchdUnits.length, LAUNCH_AGENTS_DIR)) } if (IS_LINUX) { lines.push(formatCleanupLine("systemd units", execution.removed.systemdUnits.length, SYSTEMD_USER_DIR)) } if (execution.errors.length > 0) { lines.push("") lines.push("Errors:") for (const error of execution.errors) { lines.push(`- ${error}`) } } if (execution.dryRun) { lines.push("") lines.push("Re-run with confirm=true to apply this cleanup.") } return lines.join("\n") } function normalizeAttachUrl(attachUrl?: string): string | undefined { if (attachUrl === undefined) return undefined const trimmed = attachUrl.trim() if (!trimmed) return undefined try { new URL(trimmed) } catch { throw new Error(`Invalid attach URL: ${attachUrl}`) } return trimmed } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } function normalizeRunFormat(value: unknown): OpencodeRunFormat | undefined { if (typeof value !== "string") return undefined const trimmed = value.trim() if (trimmed === "json") return "json" if (trimmed === "default") return "default" return undefined } function parseRunFormatInput(value: unknown): OpencodeRunFormat | undefined { if (value === undefined) return undefined if (typeof value === "string" && !value.trim()) return undefined const normalized = normalizeRunFormat(value) if (normalized) return normalized throw new Error(`Invalid runFormat: ${String(value)} (expected: default | json)`) } function normalizeRunSpec(run: JobRunSpec): JobRunSpec { const normalized: JobRunSpec = { ...run } if (typeof normalized.prompt === "string") { const trimmed = normalized.prompt.trim() normalized.prompt = trimmed ? trimmed : undefined } if (typeof normalized.command === "string") { const trimmed = normalized.command.trim() normalized.command = trimmed ? trimmed : undefined } if (typeof normalized.arguments === "string") { const trimmed = normalized.arguments.trim() normalized.arguments = trimmed ? trimmed : undefined } if (Array.isArray(normalized.files)) { const files = normalized.files.map((file) => String(file).trim()).filter(Boolean) normalized.files = files.length ? files : undefined } if (typeof normalized.agent === "string") { const trimmed = normalized.agent.trim() normalized.agent = trimmed ? trimmed : undefined } if (typeof normalized.model === "string") { const trimmed = normalized.model.trim() normalized.model = trimmed ? trimmed : undefined } if (typeof normalized.variant === "string") { const trimmed = normalized.variant.trim() normalized.variant = trimmed ? trimmed : undefined } if (typeof normalized.title === "string") { const trimmed = normalized.title.trim() normalized.title = trimmed ? trimmed : undefined } if (normalized.share !== true) { normalized.share = undefined } if (normalized.continue !== true) { normalized.continue = undefined } if (typeof normalized.session === "string") { const trimmed = normalized.session.trim() normalized.session = trimmed ? trimmed : undefined } if (normalized.runFormat !== "json") { normalized.runFormat = normalized.runFormat === "default" ? "default" : undefined } if (typeof normalized.attachUrl === "string") { const trimmed = normalized.attachUrl.trim() normalized.attachUrl = trimmed ? trimmed : undefined } if (typeof normalized.port === "number" && Number.isFinite(normalized.port)) { normalized.port = Math.floor(normalized.port) if (normalized.port <= 0) normalized.port = undefined } else { normalized.port = undefined } return normalized } function validateRunSpec(run: JobRunSpec): void { const hasPrompt = typeof run.prompt === "string" && run.prompt.trim().length > 0 const hasCommand = typeof run.command === "string" && run.command.trim().length > 0 if (!hasPrompt && !hasCommand) { throw new Error("Job must have either run.prompt or run.command") } if (hasPrompt && hasCommand) { throw new Error("Job cannot specify both run.prompt and run.command") } if (hasCommand && run.arguments !== undefined && typeof run.arguments !== "string") { throw new Error("run.arguments must be a string") } if (run.attachUrl !== undefined) { normalizeAttachUrl(run.attachUrl) } if (run.port !== undefined) { if (!Number.isFinite(run.port) || run.port <= 0) { throw new Error("run.port must be a positive integer") } } if (run.runFormat !== undefined && run.runFormat !== "default" && run.runFormat !== "json") { throw new Error("run.runFormat must be 'default' or 'json'") } } function getJobRun(job: Job): JobRunSpec { if (job.run) { return job.run } const fallbackPrompt = (job.prompt ?? "").trim() if (!fallbackPrompt) { throw new Error(`Job "${job.slug}" is missing a prompt. Update the job to include run.prompt or prompt.`) } return { prompt: fallbackPrompt, attachUrl: job.attachUrl, } } function sanitizeJob(job: Job): Job { const sanitized: Job = { ...job } if (typeof sanitized.workdir === "string") { const trimmed = sanitized.workdir.trim() sanitized.workdir = trimmed ? trimmed : undefined } if (typeof sanitized.scopeId === "string") { const trimmed = sanitized.scopeId.trim() sanitized.scopeId = trimmed ? trimmed : undefined } if (!sanitized.scopeId) { sanitized.scopeId = deriveScopeId(sanitized.workdir || homedir()) } if (sanitized.timeoutSeconds !== undefined) { const n = sanitized.timeoutSeconds if (typeof n !== "number" || !Number.isFinite(n) || n < 0 || Math.floor(n) !== n) { throw new Error("timeoutSeconds must be a non-negative integer") } } if (sanitized.invocation !== undefined) { const inv = sanitized.invocation as unknown if (!inv || typeof inv !== "object") { throw new Error("invocation must be an object") } const rec = inv as Record if (typeof rec.command !== "string" || !rec.command.trim()) { throw new Error("invocation.command must be a non-empty string") } if (!Array.isArray(rec.args)) { throw new Error("invocation.args must be an array") } sanitized.invocation = { command: rec.command, args: rec.args.map((v) => String(v)), } } if (sanitized.run) { const normalized = normalizeRunSpec(sanitized.run) validateRunSpec(normalized) sanitized.run = normalized } if (sanitized.attachUrl !== undefined) { sanitized.attachUrl = normalizeAttachUrl(sanitized.attachUrl) } if (sanitized.prompt !== undefined) { const trimmed = sanitized.prompt.trim() sanitized.prompt = trimmed ? trimmed : undefined } return sanitized } function normalizeJobInvocation(raw: unknown): JobInvocation | undefined { if (!isRecord(raw)) return undefined if (typeof raw.command !== "string") return undefined if (!Array.isArray(raw.args)) return undefined const command = raw.command.trim() if (!command) return undefined return { command, args: raw.args.map((v) => String(v)) } } function normalizeJobRun(raw: unknown): JobRunSpec | undefined { if (!isRecord(raw)) return undefined const run: JobRunSpec = {} if (typeof raw.prompt === "string") run.prompt = raw.prompt if (typeof raw.command === "string") run.command = raw.command if (typeof raw.arguments === "string") run.arguments = raw.arguments if (Array.isArray(raw.files)) { run.files = raw.files.map((file) => String(file)) } if (typeof raw.agent === "string") run.agent = raw.agent if (typeof raw.model === "string") run.model = raw.model if (typeof raw.variant === "string") run.variant = raw.variant if (typeof raw.title === "string") run.title = raw.title if (typeof raw.share === "boolean") run.share = raw.share if (typeof raw.continue === "boolean") run.continue = raw.continue if (typeof raw.session === "string") run.session = raw.session const runFormat = normalizeRunFormat(raw.runFormat) if (runFormat) run.runFormat = runFormat if (typeof raw.attachUrl === "string") run.attachUrl = raw.attachUrl if (typeof raw.port === "number" && Number.isFinite(raw.port)) { run.port = raw.port } return run } function normalizeJob(raw: unknown): Job | null { if (!isRecord(raw)) return null if (typeof raw.slug !== "string" || typeof raw.name !== "string" || typeof raw.schedule !== "string") { return null } const job: Job = { scopeId: typeof raw.scopeId === "string" ? raw.scopeId : undefined, slug: raw.slug, name: raw.name, schedule: raw.schedule, source: typeof raw.source === "string" ? raw.source : undefined, workdir: typeof raw.workdir === "string" ? raw.workdir : undefined, timeoutSeconds: typeof raw.timeoutSeconds === "number" ? raw.timeoutSeconds : undefined, createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(), updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined, lastRunAt: typeof raw.lastRunAt === "string" ? raw.lastRunAt : undefined, lastRunExitCode: typeof raw.lastRunExitCode === "number" ? raw.lastRunExitCode : undefined, lastRunError: typeof raw.lastRunError === "string" ? raw.lastRunError : undefined, lastRunSource: raw.lastRunSource === "manual" || raw.lastRunSource === "scheduled" ? raw.lastRunSource : undefined, lastRunStatus: raw.lastRunStatus === "running" || raw.lastRunStatus === "success" || raw.lastRunStatus === "failed" ? raw.lastRunStatus : undefined, } if (typeof raw.prompt === "string") job.prompt = raw.prompt if (typeof raw.attachUrl === "string") job.attachUrl = raw.attachUrl const run = normalizeJobRun(raw.run) if (run) job.run = run const inv = normalizeJobInvocation(raw.invocation) if (inv) job.invocation = inv return sanitizeJob(job) } function findJobByName( name: string, options?: { scopeId?: string; allScopes?: boolean; includeLegacy?: boolean } ): Job | null { const scopeId = options?.scopeId ?? currentScopeId() const slug = slugify(name) let job = loadScopedJob(scopeId, slug) || loadScopedJob(scopeId, name) if (!job) { const allJobs = loadAllScopedJobs(scopeId) job = allJobs.find( (j) => j.slug === name || j.slug.endsWith(`-${slug}`) || j.name.toLowerCase() === name.toLowerCase() || j.name.toLowerCase().includes(name.toLowerCase()) ) || null } if (!job && options?.allScopes) { const allJobs = loadAllJobsAcrossScopes() job = allJobs.find( (j) => j.slug === name || j.slug.endsWith(`-${slug}`) || j.name.toLowerCase() === name.toLowerCase() || j.name.toLowerCase().includes(name.toLowerCase()) ) || null } if (!job && options?.includeLegacy) { job = loadLegacyJob(slug) || loadLegacyJob(name) if (!job) { const allJobs = loadAllLegacyJobs() job = allJobs.find( (j) => j.slug === name || j.slug.endsWith(`-${slug}`) || j.name.toLowerCase() === name.toLowerCase() || j.name.toLowerCase().includes(name.toLowerCase()) ) || null } } return job } function updateJobRecord(job: Job, updates: Partial): Job { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const latest = loadScopedJob(scopeId, job.slug) || job const updated: Job = { ...latest, ...updates, scopeId, updatedAt: new Date().toISOString(), } saveJob(updated) return updated } function getLogPath(job: Job): string { const scopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) return scopedLogPath(scopeId, job.slug) } function buildOpencodeArgs(job: Job): { command: string; args: string[] } { const command = findOpencode() const run = normalizeRunSpec(getJobRun(job)) validateRunSpec(run) const args = ["run"] if (run.attachUrl) { args.push("--attach", run.attachUrl) } if (run.port !== undefined) { args.push("--port", String(run.port)) } if (run.command) { args.push("--command", run.command) } if (run.agent) { args.push("--agent", run.agent) } if (run.model) { args.push("--model", run.model) } if (run.variant) { args.push("--variant", run.variant) } if (run.runFormat) { args.push("--format", run.runFormat) } if (run.share) { args.push("--share") } if (run.title) { args.push("--title", run.title) } if (run.continue) { args.push("--continue") } if (run.session) { args.push("--session", run.session) } for (const file of run.files ?? []) { args.push("--file", file) } args.push("--") args.push(run.command ? run.arguments ?? "" : run.prompt ?? "") return { command, args } } function buildRunEnvironment(): NodeJS.ProcessEnv { const enhancedPath = getEnhancedPath() const existingPath = process.env.PATH const combinedPath = existingPath ? `${enhancedPath}:${existingPath}` : enhancedPath // Keep scheduled jobs non-interactive by default. // // - `question: deny` ensures scheduled runs never block waiting for a prompt. // - We merge this with any existing OPENCODE_PERMISSION JSON if present. const basePolicy: Record = { question: "deny" } const mergedPolicy = (() => { const raw = process.env.OPENCODE_PERMISSION if (!raw) return basePolicy try { const existing = JSON.parse(raw) as unknown if (isRecord(existing)) { return { ...existing, ...basePolicy } } } catch {} return basePolicy })() const baseEnv: NodeJS.ProcessEnv = { ...process.env } const config = loadSchedulerConfig() const preserveOpencodeEnv = config.env?.preserveOpencodeEnv === true const preserved = new Set(["OPENCODE_PERMISSION", ...(config.env?.preserve ?? [])]) if (!preserveOpencodeEnv) { for (const key of Object.keys(baseEnv)) { if (!key.startsWith("OPENCODE_")) continue if (key.startsWith("OPENCODE_SCHEDULER_")) continue if (preserved.has(key)) continue delete baseEnv[key] } } return { ...baseEnv, ...config.env?.set, PATH: combinedPath, OPENCODE_PERMISSION: JSON.stringify(mergedPolicy), } } function loadSchedulerConfig(): SchedulerConfig { if (!existsSync(SCHEDULER_CONFIG)) return {} try { const raw = readFileSync(SCHEDULER_CONFIG, "utf-8") const parsed = JSON.parse(raw) as unknown if (!isRecord(parsed)) return {} return parsed as SchedulerConfig } catch { return {} } } function getOpencodeVersion(opencodePath: string): string | null { try { const output = execSync(`"${opencodePath}" --version`, { env: buildRunEnvironment() }) .toString() .trim() return output || null } catch { return null } } function runJobNow(job: Job): { startedAt: string; logPath: string; pid?: number; job: Job | null } { ensureDir(LOGS_DIR) ensureDir(scopeLogsDir(job.scopeId || deriveScopeId(job.workdir || homedir()))) const startedAt = new Date().toISOString() const logPath = getLogPath(job) const logStream = createWriteStream(logPath, { flags: "a" }) const workdir = job.workdir || homedir() logStream.write(`\n=== Manual run ${startedAt} ===\n`) const { command, args } = buildOpencodeArgs(job) let child: ChildProcess try { child = spawn(command, args, { cwd: workdir, env: buildRunEnvironment(), stdio: ["ignore", "pipe", "pipe"], }) } catch (error) { const message = error instanceof Error ? error.message : String(error) logStream.write(`\n=== Run error ${new Date().toISOString()} ===\n${message}\n`) logStream.end() updateJobRecord(job, { lastRunStatus: "failed", lastRunExitCode: undefined, lastRunError: message, }) throw error } const runningJob = updateJobRecord(job, { lastRunAt: startedAt, lastRunSource: "manual", lastRunStatus: "running", lastRunExitCode: undefined, lastRunError: undefined, }) if (child.stdout) child.stdout.pipe(logStream) if (child.stderr) child.stderr.pipe(logStream) child.on("error", (error) => { logStream.write(`\n=== Run error ${new Date().toISOString()} ===\n${error.message}\n`) logStream.end() updateJobRecord(job, { lastRunStatus: "failed", lastRunExitCode: undefined, lastRunError: error.message, }) }) child.on("close", (code) => { const exitCode = typeof code === "number" ? code : undefined logStream.write(`\n=== Run complete (${exitCode ?? "unknown"}) ${new Date().toISOString()} ===\n`) logStream.end() updateJobRecord(job, { lastRunStatus: exitCode === 0 ? "success" : "failed", lastRunExitCode: exitCode, lastRunError: exitCode === 0 ? undefined : `Exit code ${exitCode ?? "unknown"}`, }) }) return { startedAt, logPath, pid: child.pid, job: runningJob } } // === HELPERS === function describeCron(cron: string): string { const parts = cron.split(" ") if (parts.length !== 5) return cron const [min, hour, dom, mon, dow] = parts if (mon === "*" && dom === "*") { if (dow === "*" && hour !== "*" && min !== "*" && !hour.includes("*") && !hour.includes("/")) { const h = parseInt(hour) const m = parseInt(min) const ampm = h >= 12 ? "PM" : "AM" const displayH = h > 12 ? h - 12 : h === 0 ? 12 : h return `daily at ${displayH}:${m.toString().padStart(2, "0")} ${ampm}` } if (hour.startsWith("*/")) { return `every ${hour.slice(2)} hours` } if (min.startsWith("*/")) { return `every ${min.slice(2)} minutes` } } if (dow !== "*" && dom === "*" && mon === "*") { const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] const day = days[parseInt(dow)] if (day && hour !== "*") { const h = parseInt(hour) const ampm = h >= 12 ? "PM" : "AM" const displayH = h > 12 ? h - 12 : h === 0 ? 12 : h return `${day}s at ${displayH}:${(min || "00").padStart(2, "0")} ${ampm}` } } return cron } function formatJobDetails(job: Job): string { const lines = [ `Job: ${job.name}`, `Slug: ${job.slug}`, `Schedule: ${job.schedule} (${describeCron(job.schedule)})`, `Working Directory: ${job.workdir || homedir()}`, ] const run = (() => { try { return normalizeRunSpec(getJobRun(job)) } catch { return undefined } })() if (run?.attachUrl) { lines.push(`Attach URL: ${run.attachUrl}`) } else if (job.attachUrl) { lines.push(`Attach URL: ${job.attachUrl}`) } if (run?.command) { lines.push(`Command: ${run.command}`) if (run.arguments) lines.push(`Arguments: ${run.arguments}`) } if (run?.prompt) { lines.push(`Prompt: ${run.prompt}`) } else if (job.prompt) { lines.push(`Prompt: ${job.prompt}`) } if (run?.files?.length) { lines.push(`Files: ${run.files.join(", ")}`) } if (run?.agent) { lines.push(`Agent: ${run.agent}`) } if (run?.model) { lines.push(`Model: ${run.model}`) } if (run?.variant) { lines.push(`Variant: ${run.variant}`) } if (run?.runFormat) { lines.push(`Run Format: ${run.runFormat}`) } if (run?.title) { lines.push(`Title: ${run.title}`) } if (run?.share) { lines.push("Share: true") } if (run?.continue) { lines.push("Continue: true") } if (run?.session) { lines.push(`Session: ${run.session}`) } if (run?.port !== undefined) { lines.push(`Port: ${run.port}`) } lines.push(`Created: ${job.createdAt}`) if (job.updatedAt) { lines.push(`Updated: ${job.updatedAt}`) } if (job.lastRunAt) { lines.push(`Last Run: ${job.lastRunAt}`) } if (job.lastRunSource) { lines.push(`Last Run Source: ${job.lastRunSource}`) } if (job.lastRunStatus) { lines.push(`Last Run Status: ${job.lastRunStatus}`) } if (job.lastRunExitCode !== undefined) { lines.push(`Last Exit Code: ${job.lastRunExitCode}`) } if (job.lastRunError) { lines.push(`Last Error: ${job.lastRunError}`) } return lines.join("\n") } function getJobLogs(job: Job, options?: { tailLines?: number; maxChars?: number }): string | null { const logPath = getLogPath(job) if (!existsSync(logPath)) return null const maxChars = options?.maxChars ?? 5000 const tailLines = options?.tailLines try { if (typeof tailLines === "number" && Number.isFinite(tailLines) && tailLines > 0) { const clampedLines = Math.max(1, Math.min(5000, Math.floor(tailLines))) try { const output = execFileSync("tail", ["-n", String(clampedLines), logPath], { env: buildRunEnvironment(), }).toString() return output.length > maxChars ? output.slice(-maxChars) : output } catch { const content = readFileSync(logPath, "utf-8") const lines = content.split(/\r?\n/) const output = lines.slice(-clampedLines).join("\n") return output.length > maxChars ? output.slice(-maxChars) : output } } const content = readFileSync(logPath, "utf-8") return content.length > maxChars ? content.slice(-maxChars) : content } catch { return null } } // === PLUGIN === export const SchedulerPlugin: Plugin = async () => { return { tool: { schedule_job: tool({ description: "Schedule a recurring job to run an opencode prompt. Uses launchd (Mac), systemd (Linux), Windows Task Scheduler, or cron fallback when needed.", args: { name: tool.schema.string().describe("A short name for the job (e.g. 'standing desk search')"), schedule: tool.schema .string() .describe("Cron expression: '0 9 * * *' (daily 9am), '0 */6 * * *' (every 6h), '30 8 * * 1' (Monday 8:30am)"), prompt: tool.schema.string().optional().describe("Prompt to run (legacy; prefer run fields)"), command: tool.schema.string().optional().describe("Optional: opencode command to run (maps to --command)"), arguments: tool.schema.string().optional().describe("Optional: arguments string for command mode"), files: tool.schema .string() .optional() .describe("Optional: comma-separated list of files/dirs to attach (maps to repeated --file)"), agent: tool.schema.string().optional().describe("Optional: agent to use (maps to --agent)"), model: tool.schema.string().optional().describe("Optional: model to use (maps to --model)"), variant: tool.schema.string().optional().describe("Optional: model variant (maps to --variant)"), title: tool.schema.string().optional().describe("Optional: session title (maps to --title)"), share: tool.schema.boolean().optional().describe("Optional: share session (maps to --share)"), continue: tool.schema.boolean().optional().describe("Optional: continue last session (maps to --continue)"), session: tool.schema.string().optional().describe("Optional: session id (maps to --session)"), runFormat: tool.schema .string() .optional() .describe("Optional: run output format (maps to opencode --format: default|json)"), port: tool.schema.number().optional().describe("Optional: server port for local server (maps to --port)"), source: tool.schema.string().optional().describe("Optional: source app (e.g. 'marketplace') - used for filtering"), workdir: tool.schema .string() .optional() .describe("Optional: working directory to run from (for MCP config). Defaults to current directory."), attachUrl: tool.schema .string() .optional() .describe("Optional: attach URL for opencode run (e.g. http://localhost:4096)."), timeoutSeconds: tool.schema .number() .optional() .describe("Optional: max runtime in seconds (0 disables)."), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const slug = args.source ? `${args.source}-${slugify(args.name)}` : slugify(args.name) const workdir = normalizeWorkdirPath(args.workdir || process.cwd()) const scopeId = deriveScopeId(workdir) if (loadScopedJob(scopeId, slug)) { return errorResult( format, `Job "${slug}" already exists in this workspace scope (${scopeId}). Delete it first or use a different name.` ) } if (loadLegacyJob(slug)) { return errorResult( format, `Job "${slug}" already exists (legacy scheduler storage). Delete it first or use a different name.` ) } const parseFiles = (raw?: unknown): string[] | undefined => { if (raw === undefined) return undefined if (typeof raw !== "string") return undefined const items = raw .split(",") .map((item) => item.trim()) .filter(Boolean) return items.length ? items : undefined } let runFormat: OpencodeRunFormat | undefined try { runFormat = parseRunFormatInput(args.runFormat) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, msg) } const run: JobRunSpec = { prompt: args.prompt, command: args.command, arguments: args.arguments, files: parseFiles(args.files), agent: args.agent, model: args.model, variant: args.variant, title: args.title, share: args.share, continue: args.continue, session: args.session, runFormat, attachUrl: args.attachUrl, port: args.port, } try { validateRunSpec(normalizeRunSpec(run)) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Invalid run spec: ${msg}`) } let attachUrl: string | undefined try { attachUrl = normalizeAttachUrl(args.attachUrl) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, msg) } try { validateCronExpression(args.schedule) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Invalid cron schedule: ${msg}`) } const job: Job = { scopeId, slug, name: args.name, schedule: args.schedule, run: normalizeRunSpec(run), // keep legacy fields as well for backwards-compat / readability prompt: args.prompt, source: args.source, workdir, attachUrl, timeoutSeconds: args.timeoutSeconds, createdAt: new Date().toISOString(), } // Snapshot invocation for supervised scheduled runs. try { job.invocation = buildOpencodeArgs(job) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Failed to build invocation: ${msg}`) } try { saveJob(job) const backend = installJob(job) const platformName = backend const reliabilityLine = backend === "schtasks" ? "Windows note: scheduled runs use Task Scheduler directly. For advanced reliability guarantees, prefer simple cron schedules or split complex jobs." : backend === "cron" ? "Cron note: missed runs during sleep are not replayed. For catch-up behavior, use launchd or systemd when available." : "The job will run at the scheduled time. If your computer was asleep, it will catch up when it wakes." const primaryLine = run.command ? `Command: ${run.command}${run.arguments ? ` ${run.arguments}` : ""}` : `Prompt: ${(run.prompt ?? "").slice(0, 100)}${(run.prompt ?? "").length > 100 ? "..." : ""}` const attachLine = run.attachUrl ? `Attach URL: ${run.attachUrl} ` : "" return okResult( format, `Scheduled "${args.name}" Schedule: ${args.schedule} (${describeCron(args.schedule)}) Platform: ${platformName} Working Directory: ${workdir} ${attachLine}${primaryLine} ${reliabilityLine} Commands: - "run ${args.name} now" - run immediately - "show my jobs" - list all - "delete job ${args.name}" - remove`, { job } ) } catch (error) { deleteJobFile(job) const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Failed to schedule job: ${msg}`) } }, }), list_jobs: tool({ description: "List all scheduled jobs. Optionally filter by source app.", args: { source: tool.schema.string().optional().describe("Filter by source app (e.g. 'marketplace')"), allScopes: tool.schema.boolean().optional().describe("List jobs across all scopes."), includeLegacy: tool.schema.boolean().optional().describe("Include legacy jobs from ~/.config/opencode/jobs"), scopeRoot: tool.schema .string() .optional() .describe("Optional: scope root directory (defaults to current directory)."), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const scopeId = args.allScopes ? undefined : deriveScopeId(normalizeWorkdirPath(args.scopeRoot || process.cwd())) let jobs = args.allScopes ? loadAllJobsAcrossScopes() : loadAllScopedJobs(scopeId!) if (args.includeLegacy) { jobs = [...jobs, ...loadAllLegacyJobs()] } if (args.source) { jobs = jobs.filter((j) => j.source === args.source || j.slug.startsWith(`${args.source}-`)) } if (jobs.length === 0) { const message = args.source ? `No jobs found for "${args.source}".` : 'No scheduled jobs yet.\n\nTry: "Schedule a daily job at 9am to search for standing desks"' return okResult(format, message, { jobs: [] }) } const lines = jobs.map((j, i) => { const run = (() => { try { return normalizeRunSpec(getJobRun(j)) } catch { return undefined } })() const preview = run?.command ? `${run.command}${run.arguments ? ` ${run.arguments}` : ""}` : run?.prompt ?? j.prompt ?? "(missing prompt)" const trimmed = preview.trim() const snippet = trimmed.slice(0, 50) + (trimmed.length > 50 ? "..." : "") return `${i + 1}. ${j.name} (${j.slug}) ${describeCron(j.schedule)} ${snippet}` }) return okResult(format, `Scheduled Jobs\n\n${lines.join("\n\n")}`, { jobs }) }, }), get_version: tool({ description: "Show the scheduler plugin version and opencode binary info.", args: { format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const packageInfo = loadPackageInfo() const opencodePath = findOpencode() const opencodeVersion = getOpencodeVersion(opencodePath) const lines = [ `Scheduler Plugin: ${packageInfo.name}@${packageInfo.version}`, `Opencode Binary: ${opencodePath}`, `Opencode Version: ${opencodeVersion ?? "unknown"}`, ] return okResult(format, lines.join("\n"), { plugin: packageInfo, opencode: { path: opencodePath, version: opencodeVersion }, platform: platform(), }) }, }), get_skill: tool({ description: "Get built-in skill templates to copy into your project.", args: { name: tool.schema .string() .optional() .describe("Skill name (default: scheduled-job-best-practices)"), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const skill = getBuiltinSkill(args.name) if (!skill) { const available = listBuiltinSkills() .map((s) => s.name) .join(", ") const requested = (args.name ?? "").trim() const label = requested ? `"${requested}"` : "that name" return errorResult(format, `No built-in skill found for ${label}. Available: ${available || "(none)"}`) } const renderedFiles = Object.entries(skill.files) .map(([filename, content]) => `--- ${filename} ---\n${content.trim()}\n`) .join("\n") const output = [ `Skill: ${skill.name}`, `Description: ${skill.description}`, `Suggested path: ${skill.suggestedPath}`, "", "Copy the file(s) below into your repo:", "", renderedFiles, ].join("\n") return okResult(format, output, { skill }) }, }), install_skill: tool({ description: "Install a built-in skill into your repo's .opencode/skill directory.", args: { name: tool.schema .string() .optional() .describe("Skill name (default: scheduled-job-best-practices)"), directory: tool.schema .string() .optional() .describe("Repo root directory to install into (defaults to current directory)."), overwrite: tool.schema.boolean().optional().describe("Overwrite existing files (default false)."), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const skill = getBuiltinSkill(args.name) if (!skill) { const available = listBuiltinSkills() .map((s) => s.name) .join(", ") const requested = (args.name ?? "").trim() const label = requested ? `"${requested}"` : "that name" return errorResult(format, `No built-in skill found for ${label}. Available: ${available || "(none)"}`) } const directory = args.directory ?? process.cwd() const overwrite = args.overwrite === true try { const installed = installBuiltinSkill(skill, directory, overwrite) const files = installed.files.map((file) => `- ${file}`).join("\n") const output = [ `Installed skill: ${skill.name}`, `Directory: ${installed.directory}`, "", "Files:", files, "", `Next: add @${skill.name} to the top of scheduled job prompts.`, ].join("\n") return okResult(format, output, { skill, installed }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Failed to install skill: ${msg}`) } }, }), get_job: tool({ description: "Get details for a scheduled job", args: { name: tool.schema.string().describe("The job name or slug"), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const job = findJobByName(args.name) if (!job) { return errorResult(format, `Job "${args.name}" not found.`) } return okResult(format, formatJobDetails(job), { job }) }, }), update_job: tool({ description: "Update a scheduled job", args: { name: tool.schema.string().describe("The job name or slug"), schedule: tool.schema.string().optional().describe("Updated cron expression"), // Legacy prompt field prompt: tool.schema.string().optional().describe("Updated prompt (legacy; prefer command/arguments/etc)"), command: tool.schema.string().optional().describe("Updated opencode command (maps to --command)"), arguments: tool.schema.string().optional().describe("Updated command arguments string"), files: tool.schema .string() .optional() .describe("Updated comma-separated list of files/dirs to attach"), agent: tool.schema.string().optional().describe("Updated agent (maps to --agent)"), model: tool.schema.string().optional().describe("Updated model (maps to --model)"), variant: tool.schema.string().optional().describe("Updated model variant (maps to --variant)"), title: tool.schema.string().optional().describe("Updated session title (maps to --title)"), share: tool.schema.boolean().optional().describe("Updated share flag (maps to --share)"), continue: tool.schema.boolean().optional().describe("Updated continue flag (maps to --continue)"), session: tool.schema.string().optional().describe("Updated session id (maps to --session)"), runFormat: tool.schema .string() .optional() .describe("Updated run output format (default|json)"), port: tool.schema.number().optional().describe("Updated port (maps to --port)"), timeoutSeconds: tool.schema.number().optional().describe("Updated timeout in seconds (0 disables)"), workdir: tool.schema.string().optional().describe("Updated working directory"), attachUrl: tool.schema.string().optional().describe("Updated attach URL (set to empty to clear)"), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const job = findJobByName(args.name) if (!job) { return errorResult(format, `Job "${args.name}" not found.`) } const updates: Partial = {} const parseFiles = (raw?: unknown): string[] | undefined => { if (raw === undefined) return undefined if (typeof raw !== "string") return undefined const items = raw .split(",") .map((item) => item.trim()) .filter(Boolean) return items.length ? items : undefined } // Start with existing run spec (or legacy prompt) and apply updates. const currentRun = (() => { try { return normalizeRunSpec(getJobRun(job)) } catch { return {} } })() const nextRunCandidate: JobRunSpec = { ...currentRun, prompt: args.prompt !== undefined ? args.prompt : currentRun.prompt, command: args.command !== undefined ? args.command : currentRun.command, arguments: args.arguments !== undefined ? args.arguments : currentRun.arguments, files: args.files !== undefined ? parseFiles(args.files) : currentRun.files, agent: args.agent !== undefined ? args.agent : currentRun.agent, model: args.model !== undefined ? args.model : currentRun.model, variant: args.variant !== undefined ? args.variant : currentRun.variant, title: args.title !== undefined ? args.title : currentRun.title, share: args.share !== undefined ? args.share : currentRun.share, continue: args.continue !== undefined ? args.continue : currentRun.continue, session: args.session !== undefined ? args.session : currentRun.session, runFormat: args.runFormat !== undefined ? parseRunFormatInput(args.runFormat) : currentRun.runFormat, attachUrl: args.attachUrl !== undefined ? args.attachUrl : currentRun.attachUrl, port: args.port !== undefined ? args.port : currentRun.port, } try { updates.run = normalizeRunSpec(nextRunCandidate) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Invalid run spec: ${msg}`) } if (args.schedule !== undefined) { if (!args.schedule.trim()) { return errorResult(format, "Schedule cannot be empty.") } try { validateCronExpression(args.schedule) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Invalid cron schedule: ${msg}`) } updates.schedule = args.schedule } if (args.prompt !== undefined) { if (!args.prompt.trim()) { return errorResult(format, "Prompt cannot be empty.") } // Keep legacy prompt field in sync if provided. updates.prompt = args.prompt } if (args.workdir !== undefined) { if (!args.workdir.trim()) { return errorResult(format, "Working directory cannot be empty.") } const normalizedWorkdir = normalizeWorkdirPath(args.workdir) updates.workdir = normalizedWorkdir updates.scopeId = deriveScopeId(normalizedWorkdir) } if (args.attachUrl !== undefined) { try { updates.attachUrl = normalizeAttachUrl(args.attachUrl) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, msg) } } if (args.timeoutSeconds !== undefined) { updates.timeoutSeconds = args.timeoutSeconds } if (Object.keys(updates).length === 0) { return errorResult(format, "No updates provided.") } const updatedJob: Job = { ...job, ...updates, updatedAt: new Date().toISOString(), } // Keep scheduled invocation snapshot up-to-date. try { updatedJob.invocation = buildOpencodeArgs(updatedJob) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Failed to build invocation: ${msg}`) } try { const oldScopeId = job.scopeId || deriveScopeId(job.workdir || homedir()) const nextScopeId = updatedJob.scopeId || deriveScopeId(updatedJob.workdir || homedir()) const scopeChanged = oldScopeId !== nextScopeId if (scopeChanged) { // Uninstall old schedule before installing the new one, to avoid overlapping runs. uninstallJob(job) } saveJob(updatedJob) installJob(updatedJob) if (scopeChanged) { // Remove the old job file if it exists. const oldPath = jobFilePath(oldScopeId, job.slug) if (existsSync(oldPath)) { try { unlinkSync(oldPath) } catch {} } } return okResult(format, `Updated job "${updatedJob.name}"`, { job: updatedJob }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) // Best-effort rollback: restore original job and schedule. try { saveJob(job) installJob(job) } catch {} return errorResult(format, `Failed to update job: ${msg}`) } }, }), delete_job: tool({ description: "Delete a scheduled job", args: { name: tool.schema.string().describe("The job name or slug to delete"), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const job = findJobByName(args.name) if (!job) { return errorResult(format, `Job "${args.name}" not found.`) } uninstallJob(job) deleteJobFile(job) // Best-effort: remove legacy job file if present. const legacyPath = join(LEGACY_JOBS_DIR, `${job.slug}.json`) if (existsSync(legacyPath)) { try { unlinkSync(legacyPath) } catch {} } return okResult(format, `Deleted job "${job.name}"`, { job }) }, }), cleanup_global: tool({ description: "Clean up scheduler artifacts globally across all scopes. Removes job definitions everywhere; optionally remove logs and run history.", args: { includeHistory: tool.schema .boolean() .optional() .describe("Also remove run history and logs across all scopes (default false)."), confirm: tool.schema .boolean() .optional() .describe("Set true to execute deletion. Default is dry run with no destructive changes."), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const includeHistory = args.includeHistory === true const dryRun = args.confirm !== true const plan = buildGlobalCleanupPlan(includeHistory) const execution = executeGlobalCleanup(plan, { dryRun, includeHistory }) const output = formatGlobalCleanupOutput(execution) return okResult(format, output, { dryRun: execution.dryRun, includeHistory: execution.includeHistory, removed: execution.removed, errors: execution.errors, scopeIds: plan.scopeIds, jobsConsidered: plan.jobsToUninstall.length, }) }, }), run_job: tool({ description: "Run a scheduled job immediately", args: { name: tool.schema.string().describe("The job name or slug"), // Optional overrides for a one-off run prompt: tool.schema.string().optional().describe("Override prompt for this run"), command: tool.schema.string().optional().describe("Override command for this run"), arguments: tool.schema.string().optional().describe("Override arguments for command mode"), files: tool.schema.string().optional().describe("Override comma-separated files/dirs to attach"), agent: tool.schema.string().optional().describe("Override agent"), model: tool.schema.string().optional().describe("Override model"), variant: tool.schema.string().optional().describe("Override variant"), title: tool.schema.string().optional().describe("Override title"), share: tool.schema.boolean().optional().describe("Override share flag"), continue: tool.schema.boolean().optional().describe("Override continue flag"), session: tool.schema.string().optional().describe("Override session id"), runFormat: tool.schema.string().optional().describe("Override run output format (default|json)"), port: tool.schema.number().optional().describe("Override port"), attachUrl: tool.schema.string().optional().describe("Override attach URL"), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const job = findJobByName(args.name) if (!job) { return errorResult(format, `Job "${args.name}" not found. Use list_jobs to see available jobs.`) } const parseFiles = (raw?: unknown): string[] | undefined => { if (raw === undefined) return undefined if (typeof raw !== "string") return undefined const items = raw .split(",") .map((item) => item.trim()) .filter(Boolean) return items.length ? items : undefined } const baseRun = (() => { try { return normalizeRunSpec(getJobRun(job)) } catch { return {} } })() const overrideCandidate: JobRunSpec = { ...baseRun, prompt: args.prompt !== undefined ? args.prompt : baseRun.prompt, command: args.command !== undefined ? args.command : baseRun.command, arguments: args.arguments !== undefined ? args.arguments : baseRun.arguments, files: args.files !== undefined ? parseFiles(args.files) : baseRun.files, agent: args.agent !== undefined ? args.agent : baseRun.agent, model: args.model !== undefined ? args.model : baseRun.model, variant: args.variant !== undefined ? args.variant : baseRun.variant, title: args.title !== undefined ? args.title : baseRun.title, share: args.share !== undefined ? args.share : baseRun.share, continue: args.continue !== undefined ? args.continue : baseRun.continue, session: args.session !== undefined ? args.session : baseRun.session, runFormat: args.runFormat !== undefined ? parseRunFormatInput(args.runFormat) : baseRun.runFormat, port: args.port !== undefined ? args.port : baseRun.port, attachUrl: args.attachUrl !== undefined ? args.attachUrl : baseRun.attachUrl, } let runOverride: JobRunSpec try { runOverride = normalizeRunSpec(overrideCandidate) validateRunSpec(runOverride) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Invalid run override: ${msg}`) } const runJob: Job = { ...job, run: runOverride, } let runResult try { runResult = runJobNow(runJob) } catch (error) { const msg = error instanceof Error ? error.message : String(error) return errorResult(format, `Failed to start job "${job.name}": ${msg}`) } const logs = getJobLogs(runJob) const attachHint = runOverride.attachUrl ? `\nAttach: opencode attach ${runOverride.attachUrl}` : "" const logSection = logs ? `\nLatest logs:\n${logs}` : "\nNo logs yet. Check again soon." return okResult( format, `Triggered "${job.name}" (fire-and-forget).\nLogs: ${runResult.logPath}${attachHint}${logSection}`, { job: runResult.job ?? job, startedAt: runResult.startedAt, logPath: runResult.logPath, pid: runResult.pid, } ) }, }), job_logs: tool({ description: "View the latest logs from a scheduled job", args: { name: tool.schema.string().describe("The job name or slug"), lines: tool.schema .number() .optional() .describe("Number of lines from the end of the log (default 200)."), format: tool.schema.string().optional().describe("Optional: output format ('text' or 'json')."), }, async execute(args) { const format = normalizeFormat(args.format) const job = findJobByName(args.name) if (!job) { return errorResult(format, `Job "${args.name}" not found.`) } const tailLines = typeof args.lines === "number" && Number.isFinite(args.lines) ? args.lines : 200 const logs = getJobLogs(job, { tailLines, maxChars: 20000 }) const logPath = getLogPath(job) if (!logs) { return okResult(format, `No logs found for "${job.name}". The job may not have run yet.`, { job, logPath, logs: "", }) } return okResult(format, `Logs for ${job.name}\n\n${logs}`, { job, logPath, logs }) }, }), }, } } // Default export for OpenCode plugin system export default SchedulerPlugin