安装
仅支持 macOS 和 Linux,明确不支持 Windows。Homebrew、PyPI、npm 装的都是同一个 预编译二进制文件——只有 cargo 这两条路需要 Rust 工具链。
brew tap soulmachine/tap https://github.com/soulmachine/openroutine
brew trust --formula soulmachine/tap/openroutine
brew install openroutine # 预编译二进制,不需要 Rust
pip install openroutine # 或从 PyPI 安装——同一个二进制,打包成 wheel
npm install -g openroutine # 或从 npm 安装
cargo install openroutine # 或从 crates.io 源码编译
brew tap 这行要带上仓库地址,因为这个仓库本身就是 tap,而不是另建一个
homebrew-* 仓库;Homebrew 6 还要求先信任第三方 formula 才会加载它。
openroutine init # 写入配置,打印一个示例任务
openroutine add hello.md # 注册一个任务文件
openroutine list # 查看哪些任务会运行
openroutine serve # 在前台运行调度器
openroutine install # 或注册为开机服务,无需 sudo
init 写入 ~/.config/openroutine/config.toml,并打印一个可以
照抄的示例任务。把它以 Markdown 存到任何地方,再用 openroutine add
注册即可。没有其他需要配置的东西。
最初五分钟
openroutine init 打印的内容(原样为英文),以及接下来该做什么。
Wrote config …/config.toml
A task is one markdown file. A starter, to save as hello.md:
---
name: hello
description: Say hello, nightly
cron: "0 9 * * *"
---
Say hello, then stop. This is a sample task — edit it freely.
Try: openroutine add hello.md # register it
openroutine list # what would run
openroutine run hello --dry-run # what it would do
openroutine serve # start the scheduler
Nothing fires until a daemon is running. `openroutine install`
registers one with your service manager, without sudo.
run --dry-run 是最先值得一试的命令。它渲染出完全解析后的执行计划 —
命令行、环境变量、工作目录、接下来的触发时刻 — 而不启动任何东西。它是一次读取,
不是一次运行;什么都不会被记录。
任务文件
任务就是单个 Markdown 文件,用 openroutine add 注册。frontmatter 是元数据,
正文是提示词,这个文件就是任务的完整定义。任务的 id 从 frontmatter 的
name 推导而来,在整台机器上唯一 — 重命名或移动文件不改变任何东西;
而把 name: 改成会推导出不同 id 的内容,就是创建了一个新任务。
---
name: hello
description: Say hello, nightly
cron: "0 9 * * *"
---
Say hello, then stop. This is a sample task — edit or delete it.
frontmatter 键
| 键 | 作用 |
|---|---|
name | 任务的名字,用自然语言写 — X timeline domain hunter 也可以。id 由它推导:转小写,连续空白变成单个连字符,[a-z0-9_-] 之外的字符被丢弃,两端修剪 — 得到 x-timeline-domain-hunter。推导出的 id 必须为 2–50 个字符,且包含字母或数字。必填。 |
description | 任务是做什么的。显示在 CLI 和 Web 界面里。必填。 |
cron | 一个 crontab 表达式,例如 "0 2 * * *"。与 at 互斥。 |
at | 一个单独的时刻,RFC 3339 格式。触发一次,之后任务变为已完成。与 cron 互斥。 |
catch_up | 补跑守护进程离线期间错过的一个时点,前提是它还没过时到不值得跑。默认关闭。 |
disabled | 在文件本身里关掉 — 一次可评审的提交,而不是不可见的机器状态。完全不参与调度,所以既不积累时点也不产生跳过 — 而且因为它从不运行,它永远不会重新读取自己:重新打开需要一次 openroutine reload。 |
agent | 由哪个已配置的智能体运行它。缺省时回落到 default_agent。 |
jitter | 触发时刻允许被推移多远,确定性地。接受 2m、30s,或裸写 0 表示精确的时点。 |
cwd | 智能体的起始目录。绝对路径直接用;相对路径相对任务文件所在目录解析,而后者也是默认值。 |
model | 通过智能体模板的 {model} 占位符注入,作为单个参数。 |
permission_mode | 通过 {permission_mode} 占位符注入,作为单个参数。 |
env | 本任务的环境变量,叠加在配置自身的环境之上。 |
时长接受 30s、5m、2h、
1d,或裸写的秒数。
正文就是提示词 — 对智能体无话可说的任务会被拒绝。
既没有 cron 也没有 at 的任务是一次性任务 —
守护进程接手后它立刻运行一次,然后显示为已完成。编辑文件后,它会在下一次
openroutine reload 时重新上膛:已完成的任务不会再运行,所以它无法
自己注意到这次编辑。
任务在运行时重新读取自己的文件。一次运行开始的那一刻 — 无论按调度、 被手动触发,还是补跑 — 任务会检查自己文件的 mtime,mtime 变了再查内容摘要;如果定义 变了,运行的就是新定义。所以你刚改好的提示词就是实际执行的那一个。没有任何监视, 也没有任何轮询:没有定时器,没有文件监听,不碰其他任务的文件,也不重读配置。
其余一切都靠重载(reload)。守护进程在启动时,以及
openroutine reload 或 POST /v1/reload 要求时,重新读取配置和
每个已注册的文件;add 和 remove 会自己请求一次。重载是让一次
编辑出现在 list、status 和 Web 界面里的方式,也是一个不会再
运行的任务能注意到编辑的唯一途径 — 已完成的一次性任务只在重载时重新上膛,
disabled: true 的任务也无法自己打开自己,因为两者都不再运行。
在触发时刻,一个已经变得无法应答这个时点的定义会撤回它,而不是运行它:
不再能解析的、现在写着 disabled: true 的、以及改了名字的,都会跳过这次
运行并等待一次重载,因为身份变更属于注册表层面的事。cron 时点即使已不在新调度里也照常
运行 — 它在被安排时是正当到期的 — 而 at: 在到点之前被改动过的一次性任务
会被跳过,并在新写的时刻重新上膛。每一种都记录为一次跳过,原因是
definition-changed。
on_failure 和 tz 在 v1 模式中已声明,但尚未生效。
OpenRoutine 会点名警告它们,而不是称之为未知键,因为一个静默无效的键是最糟的结果。
配置
由人拥有、可手工编辑,位于 ~/.config/openroutine/config.toml。
以下是 openroutine init 写入的内容(注释原样为英文):
# openroutine — https://openroutine.dev
# The agent a task gets when it names none.
default_agent = "claude"
# The task files to schedule. `openroutine add` appends here.
tasks = []
# An agent is a command template. {prompt} is substituted as a
# single argument; without it, the prompt arrives on stdin.
[agents.claude]
cmd = "claude -p {prompt}"
[agents.codex]
cmd = "codex exec {prompt}"
配置键
| 键 | 默认值 | 作用 |
|---|---|---|
tasks | [] | 已注册的任务文件,绝对路径。add 和 remove 会编辑这个列表;手工编辑同样有效。 |
agents.<name>.cmd | — | 命令模板。{prompt} 作为单个参数替换,绝不拼进 shell 字符串;没有它时提示词从 stdin 传入。{model} 和 {permission_mode} 的行为相同。 |
default_agent | 未设置 | 没有指名智能体的任务用它。默认未设置,所以省略 agent: 会让任务处于损坏状态,直到你主动设定。 |
env | 空 | 交给每次运行的环境变量,叠加在登录 shell 自身之上、任务自己设置的之下。 |
max_parallel | 未设置 | 全部任务合计同时允许多少次运行。未设置意味着硬件能扛多少是多少。 |
max_runs_per_task | 50 | 每个任务在磁盘上保留多少次运行记录。 |
max_log_bytes | 100 MiB | 一次运行的输出保留多少之后开始截断日志。 |
idle_timeout | 15m | 一次运行的智能体多久没有输出后,其整个进程组会被终止;写 none 则允许无限期沉默。从最后一次输出起算,而不是从开始起算 — 持续在输出的运行永远不会被打断,无论跑多久,总运行时长也没有上限。机器级设置:任务不能自行设定。 |
bind | 回环地址 | 本地 API 监听的位置。可以放宽,但不建议,而且永远不会取消令牌要求。 |
运行状态 — 上次运行时间、当时安排的时点、记录的跳过、暂停开关 — 由机器拥有,存放在
你仓库之外的一个 scheduled-tasks.json 里。删掉它,丢的是运行历史,
不是任务。
CLI 参考
openroutine --help 可看到同一份列表。全局的 --config <PATH>
可在任何命令上覆盖 XDG 配置路径。
| 命令 | 作用 |
|---|---|
serve | 在前台运行调度器。 |
list | 显示每个任务、它的调度和健康状态。 |
add <file.md> | 注册一个任务文件,纳入调度。校验严格 — 解析不了的文件会被拒绝,不会注册。 |
remove <name-or-path> | 注销一个任务。--delete 连文件一起删除;不加它文件仍归你。 |
reload [task] | 让正在运行的守护进程重新读取配置和每个已注册的文件 — 无条件地,不管有没有变化 — 并报告每个任务的健康状态。add 和 remove 会自动触发一次。不会再运行的任务要注意到编辑,靠的就是它。 |
init | 写入初始配置并打印一个示例任务。不注册任何东西。 |
status | 汇总守护进程的状态,以及它将要运行的东西。 |
run <task> | 立即触发一个任务。--dry-run 只描述这次运行而不启动它;--text 为本次运行附加上下文 — 是给智能体的信息,绝不是指令,也无法重新定义任务。 |
logs <task> | 显示任务最近一次运行。--follow 在运行继续输出时持续打印。 |
pause [task] | 暂停一个任务,或用 --all 暂停全部。 |
resume [task] | 恢复一个任务,或用 --all 恢复全部。 |
install | 把守护进程注册到你的服务管理器,无需 sudo。--print 只显示将要写入的内容,不实际写入。 |
uninstall | 注销守护进程。配置、状态和任务原样保留。 |
dashboard | 在浏览器中打开本地 Web 界面,已登录。 |
token | 打印 API 令牌。--rotate 更换令牌 — 使用旧令牌的一切都会停止工作。 |
REST API
守护进程监听 127.0.0.1:7373,由 openroutine token
输出的 Bearer 令牌保护。
curl -X POST http://127.0.0.1:7373/v1/tasks/todo-digest/fire \
-H "Authorization: Bearer $(openroutine token)" \
-d '{"text": "Sentry alert SEN-4521 fired in prod."}'
可选的 text 以“调用方提供的上下文”标注后送达智能体,而不是作为指令 —
任何能访问端点的一方都能发送 text,因此 text 必须无法重新定义任务本身。
端点
| 路由 | 作用 |
|---|---|
GET /v1/tasks | 每个已注册的任务,带调度和下次触发时刻。 |
GET /v1/tasks/{id} | 单个任务的详情。 |
GET /v1/tasks/{id}/runs | 该任务的运行历史。 |
POST /v1/tasks/{id}/fire | 发起一次运行。返回运行 id 和它的日志路径。 |
GET /v1/runs/{id}/{run} | 单次运行的详情。 |
GET /v1/runs/{id}/{run}/log | 该次运行的日志。 |
GET /v1/runs/{id}/{run}/log/stream | 通过 SSE 实时跟踪日志。 |
POST /v1/runs/{id}/{run}/cancel | 终止一次进行中的运行。 |
POST /v1/tasks/{id}/pause | 暂停一个任务。 |
POST /v1/tasks/{id}/resume | 恢复一个任务。 |
POST /v1/pause | 暂停这台机器上的所有任务。 |
POST /v1/resume | 恢复全部。 |
POST /v1/reload | 重新读取配置和每个已注册的任务文件;返回每个任务的健康状态。 |
部署
要让守护进程在一台你不守着的机器上无人值守地运行 — 桌下的 Mac mini、一台家用服务器 — 把它注册到系统的服务管理器。
cargo install --path . # 安装到一个稳定路径;见下文
openroutine init # 配置;打印一个示例任务
openroutine add hello.md # 用一个任务验证它能工作
openroutine install # 注册到 launchd/systemd,无需 sudo
openroutine status # daemon: running (pid …)
install 记录的是执行注册的那个二进制的路径,所以要用安装后的副本运行它,
而不是 target/release/openroutine — 一次 cargo clean 不应该能
拆掉你的调度器。它写入的是按用户的服务,从不要求 sudo。openroutine install
--print 精确显示它会写什么、会运行什么,而不实际写入。
你的智能体必须在登录 shell 的 PATH 上
守护进程通过登录 shell 运行每个智能体,所以一次运行拿到的
PATH、shim 和 API 密钥与你的终端相同。登录 shell 不等于交互式 shell:
zsh 读 .zshenv 和 .zprofile 但不读
.zshrc;bash 读 .bash_profile 但不读 .bashrc。
所以一个只靠 .zshrc 放上 PATH 的智能体 — 装在
~/.local/bin 里的是最常见的情况 — 你手工测试时找得到,launchd
启动守护进程后就不见了:
zsh:1: command not found: claude
openroutine list 会在你踩坑之前发出警告。它按服务管理器的方式采样登录
shell — PATH 以守护进程继承的裸
/usr/bin:/bin:/usr/sbin:/sbin 起步,绝不用你终端恰好有的
PATH — 所以它替守护进程回答,而不是替你回答:
warning: "claude" is on your PATH here but not under a service manager, so
scheduled Runs will fail; move its PATH export into your login profile
修复方式:把 PATH 导出移进 .zprofile(顺带把 SSH 和 cron
会话也修好了),或者用绝对路径指名智能体:
[agents.claude]
cmd = "/Users/you/.local/bin/claude -p {prompt}"
按守护进程将来看到它的方式验证 — 一个不继承你终端任何环境的登录 shell:
env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin /bin/zsh -lc 'command -v claude'
macOS
install 向 ~/Library/LaunchAgents/ 写入一个
LaunchAgent,所以守护进程在登录时启动,而不是开机时;它挂掉后
launchd 会重启它。在无人值守的机器上,请搭配自动登录(系统设置 → 用户与群组 →
自动登录为),这要求关闭 FileVault。
自动登录不只是为了让守护进程启动。智能体 CLI 把凭据保存在你的登录钥匙串里,
而钥匙串靠图形界面登录来解锁 — 没有它就启动的服务会发现钥匙串锁着,智能体报告自己
未登录。这也是 install 不写 root LaunchDaemon 的原因:在任何人登录之前
启动,恰恰是智能体无法认证的状态,LaunchDaemon 买到的那一件事正是弄坏它的那一件事。
这笔交换反过来也成立,而且是真实的代价:自动登录加上关闭 FileVault,意味着物理接触
就是一个已登录的桌面。
顺手把机器调成不会睡过自己的调度:
sudo pmset -c sleep 0 displaysleep 0 disksleep 0 # 永不休眠
sudo pmset -c autorestart 1 womp 1 # 断电恢复后自动回来
launchctl print gui/$(id -u)/dev.openroutine.daemon | grep state
Linux
install 写入一个 systemd 用户单元并启用 lingering,所以守护进程在开机时
启动、无需登录会话 — 唯一一个“无需登录”不带星号成立的平台。
systemctl --user status dev.openroutine.daemon
journalctl --user -u dev.openroutine.daemon -f
确认它扛得住
openroutine run <task> # 一次真实的端到端运行
openroutine logs <task> # 智能体实际打印了什么
直接杀掉守护进程是公平的测试:服务管理器应当在几秒内以新 pid 把它带回来。
openroutine uninstall 注销它,你的配置、状态和任务原样保留。
概念
CLI、界面和文档共用的词汇表。这里的精确是有意为之 — 其中多数词都有一个含义 微妙不同的近义词。术语本身保留 CLI 与界面实际使用的英文原词。
| 术语 | 含义 |
|---|---|
Task(任务) | 一个智能体工作单元,完全由一个用 openroutine add 注册的 Markdown 文件定义。name 是给人看的自然语言;id 从这个名字推导,是一切索引的依据。重命名或移动文件不改变任何东西。 |
Id | 任务的身份,从 name 推导:转小写,连续空白变成单个连字符,[a-z0-9_-] 之外的字符丢弃,两端修剪。整台机器上唯一 — 两个名字推导出同一个 id 会被拒绝,而不是被调和。状态文件记录的是它,runs/<id>/ 以它命名,API 路径携带它,CLI 也应答它。 |
Agent(智能体) | 一个接受提示词的已配置命令模板。任何 CLI 都可以;OpenRoutine 自己从不调用模型 API。 |
Daemon(守护进程) | 那个唯一的长驻进程,同时是调度器、运行器、API 和界面。操作系统只负责监管它,不调度任何东西。 |
Reload(重载) | 守护进程重新读取配置和每个已注册文件的时刻,不管有没有变化 — 在启动时、在 openroutine reload 时、或在一次 add 或 remove 之后。一个不会再运行的任务 — 已完成、已停用或已损坏 — 能注意到自己被编辑过的唯一途径。 |
Refresh(刷新) | 任务在即将运行的那一刻 — 按时点或被触发 — 对自己文件做的检查:先看 mtime 变没变,再看摘要变没变,然后才采用新定义,用于这一次运行。只限运行路径、只限这一个文件 — 没有监视,没有轮询,从不触发的任务也从不刷新。 |
Tick(时点) | 任务调度到期的一个瞬间。每个时点都恰好变成一次运行或一次跳过。 |
Run(运行) | 智能体对任务提示词的一次执行,由调度的时点或一次触发产生。 |
Skip(跳过) | 没有运行的时点,连同原因一起记录:overlap、daemon-down、missed、paused,或 definition-changed — 刷新发现这个时点已无法应答。绝不无声 — 每个时点都恰好变成一次运行或一次跳过。 |
Fire(触发) | 在调度之外按需发起一次运行 — 通过 API 或界面。 |
Dry run(演练) | 渲染任务完全解析后的执行计划,而不启动任何东西。是一次读取,不是一次运行;什么都不记录。 |
Idle timeout(静默超时) | 一次运行的智能体可以沉默多久。从最后一次输出起算,所以肉眼可见还在推进的工作无论多久都不会被打断,而挂死的运行会被回收。到期时运行的整个进程组被终止,运行记录为超时 — 绝不悬着不管。 |
Jitter(抖动) | 任务时点与实际触发时刻之间的确定性偏移,从任务 id 推导,因此在多次运行之间从不改变。 |
One-shot(一次性任务) | 没有 cron 的任务。有 at: 时在那个时刻触发一次;没有时在守护进程接手后立即触发一次。之后都变为已完成。 |
Completed(已完成) | 已经应答过自己时刻的一次性任务。纯机器状态:编辑文件就再次给了它事做 — 在下一次重载时,因为不会运行的任务无法刷新。 |
Catch-up(补跑) | 让任务选择补跑守护进程离线期间错过的一个时点。默认关闭。 |
Disabled(已停用) | 在自己的 frontmatter 里被关掉。完全不参与调度,所以不积累时点也不产生跳过 — 与已暂停的任务不同。 |
Paused(已暂停) | 在运行时被按住。机器拥有的状态,从不属于定义;任务只有在既未停用也未暂停时才运行。被按住期间到来的时点会变成一次跳过,而不是一段空白。 |
Interrupted(已中断) | 守护进程在其结束前消失了的运行。在下一次启动时补记,所以不会有运行永远声称自己还在运行。 |
Ready(就绪) | 定义能解析、能校验、指名的智能体存在的任务。时点能产生运行的唯一状态。 |
Broken(损坏) | 文件缺失,或定义解析、校验失败的已注册任务。总是连同错误一起醒目地展示;绝不被无声地移出调度。 |
Familiar(熟识) | 定义与上次开始运行时一致的任务。新的或被编辑过的任务会被标记出来 — 注册一个文件即是信任能编辑它的人,所以到来的变更被公示,而不是被拦下。 |