docs: 日历子系统 + 寻址发现工具组 + 权限死锁的排查记录

PHASE7-REMAINING 新增日历一节:三层分离的理由、修掉的六个真问题
(含每一个的现场取证与判据)、前端三个易错点、验证清单、未做的缺口。

写成「可核对的规格 + 踩坑理由」而不是叙事:这些 bug 的共同点是
**不报错**(模板烤死时间、{time} 渲染成 UTC、每 tick 重发、附件从未落盘、
TRIGGER 往返断开、PG schema 缺表),下一个人只有知道判据才能避开。

README 把插件工具表从「六个」更新到十一个(现在 homeagent 是十五个),
并说明寻址发现那一组解决的是**猜地址** —— 生产上真的发生过一个 Agent
猜了 opencode@/home,投递成功但那不是它的工作目录,那封邮件静默变成了
一条平行会话的开端。

PLUGIN-CONTRACT 补 B-8(权限询问转邮件)的判据表与三平台差异。
This commit is contained in:
2026-09-04 06:30:56 +08:00
parent d74f356f41
commit bca50b80c6
3 changed files with 397 additions and 7 deletions

View File

@ -108,9 +108,25 @@ pi 不是插件而是独立服务,因为 pi 扩展被加载进**一条已存
桥用 pi 的 SDK`createAgentSession`)按邮件起会话,一个进程里并存多条
不同工作目录的会话。`deploy/install.sh` 会装好它的 systemd 单元。
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
收到邮件时自动在平台侧开会话处理,回信落回同一邮件会话。
插件给模型注册十一个工具,并通过 SSE 监听新邮件:收到邮件时自动在平台侧开会话
处理,回信落回同一邮件会话。
| 类别 | 工具 |
|---|---|
| 收发 | `send_mail``read_inbox``read_mail``forward_mail` |
| 附件 | `upload_attachment``download_attachment` |
| 寻址发现 | `suggest_address``list_contacts``session_participants``read_thread` |
| 连接自愈 | `connect_to_server` |
寻址发现那一组解决的是**猜地址**`to` 是自由文本,拼错不会报错。生产上真的
发生过一个 Agent 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
那条邮件静默变成了一条平行会话的开端。有了 `suggest_address`,模型是从候选列表里
选而不是猜。
`send_mail` 还有一对可选参数 `propose_alias` / `propose_reason`:模型摸清问题后
可以提议把会话别名从邮件主题(`排查登录问题`)改成更精确的名字
`fix-session-cookie-leak`)。这只是**提议** —— 别名是人的寻址入口,
Agent 干到一半自己改掉会让人上一秒记住的地址下一秒失效,所以要等人在界面上点确认。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
@ -252,8 +268,10 @@ Agent 用 `.new` 开一串新会话绕过预算,靠**新建会话速率限制*
opencode 创建会话时就有 slug首轮对话后模型会生成摘要标题平台叫什么本侧就叫什么
不另造一套。
Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头:
别名是人的寻址入口Agent 中途改掉会让人刚记住的地址立刻失效。
Agent 干完活可以在正文里**提议**改成更贴切的名字`send_mail``propose_alias`
参数,实际以 HTML 注释形式搭在正文里发出,服务端解析后剥掉),但改不改由人点头:
别名是人的寻址入口Agent 中途改掉会让人刚记住的地址立刻失效。提议在会话页面上
显示为一条提示条,人可以接受或驳回;驳回过的名字不再反复弹。
## 对话树

View File

@ -220,3 +220,138 @@ playwright 端到端 18 项全通过(含「底部导航六项都命中自己
DSH 还有个陷阱:`assistant/chunk``finish` 子类型也带错误,
把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。
---
## 日历与待办 ✅ 后端 + 前端
参照 Outlook 的逻辑与 UI事件/提醒/重复规则、iCal 导入导出、
提醒经邮件通知指定 Agent正文可编辑、支持变量预填充、提醒可带附件。
### 三层分离
事件是日历实体,提醒是触发器,邮件是投递通道。三者刻意不合并:
- **事件**`calendar_events`)有时间、重复规则、收件方
- **提醒**由调度器在 `event_time remind_before` 触发
- **邮件**由 `SendCalendarMail` 投递,`from_name = "calendar"`
`from_name` 刻意既不是人类用户名也不是 Agent 名。用创建者的名字会让 Agent
以为人在实时找它,而人此刻可能在睡觉 —— 模型据此判断「要不要马上追问」,
来源写错会让它问一个不在线的人。
**日历提醒不扣会话预算**:预算的语义是「这件事值得模型自主发多少封信」,
提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现
「每天 9 点的日报提醒把当天预算吃掉一格」这种反直觉结果。
### 已完成
**后端**
- schema 两份(`calendar_events` + `calendar_attachments`SQLite 与 PG
- `repo/calendar.go`CRUD、`DueEvents``MarkEventFired``AdvanceRecurrence`
附件增删列、`AttachCalendarFilesToMail`
- `handler/calendar.go`9 端点 + iCal 导入导出
- `scheduler/calendar.go`30 秒 ticker、`RenderReminder``fireEvent`
**前端**
- `lib/calendar.ts`:日期边界、月/周格子、分桶、模板渲染、`datetime-local` 往返
- `CalendarView.tsx`:月/周/日三粒度 + 工具条 + iCal 导入导出按钮
- `CalendarEventEditor.tsx`:整页编辑器 + 变量按钮 + 实时预览 + 附件区
- 导航项Sidebar + NarrowNav+ `App.tsx` 路由
### 修掉的六个真问题
**1. 默认提醒模板把时间烤成字面值**
原来 `Sprintf` 出一份含字面时间的正文存进 `reminder_text`。对重复事件是错的:
`AdvanceRecurrence` 只推进 `event_time``reminder_text` 保持不动 ——
「每天 9 点」的提醒从第二天起永远写着第一天的日期,且不报任何错。
改为存变量形式(`{title}`/`{time}`/`{description}`),触发时才替换。
后端 `defaultReminderTemplate` 与前端 `DEFAULT_TEMPLATE` 逐字一致,有测试钉住。
**2. `{time}` 渲染成 UTC**
DSN 带 `_timezone=UTC`,从库里读回的 `EventTime` 是 UTC。直接 `Format`
会把人在 +0800 输入的 14:30 写成 06:30而前端预览用的是本地时间 ——
两边差 8 小时且两边都不报错。修法:`e.EventTime.Local().Format(...)`
**3. 同一提醒每个 tick 重发一次(生产实测 4 封)**
`DueEvents` 有 60 秒 lookahead调度周期 30 秒,不提前看会让提醒迟到)。
去重判据原本是 `last_fired_at < event_time` —— 触发时刻now本来就早于
落在窗口内的 `event_time`,条件恒真。实测一条 12:53:17 的事件在
12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封。
修法:新增 `fired_for` 列记录**已触发的 occurrence**(值 = 当时的 `event_time`
判据改为 `fired_for <> event_time``AdvanceRecurrence` 改了 `event_time`
就重新到期,没改就永不重发。
**4. 附件从未落盘**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 `Read`
不保证填满缓冲大文件必然短读sha256 因此算的是半截内容),而且
**文件内容压根没写进 blob 存储**,只往库里写了一条元数据。
结果是附件「上传成功」、清单里看得见、发提醒时取不到任何字节。
改为走 `Blobs.Put`(与邮件附件同一套双层大小限制)。
**5. 事件附件不会随提醒发出**
事件附件与邮件附件是两张表。缺了复制这一步,附件只存在于日历侧 ——
UI 里看得见、提醒按时发出、而 Agent 收到的那封信附件清单是空的。
新增 `AttachCalendarFilesToMail`:内容寻址下只增元数据不拷磁盘文件。
失败不阻断投递 —— 提醒正文比附件重要,少一个附件比整条提醒发不出去好。
**6. iCal TRIGGER 往返是断的**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。
更糟的是 `-P15M` 在任何合规客户端里都是「提前 **15 个月**」——
iCal duration 的 `M``T` 之前是月、之后才是分钟。
而且原来用 `maxInt(RemindBefore, 15)` 兜底,把用户明确设的「到点提醒」(0)
悄悄改成提前 15 分钟;导出不该修改语义。
修法:导出写 `-PT<n>M`,导入换成正经的 duration 解析
`parseTriggerMinutes` 支持 `-PT30M`/`-PT1H30M`/`-P1D`/`-P1W`
T 前的 `M`(月)与正号 trigger 一律忽略而不是乱换算)。
### 其他修正
- **PG schema 整块缺失日历两张表** —— `DATABASE_URL` 一旦非空,所有
`/calendar/*``relation does not exist` 上 500而 SQLite 下一切正常,
问题只在切外部库时才暴露
- **`DeleteCalendarAttachment` 曾返回 501**,让人「删整个事件来清附件」——
撤一个错传的文件不该要求把整条日程连提醒配置一起重建
- **导出忽略 `from`/`to`** —— 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件
- **导入只接受 multipart** —— 命令行调用者收到含糊的「Missing file field」。
改为同时接受 raw `text/calendar``curl --data-binary @x.ics`
- **上传附件不校验事件存在** —— 会攒下孤儿附件记录,而 `ON DELETE CASCADE`
永远清不掉它们(没有父行可删)
### 前端的三个易错点(都有测试钉住)
**周首必须是周一**`getDay()` 把周日算作 0直接减它会让周日归到上一周
末尾,月视图第一行整体错位。
**分桶用本地日期串而不是 `toISOString().slice(0,10)`**。后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。
**月视图固定 42 格**。按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。
另有一个 TS 陷阱:`replaceAll` 在 tsconfig 的 `target: ES2020` 下不存在
TS2550。改用 `split/join`**不能**退回 `replace`,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。
### 验证
- `internal/repo/calendar_test.go`CRUD、到期判定、幂等、重复推进、区间查询、
附件增删列、**lookahead 窗口内不重发**、**推进后重新到期**、
**事件附件复制成邮件附件**(含空 sha256 脏数据跳过)
- `internal/handler/ics_test.go` 14 例:解析基本形态/多事件/无 DTSTART 丢弃/
重复规则/三种日期格式/带 TZID 参数/转义换行/LF 换行/垃圾输入/
**TRIGGER duration 全形态**/**导出导入往返**/**默认模板必须是变量形式**
- `internal/scheduler/calendar_test.go`:模板渲染 7 例(含**本地时区**与
**同一时刻不同 Location 渲染一致**
- `web/test/components/calendar.test.tsx` 35 例
- 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 →
重复事件推进到次日 → **连续两个 tick 零重发**(修复前每 tick 一封)→
iCal 导入 `-PT45M`/`FREQ=WEEKLY` 正确落库 → 导出再导入闭环一致 →
清理验证数据
### 未做
- 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载
- `parseICS` 不处理折叠续行RFC 5545 的 75 字节折行):长 DESCRIPTION
会被截断。常见客户端导出的短字段不受影响
- `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL`
- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长)

View File

@ -16,6 +16,7 @@
|---|---|---|---|
| `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript |
| `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript |
| `plugins/pi-mail-bridge/` | pi | pi SDK (`createAgentSession`) | JavaScript (ESM) |
第八、九节是这两次适配的实现细节与踩坑记录 —— 规范部分(一至七节)与平台无关。
@ -112,7 +113,7 @@ name@path.session
### 能力自检脚本
接入前先回答这个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
接入前先回答这个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
```
1. 我怎么在不通过 UI 的情况下让某个会话跑一轮? → C-1
@ -330,14 +331,79 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
| B-8.1 | `relay_key` 用平台的权限 id平台不给 id 时用 `会话:工具:callId` 拼一个 | MUST |
| B-8.2 | 转发失败 → 让位给平台本地 UI不要占着钩子 | MUST |
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 `relay_key` 幂等) | MUST |
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要),让人能判断 | SHOULD |
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要、**触发这次询问的任务与派活人**),让人能判断 | SHOULD |
| B-8.5 | 权限询问**不消耗配额** | MUST |
| B-8.6 | **不传 `to`** —— 决策人由服务端解析 | MUST |
| B-8.7 | 平台支持「永久允许」时,选项里必须给出「一直同意」并**真的记住它** | MUST |
| B-8.8 | 免批的作用域是 **(会话, 工具名)**;决策文本判定用 `lib/permission-grants.js` | MUST |
> **B-8.1 为什么必须是平台的 id**:服务端会随决策事件把 `relay_key` 回传,
> 插件重启丢了内存映射也能对上(`B-4.2`)。自己生成的随机 id 重启后就对不上了。
>
> **B-8.5 为什么不收费**:人不点头 Agent 就动不了,对它收费等于收「求人费」。
> 这里的 `relay_key` 只用于幂等,不是配额豁免的凭证。
>
> **B-8.6 为什么不能自己定决策人**:插件手边最自然的候选是「来信人」
> `mailContexts` 里的 `replyTo`),但来信人可能是**另一个 Agent** ——
> Agent 把任务分派给自己或同伴的另一条会话时,权限邮件就发给了 Agent 自己。
> 后果是**死锁而不是报错**Agent 不可能在 Web 界面上点「同意」,服务端的
> 用户推送又投进一个不存在的通道(没有任何人被提醒),于是插件里那个
> `await` 永不 resolve —— 会话永久挂死,没有超时、没有日志、没有回信。
>
> 决策人必须由服务端定:它按 **会话 owner → 线索里最近的人类 → 无人可问则
> 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
> 猜不出「这条 Agent 链最初是谁派的活」。
>
> 收到 409整条链上没有人类时按 `B-8.2` 处理 —— 服务端已经判定没人可问,
> 继续等下去就是死锁。
>
> **B-8.4 为什么要带派活人**决策人未必是这条会话的参与者。Agent 转派出来的
> 会话,人从没见过它,只给一句「是否允许执行 bash」无从判断 —— 得知道这活是
> 谁派的、为的什么事。
#### B-8.7 / B-8.8 免批(「一直同意」)
默认语义是**每次都问**。这在「跑一条命令看看」时是对的,在「审查这个工程」
时是灾难 —— 实测同一条 pi 会话被问了 **15 次 bash**,人点了 15 次「同意」,
全是同一类操作。
三个平台的原生能力不同,决定了免批状态该由谁持有:
| 平台 | 原生三态 | 免批由谁记 | 选项 |
|---|---|---|---|
| opencode | `once` / `always` / `reject` | **平台**(回 `response:"always"` | 同意 / 一直同意 / 拒绝 |
| pi | 只有 block / 放行 | **桥**`createGrantStore()` | 同意 / 一直同意 / 拒绝 |
| DSH | `allowed-once` / `rejected`(无 always | 不提供 | 同意 / 拒绝 |
> **平台能记就让平台记**opencode 的 `always` 是它自己的权限模型的一部分,
> 桥再存一份就有两个真相来源(`I-1`。pi 的钩子只能答"拦/不拦",没有地方
> 表达"以后别问了",这时桥持有状态是唯一选择。
>
> **DSH 为什么干脆不给这个选项**:它的 `ApprovalOutcome` 只有
> `allowed-once | rejected | cancelled | unavailable`。桥自己记的话DSH 侧
> 仍会每次调 `approval/request`,而桥不问就答 `allowed-once` —— 那是用插件
> 内存**覆盖**平台的审批策略,且这份策略没人能审计。给不出的能力就不要在
> 界面上摆一个按钮(摆了又不生效比没有更糟,见下)。
>
> **作用域为什么是 (会话, 工具名)**:人看到的那句「是否允许执行 bash
> 就是在这个粒度上提的问,授权范围不该超出提问范围。
> - 放宽到全局 → 人为「审查 llmsproxy」批准的 bash会静默授权另一个
> 发件人派来的另一条任务。那不是他批准的东西。
> - 收紧到 `toolCallId` → 等于没有免批。
>
> **换模型重开会话必须撤销**`revokeSession`):授权是人对**那次**上下文的
> 判断,新会话重跑一遍提示,不该继承上一条的授权。
>
> **只在内存里是有意的**:长期免批该由平台自己的 settings 管
> pi 的 `settings.json`、opencode 的 permission 配置)。让守护进程的内存
> 变成事实上的安全策略,没人能审计,重启后又悄悄消失。
>
> **判定为什么必须用共用模块**`isAlwaysDecision` 用的是**精确匹配**。
> 写成 `/^同意/` 之类的前缀正则会让「同意」也判成 always ——
> 人点一次单次授权,后面所有命令都不再问,这是把单次授权静默升级成永久授权。
> 反向的坑同样真实opencode 侧的三态映射一度以 `: "once"` 兜底,
> 于是任何意外文本(空串、旧选项、服务端将来新增的选项)都会**放行**一次
> 没人批准的操作 —— 兜底必须落在 `reject` 一侧(`N-9`)。
### B-9 关停MUST
@ -365,6 +431,17 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
| **T-5** | `forward_mail` | MAY | 转发(走完整三维寻址,它是一条新线索) |
| **T-6** | `connect_to_server` | MAY | 登记密钥并注册,方便首次接入 |
| **T-7** | ~~`request_permission`~~ | **MUST NOT** | 见 `I-1`:改挂平台权限钩子 |
| **T-8** | `suggest_address` | SHOULD | 查可用收件人/工作目录/会话;**发信前应先调** |
| **T-9** | `list_contacts` | SHOULD | 列出自己参与过的全部会话及可投递地址 |
| **T-10** | `session_participants` | MAY | 列出某条会话的参与方(用于回给抄送方或转达) |
| **T-11** | `read_thread` | MAY | 查看某封邮件所在线索的完整往来 |
| **T-12** | `read_mail` | SHOULD | 读一封邮件的完整内容(含参与方地址与 reply_address |
| **T-13** | `propose_alias`T-2 参数) | MAY | 模型在 `send_mail``propose_alias` 字段提议改名 |
> **为什么 T-8 到 T-12 都是 SHOULD/MAY 而不是 MUST**:它们解决的是静默投递错误
> `suggest_address`)和信息不足(`list_contacts` / `read_mail` / `read_thread`
> 但都不影响主链路(收信 → 起会话 → 自动回信)。一个最小可行插件只注册 T-1 和 T-2
> 就能跑通主链路,上面这些是让它「不猜地址、不漏抄送方」的增强。
### T-1 `read_inbox` 的三条硬规则
@ -386,6 +463,52 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
模型可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。
平台的权限钩子拦下的那一次是事实。见 `I-1``B-8`
### T-8 ~ T-12 寻址发现工具(`lib/discovery.js`
这五个工具的渲染逻辑全部在 `lib/discovery.js`(三平台共用),与平台 SDK 无关。
它们解决的是**静默投递错误**:模型猜一个地址(如 `opencode@/home`),投递成功
但那不是 opencode 的工作目录,静默变成了一条平行会话 —— 发件人以为在续谈,其实
在跟一条空会话说话。`suggest_address` 用模型从候选列表里选而不是猜,彻底杜绝了
这种错误。
| # | 工具 | 渲染函数 | 服务端端点 |
|---|---|---|---|
| T-8 | `suggest_address` | `renderNameSuggestions` / `renderPathSuggestions` / `renderSessionSuggestions` | `GET /agent/contacts/suggest` |
| T-9 | `list_contacts` | `renderContacts` | `GET /agent/contacts` |
| T-10 | `session_participants` | `renderParticipants` | `GET /agent/sessions/{id}/participants` |
| T-11 | `read_thread` | `renderThread` | `GET /agent/mail/{id}/thread` |
| T-12 | `read_mail` | 内联渲染(含参与方地址与 reply_address | `GET /agent/mail/{id}` |
> **`read_mail` 的 `reply_address` 必须回显**:它告诉模型「发件人的可投递地址是什么」。
> 不回显的话模型只能用原始地址续谈,但那个地址的 session 位可能是默认的,回过去
> 不一定落到同一条线索里。
### T-13 `propose_alias`T-2 `send_mail` 的可选参数)
`propose_alias` 让模型在干完活后提议一个更贴切的会话别名(例如从邮件主题
`排查登录问题` 改成更精确的 `fix-session-cookie-leak`)。
| # | 规则 | 违反后果 |
|---|---|---|
| T-13.1 | 标记格式是 HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->` | 服务端正则匹配不上,提议静默消失 |
| T-13.2 | 标记必须由**共用库** `lib/rename-proposal.js` 构造 | 各平台各写一遍拼接,少个空格就失效 |
| T-13.3 | 别名不合法时不追加标记(`isProposableAlias` 返回 false | 发一个服务端匹配得上却校验失败的标记,白白浪费一轮 |
| T-13.4 | 回显**服务端返回的** `rename_proposed`,不是本地提议的值 | 服务端跑过 `normalizeAlias`(非法字符换 `-``new``session-new`),回显本地值会让模型拿一个不存在的名字寻址 |
| T-13.5 | 回显时必须说明「等人确认,生效前继续用原别名」 | 模型以为改名已生效,接着用新别名当地址发信 —— 那个别名此刻还不存在 |
### T-3 / T-4 附件参数命名差异
两个共享参数在三个平台的工具里名称不一致(历史原因,已锁定):
| 参数语义 | opencode | dsh / pi |
|---|---|---|
| 要上传的本地文件路径 | `path` | `file_path` |
| 下载后的保存路径 | `save_to` | `save_path` |
| 自定义展示文件名 | `filename` | `filename`(新增) |
这些是**工具参数**,不是 API 字段,因此与服务端协议无关。但新插件实现时应参照
自己所在平台已有插件的命名,避免同一平台内两个工具参数风格不一致。
---
## 四、降级语义 / Degradation
@ -810,6 +933,7 @@ GET /api/v1/attachments/{id}
| **N-10** | 修改模型输出的文本内容 | `I-4`:插件只搬运 |
| **N-11** | 首次 SSE 连接带 `Last-Event-ID` | 会重投历史邮件 |
| **N-12** | 让共用模块依赖平台 SDK | 那是它们能跨平台共用的前提 |
| **N-13** | 给 `/permission/request``to`(尤其是来信人) | 来信人可能是 Agent权限邮件发给它必然死锁`B-8.6` |
### 共用模块必须逐字节相同
@ -824,6 +948,10 @@ GET /api/v1/attachments/{id}
| `workspace.js` | 寻址 path 位 → 可用的 cwd |
| `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 |
| `catchup.js` | 离线期间积压邮件的补投选择 |
| `addressing.js` | 三维地址的拆分与校验 |
| `discovery.js` | 寻址发现工具的渲染(`T-8`~`T-12` |
| `rename-proposal.js` | 会话改名标记的构造与回执文案(`T-13` |
| `permission-grants.js` | 权限决策文本判定 + 免批授权表(`B-8.7` / `B-8.8` |
> **为什么必须逐字节相同而不是「行为一致」**:一侧改了另一侧没改,两个平台的行为
> 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都
@ -914,6 +1042,15 @@ GET /api/v1/attachments/{id}
[ ] 模型主动调 send_mail 回信的那一轮
→ 只有一封邮件没有额外的自动转发B-5.3
[ ] 模型带 propose_alias 发信T-13
→ 入库正文里**没有** agentmail:rename-session 标记(已被剥掉)
→ mails.rename_alias / rename_reason 记下了提议
→ GET /sessions/{id}/rename-proposal 返回该提议
→ 人接受PUT /sessions/{id}/alias后别名真的改了alias_source 变 manual
→ 接受后 rename-proposal 返回 null提示条不再反复弹
→ 新别名可寻址:<name>@<目录>.<新别名> 落进同一条会话
→ 桥的后续命名同步**不覆盖** manual 别名
```
### 7.4 工作目录
@ -956,6 +1093,25 @@ GET /api/v1/attachments/{id}
→ 平台侧那次工具调用继续执行D-2 / B-4.1
[ ] 重复触发同一次询问(事件重放)
→ 只产生一封邮件W-6 幂等)
[ ] **Agent 自己给自己派活的会话里触发权限询问**B-8.6 / N-13
造:让 Agent 用 send_mail 发给自己 <name>@<目录>.new正文要求它跑 bash
→ 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent
sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …"
→ 正文里带得出「触发任务」与「任务来自」B-8.4
→ 整条链上确实没有人类时:服务端返回 409插件让位给本地 UI
**不是**无声挂起(日志里要能看到让位那一行)
[ ] **「一直同意」真的免批**B-8.7 / B-8.8
造:一封信里要求连续三次单独调用 bash
→ 第一次弹权限,点「一直同意」
→ 后两次**不再产生邮件**
sqlite3 "SELECT COUNT(*) FROM permission_requests WHERE session_id='…'"
→ 应为 1不是 3
→ 换个工具write仍会问一次 —— 授权不跨工具
→ 另一条会话的 bash 仍会问 —— 授权不跨会话
反例(修复前的真实数据):同一条会话 15 封 permission_request
result 全是「同意」,界面上那个「一直同意」按钮点了等于没点
```
### 7.7 上报
@ -1001,6 +1157,10 @@ GET /api/v1/attachments/{id}
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`**不是** `getModels()`1221 条里只有 1 条能用) |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**SDK 会话没有平台标题,见下) |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl | `console.error` |
| 工作区分组 | `session.create({directory})` 自带 | `ctx.get('workspaceRegistry')``create(cwd)` + `attachSession()` | 按 cwd 自动分目录(`~/.pi/agent/sessions/--tmp-x--/`),无需注册 |
| 工具参数命名 | `path` / `save_to` | `file_path` / `save_path` | `file_path` / `save_path` |
| 权限三态 | 原生 `once`/`always`/`reject` | **只有** `allowed-once`/`rejected` | 只有 block/放行 |
| 「一直同意」 | 给,平台自己记 | **不给**(表达不了,桥代劳会覆盖平台策略) | 给,桥用 `createGrantStore()` 记 |
> **pi 为什么是守护进程而不是扩展**pi 扩展被加载进**一条已经存在的**会话,
> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace`
@ -1163,6 +1323,83 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉
所有真正在干活的会话。
### 9.14 改名提议的标记拼错是**静默失败**
`<!-- agentmail:rename-session alias="x" reason="y" -->` 由服务端正则解析
`gateway/internal/handler/rename_proposal.go`)。少个空格、把双引号写成单引号、
或把 `reason=""` 写成空属性 —— 邮件照常发出,提议凭空消失,而模型以为自己提过了,
在后续对话里当作已完成的事引用。
> 这就是为什么拼接必须收在共用库 `lib/rename-proposal.js``T-13.2`
> 三平台各写一遍的话,任一处的偏差都不会有人发现。
> `test/rename-proposal.test.mjs` 里内置了一份服务端正则的等价实现来验证生成结果。
### 9.15 换 Gateway 地址后必须清 `lastEventID` 并重连 SSE
守护进程形态的插件pi提供 `connect_to_server` 时有个额外义务:改完
`baseURL` 要**重建 SSE 长连**,因为旧长连仍连着旧地址。
更微妙的是断点:`lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿它去问新
Gateway 会命中一段完全无关的历史(或被拒),收到的事件属于别人的会话。
`GatewayClient.reconfigure()` 在地址变化时清空它,于是重连以「首次连接」姿态
(不带 `Last-Event-ID`,见 `N-11`)进行。
插件形态opencode/dsh没有这个问题它们的 SSE 由宿主生命周期管,
`connect_to_server` 只改配置,下次宿主重连时自然用新值。
### 9.16 把来信人当权限决策人 → 会话永久挂死
插件手边最自然的决策人候选是「来信人」(`mailContexts` 里的 `replyTo`
pi 侧一度就是这么传的。它在人给 Agent 派活时看起来完全正确,直到
**Agent 给 Agent 派活**
```
jianf → pi会话 A pi 把子任务分给自己(会话 B
会话 B 触发 bash 权限询问
replyTo = "pi" → 权限邮件发给 pi 自己
```
后果不是报错而是**死锁**,而且是三重静默:
1. Agent 不可能在 Web 界面上点「同意」
2. 服务端 `SendToUser("pi")` 投进一个不存在的用户通道 —— 没有任何人被提醒
3. 插件里 `await new Promise(...)` 永不 resolve —— 没有超时、没有日志、没有回信
会话就那么停在那里,从外面看像「模型在思考」。实测发生过:两条会话排了任务,
只有一条在干活,另一条的最后动静是一封 `to_name=pi``permission_request`
> 修法见 `B-8.6` / `N-13`**不传 `to`**,决策人由服务端沿会话上溯解析。
> 服务端是唯一能看到整条线索的地方;插件只有本地那点上下文,猜不出
> 「这条 Agent 链最初是谁派的活」。
>
> 排查这类问题的第一个动作:
> `SELECT to_name FROM mails WHERE mail_type='permission_request'` ——
> 出现 Agent 名就是它。
### 9.17 摆一个「一直同意」按钮却不实现它
比不给这个选项更糟的是给了但不生效。pi 侧一度在 `options` 里放了
`['同意','一直同意','拒绝']`,放行正则也认 `一直同意` ——
但**没有任何地方记住它**,所以点完下一条命令照样来一封邮件。
生产数据(修复前):
```
同一条 pi 会话被问了 15 次 bash
permission_requests.result 全是「同意」—— 一次「一直同意」都没有
```
人不是不想点,是点了发现没用,于是退回去一条一条点「同意」。
> 判断一个平台该不该提供这个选项,看它的钩子能不能表达"以后别问了"
> - opencode 能(`response:"always"`)→ 给,且让平台记
> - pi 不能(钩子只回 block/放行)→ 给,桥自己记(`B-8.8`
> - DSH 不能且不该由桥代劳(会覆盖平台审批策略)→ 不给
>
> 自查:在 `options` 里加任何选项之前,先在代码里搜一遍这个选项的文本,
> 看它除了"被展示"之外还出现在哪里。只出现在 `options` 和一条放行正则里,
> 就是这个坑。
---
## 附:文档关系