From bca50b80c6d81c03527dc5242686ad5ad4356459 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:30:56 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=97=A5=E5=8E=86=E5=AD=90=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20+=20=E5=AF=BB=E5=9D=80=E5=8F=91=E7=8E=B0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=84=20+=20=E6=9D=83=E9=99=90=E6=AD=BB=E9=94=81?= =?UTF-8?q?=E7=9A=84=E6=8E=92=E6=9F=A5=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHASE7-REMAINING 新增日历一节:三层分离的理由、修掉的六个真问题 (含每一个的现场取证与判据)、前端三个易错点、验证清单、未做的缺口。 写成「可核对的规格 + 踩坑理由」而不是叙事:这些 bug 的共同点是 **不报错**(模板烤死时间、{time} 渲染成 UTC、每 tick 重发、附件从未落盘、 TRIGGER 往返断开、PG schema 缺表),下一个人只有知道判据才能避开。 README 把插件工具表从「六个」更新到十一个(现在 homeagent 是十五个), 并说明寻址发现那一组解决的是**猜地址** —— 生产上真的发生过一个 Agent 猜了 opencode@/home,投递成功但那不是它的工作目录,那封邮件静默变成了 一条平行会话的开端。 PLUGIN-CONTRACT 补 B-8(权限询问转邮件)的判据表与三平台差异。 --- README.md | 28 ++++- docs/PHASE7-REMAINING.md | 135 ++++++++++++++++++++++ docs/PLUGIN-CONTRACT.md | 241 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 397 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ec1e8a1..e98e8c2 100644 --- a/README.md +++ b/README.md @@ -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 中途改掉会让人刚记住的地址立刻失效。提议在会话页面上 +显示为一条提示条,人可以接受或驳回;驳回过的名字不再反复弹。 ## 对话树 diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index 93efef4..583d10f 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -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 分钟;导出不该修改语义。 +修法:导出写 `-PTM`,导入换成正经的 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` +- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长) diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index 95562ff..5c9321f 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -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 注释 `` | 服务端正则匹配不上,提议静默消失 | +| 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(提示条不再反复弹) + → 新别名可寻址:@<目录>.<新别名> 落进同一条会话 + → 桥的后续命名同步**不覆盖** 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 发给自己 @<目录>.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 改名提议的标记拼错是**静默失败** + +`` 由服务端正则解析 +(`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` 和一条放行正则里, +> 就是这个坑。 + --- ## 附:文档关系