# anon-notify

Pi Agent 生命周期通知扩展。Agent 完成或失败后，扩展把结构化事件发送到 YesImBot Webhook；YesImBot 将事件注入 Anon 的主对话流。插件不附加模型转告策略，也不代替 Anon 发消息。HTTP 接受、Anon 上下文接收和最终 QQ 发言是三个不同阶段；前两个阶段成功不保证 Anon 一定转告。

默认路径上，扩展和 `notify-webhook` 插件都不直接向 QQ 发送消息，也不使用通知专用模型、QQ 引用回复轮询或本地 reply journal。插件另有一个显式旁路：请求体带 `delivery: "direct"` 时，事件不经过 Anon、不经过模型，按固定格式直接发到配置的 Koishi 目标（用于上游模型配额这类无需人格处理的告警）。

## 安装

需要 Node.js 22.19+ 和提供 `agent_settled` 等扩展 API 的 Pi（已验证 Pi 0.87.1）。只安装扩展，不升级 Pi 本体：

```bash
pi install npm:@cr1ms0n/anon-notify@0.3.0
```

先按下方配置服务端地址、目标 QQ 与 Token，然后在 Pi 会话中重新加载：

```text
/reload
```

使用裸命令 `/anon-notify` 开启当前会话。新安装不带个人目标，未配置有效 QQ 号时不能开启。

若之前通过 `~/.pi/agent/extensions/anon-notify` 的源码目录联接加载，请先保存旧 `config.json` 到下方的稳定用户配置位置，再将旧联接移出自动发现目录，避免和 npm 包重复注册。只移动联接，不删除它指向的源码仓库。Token、journal 和 session activation 无须重建。

源码开发仍可直接使用仓库目录作为 Pi extension；npm 发布包仅包含运行文件、配置示例和说明，不包含本机配置、测试、备份或开发记录。

## 测试

依赖 Node 内置类型擦除和 `node:test`，不需要安装依赖：

```bash
node --test ./*.test.ts ./server/*.test.mjs
```

## 配置

用户配置放在包目录之外，不会被 npm 更新覆盖：

```text
~/.pi/agent/extension-data/anon-notify/config.json
```

可从 [config.example.json](config.example.json) 开始，填写自己的 `endpoint`、`recipient.qq` 和 `recipient.anonQq`。如果设置了 `PI_CODING_AGENT_DIR`，此配置位于对应 Agent 目录的 `extension-data/anon-notify/config.json`。旧源码目录的 `config.json` 仍作为兼容后备，稳定用户配置优先于它；该旧文件不发布到 npm。

可信项目可以覆盖配置：

```text
<project>/.pi/anon-notify.json
```

合并顺序为内置默认值、旧源码目录配置、稳定用户配置、可信项目配置、环境变量；后者覆盖前者。可用环境变量：

- `ANON_NOTIFY_ENABLED`
- `ANON_NOTIFY_ENDPOINT`
- `ANON_NOTIFY_TOKEN`
- `ANON_NOTIFY_SOURCE`
- `ANON_NOTIFY_RECIPIENT_QQ`
- `ANON_NOTIFY_ANON_QQ`

公开包不预设目标 QQ。请将下面的空值填为实际 QQ 号：

```json
"recipient": {
  "qq": "",
  "anonQq": ""
}
```

服务端仍以 `notify-webhook` 的 `recipientId` 为最终投递目标；Pi 不会把目标 QQ 放进每次 webhook 请求，也不会把普通网络错误猜测成“未添加好友”。启用前和 TUI 状态栏会显示本地目标与服务端返回的目标状态；`unknown/not_checked` 只会显示警告。

Token 默认从这里读取：

```text
~/.pi/agent/extension-data/anon-notify/token
```

### 主要配置项

- `enabled`、`endpoint`、`source`、`projectConfig`、`mainAgentOnly`。默认 endpoint 是本机 `http://127.0.0.1:5140/api/agent-notify/v1/events`，不预设任何个人服务地址。
- `recipient.qq`：通知目标 QQ；`recipient.anonQq`：Anon 的 QQ 号。它们用于明确显示和启用前校验，不允许请求方通过 Webhook 自行指定收件人。

其中 `enabled` 是扩展的**全局配置总开关**，不等于当前会话已经启用。没有持久 activation 记录的旧 session 和全新 `/new` session 默认 inactive；用户显式执行裸命令 `/anon-notify` 开启后，raw activation 会写入该 session 的 JSONL，之后才会发送生命周期通知、轮询 `/commands`，并开始发送在线 heartbeat。activation custom entry 不进入模型上下文，也不保存 Token、QQ、endpoint 或其他配置。
- `notify.*`：开始、进展、完成、失败、单工具错误通知开关
- `progress.*`：首次进展时间、周期、工具数阈值、错误通知冷却
- `delivery.*`：HTTP 超时、重试次数、退避时间和非最终通知的最小间隔
- `command.*`：指令轮询开关、journal 与空闲轮询间隔。`deliveryWhenBusy` 默认 `steer`；旧配置值 `followUp` 仍可读取，但会归一化为 `steer`，不会恢复等待整个任务空闲的行为。`maxBatchSize` 为配置兼容保留，新扩展每次只领取一条。
- `presence.*`：`heartbeatIntervalMs`（本地 heartbeat 发送间隔，默认 10 秒）与 `heartbeatTimeoutMs`（本地参考上限，默认 30 秒）。服务端以 `notify-webhook` 的 `sessionHeartbeatTimeoutMs` 为权威 TTL；Pi 只按 `heartbeatIntervalMs` 发送，过期判断在服务端。
- `content.*`：任务/摘要长度，以及项目、最终回复是否写入摘要。旧 `includeModel`、`includeDuration`、`includeToolCounts` 仅兼容读取并校验 boolean 类型，统一归一化为 false，不能再打开自动附加的模型名称、耗时或工具统计；不会修改用户配置文件。

配置会做严格类型检查。`maxTaskChars` 最大为 200，`maxSummaryChars` 最大为 1200，与 Webhook 字段限制一致。

> `notify.cancelled` 仅为旧配置兼容而保留。TUI 中按 Esc、中止活跃低层 Agent，或退出/切换会话时始终静默，不发送 Anon 通知。Pi 默认的单次 Ctrl+C 是清空编辑器，双击 Ctrl+C 是退出，不属于活跃 Agent 的中断键。

## source 模板变量

`source` 支持 `{project}`、`{cwd}`、`{sessionId}`、`{model}`、`{mode}`。默认值：

```json
"source": "Pi Agent · {project}"
```

## 生命周期判定

- 子 Agent 会话默认被识别并抑制，不产生通知。
- 普通 assistant `stop` 视为正常完成。
- 完整执行且每个结果都声明 `terminate: true` 的最终工具批次视为正常完成。
- `length`、`error`、不完整/出错/非终止的最终 `toolUse` 视为失败。
- 扩展在每个低层 `agent_start` 监听当前 `AbortSignal`，并在 TUI 中观察独立 Esc 输入，以覆盖自动重试、自动压缩和低层 run 之间的间隙。即使中断发生在工具执行期间、最后一条 assistant 仍显示为 `toolUse`，也会判定为用户中断并静默处理。
- `session_shutdown` 的 `quit`、`reload`、`new`、`resume`、`fork` 都属于用户或会话生命周期操作，不发送结束通知。
- 最终通知在 `agent_settled` 后发送，即 Pi 已没有自动重试、压缩重试或 follow-up。

扩展在未启用通知时仍记录主会话的运行事实，但不发送通知、不启动进展定时器。运行中执行 `/anon-notify` 后，heartbeat 根据 Pi 的 `ctx.isIdle()` 立即报告真实状态；正常结束仍发送一次完成事件。开始和结束都会请求立即 heartbeat，在途请求结束后会补发最新状态，不必等待下一个周期。

完成和失败通知会绕过本地 `delivery.minIntervalMs`，避免最终状态被进展通知的限流窗口吞掉；进展类通知仍受限流控制。服务端事件限额独立于 heartbeat/命令流量。HTTP 429 的 `Retry-After` 在有限重试内生效，单次附加等待最多 60 秒；无效或显式 `failed` 回执不算成功。正常 reload/shutdown 会等待实际在途通知结束，但没有跨进程崩溃的本地持久 outbox。

## YesImBot 主对话流

Webhook 接收事件后，`notify-webhook` 插件会把事件转换成带边界标记的 runtime event，并调用 Anon 的主会话 messenger；事件正文直接位于边界标记之间：

```text
[EXTERNAL_AUTOMATION_EVENT]
{event data}
[/EXTERNAL_AUTOMATION_EVENT]
```

数据块之后没有附加处理策略。payload 里出现的闭合标记会被转义成 JSON 可逆形式（`[\/EXTERNAL_AUTOMATION_EVENT]`），因此模型生成的 `summary` / `details` 不能提前结束数据块。

事件使用 `trigger: true` 写入 Anon 主会话历史，并在主会话空闲时启动模型回合；如果 Anon 正忙，则通过 `ifBusy: "defer"` 延后到安全边界处理。已经发出的模型请求不会被追加入这条事件。`queued` / `processing` 表示服务端接受或正在处理；`context_injected` 表示 messenger 已接收；只有显式 direct 路径的 `delivered` 记录 Koishi 发送结果。若 messenger 已成功但回执落盘失败，当前进程只重试保存回执，不立即重复注入，也不移除下一条事件。进程在外部副作用与落盘之间崩溃时仍存在重复投递的不确定性。

这样可以避免两条并行的通知链：不会再出现“插件先生成固定 QQ 摘要，再由 Anon 处理一次”的重复通知，也不会把普通 QQ 消息自动转回原 Pi Agent 会话。

事件正文包含 `event_id`、`session_id`、`source`、`task`、`status`、`summary`、`context_usage`、`details`、`url` 和时间戳。Pi 发送方仍使用 Bearer Token、幂等键和 HTTP 重试；服务端负责队列、幂等去重和主上下文注入状态记录。

## 上下文占用

每条生命周期通知和当前会话的外部扩展通知都在构造 payload 时通过 Pi 的公开 `ctx.getContextUsage()` 同步读取一次，作为独立 `context_usage` 字段发送；它不会因为摘要截断而丢失，HTTP 重试也不会重新采样。

示例字段：

```json
"context_usage": {
  "used_tokens": 48000,
  "context_window": 200000,
  "percent": 24
}
```

这是通知构造当下 Pi 的**当前上下文估计**，不是累计 token 消耗、收费量或 provider 的实时精确余量；尚未持久化的流式消息或刚完成的工具结果不保证已计入。百分比是数值，有限的超过 100% 的报告也会保留，不截断到 100%。

压缩后用量暂时未知、但窗口已知时，返回 `{ "used_tokens": null, "context_window": 200000, "percent": null }`。没有可用模型、接口不可用、读取异常或数值非法时，整个字段为 `null`；未知不会冒充 0%，也不阻止原通知发送。

Koishi 会校验并保存该快照，事件查询和 Anon 的 `EXTERNAL_AUTOMATION_EVENT` 数据块都包含它。旧请求/旧 state 没有该字段时视为未知，无须迁移；非空对象必须含全部三个字段且无额外属性，tokens/窗口为非负/正安全整数，percent 为非负有限数，tokens 与 percent 必须同时已知或同时为 null。非法字段返回 HTTP 400 `invalid_context_usage`，不入队。

需要两端都更新才能让 Anon 收到上下文占用：旧 Koishi 插件会忽略新字段；新插件接收旧 Pi 请求时显示未知。本仓库的实现和测试不会自动发布 npm、部署远端插件或更新当前 Pi session。

## Anon 显式指挥 Pi Agent

Anon 如果根据主对话上下文明确决定要继续处理某条工作，可以显式调用 `agent_command`，并同时提供：

- `event_id`：通知事件的精确 ID；
- `session_id`：产生该通知的 Pi Agent session ID；
- `instruction`：长度受限的自然语言指令。
- `images`（可选）：最多 4 张受限图片，每项使用不带 `data:` 前缀的 base64 `data` 与 `mime_type`（支持 PNG/JPEG/GIF/WebP）。文字字段仍然必填。

服务端会验证通知与 session 的绑定，并把指令写入独立 command queue。工具返回“已入队”只表示 Pi 指令已经进入可靠队列，不表示 Agent 已完成，也不表示用户已经收到 QQ 消息。

待发内容保留在服务端 command queue。Pi 空闲时可以领取一条并立即启动处理；忙时只在当前轮次的 `turn_end` 边界领取一条，不提前批量交入 Pi 原生队列，也不等整个任务结束。空闲轮询的响应若在 Pi 已变忙后才到达，会推迟到安全边界交付。

扩展用 Pi 的公开 `pi.sendMessage(..., { deliverAs: "steer", triggerTurn: true })` 发送类型为 `anon-notify:command` 的可见 custom message。没有图片时 content 仍是纯 `instruction` 字符串；有图片时 content 是一个文字块后跟原生 `ImageContent` 图片块。`details` 保存 command/event/session ID，扩展包装、ID、JSON、base64 和 marker 不进入模型内容，但指令与图片会正常作为用户级内容显示/发送。该路径不经过异步用户输入预处理，不修改 system prompt。API 返回只表示交付，不表示执行完成。

交付后，指令 ID 先持久写入独立 `commands.jsonl` journal，再 ACK 服务端，同时保留 session branch marker。租约重试、reload 或重启可通过 journal 去重。该通道不读取普通 QQ 消息，也不管理用户手工排队的内容。

### 在线会话发现与主动投递

`agent_command` 仍要求关联一个已处理的通知事件。Anon 若要根据当前对话直接管理仍然在线的 session，可改用另外两个工具：

- `agent_sessions`：返回所有 heartbeat 有效的在线 session 安全投影，每项只包含 `session_id`、`project`（目录 basename）、`model`、`idle/running`、`supports_images`、`last_activity_at` 和 `last_seen_at`。不返回 Token、完整路径、会话正文、journal 内容或 QQ 信息。
- `agent_session_message`：向指定在线 session 投递显式内容，参数为 `session_id`、`content` 和可选 `images`。只有服务端 registry 里 heartbeat 未过期的 session 才能接收；离线、过期、未开启或未知 session 直接被拒绝，不会排队等待下次启动。图片要求当前 Pi client 支持 image protocol。

投递复用同一套通道：服务端检查在线状态后写入 command queue，队列记录带 `origin: "proactive"` 与生成的关联 `event_id`；目标 session 按上述 steer 边界交付，本地 journal 持久化后才 ACK。相同工具调用重试按 `runtime_id:turn_id:tool_call_id` 幂等，不会重复创建指令；图片内容也参与 request-key 冲突判断。

图片 command 使用 protocol v2。新 Pi client 的 heartbeat 会声明 `command_protocol: 2`，轮询时声明 `capabilities=images`；旧 client 不会领取图片 command，避免把图片静默降级为纯文本。`agent_sessions` 返回 `supports_images` 供 Anon 选择目标。

图片限制为每条 command 最多 4 张、单张解码后最多 4 MiB、单条总计最多 8 MiB，服务端 pending/leased 图片总预算默认 32 MiB。MVP 只接受 raw base64，不接受 URL 远程抓图；终态 command 会清理图片原文，只保留数量、字节数和摘要元数据。`agent_commands` 不返回图片内容。

在线状态来自每个已启用 runtime 自己发送的 heartbeat，而不是扫描磁盘 session 文件：session 文件无法唤醒另一个 live runtime。注册表只存在于服务端内存中，并使用 `sessionHeartbeatTimeoutMs` TTL 清理；server 重启后由在线 runtime 自然重新登记。每个 runtime 携带随机 `runtime_id`，旧 runtime 的 offline 请求不能删除同一 session 的新记录。

### 查询与撤回待发 steer

同一个已配置私聊 scope 内，Anon 可使用：

- `agent_commands({ session_id, limit? })`：查询该 session 的指令 ID、来源、状态、`cancellable`、时间和最多 200 字符的内容预览；最多返回 50 条，不暴露 lease token 或其他 session 的内容。
- `agent_command_cancel({ session_id, command_id })`：撤回尚未开始交付的指令。重复撤回幂等；已撤回记录和请求键去重会持久保存，重启后不会复活。

创建工具也会返回 command ID、状态和 `cancellable`。查询和撤回已持久保存的待发内容不要求 session 当前在线；创建主动消息仍要求有效 heartbeat。

**撤回截止点是服务端第一次原子领取、开始交付。** `pending` 且从未开始交付时可撤回；租约已开始返回 `delivery_started`，Pi 已 ACK 返回 `already_accepted`。租约过期或残缺不能证明内容未进入 Pi，因此不会重新变成可撤回。`cancelled` 是终态，不再领取或计入活跃队列。工具不能删除 Pi 原生 steer/follow-up 队列中的内容，也不会清空其他消息或过滤历史上下文来冒充撤回。

## 会话级启用与持久化

扩展仍会被 Pi 自动发现和加载，但**不会默认作用于所有会话**。activation 是具体 Pi session 的属性，不写入全局配置：

```text
/anon-notify
```

- `/anon-notify` 是唯一的命令形式，不再接受任何子命令。再次执行即关闭。带参数的输入不会改变状态，只会提示使用裸命令。
- 开启和关闭都会先向当前 session JSONL 追加 `customType: "anon-notify:activation"`、`version: 1` 的布尔记录，再更新内存状态、TUI、EventBus、polling 和 heartbeat。写入失败时不会声称状态已经改变。
- 同一 session 在 Pi `/reload`、退出后 resume、切换后返回和 runtime wrapper 重建时，会从全部 session entries 中恢复最新记录；同一文件内的 `/tree` branch navigation 不会改变 activation。
- 没有记录的旧 session 和全新 `/new` session 默认 inactive，不继承上一个 session。
- `/fork` 与 `/clone` 创建的新 session 会读取源 session 当前的 session-wide activation，并在子 session 写入一条继承快照；此后父子 session 独立启用或停用。
- 最新匹配记录 malformed 或版本未知时 fail closed 为 inactive，不会回退到更旧的 active 值。
- 启用前仍会校验目标 QQ 和 Anon QQ；如果服务端明确返回 `not_added` 或 `unreachable`，Pi 会显示包含两个 QQ 号的引导，要求先添加 Anon 或确认私聊可达后再重试。
- 服务端没有可靠好友状态检查时会返回 `unknown/not_checked`；这只会显示警告，不会被误判为未添加。
- 再次执行 `/anon-notify` 会持久化 inactive，停止生命周期通知与 `/commands` 轮询，并尽力发送一次 offline heartbeat。`session_shutdown` 同样清理 timer、停止 heartbeat 并 best-effort 注销；它不会写入 inactive。
- 配置重载使用 Pi 自己的 `/reload`，不再提供带参数的通知命令；会话和服务端状态分别看 TUI 状态栏 / EventBus 与 `agent_sessions`。
- subagent 即使恢复了 active 记录，也始终受 `mainAgentOnly` 规则抑制，不会发送 heartbeat，也不能通过 activation 绕过。

### Pi Web 结构化状态协议

Wrapper 存活时，扩展通过当前 AgentSession 的 EventBus 发布 `anon-notify:state` version 1 payload，Pi Web 继续以带 `runtimeId`/`revision` 的 runtime snapshot 为权威。payload 只包含稳定枚举和布尔事实：`sessionId`、`status`、`sessionActive`、`servicesEnabled`、`tokenConfigured`；它不包含 Token、目标 QQ、Webhook 内容或人类展示文本。

Wrapper dormant 时，Pi Web server 从同一 session JSONL 读取 activation custom entry，并返回 `source: "dormant"` 的只读 projection。Dormant active 会显示为已开启且允许 disable，但不会猜测 Token、全局配置或服务 readiness；新 wrapper 启动后，runtime snapshot 会接管显示。浏览器不直接读取 JSONL，也不使用 `localStorage` 保存 activation。

Pi Web 工具栏开关继续通过现有 SSE + `set_anon_notify` RPC 调用 `/anon-notify` command handler（裸命令 toggle：当前 active 则关闭，否则开启）；它不会调用 prompt、创建聊天气泡或模型回合，也不复制 recipient readiness、polling、webhook 或 command queue 逻辑。修改扩展后需要用户自行执行 Pi `/reload`；修改 Pi Web 源码后需要单独构建并重启对应 server 才会部署，本仓库测试不会自动执行这些运行时操作。

### 模型上下文边界

扩展不修改 system prompt、不注入扩展状态消息，也不注册模型可见的状态工具。active、inactive 和 subagent 抑制状态仅由扩展内部逻辑、结构化 EventBus 和 TUI 状态栏展示。

实际由 Anon 发回的 Agent command 作为明确标记的 custom-message `steer` 进入 Pi session，并由 Pi 转为用户级模型消息，因为它本身就是需要 Agent 处理的指令；这不属于扩展运行状态提示词。

## 命令

```text
/anon-notify            # 当前会话未开启则开启，已开启则关闭
```

只有这一个裸命令。旧的带参数子命令已全部移除；带任何参数的输入都不会改变状态。

- 开启前会校验目标 QQ 和 Anon QQ。如果目标配置缺失/无效，开启会被拒绝；如果服务端只返回网络错误，Pi 会允许开启但明确提示“状态未核验”，不会伪造好友状态。
- 开启或关闭会写入 activation custom entry，并同步 TUI 状态栏、EventBus、`/commands` 轮询和在线 heartbeat。
- 需要重读配置时使用 Pi 自带的 `/reload`；需要查看在线会话时由 Anon 调用服务端 `agent_sessions` 工具。

## 扩展间调用

其他 Pi 扩展可以通过事件总线发送通知：

```ts
pi.events.emit("anon-notify:send", {
  status: "info",
  source: "另一个扩展",
  task: "索引构建",
  summary: "已处理 500 个文件",
  eventId: "index-build-001"
});
```

状态可用：`completed`、`failed`、`warning`、`info`。

## 更新与回退

npm 包同时附带 [server/notify-webhook.js](server/notify-webhook.js)、[server/command-queue.js](server/command-queue.js) 和 [server/session-registry.js](server/session-registry.js)。服务端需要已有的 Koishi 和 YesImBot 运行环境，安装 Pi 扩展不会自动部署或重启远端插件。先更新 Koishi 插件，再在 Pi 中 `/reload` 扩展。服务端 health 提供指令通道能力用于核对版本；本仓库的代码修改和测试不会自动部署、重启服务或更新 Pi 本体。

部署前备份服务端状态文件。旧服务器可能不认识 `cancelled` 和首次交付字段，回退前必须确认旧代码不会把撤回记录重新解释成可发送状态，不能通过删除 tombstone 回退。已经交入旧版 Pi 原生队列的内容也不能通过升级后撤回。

## 默认行为

- 扩展自动加载；没有 activation 记录的 session 默认 inactive，已记录的 session 会恢复其最新 raw activation。
- 只通知主 Agent，不通知 subagents。
- 只有 active session 才会发送生命周期通知、轮询 Agent commands，并发送在线 heartbeat。
- heartbeat 只用于在线发现；离线 session 不会被排队为后续投递目标。
- 开始、进展和单工具错误即时通知关闭。
- 正常完成和失败通知开启（仅对 active session 生效）。
- 用户中断与会话切换始终不通知。
- 通知通过 Webhook 注入 Anon 主对话流，不直接发送 QQ。
- Webhook 超时 12 秒，最多尝试 3 次。
- 非最终通知默认至少间隔 5 分钟；完成和失败不受该间隔限制。


## 许可

当前 npm 包标记为 `UNLICENSED`，未授予开源许可。公开下载不表示可按 MIT 等开源许可证使用。
