Fork notice. This is a maintained fork of
different-ai/opencode-scheduler(MIT, by Benjamin Shafii & contributors). It backports upstream's pending command-injection fix (PR #22), adds a regression test suite, suppresses theCreated symlinkstdout leak that corrupts the opencode agent TUI, and emits a toast notification in the TUI when a scheduled run completes. See CHANGELOG.md and SECURITY.md for the full delta. Original code © 2025 Different AI — MIT.
- Security backport (Patch B, cherry-picked from upstream PR #22):
schedule_job.sourceis now run throughslugify(), and every OS scheduler call (launchctl,systemctl,schtasks) usesexecFileSync- argument array instead of
execSyncshell strings. Closes a same-user command-injection vector. 8 regression tests intest/security.test.ts.
- argument array instead of
- Stdout suppression (Patch A):
installSystemdJob'sdaemon-reload/enable/startcalls all pass{ stdio: "ignore" }, sosystemctl --user enableno longer leaksCreated symlink ...into the opencode TUI as a synthetic prompt. 4 regression tests intest/install-systemd.test.ts. - TUI notifications on run completion (Feature C, v1.5.0-ext.1+):
foreground
run_jobshows a single toast on completion; background cron/OS-scheduler runs are surfaced via thechat.messagehook as ONE consolidated toast + ONE prompt-input summary per batch of new runs since last check. Cutoff timestamp persisted at~/.config/opencode/scheduler/last-notified-at.txt(mode 0o600) so TUI restarts do not flood the user with historical completions. Cold-start seeding initializes the cutoff toMAX(finishedAt)across existing runs to avoid history floods on first install. - Session-bound routing + auto-resume (Feature C v1.5.0-ext.1):
each job remembers the chat session that scheduled it (captured via a
new
tool.execute.beforehook). Completion summaries route to the originating session viaclient.session.prompt({ noReply: true }), so a user in unrelated chat B does not see job completions scheduled in chat A. On plugin init,autoNotifyOnResume()scans for finished runs sincelastNotifiedAtand (inactivemode, the default) also fires an additionalclient.session.prompt({...})so the LLM starts processing the summary in each owning session without waiting for user input. Configure viaopencode-scheduler.json:Values:{ "autoNotify": { "mode": "active" } }off(skip auto-resume),silent(inject summary, do not trigger model),active(inject + trigger model — default). - Source maps in the published
dist/(--sourcemap=external). - CI (
.github/workflows/ci.yml) runningbun install+bun testbun run buildon every push.
This fork writes the following files under ~/.config/opencode/scheduler/:
supervisor.pl— Perl supervisor used by the cron fallback backend.last-notified-at.txt— Feature C cutoff timestamp (ISO 8601, mode 0o600). Delete to reset notification history (the nextchat.messagehook will re-notify all runs finished strictly afterMAX(finishedAt)in the existingruns/*.jsonl).jobs/<scopeId>/<slug>.json— each scheduled job now also storessessionId(the chat session that scheduled it), used bylookupSessionForJobfor per-session routing in v1.5.0-ext.1+.
opencode-scheduler.json (under ~/.config/opencode/):
envis forwarded to scheduled runs (see upstream docs).autoNotify.modecontrolsautoNotifyOnResume()on plugin init (v1.5.0-ext.1+).autoNotify.pollIntervalSec(v1.6.0-ext.1+) sets how often the plugin pollsruns/*.jsonlfor newly completed runs. Solves the silent-completion bug where cron-triggered runs were invisible until the user sent a chat message. The poll path ONLY injects per-session summaries — it does NOT trigger the LLM inactivemode (that is reserved for plugin init).
Add to your opencode.json:
{
"plugin": ["opencode-scheduler-ext"]
}Then npm i opencode-scheduler-ext (or use a workspace alias if you
publish this fork to a private registry).
Run AI agents on a schedule. Set up recurring tasks that execute autonomously—even when you're away.
Schedule a daily job at 9am to search Facebook Marketplace for posters under $100 and send the top 5 deals to my Telegram
This is an OpenCode plugin that uses your OS's native scheduler (launchd on macOS, systemd on Linux, Task Scheduler on Windows), with cron fallback where native backends are unavailable.
As of v1.2.0, jobs are scoped by workdir (so different projects don't collide), and scheduled runs are supervised (no overlap + optional timeout).
Add to your opencode.json:
{
"plugin": ["opencode-scheduler"]
}Daily deal hunting:
Schedule a daily job at 9am to search for standing desks under $300
Weekly reports:
Schedule a job every Monday at 8am to summarize my GitHub notifications
Recurring reminders:
Schedule a job every 6 hours to check if my website is up and alert me on Slack if it's down
| Command | Example |
|---|---|
| Schedule a job | Schedule a daily job at 9am to... |
| List jobs | Show my scheduled jobs |
| Get version | Show scheduler version |
| Install skill template | Install the scheduled job best practices skill |
| Get job | Show details for standing-desk |
| Update job | Update standing-desk to run at 10am |
| Run immediately | Run the standing-desk job now |
| View logs | Show logs for standing-desk |
| Delete | Delete the standing-desk job |
| Global cleanup (dry run) | Run scheduler global cleanup |
- You describe what you want scheduled in natural language
- The plugin writes a job file (scoped by
workdir) and installs a timer in your OS scheduler - At the scheduled time, the OS scheduler calls a small supervisor script
- The supervisor runs the job, appends logs, and updates job metadata
You can also trigger a job immediately via run_job—it runs fire-and-forget and appends to the same log file.
Jobs run from the working directory where you created them, picking up your opencode.json and MCP configurations.
- No overlap: if the previous run is still active, the next scheduled tick is skipped.
- Non-interactive by default: scheduled runs force
OPENCODE_PERMISSIONto deny "question" prompts, so jobs don't hang waiting for approvals. - Optional timeout: set
timeoutSecondsto hard-stop long runs (SIGTERM, then SIGKILL).
| Platform | Scheduler backend | Notes |
|---|---|---|
| macOS | launchd |
Full support (supervised scheduled runs) |
| Linux (systemd available) | systemd --user |
Full support (supervised scheduled runs) |
| Linux / POSIX (no systemd) | cron (crontab) |
Fallback backend (no missed-run catch-up) |
| Windows | schtasks (Task Scheduler) |
Supported with cron subset mapping (see limits below) |
Windows Task Scheduler limits:
- Cron expressions that use unsupported combinations (for example, month + weekday constraints, or month-only without explicit day-of-month) return a clear error with guidance.
- Complex cron schedules may be expanded into multiple Windows tasks under
\\OpenCode\\opencode-job-.... - Windows scheduled runs currently do not use the supervisor pipeline used on macOS/Linux, so no-overlap and timeout enforcement are not guaranteed by the OS integration itself.
Jobs use standard 5-field cron expressions:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *
| Expression | Meaning |
|---|---|
0 9 * * * |
Daily at 9:00 AM |
0 */6 * * * |
Every 6 hours |
30 8 * * 1 |
Mondays at 8:30 AM |
0 9,17 * * * |
At 9 AM and 5 PM daily |
| Tool | Description |
|---|---|
schedule_job |
Create a new scheduled job |
list_jobs |
List all scheduled jobs |
get_version |
Show scheduler and opencode versions |
get_skill |
Get built-in skill templates (best practices) |
install_skill |
Install a built-in skill into your repo |
get_job |
Fetch job details and metadata |
update_job |
Update an existing job |
delete_job |
Remove a scheduled job |
cleanup_global |
Remove scheduler artifacts across all scopes (dry-run by default) |
run_job |
Execute a job immediately (fire-and-forget) |
job_logs |
View the latest logs from a job |
schedule_job and update_job accept an optional timeoutSeconds (integer seconds). Use 0 (or omit) to disable.
Tools accept an optional format: "json" argument to return structured output with success, output, shouldContinue, and data.
Use cleanup_global to clean scheduler artifacts across all scopes. It always starts in dry-run mode unless you pass confirm: true.
- Dry run (safe default):
{ "confirm": false }- Execute global cleanup of job definitions + lock files + scheduler units:
{ "confirm": true }- Also delete logs and run history:
{ "confirm": true, "includeHistory": true }The tool reports exactly how many artifacts were removed, grouped by location (jobs, locks, logs, runs, launchd/systemd units).
| What | Where |
|---|---|
| Job configs (scoped) | ~/.config/opencode/scheduler/scopes/<scopeId>/jobs/*.json |
| Run records (scoped) | ~/.config/opencode/scheduler/scopes/<scopeId>/runs/*.jsonl |
| Locks (scoped) | ~/.config/opencode/scheduler/scopes/<scopeId>/locks/*.json |
| Logs (scoped) | ~/.config/opencode/logs/scheduler/<scopeId>/*.log |
| Supervisor script | ~/.config/opencode/scheduler/supervisor.pl |
| launchd plists (Mac) | ~/Library/LaunchAgents/com.opencode.job.<scopeId>.*.plist |
| systemd units (Linux) | ~/.config/systemd/user/opencode-job-<scopeId>-*.{service,timer} |
| Task Scheduler entries (Windows) | \\OpenCode\\opencode-job-<scopeId>-* |
Legacy note: older versions stored jobs in ~/.config/opencode/jobs/*.json and used unscoped unit names. delete_job removes both scoped and legacy artifacts.
Jobs run from a specific directory to pick up MCP configs:
Schedule a daily job at 9am from /path/to/project to run my-task
By default, jobs use the directory where you created them.
Scopes are derived from the job's workdir (normalized absolute path). This isolates job storage, logs, and OS scheduler unit names per project.
list_jobsdefaults to the current scope (your current working directory).- Use
allScopes: trueto list jobs across all scopes. - Use
includeLegacy: trueto include pre-v1.2.0jobs stored in~/.config/opencode/jobs.
If you have an OpenCode backend running via opencode serve or opencode web, you can set attachUrl on a job so runs use that backend:
Update the standing-desk job to use attachUrl http://localhost:4096
- This plugin is intentionally a thin wrapper: it schedules
opencode runvia launchd/systemd/schtasks, with cron fallback when native backends are unavailable. - Logs are the source of truth for scheduled runs:
~/.config/opencode/logs/*.log. - Resiliency/reporting roadmap (not implemented):
PRD-resilient-execution.md.
To install the built-in skill into your project (no copy/paste), open OpenCode in your repo and run:
Install the scheduled job best practices skill
This calls the plugin’s install_skill tool and writes .opencode/skill/scheduled-job-best-practices/SKILL.md.
(If you prefer, you can also say: Get skill from opencode-scheduler and add it to my skills.)
Then add @scheduled-job-best-practices at the top of scheduled job prompts.
(Manual option: use get_skill to fetch scheduled-job-best-practices and copy it into .opencode/skill/scheduled-job-best-practices/SKILL.md.)
Jobs not running?
-
Check if installed:
- Mac:
launchctl list | grep opencode - Linux:
systemctl --user list-timers | grep opencode - Windows:
schtasks /Query /TN "\\OpenCode\\opencode-job-*"
- Mac:
-
Check logs:
Show logs for my-job -
Verify the working directory has the right
opencode.jsonwith MCP configs
MCP tools not available?
Make sure the job's working directory contains an opencode.json with your MCP server configurations.
MIT
{ "env": { "preserve": ["PATH"], "set": { "LANG": "C.UTF-8" } }, "autoNotify": { "mode": "active", // off | silent | active — default "active" "pollIntervalSec": 30 // background poll for cron-run completions // — default 30, set 0 to disable (chat.message only) } }