# Phase 7 剩余项与已知生产缺陷追踪 7.7 DSH 插件已完成(见 `docs/PLUGIN-CONTRACT.md` 与 PLAN.md §7.7)。 ## 7.8 跨主机 Agent —— 协议层面已支持 **原计划**(Gateway + Registry 拆分、etcd/Consul 服务注册、跨主机路由)**不做**。 它解决的是「Gateway 怎么找到 Agent」,而这个方向从一开始就不成立: **连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。** 因此「发现」不是 Gateway 的问题,是 Agent 的配置问题:它只需要知道一个 公网 URL 加一把密钥。注册中心要解决的「被叫方在哪」在这个架构里不存在 —— 被叫方自己会打进来。 同一个理由让平台会话同步走插件上报(见 PLUGIN-CONTRACT 的 `W-3`): Gateway 不外呼,就不需要知道任何 Agent 的地址。 ### 已验证可用(2026-09-02,从 192.168.2.106 打到公网) 完整一轮往返跑通了:`admin` 在本机发信给 `remotebot@/tmp/remotebot-ws`, `.106` 上的脚本收到并回信入库。 | 能力 | 结果 | |---|---| | 注册(`POST /agent/register`) | 通,`workspaces` 落库为 `[{"name":"demo","path":"/tmp/remotebot-ws"}]` | | 心跳(`POST /agent/heartbeat`) | 通,`models_synced: 1`、回传 `allowed_models` 与 `models_unrestricted` | | SSE 长连(`GET /events/stream` + Bearer) | 通,收到 `connected` | | 收件箱 + 标记已读 | 通 | | 发信(`POST /mail/send` 带 `reply_to`) | 通,`from_workspace` 正确 | | Gateway 侧在线状态 | `status=online`,`last_seen` 随心跳推进 | 验证用的是一个**只依赖 python 标准库的脚本**(`deploy/remote-agent-demo.py`), 它没装 AgentMail 的任何代码。这就是「协议层面已支持」的含义: 跨主机不需要新组件,只需要三个环境变量。 ### 写这个脚本时踩的两个坑(新平台接入会重复踩) **`workspaces` 是对象数组 `[{name, path}]`,不是字符串数组。** 传字符串原先只得到一句固定的 400 `Invalid JSON` —— 完全没指向是哪个字段, 只能靠翻服务端结构体才能发现。两个正式插件都传 `workspaces: []`, 所以这个坑一直没暴露过;第三方客户端没有「翻服务端源码」这个条件。 **已修**:新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去。 现在同样的请求回: ```json {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"} ``` 刻意不回显 `encoding/json` 的原文——它带 Go 类型名(`models.Workspace`), 那是本侧的实现细节,不该出现在公开 API 的响应里。 `internal/handler/decode_test.go` 钉住这一点(含「不得泄漏 Go 类型名」的断言)。 **SSE 只推连上之后的事件,离线期间的邮件要靠心跳的 `pending_mails` 补拉。** 第一版脚本只挂了 SSE,于是启动前发的那封邮件永远不会被处理 —— 日志里 `pending=1` 明明写着有一封未读,却没人去拉。 **这个坑两个正式插件也有**(原以为它们做了补拉,查了才发现没有)。已修: 新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。 - 只在**首个**心跳后补,不是每轮:每轮都补会把「模型正在处理中、尚未标已读」 的邮件重复投递 - 串行投递、一次最多 5 封:每封都要起一轮模型,并发放出去等于对上游打 N 个 并发请求,且最后那几封要等前面全部跑完 - 与 SSE 共用 `deliveredMails` 去重:心跳与 SSE 建连之间有个窗口, 那期间到的邮件既在 `pending_mails` 里也会被 SSE 推一次 - 按时间**正序**补投(收件箱是倒序返回的):同一会话里的多封邮件倒着塞进去, 上下文顺序是乱的 - `permission` 类邮件不补投:原来的工具调用早随进程一起没了, 投过去模型没有可恢复的上下文 端到端验证(两平台各一次):停插件 → 发邮件 → 启插件 → 日志出现 「补投 1 封离线期间的邮件」→ 回信入库。随后在线状态再发一封确认只回一次。 ### 剩下的确实是运维便利,不是能力缺失 - [ ] 一条命令为远端主机建密钥并打印那三个环境变量(现在要手工调 admin API) - [ ] 密钥轮换(现在换密钥要重启远端 agent) - [ ] Agent 列表显示来源主机(`agents.host_url` 列已存在但没人写, 要写的话应当由心跳带上自报的地址 —— 仍然不是 Gateway 去探测) 这三项都不阻塞跨主机使用,因此不再归入 Phase 7。 ## 可以立即推进的生产缺陷 ### P0 — SSE Last-Event-ID 补投 **根因**:EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了—— 所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。 **影响**:重连后永远看不到断线期间收到的邮件(除非手动刷新)。 **修法**:服务端维护一个有界循环缓冲区(ring buffer),每次 Broadcast 同时写入, SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传了 Last-Event-ID 就从那里), 没有则从头(只带最近 N 条)。缓冲区大小设 500,内存 < 2MB。 ### P0 — 连接状态指示器 **根因**:SSE 断线后前端无任何可见反馈——用户以为系统正常,实际通知已停。 **影响**:实时性是 Agent 协作的核心体验,断线无提示会让人以为「Agent 没在动」。 **修法**:header 旁加一个连接状态点(绿/黄/红),SSE 的 onopen/onerror 事件驱动。 ### P1 — 登录限速跨进程问题 ✅ **根因**:LoginLimiter 是进程内内存计数器,多实例部署时每个实例独立计数。 **修法**:改为 DB 事务(rate_limits 表 + IMMEDIATE 事务),多实例共享同一份计数。 ### P1 — 新建会话限速同理 ✅ **根因**:sessionRateLimiter 也是进程内计数器。 **修法**:同上,sessionrate.go 重写为调用 RateLimitCheckAndRecord。 ### P2 — 组件级测试 **现状**:有结构性回归(`client/electron/test/narrow-layout.test.mjs`,28 条断言, 读源码验形态)与一套 playwright 手工脚本,但**没有渲染组件跑断言的测试**。 **范围**:关键组件(AddressInput 补全、PermissionPanel 决策、WorkCard 预算渲染)。 **已有的浏览器实测**:`client/electron/test/manual/`(`npm run test:narrow` / `npm run test:wide`)—— 连本机共享 Chromium 量真实盒子与命中区。 不在 `npm test` 里,因为要一个跑着的浏览器加一个活的 Gateway。 剩下的是把它接进 CI(需要一个 headless 环境与一个测试用 Gateway 实例)。 ### 窄屏实测修复(2026-09-02)✅ 用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px(iPhone SE) 两档实测。**一个功能性 bug 加五处可用性问题**: **抽屉式侧栏遮挡底部导航(真 bug,已删抽屉)** 抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有 z-index。抽屉打开时点最左那一项「收件」,`elementFromPoint` 命中的是抽屉里的 SVG,不是导航按钮。 修法是**删掉抽屉**而不是给导航加 z-index:抽屉里六项(收件/发件/联系/用户/ 新建/管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed 层级 + 遮罩不划算,何况它还引入了遮挡、Esc 关不掉、底层未锁滚三个问题。 退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那里此前根本 没有退出入口。 **触摸命中区(新增 `.tap`)** 详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」 54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。 直接加 padding 会把本来就挤的头部撑散、在 320px 上换行,因此改用居中的透明 伪元素扩大命中区:**视觉一像素不动**。只在 `max-width: 767px` 生效 —— 桌面用鼠标精度足够,而扩大后的命中区在密排工具栏里会互相重叠。 覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage / ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。 **悬停才显形的按钮在触摸设备上永远透明却按得动(新增 `.reveal`)** `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 `opacity: 0`, 但仍然接收点击 —— 实测联系人列表里 `elementFromPoint` 命中的就是那个看不见的 「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片, 实际归档了一条会话。 改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏 —— 单看 `hover` 会把带触摸板的平板算进去。 **对话树缩进在 320px 下把卡片压成竖条** 固定「每级 20px、上限 8 级」= 最多 160px;320px 屏还要去掉 `px-4` 的 32px 与 连接线 18px,卡片只剩 110px,发件人一行直接被 truncate 吃掉。 窄屏改成每级 10px、上限 5 级。 **对话树窄屏没有返回出口** 只有「关闭」。两者语义不同:返回退出整个详情栏回到列表,关闭只收起树、 留在这封邮件上。补了 `BackButton`。 **「我的」页根本没有滚动容器(用户反馈)** 窄屏外壳是 `h-full flex flex-col overflow-hidden`、页面是 `flex-1 flex flex-col`, 中间缺一层 `overflow-y-auto` —— 内容超出的部分**直接被裁**,滚不到也点不到。 实测 390px 下内容需 860px、容器 795px,「退出登录」连同下面 65px 一起消失; 1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了。 **登录页 / 初始化页在矮屏滚不到底** 卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出, 溢出到顶部那段滚不到(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机、 或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto` 也够不着。改用卡片自己的 `my-auto`:auto margin 在空间不足时退化为 0, 矮屏变成顶对齐可滚布局,高屏仍然垂直居中。 #### 验证 playwright 端到端 18 项全通过(含「底部导航六项都命中自己」、 「工具按钮命中区 >= 44px」、五个页面各有纵向滚动容器、无横向溢出、 320px 无横向溢出);宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、 无返回按钮、`.tap` 伪元素在宽屏不生效、无溢出)。 `client/electron/test/narrow-layout.test.mjs` 从 20 条扩到 37 条,把上述每一条都钉住。 滚动检查的判据是「**有**滚动容器」而不是「当前正在滚动」: 内容暂时不够高时后者为假,但页面是健康的 —— 真正的 bug 是根本没有那一层。 ### P2 — 深色主题 **现状**:只有浅色主题,深夜使用刺眼。 **范围**:tailwind dark: 前缀覆盖主要组件。 ### P1 — 每平台可用模型范围 ✅ **需求**:配置页面为每个 Agent 平台划定「邮箱调用场景下可用的模型范围」, 端侧插件按范围**逐个降级尝试**,全部失败时把失败原因封装成邮件回复。 选择而非手打模型名 —— 平台上报目录,管理员勾选。 **已完成**: - `agent_model_catalog`(平台上报的目录)+ `agent_allowed_models`(管理员的选择) 两张表,两份 schema - `repo/models_scope.go`:`ReplaceModelCatalog` / `ListModelCatalog` / `ListAllowedModels` / `SetAllowedModels` **为什么分两张表**:模型会从平台目录里消失(换了 provider 配置、上游临时下线), 整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后 「选了什么」是持久的,目录只决定「这一项现在是否可用」。 **已完成(全部)**: - [x] handler + 路由:`GET/PUT /admin/agents/{name}/models`、`GET /agent/models/allowed` - [x] **目录上报走心跳**而不是另设端点:模型清单会在运行中变, 心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」 留两个答案 - [x] 心跳响应回传 `allowed_models`:管理员改了范围后最多一个周期生效,不必重启 - [x] `lib/model-scope.js`:目录整理(两平台)、`modelAttemptOrder`、`renderFailureReport` - [x] 插件按 rank 逐个尝试,全部失败发一封说明原因的邮件(走免配额通道) - [x] 前端 `ModelScopePanel`:勾选 + 上下移调序 + stale 标记 **最难的一点**(两个平台都踩了):**模型失败不是同步抛出的**。 `promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,只包 try/catch 第二个模型永远不会被试到。要等异步结论: - opencode → `session.error` 事件 - DSH → `turn/end` 的 `reason.kind === 'error'` 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 渲染一致**) - `client/electron/test/components/calendar.test.tsx` 35 例 - 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 → 重复事件推进到次日 → **连续两个 tick 零重发**(修复前每 tick 一封)→ iCal 导入 `-PT45M`/`FREQ=WEEKLY` 正确落库 → 导出再导入闭环一致 → 清理验证数据 ### 未做 - 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载 - `parseICS` 不处理折叠续行(RFC 5545 的 75 字节折行):长 DESCRIPTION 会被截断。常见客户端导出的短字段不受影响 - `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL` - 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长) --- ## B-8(权限询问转邮件)在 HomeAgent 上不适用 四平台能力矩阵里 homeagent 的 B-8 一直标着「❌ 要先摸清 homed approval API」。 本轮查清了:**那个 API 不存在,而且不该存在。** 判据:在 SDK(`third_party/homeagent-sdk/sdk/*.go`)与核心 (`internal/**/*.go`)两处 grep `approval|consent|permission|confirm`, 命中数均为 **0**。 原因是设计取向不同。另三个平台各有一个现成的审批环节,桥做的只是把它从 本地 TUI **改道**到邮件通道: | 平台 | 审批钩子 | |---|---| | opencode | `permission.ask` | | DSH | `approval/request` | | pi | `tool_call` | HomeAgent 的核心是一个**纯思维核** —— 本身没有任何对外交互能力, 全部能力来自插件,也没有会话这一层(单事件循环)。它不问人: 工具调用直接执行。 它确实有一个可以拦下调用的位置:`StageBeforeToolcall` (`internal/agent/core/process.go:273`,插件给 `ctx.Response` 赋值即视为拒绝, 核心会把「工具 X 已被插件拒绝」当作 tool 结果喂回模型并发 `status: "denied"` 事件)。但那是「插件可以否决」而不是「平台在征求同意」:没有待批准的请求、 没有选项、也没有等人的语义。 **因此 B-8 在这个平台上是 N/A,不是待实现项。** 硬要补等于给平台加一层 它本来没有的能力:要自己划高风险工具白名单、自己定义超时与 fail closed 语义、自己决定人不在时怎么办 —— 那些都是产品决策而不是契约合规。 (技术上可行:`RegisterTool` 的 handler 是我们的代码,能在执行前发权限邮件 并阻塞等待;`InjectInputSync` 证明这套 SDK 里「同步等外部答复」是既有形态。 但没有需求驱动就不做。) ### 随之而来的一个事实,需要知道 homeagent 的工具**全部无条件执行**,其中包括 `cmd_run` 这类能力。 它接入 AgentMail 之后,任何能给 `homeagent@…` 发信的人或 Agent 都能间接 触发这些工具,中间没有人类确认环节。 另三条链上至少有一道兜底:桥收到 409(Gateway 判定整条会话树上没有人类 可路由)时当场表态拒绝。homeagent 这条链没有这一环 —— 因为它根本不发起询问。 这不是缺陷,是那个平台的信任模型:homed 及其插件都跑在用户自己的机器上, 默认完整权限。记在这里是为了让「谁能给 homeagent 发信」这个问题被当作 访问控制来对待,而不是当作邮件权限。 ### 契约文档的改动 - `B-8` 标题从「若平台支持,MUST」改为「若平台有**审批环节**,MUST;没有则 N/A」, 并补一段说明前置条件与 `StageBeforeToolcall` 的语义差异 - 平台差异对照表补齐 **HomeAgent 一列**(14 行)。它是四个平台里 唯一不需要为每封邮件开平台侧会话的 —— 没有会话概念,所有邮件注入同一个 事件循环,靠 `output_send__agentmail` 输出通道送回复 - 检查清单里「未决权限询问 fail closed」标注适用条件