This document describes how the scheduler talks to an operating system's timer facility, and how to add support for a new OS.
macOS (launchd) is the primary, fully-supported and tested backend. Linux (systemd/cron) and Windows (Task Scheduler) are supported through the same contract and kept functional, but see less active testing.
tool call ─▶ src/tools.ts ─▶ src/backends/registry.ts ─▶ a SchedulerBackend
│ │
│ ▼
│ installs an OS timer that runs…
▼ │
src/core/* (job storage, run-spec, ▼
invocation snapshot, supervisor) the supervisor (bun|node JS)
│
▼
opencode run … (the job)
Two ideas make this extensible:
-
The
SchedulerBackendcontract (src/backends/contract.ts) is the only thing a new OS must implement. A backend translates aJob(with its cronschedule) into an OS timer and installs/removes it. -
The supervisor (
src/supervisor/main.ts, compiled to a JS payload) is a runtime-agnostic wrapper that every backend schedules instead of runningopencodedirectly. It provides the reliability guarantees (no-overlap lock, timeout, retry, run history) uniformly, so a backend author never re-implements them. It runs under bun or node (whichever is resolved at install time), using only APIs shared by both.
interface SchedulerBackend {
readonly id: SchedulerBackendId
isSupported(): boolean
isAvailable(): boolean
describeReliability(job: Job): BackendReliability
install(job: Job): void
uninstall(job: Job): void
listInstalledArtifacts(): string[]
}Rules (enforced by convention; see the doc comment in contract.ts):
- Stateless. All durable state — job JSON, locks, run history, logs — lives
on disk under
src/core/paths.tsand is owned by the core. A backend owns only its OS timer artifacts. - Delegate execution. Schedule
buildSupervisorCommand(job), neveropencodedirectly. A backend that physically cannot (e.g. Windows Task Scheduler today) must declaremode: "direct"indescribeReliability()so callers can warn the user. installis idempotent + replacing. Remove any existing/legacy entry first, then create the current one. Re-installing never duplicates timers.uninstallis tolerant. Removing a job with no entry is a no-op.- Availability ≠ support.
isSupported()= "right backend for this OS?";isAvailable()= "can I use it right now?" (command present, daemon up).
Say you want to add support for a hypothetical fooclock.
Create src/backends/fooclock.ts:
import type { Job } from "../core/types"
import type { BackendReliability, SchedulerBackend } from "./contract"
import { parseCron } from "../core/cron"
import { buildSupervisorCommand, ensureSupervisorScript } from "../core/supervisor"
import { ensureDir, jobScopeId, scopeLogsDir } from "../core/paths"
export function cronToFooclock(cron: string): string {
const { minute, hour /* … */ } = parseCron(cron) // reuse the shared parser
// …translate to fooclock's schedule syntax…
return "…"
}
export const fooclockBackend: SchedulerBackend = {
id: "fooclock" as any, // add to SchedulerBackendId in contract.ts
isSupported() {
return process.platform === "foo"
},
isAvailable() {
return this.isSupported() /* && probe for the fooclock daemon */
},
describeReliability(): BackendReliability {
return {
mode: "supervised", // because we schedule the supervisor
catchesUpMissedRuns: true,
note: "The job runs at the scheduled time; missed runs are replayed.",
}
},
install(job) {
ensureDir(scopeLogsDir(jobScopeId(job)))
ensureSupervisorScript()
const { command, args } = buildSupervisorCommand(job) // ← always this
// …write/replace the fooclock timer that runs [command, ...args]…
},
uninstall(job) {
// …remove the fooclock timer for this job (tolerant of "not found")…
},
listInstalledArtifacts() {
return [] // absolute file paths, if your timers are files
},
}In src/backends/registry.ts:
- add
fooclockBackendto theBACKENDSarray, - add a branch to
resolveBackend()for when it should win, - if it should participate in per-platform uninstall, add it to
uninstallJob().
Add "fooclock" to SchedulerBackendId in contract.ts.
Add test/fooclock.test.ts covering cronToFooclock (the pure mapper) with the
same table of expressions the other backends test: 0 9 * * *, */15 * * * *,
0 */6 * * *, 30 8 * * 1, and the day-of-month + day-of-week OR case.
That's it — tools.ts, storage, the supervisor, cleanup, and every tool work
unchanged, because they only ever talk to the contract and the registry.
When both day-of-month and day-of-week are restricted (neither is *),
cron treats them as an OR: the job runs when either matches. Backends must
preserve this. launchd emits two calendar sets; systemd emits two OnCalendar=
groups. Reuse parseCron() and mirror that handling.
Ranges (1-5) are intentionally unsupported by the shared parser — expand to
comma lists (1,2,3,4,5).
buildSupervisorCommand(job) returns { command, args } = [<bun|node>, supervisor.js, <jobPath>] (runtime resolved by findSupervisorRuntime: bun
preferred, node fallback, OPENCODE_SCHEDULER_RUNTIME override). The supervisor:
- reads the job JSON from
<jobPath>(so the OS timer only needs the path), - takes a per-job lock and skips if a prior run is still alive (no overlap),
- forces
OPENCODE_PERMISSIONquestion=deny(non-interactive), - enforces
timeoutSeconds(SIGTERM → SIGKILL, exit 124), - applies the retry policy (
retries,retryOn,retryBackoffSeconds), - writes
lastRun*job metadata atomically and appends a JSONL run record.
It re-derives its paths from HOME + scopeId + slug, so the on-disk layout
in core/paths.ts and the supervisor must stay in sync.
OPENCODE_SCHEDULER_SOURCE distinguishes scheduled (default) from manual
(set by run_job). Both paths go through the supervisor, so manual and
scheduled runs behave identically.
The supervisor is authored in TypeScript at src/supervisor/main.ts (so it is
type-checked and unit-tested with the rest of the plugin), then bundled by
scripts/build-supervisor.ts into a dependency-free JavaScript string embedded
at src/supervisor/payload.generated.ts. core/supervisor.ts writes that
payload to ~/.config/opencode/scheduler/supervisor.js on install.
Because it must run under both bun and node, main.ts may use only APIs
common to both (node:fs, node:child_process, node:process, node:path) —
no bundler-only globals, no npm dependencies. The build targets node, which
bun also runs. Regenerate the payload with bun run build:supervisor (it runs
automatically before build, test, and typecheck).
A working opencode does not guarantee a runtime is on PATH (opencode ships
as a compiled binary), so findSupervisorRuntime probes PATH, then next to the
resolved opencode binary, then common install locations — bun first, then
node. If neither is found it throws at install time with an actionable message.