OpenRoutine

文档

OpenRoutine 的一切都由两个纯文本文件驱动:一个你拥有的 Markdown 任务文件,和一个 写明哪些文件要运行、智能体是什么的 config.toml

安装

仅支持 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 键

任务文件接受的 frontmatter 键。namedescription 和非空的 正文是必填的;其余键全部可选。
作用
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触发时刻允许被推移多远,确定性地。接受 2m30s,或裸写 0 表示精确的时点。
cwd智能体的起始目录。绝对路径直接用;相对路径相对任务文件所在目录解析,而后者也是默认值。
model通过智能体模板的 {model} 占位符注入,作为单个参数。
permission_mode通过 {permission_mode} 占位符注入,作为单个参数。
env本任务的环境变量,叠加在配置自身的环境之上。

时长接受 30s5m2h1d,或裸写的秒数。

正文就是提示词 — 对智能体无话可说的任务会被拒绝。

既没有 cron 也没有 at 的任务是一次性任务 — 守护进程接手后它立刻运行一次,然后显示为已完成。编辑文件后,它会在下一次 openroutine reload 时重新上膛:已完成的任务不会再运行,所以它无法 自己注意到这次编辑。

任务在运行时重新读取自己的文件。一次运行开始的那一刻 — 无论按调度、 被手动触发,还是补跑 — 任务会检查自己文件的 mtime,mtime 变了再查内容摘要;如果定义 变了,运行的就是新定义。所以你刚改好的提示词就是实际执行的那一个。没有任何监视, 也没有任何轮询:没有定时器,没有文件监听,不碰其他任务的文件,也不重读配置。

其余一切都靠重载(reload)。守护进程在启动时,以及 openroutine reloadPOST /v1/reload 要求时,重新读取配置和 每个已注册的文件;addremove 会自己请求一次。重载是让一次 编辑出现在 liststatus 和 Web 界面里的方式,也是一个不会再 运行的任务能注意到编辑的唯一途径 — 已完成的一次性任务只在重载时重新上膛, disabled: true 的任务也无法自己打开自己,因为两者都不再运行。

在触发时刻,一个已经变得无法应答这个时点的定义会撤回它,而不是运行它: 不再能解析的、现在写着 disabled: true 的、以及改了名字的,都会跳过这次 运行并等待一次重载,因为身份变更属于注册表层面的事。cron 时点即使已不在新调度里也照常 运行 — 它在被安排时是正当到期的 — 而 at: 在到点之前被改动过的一次性任务 会被跳过,并在新写的时刻重新上膛。每一种都记录为一次跳过,原因是 definition-changed

on_failuretz 在 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[]已注册的任务文件,绝对路径。addremove 会编辑这个列表;手工编辑同样有效。
agents.<name>.cmd命令模板。{prompt} 作为单个参数替换,绝不拼进 shell 字符串;没有它时提示词从 stdin 传入。{model}{permission_mode} 的行为相同。
default_agent未设置没有指名智能体的任务用它。默认未设置,所以省略 agent: 会让任务处于损坏状态,直到你主动设定。
env交给每次运行的环境变量,叠加在登录 shell 自身之上、任务自己设置的之下。
max_parallel未设置全部任务合计同时允许多少次运行。未设置意味着硬件能扛多少是多少。
max_runs_per_task50每个任务在磁盘上保留多少次运行记录。
max_log_bytes100 MiB一次运行的输出保留多少之后开始截断日志。
idle_timeout15m一次运行的智能体多久没有输出后,其整个进程组会被终止;写 none 则允许无限期沉默。从最后一次输出起算,而不是从开始起算 — 持续在输出的运行永远不会被打断,无论跑多久,总运行时长也没有上限。机器级设置:任务不能自行设定。
bind回环地址本地 API 监听的位置。可以放宽,但不建议,而且永远不会取消令牌要求。

运行状态 — 上次运行时间、当时安排的时点、记录的跳过、暂停开关 — 由机器拥有,存放在 你仓库之外的一个 scheduled-tasks.json 里。删掉它,丢的是运行历史, 不是任务。

CLI 参考

openroutine --help 可看到同一份列表。全局的 --config <PATH> 可在任何命令上覆盖 XDG 配置路径。

openroutine 的全部子命令。
命令作用
serve在前台运行调度器。
list显示每个任务、它的调度和健康状态。
add <file.md>注册一个任务文件,纳入调度。校验严格 — 解析不了的文件会被拒绝,不会注册。
remove <name-or-path>注销一个任务。--delete 连文件一起删除;不加它文件仍归你。
reload [task]让正在运行的守护进程重新读取配置和每个已注册的文件 — 无条件地,不管有没有变化 — 并报告每个任务的健康状态。addremove 会自动触发一次。不会再运行的任务要注意到编辑,靠的就是它。
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 必须无法重新定义任务本身。

端点

本地 API 提供的全部路由。
路由作用
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 与界面实际使用的英文原词。

CLI、Web 界面和本文档以完全相同方式使用的术语。
术语含义
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 时、或在一次 addremove 之后。一个不会再运行的任务 — 已完成、已停用或已损坏 — 能注意到自己被编辑过的唯一途径。
Refresh(刷新)任务在即将运行的那一刻 — 按时点或被触发 — 对自己文件做的检查:先看 mtime 变没变,再看摘要变没变,然后才采用新定义,用于这一次运行。只限运行路径、只限这一个文件 — 没有监视,没有轮询,从不触发的任务也从不刷新。
Tick(时点)任务调度到期的一个瞬间。每个时点都恰好变成一次运行或一次跳过。
Run(运行)智能体对任务提示词的一次执行,由调度的时点或一次触发产生。
Skip(跳过)没有运行的时点,连同原因一起记录:overlapdaemon-downmissedpaused,或 definition-changed — 刷新发现这个时点已无法应答。绝不无声 — 每个时点都恰好变成一次运行或一次跳过。
Fire(触发)在调度之外按需发起一次运行 — 通过 API 或界面。
Dry run(演练)渲染任务完全解析后的执行计划,而不启动任何东西。是一次读取,不是一次运行;什么都不记录。
Idle timeout(静默超时)一次运行的智能体可以沉默多久。从最后一次输出起算,所以肉眼可见还在推进的工作无论多久都不会被打断,而挂死的运行会被回收。到期时运行的整个进程组被终止,运行记录为超时 — 绝不悬着不管。
Jitter(抖动)任务时点与实际触发时刻之间的确定性偏移,从任务 id 推导,因此在多次运行之间从不改变。
One-shot(一次性任务)没有 cron 的任务。有 at: 时在那个时刻触发一次;没有时在守护进程接手后立即触发一次。之后都变为已完成。
Completed(已完成)已经应答过自己时刻的一次性任务。纯机器状态:编辑文件就再次给了它事做 — 在下一次重载时,因为不会运行的任务无法刷新。
Catch-up(补跑)让任务选择补跑守护进程离线期间错过的一个时点。默认关闭。
Disabled(已停用)在自己的 frontmatter 里被关掉。完全不参与调度,所以不积累时点也不产生跳过 — 与已暂停的任务不同。
Paused(已暂停)在运行时被按住。机器拥有的状态,从不属于定义;任务只有在既未停用也未暂停时才运行。被按住期间到来的时点会变成一次跳过,而不是一段空白。
Interrupted(已中断)守护进程在其结束前消失了的运行。在下一次启动时补记,所以不会有运行永远声称自己还在运行。
Ready(就绪)定义能解析、能校验、指名的智能体存在的任务。时点能产生运行的唯一状态。
Broken(损坏)文件缺失,或定义解析、校验失败的已注册任务。总是连同错误一起醒目地展示;绝不被无声地移出调度。
Familiar(熟识)定义与上次开始运行时一致的任务。新的或被编辑过的任务会被标记出来 — 注册一个文件即是信任能编辑它的人,所以到来的变更被公示,而不是被拦下。