pi-nightshift
v0.1.1
Published
Background jobs for pi with auto-wake on completion. 在后台工作,干完自己来交班。
Downloads
49
Maintainers
Readme
pi-nightshift
English | 中文
给 pi 的后台任务扩展 —— 在后台工作,干完自己来交班。
你的 agent 不再被长命令卡住:job_start 立刻返回,pi 继续干活,等任务结束时 agent 会自己醒过来收取结果——没有轮询、没有 sleep、没有人类插话。
在用 Deepseek Harness 时发现了内置的好用工具,尝试在 pi 中复现。
场景模拟
# 会话 1
你 ▸ pnpm test 要跑 3 分钟。放后台跑,继续干活。
pi ▸ 已启动后台任务 bash-1 (bash: pnpm test)。
(pi 继续做你的其他事……)
⏰ background job bash-1 (bash: pnpm test) finished [status: completed, exit code: 0].
pi ▸ 测试通过 —— 42 过 0 挂。(自己醒过来,读了输出,回来汇报)完成通知只带 job id 和状态;输出留在 registry,被唤醒的 agent 自己决定要不要用 job_output 去读。不为没人要的输出白花一次 turn。
安装
# 从 npm 安装(推荐)
pi install npm:pi-nightshift
# …或从本地 checkout 安装
pi install ./pi-nightshift装完即用——四个工具(job_start、job_output、job_list、job_kill)立刻生效。可选配置在 ~/.pi/agent/nightshift.json(见配置)。
工具
| 工具 | 作用 |
|------|------|
| job_start(command, label?) | 后台跑一条 shell 命令;立刻返回 bash-N 式 job id |
| job_output(job_id, wait?, timeout_ms?) | 读上次读之后的增量(不重复);结尾总带 [status: ...]。wait: true 阻塞到任务结束或超时(超时返回 [status: running],任务还活着) |
| job_list() | <id> [<kind>] <status> - <label>,空列表返回 (no background jobs) |
| job_kill(job_id, reason?) | 立即请求取消(连进程树一起杀);已结束的任务返回状态且不消费未读输出 |
文件布局
~/.pi/agent/
nightshift.json # 可选配置
pi-nightshift/
<sessionId>/
bash-1.json # 未送达的完成通知(durable)通知文件在发送之前写入,确认送达后才删除——两者之间崩溃,文件留在磁盘上,重开会话时重放。
工作原理
完成检测
job_start 通过 pi 内置 bash 用的同一个 shell 启动命令(Windows 上是 Git Bash,其他平台 /bin/bash)。stdout/stderr 累积进 job,cursor 记录已读位置。任务在子进程的 close 事件(进程退出且 stdio 关完——此时输出才完整)时settle,不是 exit。
两条交付车道
任务 settle 时,自主决定通知去哪:
- 空闲 agent → 唤醒 ——
followUp + triggerTurn:开一个全新 turn,和用户输入完全一样。模型看到通知,可以立刻调job_output。 - 忙碌 agent → 注入 —— 通知进当前 turn 的下一步。
wake 预算(防自激链)
被唤醒的 turn 可能又起一个任务,完成后再唤醒它——无限循环会烧 token。maxConsecutiveWakes(默认 3)限制一次会话能靠唤醒开 turn 的次数,超出的通知降级为注入。只有真人输入回填预算——通知永远不会回填自己花掉的额度。
批处理
150ms 内结束的多个任务合成一条通知(triggerTurn 取各项的 or),一批并行的任务只唤醒一次,不是 N 次。从第一个 settle 起 1 秒硬上限,单个任务的孤立通知不会永远等着。超过 4KB 的通知退化成 job id 列表,再超退化成「N 个任务完成,用 job_list 看」。
durable 交付
通知在 settle 时先写盘(tmp+rename 原子写),再发送。送达 → 删文件。中途崩溃 → 文件存活 → 同一会话重开时以 steer 重放(不 triggerTurn)。任务本身随 pi 一起死——只有未送达的通知保证存活。
去重
模型已经知道任务结果——通过 job_kill、终态读、或返回终态的 wait: true——就不发通知。
| 途径 | 为什么模型已经知道 |
|------|-------------------|
| job_kill | 模型亲手杀掉了任务,当然知道它结束了——再通知就是废话 |
| 终态读 | job_output 读到终态任务时,返回值里带着 [status: completed],从这次读取里就知道了 |
| 返回终态的 wait: true | job_output(job_id, wait: true) 阻塞到任务结束才返回,模型是等它结束的 |
三种途径都会把任务标成 reported,settle 时通知直接丢弃(decideLane 返回 drop)。
配置
文件:~/.pi/agent/nightshift.json(不存在 = 全默认)。非法值启动即报错。
| 键 | 默认 | 含义 |
|-----|---------|------|
| waitTimeoutMs | 30000 | wait: true 不带 timeout_ms 时用的等待时长 |
| maxWaitTimeoutMs | 600000 | 模型给的超时上限,超出被钳制 |
| maxConsecutiveWakes | 3 | 一次会话能靠唤醒开 turn 的上限,之后降级为塞入 |
| completionDelivery | "wakeup" | "wakeup" = 空闲就开 turn;"quiet" = 只塞不拉起,通知等下一次真实 turn |
waitTimeoutMs 大于 maxWaitTimeoutMs 时加载即失败。
故障排查
| 症状 | 原因 | 修复 |
|------|------|------|
| 任务永不 settle、无通知 | 有后代进程握着 stdout(daemon 型) | 任务还在产出输出——job_kill 它,或等它自然结束 |
| agent 没被唤醒 | wake 预算耗尽(maxConsecutiveWakes),或 completionDelivery: "quiet",或任务在 turn 中途结束(塞入车道) | 随便输入点什么——通知会搭下一次真实 turn 的便车 |
| 重启后通知又出现 | durable 重放——上次投递被中断 | 设计如此;重放只走 steer,从不自己开 turn |
| 同一条通知出现两次 | 「发送已确认」和「删文件」之间崩溃 | 极小的崩溃窗口,接受的取舍 |
| job_start 失败 | 找不到 bash(Windows 没装 Git Bash) | 装 Git for Windows,或在 pi 设置里配 shellPath |
| 配置改了没生效 | 路径不对,或 JSON 非法(加载时大声报错) | 检查 ~/.pi/agent/nightshift.json |
| job_output 输出被截断 | 输出超过 pi 的 50KB 工具上限 | 截断是设计;多次调用增量读 |
测试
# 单元测试 —— 无 LLM、确定性、快
npm test
# 端到端测试 —— 需要 pi + API key
node test/e2e-wake.mjs # 空闲 agent 被真实任务完成自动唤醒
node test/e2e-batch.mjs # 3 个任务同时结束恰好唤醒一次
node test/e2e-durable.mjs # 崩溃幸存的通知在会话恢复后重放| 层级 | 命令 | 要求 | 测什么 |
|------|------|------|--------|
| 单元 | npm test | 无 | 注册表状态流转、增量读、车道决策、批窗口、配置校验、durable 存储 |
| E2E | node test/e2e-*.mjs | pi + API key | 完整闭环:真实任务 → settle → 唤醒 → 模型读输出 |
开发
无构建步骤——pi 直接加载 TypeScript。
pi -e ./src/index.ts # 带扩展起交互会话
npm test # 单元检查结构:src/index.ts 只接 pi API;逻辑都在纯模块里(jobs.ts 注册表、notify.ts 车道决策、batch.ts 批窗口、config.ts 配置校验、durable.ts 存储),所以能用裸 node --test 跑,不需要 pi。
发布(维护者)
npm version patch # 或 minor / major
npm publish # 需要 npm 账号;pi-package 关键词让包出现在 pi 包目录
pi install npm:pi-nightshift # 验证发布后的安装已知缺口
- agent 状态切换窗口可能吞通知 —— turn 循环最后一次检查收件箱与 agent 正式进入 idle(
agent_settled)之间有一小段窗口,此刻 agent 仍被判定为忙碌,正好在这时结束的任务,通知会走 steer 车道排队但没有东西唤醒它。修这个窗口要改 pi 的 agent loop,扩展层没有对应的钩子。 - steer 已排队但会话先结束时通知会丢 —— durable 文件在发送被接受时(
sendCompletion返回 true)就删了,但 steer 车道里「接受」只代表排队成功、不代表送达。通知排队后、送达前会话结束(退出、/new、/resume切走),inbox 被清空、文件已删,这条通知就丢了。wake 车道当场开 run 送达,不受影响;重放路径(session_start 扫盘)也只覆盖还没发出去的文件。 - 唤醒预算在session中不会恢复 —— wake 预算只在收到真人输入时清零回满,时间流逝不会恢复。所以无人值守的 agent 一旦花光
maxConsecutiveWakes(默认 3)次唤醒额度,之后完成的任务都只降级为 steer 排队,不会自己再醒过来;通知一直攒着,直到用户回来输入、或别的扩展开了个 turn 才送达。这是防自激链的有意设计,代价是「放它自己在后台跑一批活」最多自动唤醒 3 次。 - 流式读适用于单消费者 —— 每个任务只有一个读游标,
job_output读一次游标就前进一次,增量被消费后不再返回。模型自己是唯一的消费者所以一切正常;但如果同一回合并行调两次job_output,或将来有第二个观察者(另一个扩展、另一个 session),后读的人会拿到(no new output)。多个消费者需要新的 API(按观察者各维护一个游标)。 - 输出无上限累积 —— 长流式任务未读的输出在内存里一直长;终态型任务(构建、测试)不受影响。
