macOS CLI 工具,用于为工作区配置 AI 驱动的定时健康检查。通过 macOS launchd 调度,定期将用户自定义的 prompt 喂给 claude 执行,并将结果持久化到日志。
- macOS(使用 launchd / LaunchAgents)
- Bun 运行时
- Claude Code CLI(
claude命令需在 PATH 中)
git clone <repo>
cd heartbeat-cli
bun install注册为全局命令:
bun link或直接通过 Bun 执行:
bun src/index.ts <command>1. 在工作区初始化 HEARTBEAT.md:
heartbeat init /path/to/your/project生成一个带默认内容的模板文件,按需编辑 prompt 描述 Claude 应该检查什么。
2. 注册到 launchd:
heartbeat add /path/to/your/project生成 .plist 并加载到 ~/Library/LaunchAgents/,launchd 从此按配置的 schedule 自动触发。
3. 查看已注册的工作区:
heartbeat list4. 手动触发一次:
heartbeat run /path/to/your/project5. 查看运行日志:
heartbeat logs # 所有工作区
heartbeat logs /path/to/your/project # 指定工作区6. 注销工作区:
heartbeat remove /path/to/your/project每个工作区需要有一个 HEARTBEAT.md,由 YAML frontmatter 和 prompt 正文两部分组成,用 --- 分隔:
---
name: "My Project"
schedule: "0 9 * * 1-5"
enabled: true
timeout: 120
---
检查这个工作区是否一切正常。
如果没有问题,回复:HEARTBEAT_OK
如果需要关注,回复:ATTENTION: <简要描述>| 字段 | 必填 | 说明 |
|---|---|---|
name |
否 | 显示名称,默认使用目录名 |
schedule |
是 | 执行时机,支持间隔简写、cron 表达式或数组 |
enabled |
否 | 是否启用,默认 true |
timeout |
否 | Claude 最长运行秒数,默认 120 |
间隔简写:
| 值 | 含义 |
|---|---|
15m |
每 15 分钟 |
1h |
每小时 |
6h |
每 6 小时 |
1d |
每天 |
5 段 cron 表达式:
┌─ 分钟 (0-59)
│ ┌─ 小时 (0-23)
│ │ ┌─ 日期 (1-31)
│ │ │ ┌─ 月份 (1-12)
│ │ │ │ ┌─ 星期 (0-6,0=周日)
│ │ │ │ │
0 9 * * 1-5 # 工作日早上 9 点
支持 *(通配)、1-5(范围)、1,3,5(列表)、*/15(步进)。
多组 schedule(数组):
当需要在多个时间点触发时,可将 schedule 配置为数组。所有项均须为 cron 表达式(间隔简写不支持多组):
schedule:
- "0 9 * * 1-5" # 工作日早上 9 点
- "0 18 * * 1-5" # 工作日下午 6 点多组 cron 会被合并进同一个 StartCalendarInterval 数组,注册为单个 launchd job。
Claude 的回复会被分类为三种状态:
| 状态 | 触发条件 | 含义 |
|---|---|---|
ok |
回复中包含 HEARTBEAT_OK |
一切正常 |
attention |
回复中包含 ATTENTION: <描述> |
需要关注 |
error |
超时或空响应 | 执行失败 |
prompt 中必须明确指示 Claude 用这两个关键词之一作答。
HEARTBEAT.md(每个工作区)
│
▼
parseHeartbeat() 解析 YAML frontmatter + prompt 正文
│
▼
registerJob() 生成 plist → launchctl load → launchd 接管调度
│
│ (launchd 按 schedule 触发)
▼
runHeartbeat() spawn claude 子进程,stdin 传入 prompt
读取 stream-json 输出,提取最终文本
│
▼
classify() 检测 HEARTBEAT_OK / ATTENTION 关键词
│
▼
appendLog() 追加到 ~/.heartbeat/heartbeats.jsonl
每个工作区的唯一标识取工作区绝对路径的 SHA-256 哈希前 8 位:
createHash('sha256').update(absolutePath).digest('hex').slice(0, 8)路径不变则 ID 不变,保证注册操作幂等,同时用于 plist 命名、日志过滤和配置索引。
launchd 支持两种触发机制,转换逻辑如下:
间隔简写 → StartInterval
<key>StartInterval</key>
<integer>3600</integer>cron 表达式 → StartCalendarInterval
cron 中的通配符、范围、列表、步进会被完全展开为笛卡尔积,每个时间点生成一个 <dict>:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
<key>Weekday</key><integer>1</integer>
</dict>
<!-- ... -->
</array>多组 cron 各自展开后合并进同一个 <array>,共用一个 plist。
claude --output-format stream-json --verbose --print --dangerously-skip-permissions -| 参数 | 作用 |
|---|---|
--print - |
非交互模式,从 stdin 读取 prompt |
--output-format stream-json --verbose |
以换行分隔的 JSON 事件流输出 |
--dangerously-skip-permissions |
允许无人值守时自动批准所有工具调用 |
Claude 的输出是逐行的 JSON 事件流:
{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
{"type":"tool_use", ...}
{"type":"result","result":"HEARTBEAT_OK"}extractFinalText() 解析策略:
- 优先:从后向前扫描,取第一个
type=result的result字段 - 回退:拼接所有
type=assistant事件中的 text block
每次运行追加一行到 ~/.heartbeat/heartbeats.jsonl:
{
"ts": "2026-03-01T09:00:00.000Z",
"taskId": "0422840c",
"name": "My Project",
"workspacePath": "/path/to/project",
"status": "ok",
"message": "ok",
"durationMs": 4200,
"raw": "..."
}Append-only,无需加锁,易于用 jq 查询。
~/.heartbeat/
config.json # 工作区注册表
heartbeats.jsonl # 追加式运行日志
logs/
<taskId>.stdout.log # launchd 触发时的 stdout
<taskId>.stderr.log # launchd 触发时的 stderr
~/Library/LaunchAgents/
com.heartbeat.scheduler.<taskId>.plist # 每个工作区对应一个 launchd job
| 文件 | 职责 |
|---|---|
src/index.ts |
CLI 入口,命令分发 |
src/config.ts |
工作区注册表的读写;Task ID 生成 |
src/heartbeat.ts |
解析 HEARTBEAT.md,拆分 frontmatter 和 prompt |
src/runner.ts |
spawn claude 子进程,解析 stream-json,分类结果,写日志 |
src/scheduler.ts |
生成 plist XML,转换 schedule,调用 launchctl |
src/logs.ts |
JSONL 日志的追加与读取 |
| 层 | 技术 |
|---|---|
| 运行时 | Bun — 直接执行 TypeScript,无需构建 |
| 语言 | TypeScript(ESM 模块) |
| 调度 | macOS launchd(launchctl + .plist) |
| AI 引擎 | Claude Code CLI(stream-json 模式) |
| YAML 解析 | js-yaml |
| 日志格式 | JSONL(换行分隔 JSON) |
| 决策 | 原因 |
|---|---|
| 使用 launchd 而非自研调度器 | 无需常驻进程,系统级可靠性,开机自动恢复 |
| Task ID = SHA-256(path)[:8] | 路径唯一确定工作区,幂等注册 |
| stream-json 模式 | 支持工具调用(Task / Skill / Write / Bash),不限于文本响应 |
| prompt 完全由用户定义 | 不限制检查逻辑,Claude 可运行任意工具 |
| JSONL 日志 | Append-only,无锁,便于 grep / jq 查询 |
检查未提交的代码变更:
查看这个 git 仓库,如果没有未提交的改动或长期未合并的分支,回复 HEARTBEAT_OK。
否则回复 ATTENTION: <发现的问题>。
监控日志文件异常:
读取 app.log 最后 50 行,如果没有 ERROR 或 FATAL 记录,回复 HEARTBEAT_OK。
否则回复 ATTENTION: <错误摘要>。
依赖版本审计:
执行 `bun outdated`,检查是否有主版本落后的依赖。
如果一切最新,回复 HEARTBEAT_OK。
如果有主版本更新可用,回复 ATTENTION: <需要更新的包列表>。
每日新闻汇总(多工具协作示例):
schedule:
- "0 9 * * *"
timeout: 600用 Task 工具并行启动两个 agent,分别调用 hubtoday-daily 和 linuxdo-news-analyzer 技能。
等两个 agent 完成后,用 Bash 获取今日日期(date +%Y-%m-%d),将汇总内容写入当前目录的 YYYY-MM-DD.md。
文件写入成功后回复 HEARTBEAT_OK,否则回复 ATTENTION: <失败原因>。