# pi-nightshift

[![npm version](https://img.shields.io/npm/v/pi-nightshift?color=cb3837&logo=npm)](https://www.npmjs.com/package/pi-nightshift)
[![npm downloads](https://img.shields.io/npm/dm/pi-nightshift?color=cb3837&logo=npm)](https://www.npmjs.com/package/pi-nightshift)
[![license](https://img.shields.io/npm/l/pi-nightshift)](LICENSE)

[English](README.md) | 中文

**给 [pi](https://github.com/earendil-works/pi/) 的后台任务扩展 —— 在后台工作，干完自己来交班。**

你的 agent 不再被长命令卡住：`job_start` 立刻返回，pi 继续干活，等任务结束时 agent 会**自己醒过来**收取结果——没有轮询、没有 sleep、没有人类插话。

在用 [Deepseek Harness](https://github.com/deepseek-ai/deepseek-harness) 时发现了内置的好用工具，尝试在 [pi](https://github.com/earendil-works/pi/) 中复现。

## 场景模拟

```text
# 会话 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。

## 安装

```bash
# 从 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 工具上限 | 截断是设计；多次调用增量读 |

## 测试

```bash
# 单元测试 —— 无 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。

```bash
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。

## 发布（维护者）

```bash
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（按观察者各维护一个游标）。
- **输出无上限累积** —— 长流式任务未读的输出在内存里一直长；终态型任务（构建、测试）不受影响。
