horology
v0.2.1
Published
opencode plugin: schedule prompts and slash-commands into a live opencode session using system-native timers (systemd user timers), so recurring jobs and one-shot reminders survive restarts and land in the right session.
Maintainers
Readme
horology
An opencode plugin that schedules prompts and slash-commands into a live opencode session using system-native timers.
opencode has no built-in scheduler. horology splits the problem in two:
- Timing is delegated to systemd user timers. They survive an opencode restart and a logout (with lingering enabled), so a recurring job or a one-shot reminder is not lost when you close the TUI.
- Firing is session-aware. When a timer fires it does not spawn a fresh
opencode run; it makes a tiny loopback HTTP call back into the already running opencode server. The plugin — living inside that server — injects the job's prompt (via the async prompt endpoint, so it queues natively) or slash-command into the session it has been tracking. Output lands where you are actually looking, not in some detached headless run.
How it works
systemd user timer ──fires──▶ curl 127.0.0.1:<port>/fire {name} ──▶ horology (in opencode)
│
tracks the live session id ◀──────────┘
│
session.promptAsync / session.command ◀────┘ (into that session)- On load (active by default), horology starts a loopback trigger server on
127.0.0.1, then arms one transientsystemd-run --usertimer per configured job (no unit files to install or clean up). - It tracks the active session id from opencode's
chat.messageand event stream. - A fired timer POSTs to
/firewith a per-run shared token; horology injects the job into the tracked session (or the newest session in the project directory, or a fresh one). - On
dispose(opencode shutdown) and onSIGINT/SIGTERM, horology stops the trigger server and disarms every timer it armed — so nothing fires into a dead server and the next start rebuilds cleanly.
Requirements
- Linux with a systemd user instance (
systemctl --user). horology shells out tosystemd-run --userandsystemctl --user. - To keep timers running after you log out / close the terminal, enable lingering once:
loginctl enable-linger "$USER" curlonPATH(used by the timer to call the loopback endpoint).
Install
Add the package to the plugin array of your opencode config (opencode.json in your project, or ~/.config/opencode/opencode.json globally):
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["horology"]
}opencode auto-installs listed npm plugins at startup (cached under ~/.cache/opencode/). No manual npm install and no .opencode/plugins/ wrapper is needed for the published package.
Configuration
All configuration is via environment variables. horology is active by default whenever the plugin is loaded — the /schedule command and tools are available in every session, and any HOROLOGY_JOBS baseline is armed. Set HOROLOGY_DISABLE=1 to load it inert.
| Variable | Default | Description |
| --- | --- | --- |
| HOROLOGY_DISABLE | unset | Set to 1 to load the plugin inert (no timers, no /schedule). HOROLOGY=0/false/off/no also disables. |
| HOROLOGY_JOBS | unset | JSON array of job objects (see below), or a path to a .json file containing that array. Optional — /schedule works without it. |
| HOROLOGY_PORT | ephemeral | Loopback port for the trigger server. Leave unset for an OS-chosen port. |
| HOROLOGY_UNIT_PREFIX | horology | systemd unit namespace. Units are named <prefix>-<job>.timer/.service. |
| HOROLOGY_TOKEN | random per run | Shared secret the timer must present on /fire. Auto-generated if unset. |
| HOROLOGY_DIRECTORY | plugin project dir | Directory used to scope session.list when resolving a target session. |
| HOROLOGY_SCHEDULE_AGENT | unset | Optional agent to run the /schedule translation turn under. |
| HOROLOGY_DEBUG | unset | 1 for verbose logging (to the opencode app log, service horology). |
Job shape
[
{
"name": "inbox-sweep", // unique, kebab-case (becomes the unit suffix)
"on": "Mon..Fri *-*-* 07..18:00,30:00", // systemd OnCalendar expression
"prompt": "Run the inbox-sweep skill …", // enqueue this text into the live session
"agent": "build" // optional agent override
},
{
"name": "standup-reminder",
"on": "Mon..Fri *-*-* 09:25",
"command": "remind", // OR run a slash-command instead of a prompt
"arguments": "stand-up in 5 minutes",
"oneShot": false
}
]- Provide either
prompt(enqueued via the async prompt endpoint) orcommand(+ optionalarguments, run via the session command endpoint). onis any valid systemdOnCalendarexpression. Test one withsystemd-analyze calendar "<expr>".oneShot: truedisarms the timer after it fires once (useful for reminders).
Scheduling from the TUI (/schedule)
Besides the HOROLOGY_JOBS baseline (read once at load), you can schedule jobs live from the opencode TUI in plain language. horology contributes a /schedule command and a set of tools the model calls to arm/list/cancel timers.
/schedule remind me to check my email in 10 minutes
/schedule every weekday at 9am, summarize what changed in the repo overnightHow it works: the /schedule command hands your request to the model, which translates the timing and calls the horology_schedule tool. For relative reminders ("in N minutes/hours") the model passes inMinutes and horology computes the absolute fire time itself (so it never has to guess the wall-clock time); these are always one-shot. For specific or recurring times the model passes a systemd OnCalendar expression.
The model can also call:
horology_list— show the currently armed jobs and their next fire times.horology_cancel <name>— disarm and forget a job.
Ephemeral: jobs added via
/schedulelive only for the current opencode process. They are not persisted — to make one permanent, add it toHOROLOGY_JOBS. On restart, horology re-arms exactly what's inHOROLOGY_JOBS.
Set HOROLOGY_SCHEDULE_AGENT to run the /schedule translation turn under a specific agent.
Inspecting / debugging
systemctl --user list-timers 'horology-*' # what's armed and when it next fires
journalctl --user -u 'horology-*' -f # watch fires
systemctl --user list-units 'horology-*' # timers + servicesLocal development (from this repo)
The published package needs no wrapper, but to exercise the plugin in-repo before publishing there is a thin dev wrapper at .opencode/plugins/horology.ts that re-exports from this package's built output:
cd opencode-plugin
npm install
npm run build # emits dist/ that the wrapper importsThen start opencode from the repo root (horology is active by default; set HOROLOGY_JOBS for a baseline, or use /schedule). Rebuild (npm run build) after editing anything under src/. npm run typecheck runs a no-emit strict type check.
Publishing
cd opencode-plugin
npm run typecheck # strict type check
npm publish --dry-run # inspect the tarball (should contain only dist/, package.json, README, LICENSE)
npm login # first time only
npm publish --access publicprepublishOnly runs the build automatically, so dist/ is always fresh in the published tarball. Only dist/ is shipped (see files in package.json); src/ and node_modules/ are excluded.
Security notes
- The trigger server binds
127.0.0.1only and requires the per-runHOROLOGY_TOKENon every/firecall. It is not reachable off-host. - horology executes
systemd-run/systemctlas your user and injects the prompts/commands you configured. Only run job definitions you trust.
License
MIT (c) Quintin De Kok
