Commit Graph

198 Commits

Author SHA1 Message Date
a456d2127e feat(webui): 横屏导航改为侧边(不再压底部),列表栏固定 320
用户:「导航栏在窄屏横屏情况下不也应当是在侧边吗?」。

横屏缺的是**竖向**空间:底部导航吃掉本就不多的高度(844×390 里它占了 61px),
而横向反而有余(824px)。所以在紧凑双栏下复用桌面那条 60px 图标栏,去掉底部导航。

## 顺带修掉"横屏没自适应"的另一半原因

列表栏宽度写的是 `lg:w-[320px]`,而 lg 断点是 **1024px** —— 844 宽的横屏不满足
⇒ 它退回 `w-full` ⇒ **列表吃掉整宽、详情被挤成一条缝**。之前我只改了排布方式,
没管这件事,所以横屏仍然不好用。现在按"紧凑双栏"这个**语义条件**给宽度
(`.compact-2pane .comm-pane { flex: 0 0 320px }`),而不是再猜一个像素断点。

**实测**(844×390):侧栏 60×380 在左侧;导航项 = 通信/日历/联系/我;底部导航**不存在**;
列表栏 x=80 宽 **320**;详情 434;无横向溢出。
2026-09-14 11:30:51 +08:00
d11e6df9a8 feat(webui): 日历翻页过渡动画(方向跟随手势/按钮)
用户:「日历滑动页面为什么没有切换动画?」。

## 做法

`shift()` 里记下方向(手势与"上一页/下一页"按钮**都走这一个函数** ⇒ 方向只有一个来源),
容器用 **key + 声明的动画类**:

    <div key={`${anchor.getTime()}-${scale}`} className={`flex-1 min-h-0 flex ${slideClass}`}>

## 为什么不是"命令式加类"

第一版用 `el.classList.add()`(与视图切换动画同一套写法),**类根本没进 DOM**。
原因:切月时 `loading` 会把网格整块换成加载态、再换回来,命令式加上的类会被
React 的重渲染与那次重挂载抹掉。key 变化 ⇒ 新元素自带动画类出现 ⇒ 必然重放。

## 顺带修掉一个守卫 bug

`firstRender` 守卫原先只在 `el` 存在时才消费。挂载时 `loading=true` ⇒ 容器还没渲染
⇒ 守卫一直留着 ⇒ **用户第一次真正翻页的动画被吞掉**(实测:点第一下不动、第二下才动)。
现在无条件消费。

## 判据

`test/manual/calendar-swipe-verify.mjs` 增加三条(与滑动手势同一条线索):
反向对照"刚进页面不跑动画"(animationName=none)、翻页时 `cal-slide-next` +
`cal-in-next` 且时长 >0、反向翻页是 `cal-slide-prev`(方向正确性)。
**实测**:静止 none → 点下一页 `cal-slide-next`/`cal-in-next` 0.2s → 上一页 `cal-slide-prev`/`cal-in-prev`。
2026-09-14 11:27:37 +08:00
ef68c4950c fix(webui): 统一玻璃语言 —— 导航改深色玻璃 + 内层面板自带圆角
用户:「你不觉得页面设计很割裂吗?尤其是通信页,大面积的非圆角元素。
同时还有大面积的非玻璃样式。导航栏不也应该改为玻璃样式吗,为什么还是黑色」。

## ① 导航还是黑的:因为我自己留了一条"必须不透明"的规则

`html[data-bg='on'] .bg-chrome-900 { background-color: rgb(var(--c-chrome-900));
backdrop-filter: none }` —— 这是**更早一轮**用户的要求(当时面板太透,壁纸从导航里
透出来显得脏)。现在整套语言统一到玻璃之后,导航继续做一块实心黑就成了全页唯一的
例外,也就是"割裂"的来源。

改成**深色玻璃**(α=0.72 + blur(18px) + saturate)——用深色而不是白色玻璃:
白字压在深色上才有对比度。没开壁纸时用 0.82(后面是页面底色而不是照片,太透显脏)。

## ② 通信页"大面积非圆角":是我修滚动时带出来的

圆角原本只加在外壳上,靠 `overflow: hidden` 裁掉内层的直角 —— 但那个 overflow
会杀掉滚动(上一封),必须去掉。**于是内层白底面板的直角就从圆角外壳里戳了出来。**
现在把圆角直接给内层面板(`.comm-pane > *`),两边对齐,且不依赖裁剪 ⇒ 与滚动不冲突。
MailView 的两条工具条也不再自带宽底(只留上边框,底色继承面板),否则同样会戳角。

## 判据

`background.test.mjs` 里两条**旧判据**(断言"导航必须不透明、不参与模糊")按现行契约
重写为"深色玻璃(α∈[0.6,0.9])+ 参与模糊",并新增"内层面板自带圆角"。
要求反转后旧判据必须一起改 —— 留着只会让下次改动"要么违规、要么把缺陷写回去"。

**实测**(真浏览器,1280×900,壁纸态):导航 α=0.72 / blur(18px) / radius 14px;
内层 MailList 面板 radius **14px**(不再是直角)。背景套件 31 条全绿。
2026-09-14 11:17:12 +08:00
8a9bc58433 feat(webui): 横竖屏自适应(紧凑双栏)+ 旋屏重算高度
用户:「为什么窄屏的横屏和竖屏没有自适应」。

## 根因

断点是**纯宽度**的:`(max-width: 1023px)`。手机竖屏 390 与横屏 844 **都落在同一档**
⇒ 布局一个像素都不变,横屏多出来的 ~450px 全浪费在空白上。看起来就是"没自适应"。

## 改动

- 新增 `useCompactTwoPane()`:`(min-width: 700px) and (max-height: 560px)`
  —— 仍是紧凑外壳(悬浮底部导航),但内容区从**覆盖式**改成**列表 + 详情并排**。
  用高度而不是 `landscape` 判:桌面窗口压扁、分屏、软键盘弹起都会命中,
  而它们要的是同一件事(别浪费横向空间)。
- App:`compactTwoPane && hasList` 时走并排分支,其余情况仍是 NarrowStack 覆盖式。
- `--app-height` 增加 `orientationchange` 监听并延后一帧重算(iOS 上部分版本的
  resize 不跟着方向变化走;立刻读会拿到旧高度)。

## 判据

`test/manual/rotate-verify.mjs` 8 条,含**可逆性**与前置对照:
竖屏覆盖式 → 横屏并排(真的换了 class)→ 旋回竖屏恢复覆盖式;
三态的 `--app-height` 分别为 844 / 390 / 844(不是旧值);横屏无横向溢出、
底部导航仍完整可见且浮着(left=20)。实测 **8/8**。

同一轮还补上了 `test/manual/calendar-swipe-verify.mjs`(日历滑动,4 条)。
2026-09-14 11:13:24 +08:00
0a4b98144c feat(webui): 通信二级页签与列表头一体 + 沉浸式(PWA 全屏)+ 日历滑动验收脚本
用户三条(同一线索):「通信页面的二级页面与其他位置极其割裂」、
「不支持沉浸式网页」、「日历页面还不支持左右滑动手势」。

## ① 二级页签不再割裂

页签原先是**带 shadow 的白色胶囊**浮在面板上,看起来像硬贴上去的另一套控件。
改成**下划线页签**:与列表头同一内边距、同一条下边框,选中态用蓝色下划线 +
`-mb-px` 压住分隔线(否则会出现"两条线"的接缝)。

## ② 沉浸式

根因不是 viewport(`viewport-fit=cover` 早就有了,安全区也接了
`env(safe-area-inset-*)`),而是**没有 Web App Manifest**:手机上"添加到主屏幕"后
打开仍然是带地址栏的网页。现在加了 `manifest.webmanifest`(`display: standalone`)
+ iOS 的 `apple-mobile-web-app-capable` / `black-translucent`(状态栏内容叠在页面上)。

manifest 放在**根路径**而不是 /assets/ 下:它里面的 `start_url`/`scope` 是相对
manifest 自己的 URL 解析的,挂在 /assets/ 下就得写 "../"。静态只挂了 `/` 与 `/assets/*`,
所以显式加了一条路由(并从 embed 读,而不是读磁盘 —— 前端产物必须与应用同源同版本)。
图标由项目唯一图标源生成 192/512(尺寸与声明一致,我用 struct 读文件头核对过)。

**实测**:`/manifest.webmanifest` → HTTP 200 `application/manifest+json`。

## ③ 日历滑动:补上真正的验收脚本

`test/manual/calendar-swipe-verify.mjs` 四条,含**反向对照**(纵向拖动不得翻页)
与前置断言。写它时又踩了一次自己的坑:标题真实格式是「2026 年 9 月」(数字与"年月"
之间有空格),我第一版正则按无空格写 ⇒ 匹配不到 ⇒ 三个值全是 null,
**看起来像"滑动没生效",其实是探针瞎了**。所以脚本里第一条就是"标题读得到"。

实测:9 月 →左滑→ 10 月 →右滑→ 9 月,纵向拖动不动。

## 顺带

把我为验收造的测试数据**归档**(不是删除):10 个 `/tmp/scrollprobe-*` 独立会话 +
12 封"滚动验收/窄屏验收样例"邮件。
2026-09-14 11:07:03 +08:00
50ee10a522 fix(webui): 修「通信页面完全无法上下滑动」+ 日历左右滑动手势
## 滑动(用户:「通信页面的各个子页面还是无法滑动啊,我真服了」)

根因不是我第一次以为的 `overflow: hidden`,而是**我把列表包进了一个 flex 列**:

- `MailList` 的根是 `w-full lg:w-[320px] shrink-0 ... flex flex-col` —— 这个 `shrink-0`
  是给**行**方向布局写的(控制宽度),放进列方向后它作用在**高度**上:列表连高度
  都不肯让 ⇒ 内部的 `flex-1 overflow-y-auto` 永远拿不到可用高度 ⇒ 滚不动。
- 表现极具误导性:容器**自己也没有溢出**,所以连滚动条都不出现,看起来像"页面被裁掉了"。
- 为什么以前没坏:列表原先直接挂在窄屏覆盖层(`absolute inset-0 flex`,**行**方向)下,
  行方向的高度来自 `align-items: stretch`,不需要 `min-height:0`。

修法:`.comm-pane > *:not([data-testid='comm-tabs']) { flex: 1 1 0%; min-height: 0 }`
—— 让子面板既能压缩、也填满这一列。对收件/发件/授权/联系人一视同仁,避免只修好一个。

**实测**(390×844,11 行邮件):修复前 `scrollerCount: 0`(一个可滚动容器都没有);
修复后滚动容器 = `flex-1 overflow-y-auto p-2.5`、`overflow-y: auto`、可滚范围 236px、
`scrollTop` 实际移动 200px。导航仍完整可见(bottom=834 ≤ 844)。

## 我自己的两个方法错误(都写在这里,避免下次再犯)

1. **判据没断言前置条件**:前三次探针都报"没有滚动容器",其实是 gui-lab 收件箱太短
   (12 封全落进同一个会话 ⇒ 只显示 1 组)⇒ 内容根本没溢出 ⇒ 我量的对象不存在。
   最后用 10 个**独立会话**把列表撑高才复现出来。用户报的缺陷是真的,是我的探针没到位。
2. 第一条修复(去掉 `overflow:hidden`)方向不对,但我当时**差点把它当成修好了** ——
   因为探针依旧是 0 滚动容器,只是我没深究。结论必须是三态,不能把"量不到"当"没问题"。

## 日历左右滑动(用户:「日历页面还不支持左右滑动手势」)

在日历主体上挂 touchstart/touchend,**复用 `shift()`**(与"上一月/下一月"按钮同一套翻页
逻辑);阈值:水平位移 ≥40px、且 ≥1.5×垂直位移、且 <600ms —— 否则会把纵向滚动误判成翻页。

**这条我还没验成功**:探针取日历标题的方式不对(返回 null ⇒ 判不了),
不能说它好了。修好探针再补验。
2026-09-14 11:01:10 +08:00
76ce201c3a feat(webui): 导航合并 + 悬浮玻璃 + 页面动画 + 控件档透明度 + 圆角无条件生效
用户四封信(同一线索)提的六件事,都在这里:

## ① 导航合并(「导航栏的内容有点多了」)

收件箱/发件箱/授权 → 一项「通信」,进去由**内部页签**区分(CommTabs);
新建 → 「通信」页内**悬浮圆形加号**(ComposeFab);导航只剩 通信/日历/联系人;
管理 → 合进「我的」,管理员在页面**最下面**看得到(非管理员不渲染,不是禁用)。

`viewMode` 仍是原来那三个值(几十处调用点不用改),新增 `commTab` 记住上次用的子页签;
通信项的徽标 = 未读 + 待决策(授权信息不能因为合并而消失)。

## ② 窄屏底部导航:悬浮玻璃(「还是固定底部延伸,没有玻璃效果,也没有悬浮」)

原来是 `border-t bg-chrome-900` 通栏贴底。改成:左右/离底各留 10px、圆角、
深色半透明 + `blur(18px)`、投影,安全区并入下外边距。

## ③ 页面动画(「页面极度缺少动画,所有页面都是直接出现」)

在 `<html>` 上挂一个短命类触发外壳直接子面板的入场动画。**没有用 key 重挂载**:
面板是 flex 链上的一环,套 wrapper 会改掉 flex 传递(窄屏覆盖层最先坏)。
并且尊重 `prefers-reduced-motion`(关了就一点都不动)。

## ④ 控件档透明度(「复选框和地址猜测项…他们才是真正需要拉低透明度的地方」)

新增第三档 `--bg-glass-control`(0.5/0.55):授权多选胶囊、地址建议菜单。
三档递减:正文面 0.88 > 嵌套 0.82 > 控件 0.5。

## ⑤ 打底 vs 透明(上一封「该打底的地方透了、该透的空白处糊了」)

正文面回到 0.88(读得清优先)、空白/页面底透明**且不模糊**、面板模糊降到 10px。

## ⑥ 圆角与玻璃**无条件**生效(「大面积缺失圆角与玻璃效果…都是硬截断」)

根因:`.app-shell` 的留缝/圆角/投影原先全写在 `html[data-bg='on']` 里
⇒ **没开壁纸的账号**看到的是硬边不透明面板。现在几何下沉为无条件基线,
壁纸相关的加强仍叠加在上面。窄屏内容面板同样浮起来。

## 判据

- `test/nav-merge.test.mjs` 8 条(含自检:重构前的写法必须判红)——
  改完跑老套件 254 条**全绿却一条都没测导航结构**,结构改动必须自带判据。
- `test/manual/nav-restructure-verify.mjs`:真浏览器 9 条(导航项数、页签切换真的换内容、
  加号 56×56 且 `elementFromPoint` 命中自己、我的页底部管理入口在退出登录之后、
  控件档 α=0.5 对正文面 α=0.88 的反向对照)。
- `test/manual/narrow-glass-verify.mjs` 6 条:悬浮几何(三边留缝 10px、圆角 14px、
  缝隙处命中的是页面底 ⇒ 真的浮着)、玻璃(α=0.82 + blur18)、触摸高度最小 49px、
  动画真的在跑(静止时 `animationName=none`,切换后 =pane-in)且 reduced-motion 下不动。
- `test/background.test.mjs`:把三条**过时判据**(早前那版"越透越好")按现行契约重写 ——
  旧判据留着只会把我拽回错误方向。30 条全绿。

## 顺带

窄屏探针原先只调视口、**没模拟触屏**,于是 `(hover: hover)` 仍为真,
把「悬停才显形的次要动作」误报成透明 —— 差点去"修"一个本来就对的规则。
已改成 hasTouch + isMobile + DPR 的触屏上下文,且收尾只关自己的 context
(CDP 连的是共享 Chromium,`browser.close()` 会把别人的标签页一起关掉)。
2026-09-14 10:35:05 +08:00
552fbc731e fix(inbox): read_inbox 按会话收窄 —— 修「不同 session 的 agent 都能看到全部邮件」
用户问:「你之前不是说你已经处理了不同 session 的 agent 都可以看到全部邮件的
问题了吗?」——**我得先纠正事实:上一轮我只做了诊断并问要不要动手,没有实施。**
这是我的表述问题(把"已定位并给了方案"说成了像"已处理")。现在实施。

## 缺陷

`read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信),
并按契约把列出来的都标成已读 ⇒ A 会话的 worker 标掉 B 会话的未读。平时看不出来
(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 `?status=unread` ——
被标掉的那封**再也不会补投** ⇒ 静默丢信。现场实例:另一条会话的来信在
`mail_reads` 里的 reader=pi、时间正是我读自己收件箱的那一刻。

## 改动

- **网关**:`GET /mail/inbox` 与 `POST /mail/read` 支持可选 `session_id`。
  不带 = 旧语义(整个 Agent 的收件箱,浏览器/脚本仍可用);带了就只在这条会话内
  列与标。`ListInbox` / `MarkAllInboxReadFor` 保持原签名并委托给新变体 ——
  老调用点一个都不用改。
- **pi 桥**:`read_inbox` 把自己那条会话拼进 URL(worker 通过闭包把**邮件会话 id**
  递给工具,而不是在启动时取快照)。

## 判据

- repo 三条:列表按会话收窄(含"不带会话时两条都在"的反向对照)、
  ★"标会话 A 不动会话 B"、会话内计数与列表口径一致(否则界面会出现"徽标 2、列表 1")。
- handler/网关:非法 `session_id` ⇒ 400(不静默忽略)。
- pi 接线三条(URL 拼了收窄、worker 递了 id、判据自检:旧写法必须判红)。
- 线上只读 E2E:两条真实会话 A/B 列表**无交集**、不带会话能列出全部、非法 id 400。

## 过程中测试当场抓到"只改了一半"

`MarkAllInboxReadForSession` 里插 `mail_reads` 的语句我加了会话条件,
**刷新冗余列的 UPDATE 忘了加** ⇒ 返回的"标掉几封"变成 2(应 1)。
判据一眼看出来了 —— 这类"改一半"正是这次要防的。

## 范围(诚实说明)

另外四家桥(dsh/opencode/zcode/homeagent)的 `read_inbox` 工具签名里**没有会话上下文**
(`execute(args)` / `execute(args, ctx)` 各不相同),要按各自框架的上下文 API 接线,
不是一行改动 ⇒ **未做**,列为待办(位置已定位)。所以:pi 上这个缺陷已消除,
另外四家仍在。

## 部署

网关已部署并线上验证;pi 桥的部署**延迟到本轮结束后 150 秒**执行
(重启 pi 桥会掐掉我自己这一轮 —— 之前真发生过),日志
`/var/log/agentmail-pi-redeploy.log`,可用 `node deploy/check-deploy-drift.mjs` 核对。
2026-09-14 09:19:25 +08:00
be13ae5959 feat(webui): 界面加构建戳 —— 让"我这边改了没"变成可核对的
用户连续三轮说「webui 还没改」,而我每次都能证明部署是活的(入口 no-cache、
资源哈希 immutable、线上 bundle 与本地构建逐字节一致、无头浏览器实测生效)——
问题在于**隔着屏幕说不清对方的浏览器跑的是哪一份**。

现在界面里显示一行 `界面构建 <git短哈希>·<月日-时分>`(外观设置面板页脚):

- `vite.config.ts` 在构建时注入 `__BUILD_STAMP__`(git 短哈希 + 构建时刻)
- `BackgroundPicker` 渲染它,并带 title 说明格式
- 判据 4 条(vite 注入 / 组件渲染 / **产物里真的有** / 判据自检:正则不能匹配随便一段文本)

实测:产物与线上都是 `d2904fc·0914-0910`;刷新后数字变了就是拿到了新构建。

顺带修正一处过时文案:图片说明还写着"保存在本机",而它现在同时存到账号里
(服务端 + 本地缓存)。

前端 254 条 + 新增 4 条、打包一致性、server 10 包全绿;桌面包已重打。
2026-09-14 09:11:36 +08:00
d2904fc45d style(webui): 导航栏改为完全不透明 + 内容面板更通透 + 整屏浮动圆角玻璃
用户 2026-09-14 原话:「导航栏应当完全不透明……没有正文的位置过于不通透,
同时导航栏应当现代化一下,整个界面应当圆角化玻璃化」。

这三句是**两个方向**的要求,我上一轮正好把导航栏做反了(越改越透):

## ① 导航栏:完全不透明(含窄屏底部导航)

`chrome-800/900` 在背景模式下不再吃 alpha、也不参与模糊。它是**框架**,
不该跟着壁纸一起虚化。它与"内容要通透"是两个方向的要求,所以写成独立规则、
并在注释里点名 —— 合并成一条必然互相打架。

## ② 内容面板:更通透

`--bg-glass` 0.62 → **0.45**(深色 0.66 → 0.5),嵌套层 0.3 → 0.22。
实测(纯红壁纸读绿通道):邮件列表有效不透明度 115/255 ≈ **0.45**,正文区全透。

## ③ 整屏浮动圆角玻璃

给宽屏外壳加了稳定钩子 `app-shell`,背景开启时:外壳 **padding/gap 10px**
(面板之间露壁纸 —— 没有缝隙的"玻璃"看上去仍是一整块板)、顶层面板
**圆角 var(--radius-card)** + 投影。实测三栏:导航 60px/圆角14px/不透明、
列表 320px/圆角14px/0.45、正文 1020px/圆角14px/全透。

## 判据

`test/background.test.mjs` 新增 5 条形态判据(导航不透明、导航不参与模糊、
`--bg-glass ≤ 0.55`、外壳留缝、顶层面板圆角),共 **28 条**。
前端 254 条 + 打包一致性全绿。

## 过程中的三处自纠

1. **我的 App.tsx 编辑第一次根本没落盘**:那份 python 脚本第 18 行语法错误就整体
   没执行,而我把"✓ 已加钩子"当成了成功 —— 后来 `.app-shell 存在: false` 才暴露。
   教训:脚本报成功不等于目标文件变了,**改完要回读**。
2. **JSX 里把 `{/* … */}` 放在 `return (` 与根元素之间**是非法的 ⇒ 构建失败。
   改为 JS 注释放在 `return` 之前。
3. ★ 这两次失败都是**新加的部署闸门先拦住的**("前端 dist 比源码旧"),
   也就是上一轮刚补的那道门当天就发挥了作用 —— 换成以前,会再次静默部署旧界面。

## 顺带清理

验收截图用的 2 封样例邮件已删、随之产生的空会话已归档;测试账号外观已重置。
2026-09-14 09:07:49 +08:00
ca96f77a4b fix(appearance): 浏览器里同步从来没跑起来(三处叠加)+ 部署链加"前端不得比源码旧"闸门
用户说「webui 你也没改呢」。查证:**部署是活的**(本地产物 = 线上产物、CSS 里壁纸
修复的规则都在、入口 `Cache-Control: no-cache`、资源哈希+immutable)——是我新加的
"外观存服务端"那套在**浏览器**里根本没生效。沿途挖出三处叠加缺陷 + 一处部署链真空子:

## ① 路径写成绝对 `/api/v1/...`(双前缀 ⇒ 404)

`resolveBase()` 解析出来的 base 已经含 `/api/v1`(默认就是它),既有调用者传的都是
`/me/mail/inbox` 这种**相对基地址**的形状。我写成 `/api/v1/me/appearance` ⇒ 实际请求
`/api/v1/api/v1/me/appearance` ⇒ 404。
**单测全绿却没抓住**:我只断言了方法、报文,没断言 URL。现在补了 URL 判据
(含"不得出现 /api/v1/api/v1"这条)。

## ② 浏览器密码登录只有 cookie、没有 Bearer ⇒ `currentAuth()` 直接短路

`currentAuth()` 原先要求 token 非空,而密码登录只建 cookie 会话(桌面端粘贴用户密钥
才设 Bearer)⇒ WebUI 里 `pull/push` 从来没跑过。已放宽为"只要有 base",并补了两条
判据(cookie 会话也要能拉、能推)。
("完全没有网关地址 ⇒ local-only"这条判据删掉了:`resolveBase()` 总有默认值,
那个状态到不了 —— 判据不量够不着的对象。)

## ③ 服务端"无记录"时拿默认值覆盖本地

首次启用同步时每个老用户都会中招:服务端回默认值(theme=system / bg=none),
客户端照着应用 ⇒ **用户已有的主题与本地壁纸被静默重置**。现在改为"以本地为准、
推上去认领",并补判据(含"有记录时以服务端为准"的反向对照)。

## ④ 部署链真空子:dist 比源码旧也能"同步成功"

改完源码忘了 `vite build`,`redeploy-gateway.sh` 照样把旧 dist 打进二进制 —— 这正是
①在线上一直没被发现的直接原因。现在部署脚本会比对 `src/**` 与 `dist/index.html`
的 mtime,旧了就 **FAIL** 并提示先 build。

## 顺带:我自己在真实账号上留的测试数据

线上 E2E 时我把 `theme=dark/bg=preset(dusk)/dim=35` PUT 到了 **jianf** 这个真实账号
(应该用测试账号)。已删掉那条记录(接口现在回 `saved:false`),配合 ③ 的修复,
用户本地那份外观会被认领上去而不会被覆盖。

## 验证

- 浏览器实测(自带无头 Chromium + 真实功能,非注入 CSS):
  `200 GET /api/v1/me/appearance` → `data-bg=on`、`dark=true`、本地缓存写入 ✓
- 三张对比图(自定义图片档 / 关背景 / 预设渐变)已随邮件发给用户
- 前端 253 条(含新增 URL 判据与 cookie 会话判据)、server 10 包、打包一致性全绿
2026-09-14 08:59:32 +08:00
69b1887eae chore(deploy): 清理部署残留(1.0GB)+ 把清理写成可复跑脚本
用户说「清理一下」。实测 /opt/agentmail 累计 1.3GB:
- 网关旧二进制 33 份 × 24MB ≈ 790MB(每次 redeploy 留一份,从 09-05 起没清过)
- 插件快照:opencode 6 份 339MB、dsh 7 份 195MB、zcode 16 份、pi 9 份
- /tmp 里的部署前数据库备份 10 份(tmpfs,占内存)

新增 `deploy/prune-deploy-artifacts.sh`(可复跑,不手敲 rm),两条硬规矩:
① **保留回滚窗口**:网关留最新 3 份、每个插件留最新 3 份快照,`current` 永远保留
(发布纪律要求有回滚目标,所以不是全清);
② ★ **绝不删正在使用的快照**:扫 /proc 的 cmdline 与 cwd,命中就跳过。

执行结果:**/opt/agentmail 1305MB → 302MB(释放 1003MB)**,7 个服务全部 active,
`current` 指向未变,`check-deploy-drift` 仍报「四个宿主都在跑当前代码」。

## 过程中的一处自纠

"在用"闸门第一版有**自我匹配**缺陷:我的测试命令把路径写在命令行里,于是扫 /proc 时
扫到了自己 ⇒ 对不存在的路径也判"在用"(与 `pkill -f` 杀掉自己那条命令同一类)。
已改为排除本进程及其祖先链,并**换正确方式重测**(路径从文件读、不进 cmdline):
在用快照判"在用" ✓、不存在的路径不误判 ✓ —— 判据两侧都验过才敢执行删除。

文档:docs/DEV-TOOLING.md 记了用法与那两条规矩。
2026-09-14 08:43:54 +08:00
51789ee72e deploy: 标准目录部署 —— 运行时不再依赖源码目录
用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
查证后有三处实证(都不是猜测):

1. ★ **失败通知钩子执行的是仓库里的脚本**
   (`/home/program/agentmail/deploy/service-failure-notify.mjs`,8 处引用:
   4 个 drop-in + zcode/zcode-mail-bridge 单元 + agentmail-failure-flush)。
   仓库一挪/一改名,故障通知就**静默失效** —— 而那条管线正是用来报告服务故障的。
2. **opencode-serve 的 cwd 就是源码目录**(`WorkingDirectory=/home/program/agentmail`)。
3. ★ **仓库里的 `deploy/*.service` 是旧的源码目录版本**(ExecStart 指向
   `/home/program/agentmail/plugins/...`),而机器上的已被改过 —— 也就是说
   **谁跑一次 install.sh 就会把部署退回源码目录**。drop-in 更是只存在于 /etc 里,
   仓库完全没有它们。

## 改动

- **唯一真相**:`deploy/systemd/` 镜像 systemd 目录结构,收进全部单元与 drop-in
  (8 个单元 + 12 个 drop-in),路径全部改到 `/opt`。
- 运行时脚本装到 **`/opt/agentmail/bin/service-failure-notify.mjs`**(自包含,
  无相对导入);`install.sh` 与 `redeploy-gateway.sh` 都会幂等地装它。
- opencode 的 cwd 改为 `/opt/agentmail`(与网关一致),已重启生效
  (`/proc/<pid>/cwd` 已核)。
- 删掉 `deploy/*.service` 的旧副本,避免两个真相。
- dsh 的 `cordis.patch.yml` 注释里的安装示例也改到标准位置(运行时用的是环境变量,
  那条注释是唯一残留)。

## 判据(`deploy/check-deploy-drift.mjs` 新增「标准目录部署」四条 + 自检)

① 任何 unit/drop-in 都不得引用源码目录;② 已安装单元与 `deploy/systemd/` 逐字节一致;
③ 通知脚本在标准位置且可执行;④ 各服务的 cwd/ExecStart 不在源码目录
(homeagent/dsh/zcode 是**别的产品**的标准位置,按白名单放行)。
自检两个样本:引用源码目录的必须红、干净样本必须绿(证明不是恒真)。

顺带修掉一处**真漂移**:仓库里 dsh 的 `dist/index.js` 落后于部署件(改了 src 没重建),
重建后 `check-deploy-drift` 报「四个宿主都在跑当前代码」。

## 复核

- `/etc/systemd/system/` 引用仓库:**0** 个文件;`/opt/agentmail` 下只剩旧二进制/备份里
  的构建路径(Go 嵌的源码路径,无害)与一条注释。
- 四个宿主都在跑当前代码;标准目录四项全绿。
- 全部服务 active,opencode/网关 cwd 均已在安装根下。
2026-09-14 08:38:33 +08:00
5b6fef764f feat(appearance): 主题与壁纸搬到服务端(账号级)—— 回答"为什么背景存在本地"
用户质问:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写
localStorage:换设备/换浏览器就没了,而且**多账号共用一份**(键是全局常量
`agentmail.background`)—— 同一台机器换账号背景不跟着走。而 localStorage 的 ~5MB
配额也解释了客户端那套"压到 2.4MB 以内"的限制本来就是为本地存储设计的。

现在:**服务端是权威(账号级),本地只是缓存**(首屏秒开、离线可用)。

## 服务端

- 新表 `user_appearance`(两种方言),用**列**而不是 JSON:blob GC 要一眼看出
  "这张图还有没有人用"。
- `/api/v1/me/appearance`:GET / PUT(主题+背景档)/ POST image(multipart)/
  GET image / DELETE image。鉴权同其余 /me/*(cookie 或 Bearer)。
- 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256;上限 4MB 兜底
  (客户端会先压到 ~2.4MB),只收图片类型(非图片 415 —— 浏览器会把非图片渲染成
  空白,用户只会看到"设置了却没变化"),超限 413 不静默截断。
- ★ **blob GC 的引用源加了这张表**:我在实现前先读了 `SweepUnreferencedBlobs`,
  它只认 attachments / calendar_attachments。漏了这一处,壁纸会在下次 GC 时被当
  孤儿删掉,而库里那行还在 —— 表现为"图 404、设置却显示已设置"。判据同时验了
  壁纸存活**与**孤儿确实被清(否则"还在"可能只是因为 GC 没跑)。

## 客户端

- `lib/appearance.ts`(纯函数:两侧形状换算、data URL→Blob)+ `stores/appearanceSync.ts`
  (pull / push / 去抖订阅 / 账号切换重新拉取)。
- 三条不变量都有判据:拉取以服务端为准;★ **拉取不会再推回去**(否则是自触发回环,
  一次拉取顺带一次 PUT,服务端 updated_at 被无意义刷新);本地改动会推上去。
- 壁纸**只在换图时上传一次**(几 MB 不该每次 PUT 都跟着走)。
- 降级**必须可见**:未登录/不可达 → `local-only`,推失败 → `pending`,背景设置里
  有徽标与说明("已同步 / 待同步 / 仅本机")。静默降级会让人以为已经同步,
  然后在另一台机器上发现没有 —— 正是这次的缺陷。
- 图片用**带认证的 fetch** 取回再转 data URL:`<img src>` 发不出 Bearer,而
  `?token=` 会把密钥写进历史记录与服务端日志(明确不做)。

## 判据

- Go 10 条:往返、★多账号隔离、非法值归一、上传/取回字节一致、非图片 415、
  超限 413、删除、未登录 401(五个端点)、★GC 存活 + 孤儿对照。
- 客户端 10 条:形状换算、image 无图退回 none、越界夹取、拉取生效、
  ★拉取不推送、推送 payload、未登录/500 → local-only、推失败 → pending、
  ★壁纸只上传一次。
- 全量:server 10 包全绿、客户端 249 通过(含打包一致性判据 —— 它先红后绿,
  因为前端改了必须重打安装包,这条护栏是先前特意留下的)。

## 线上验证与交付

- jianf 设置 → 回包 saved=true;**gui-lab 读到自己那份默认值**(隔离生效);
  gui-lab 上传 67B PNG → 取回 sha256 一致、`has_image=true`;DELETE 后 404。
- 网关已重打(WebUI 内嵌)并部署;Electron 安装包已重打(AppImage + deb)。

遗留:鸿蒙端还没有外观功能(数据已在服务端,将来可直接读);本地缓存仍在(离线可用)。
2026-09-14 08:32:22 +08:00
1ec88866ac fix(permission): 另外四家桥的"人类说明"缺口 —— 三家修、一家本来就有
承接 453f451(pi 桥)。用户批准后把同款缺口在其余四家逐一核对:
**zcode 本来就有**(`说明:${decision.note}`),homeagent / dsh / opencode 三家缺。

## homeagent(Go,能完整修)

SSE 事件结构里**根本没有 Note 字段**(json 里只有 decision/decided_by)⇒ 备注在
解码那一步就没了。补上字段,并把提示词抽成纯函数 `permissionDecisionPrompt(evt)`,
加了判据(说明必须出现 + 反向对照:无说明/空白说明不得凭空造出说明段)。
构建(`go build -buildmode=plugin`)后 install 到
`/home/newqqagent/plugins/homeagent-mail-bridge/plugin.bin` 并重启,已核验部署件
含新符号(`grep -a`,中文用 strings 查是查不到的)。

## dsh / opencode(平台回执放不下理由 → 分两步)

两家的审批回执都是**三态字符串**:DSH `ApprovalOutcome` 只有
allowed-once / rejected / cancelled / unavailable,openCode 只有 once / always / reject
—— **没有地方放人类的说明**。所以:

1. 提示词("你之前发起的权限请求已有结论:…")统一走 `permissionPrompt(data)`,
   带上 `用户的说明:…`。dsh 原有**三处**内联文案(续谈/新会话/通知投递),
   措辞分叉正是这类信息漏掉的地方 —— 判据直接钉"只有一处拼这句话"。
2. 带说明的决策**另投一趟通知**,让模型在会话里看到理由。代价是多一轮;比悄悄
   丢掉人的指令轻(原缺陷就是丢了指令,模型把同一条命令换写法又问一遍,连问 9 次)。
3. 决策回执不再被当成"新任务"(内容已随 permission_decision 交付),并记下
   `decision_mail_id` 防重复 —— 与 pi 桥同源。

判据:dsh / opencode 各 5 条(含"拿缺陷时的源码形态喂进来必须判红"的自检)。

## 部署与代价

- dsh → 快照 20260914-081456、opencode → 20260914-081516、homeagent → 新 plugin.bin,
  三家的服务 active 且心跳/连接已核。
- 重启 dsh 时它正在"续谈"一封邮件(08:10:45 日志)——事后核对:那一轮**已回完**
  (faad0037 的 parent = 4919aa88),没有丢活。
- 套件:dsh 372、opencode 323、homeagent go test ok、zcode 382 全绿。
2026-09-14 08:17:23 +08:00
ababe4ae56 fix(webui): 壁纸被不透明层盖住 —— 玻璃不再层层相乘 + 浅色表面类全部接管
用户报的:「webui 目前在壁纸底上叠了太多不透明层,导致壁纸效果很差,几乎看不出来」,
追问后补充:「不只是玻璃,而是很多界面是不透明的」。两句都成立,是两个叠加的原因。

## ① 玻璃层层相乘

每层面板都吃同一个不透明度 a,两层就是 1-(1-a)²。a=0.82 时两层 0.97、三层 0.995
—— 壁纸在数学上被吃掉,而且每层各做一次 16px 模糊,图案被糊成灰块。
改法:玻璃只出现一次(外层 0.62 + 18px 模糊;嵌套层只留 0.3 色调且不再模糊;
第三层透明)。同时把遮罩 24%→12%、照片自身模糊 8→4px(那张被洗白的锅之一)。

## ② 14 个浅色表面类根本没被接管("很多界面是不透明的"就是这条)

旧规则只接管 white / gray-50 / slate-100,而源码里在用的是
gray-100(24 处)、blue-50(17)、red-50(13)、blue-100(8)、gray-200(8)、green-50(8)…
—— 全是实心的,正好盖住壁纸。

清单改为**从源码用法生成**(`scripts/gen-background-takeover.mjs` → 生成的 CSS,
构建前自动跑),因为手写清单必然烂;判据保证"源码里出现的浅色表面类必须都被接管",
并明确排除页面底(gray-50/slate-100 必须保持**全透明**,不能被改成半透明)。
按钮/徽标(*-600/700、chrome-600/700)**故意保持不透明**:小控件可读性优先
(index.css 里原有注释记着实测 4.46:1 的教训)。

## 判据

- `test/background.test.mjs` 23 条(新增 8 条):玻璃算式(两层 ≤0.85)、外层 ≤0.70、
  嵌套规则存在、**判据自检**(旧值 0.82 必须算得出 >0.95)、表面类覆盖、页面底不得进清单、
  清单非空跑。犯过一次错:第一版把判据追加在 `process.exit()` **之后** ⇒ 根本没执行,
  从"测试数没变"才发现。
- `test/manual/wallpaper-layers-verify.mjs`(真实渲染,自带无头 Chromium):
  用**纯红壁纸读绿通道**测有效不透明度(纯色下 backdrop-filter 不影响读数)。
  面板 p95:**修复前 82% → 修复后 62%**;反向对照(改回旧值)能分辨 ≥10 个百分点;
  深色照片面上面板仍是浅底;关掉壁纸的同坐标对照更实。5/5。
  过程中作废了两个指标:只看绿通道会把深色元素误判成"盖死";"面积占比"类阈值
  (Δ≥8/≥40)在 0.97 时仍会蹭过门槛,饱和而无分辨力 —— 同坐标比值才可信。

## 交付

已部署(网关内嵌 WebUI 重建):线上 CSS 已含 `--bg-glass-inner`、嵌套规则与 14 个接管类。
2026-09-14 08:09:22 +08:00
453f451fbb fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务
用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
2026-09-13 22:47:25 +08:00
773acd079f harden(migrate): 抄送回填取切换时刻改 CAST(TEXT) —— PG 的 timestamptz 会被扫成 time.Time
判据只在 SQLite 上跑过:把 MIN(read_at) 扫进 sql.NullString 在 PG 上依赖驱动返回类型,
不可靠。改成 CAST(... AS TEXT) 再解析,并把 PG 的文本形态(+00 时区后缀)加进可识别的
layout 列表。认不出来时退回现在= 把当下已有的已读邮件全算作迁移前 —— 首次升级时
正是对的,且有一次标记守着不会反复跑。
2026-09-13 16:22:00 +08:00
2e5d84330b fix(gateway): 已读迁移对抄送方保持行为不变 —— 我上一版迁移把桥的补投判据放大了
上一提交(1619399)把已读改成按读者记录后,回填只把历史 `status='read'` 记到**主收件人**
名下 —— 对抄送方等于"突然多出一批未读旧邮件"。这不是理论风险,**当天就在野外发生了**:

  opencode 桥(部署后 46 分钟):
    16:06:48 [mail-bridge] 已接入 http://127.0.0.1:8180,身份 opencode(密钥认证)
    16:06:49 [mail-bridge] 补投 2 封离线期间的邮件(共 2 封未读)
  → 它对 05:42 那封「打个招呼」**又回了两次信**(08:07:21Z / 08:08:37Z)

即桥的 `pending_mails = CountUnread` 因迁移变大 ⇒ 桥一重启就把旧信当漏投重放并再次回信。
两个人工探针当时都只覆盖主收件人,恰好绕过这个面("同一封被多人共享"的坑,
判据必须站到每个收件人各自的位置上)。

修法(`backfillMailReadsCC`):迁移前的邮件(`created_at <` 切换时刻)凡 `status='read'`,
给它的**所有收件人**(主 + 抄送)各补一行 —— 与旧模型下"所有人看到的都是已读"完全一致;
迁移后的邮件一律不碰(那条界线是判据核心:越界就会把"某个人读过"错写成"所有收件人都读过")。
切换时刻:迁移时写进 `app_meta(read_model_switchover_at)`;老库没有这个键时退化成
`MIN(mail_reads.read_at)`(那张表的第一笔写入就是回填批次)。

判据 `internal/db/migrate_reads_test.go`:迁移前的老邮件必须补到抄送方、**迁移后的不能碰**、
重复执行不重复插。扰动验证:去掉时间界线 → 判据红(补记 2 行,期望 1)。

实测收口:
- 迁移日志「再给 4 个抄送方补记历史已读」;"抄送方仍算未读(已读邮件)" 计数 **0**。
- **重放反证**:重启 opencode / pi 的桥 → 无"补投"行、3 分钟内 0 封新邮件 ✓
  (对比修复前 opencode 重启即补投并回信)。
- 清掉那 2 封由这次迁移产生的误回信(happy-pixel 回到 6 封)。
- 全量 server 10 包 + client/electron vitest 239 + 五 Agent 演练 20/20 全绿。

教训:**语义迁移必须让"可观测状态"保持不变**,新语义只对迁移后新增的对象生效 ——
否则用户会看到一批凭空冒出来的未读,而下游(这里是桥的补投)会把它当真实信号动作。
2026-09-13 16:18:37 +08:00
1619399470 fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。

实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
  gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
  而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。

三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。

改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
  单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
  status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
  用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
  写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。

★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。

判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。

线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** 
2026-09-13 14:25:44 +08:00
49ec22f916 fix(pi): 等人点头的 worker 不再占并发额度,也不会被硬超时杀掉
实测(今天全 Agent 演练时撞到的):pi 的 `maxWorkers=3` 被**三个正在等人工授权**的
worker 吃满,于是新邮件只能排队 —— 而人可能十分钟后才看邮箱。清掉卡住的请求后队列
立即排空,机制本身没错,错的是"等待"被当成了"在干活"。

两处改动(`src/pool.mjs`):

1. **等待期间让出并发额度**。worker 发 `permission_pending` 时把它的 entry 标为
   parked,`pump()` 只数"在干活"的(`activeCount()`)。停放另有上限 `maxParked`
   (默认 5,防止内存无界:每 worker 约 140MB);超出后仍占额度并打日志说明。
   决策到达(`routePermission`)时解除停放,回到额度里。

2. **等待期间暂停硬超时**。硬超时的用途是回收**卡死**的进程,而等人点头不是卡死:
   停放时清掉计时器,决策到达后重新起一个完整窗口。否则 worker 会在人还在读邮件时
   被 SIGKILL —— 那次工具调用直接消失,人后来批了也没人接(这类"批了没反应"的现象
   与此吻合)。

判据(`test/pool.test.mjs` 新增 3 条):
  · ★ 等待授权的 worker 让出额度:另一个会话的邮件必须能开跑
  · ★ 等人点头期间(800ms > 300ms 硬超时)不得被强杀,且恢复后能跑完
  · 停放有上限:超出后仍占额度(不无限超发)
**扰动验证**:整块回退到 HEAD → 3 条全红;修复后 3/3。全量 pi 套件 420/420。

已部署(快照 20260913-131216,桥重启并重新心跳)。
2026-09-13 13:14:56 +08:00
35f71d5144 test(webui): 全流程演练的两处判据修正(同主题请求的定位、工作区目录)
第三次跑通 **37/37(0 失败 0 无法判定)**,含真 Agent 闭环:写信带附件 →
pi 请求 bash 授权 → **在界面点同意** → 决策落库 → pi 回信把附件原样带回
(sha256 一致)→ 回信出现在收件箱。

两处都是**判据/前置条件**的问题,不是产品缺陷,但都会伪装成产品故障:

1. 授权页上同一 Agent 的请求主题**一模一样**("是否允许执行 bash?")。原先按主题
   `.first()` 选行,于是点到了**别的会话那条旧请求**上 —— 网关回 `expired` 警告,
   界面看起来"点了但没生效"。现在按**本轮会话别名**定位分组容器
   (`header.locator('xpath=..')`,DOM 探针确认头与请求行同容器),且**只在折叠时**
   才点头按钮(已展开时再点会把它折起来,那正是第二次又落到别人行上的原因)。

2. **工作区寻址里的 path 必须是已存在的目录**,否则桥按设计回退到自己的兜底目录
   (`~/.pi/mail-sessions/<会话>`),产物就落在别处而不是我指定的目录。脚本现在
   先 mkdir —— zcode 那次也栽在同一条规则上。

顺带记录一个**产品层面值得决策**的现象:pi 的 worker 池上限是 3,而**"等人点头"的
worker 占着池子**。我上一轮的误点让 3 个 worker 全卡在等决策上 → 池满 → 新邮件排队
(设计如此:满载排队不丢信),于是我 300s 等不到回信。清掉卡住的请求后队列**立即
排空**、排队那封信被取出并起了一轮。也就是说:人在开会时,同一个 Agent 接不了新活。
是否把等待授权的 worker 挪出池子(或单独给额度),需要你定。
2026-09-13 12:06:40 +08:00
2922fb711f fix(deploy): 故障通知的三处缺陷 —— 死信目录、隔夜补投、失败无原因
起因:用户邮箱里「[dsh] 桥服务异常终止」反复出现。查下来有两层。

**第一层:崩溃本身(已修,是历史)**
dsh 在 2026-09-12 11:40 起崩溃循环,根因是当时那次插件快照切换后
`/root/.dsh/profiles/web/node_modules/dsh-mail-bridge/cordis.patch.yml` 不存在
(`failed to read overlay … ENOENT`)→ 起不来 → systemd 反复重启。
现在快照里该文件在、dsh `NRestarts=0`、今天 0 次失败。

**第二层:通知管线本身坏了(本次修)**

1. ★ **死信目录**:`zcode.service`(应用单元)既没有 `AGENTMAIL_AGENT_NAME` 也没有
   密钥,报告就落进 `unknown-agent/` —— 而 flush **只读自己那个 Agent 的目录**,
   于是 25 份 zcode 崩溃告警永久投不出去。修法是两件事:
   · `resolveIdentity()`:身份按「单元 env → 单元名推导 → /etc/agentmail/<agent>.env」
     解析;`zcode.service`→zcode、`pi-mail-bridge.service`→pi……
   · 支持 `AGENTMAIL_AGENT_SECRET`:zcode 只配了 secret 没有 key,而脚本原先只认
     Bearer key ⇒ 就算目录对了也发不出去(网关的 AgentAuth 两种都认)。

2. ★ **隔夜补投**:补投原先只在**同一个单元**的下次 `ExecStartPost` 跑,于是
   网关不可达时攒下的报告要等到那个服务自己重启才补投 —— 实测 4 封 Sep-12 的
   告警在 Sep-13 10:35 才到。现在新增 `agentmail-failure-flush.timer`(每 10 分钟
   `--flush-all`),它遍历所有 Agent 目录、按目录名逐个解析身份后补投。
   过时报告还会在主题与正文上标 **「补投:这是 N 分钟前的故障报告,不代表现在仍在
   故障」**(原先正文里只有昨天的时间戳,读起来像刚崩)。

3. **补投失败只报数不报因**:`catch { failed++ }` → 日志只有 `spool: sent=0 failed=4`,
   没人知道卡在哪。现在每条失败都带回原因(HTTP 状态码/网关不可达/身份未配置)。

4. 顺带两处准确性问题:
   · 主题写**单元名**而不是笼统的「桥服务」—— 25 封标题写着"桥服务异常终止",
     实际崩的是 zcode **应用**单元,照标题去查桥方向就错了。
   · `created_at_ms` 是我们的元数据,但 `/mail/send` 是**严格解码**的(实测 400
     不认识的字段)→ 发送前剥离,线格式保持干净。

判据:新增 `deploy/service-failure-notify.test.mjs`(12 条,含假网关做真实投递、
严格解码断言、补投标记的正反两向)。端到端验证:模拟"没有身份的 zcode.service
崩溃"——修复前落 `unknown-agent/` 永久死信;现在**真的投出去了**,且
`from_name=zcode`、主题 `[zcode] zcode.service 异常终止 #…`。

积压清理:27 份(dsh 2 + unknown-agent 25)全部来自 09-12 那两轮崩溃、事件已在
邮箱与 journal 里出现过,按**不再补投**处理(避免把隔夜告警灌进邮箱),
原始文件留档 /root/gotmp/failure-spool-backlog-20260913.tar.gz(600),
spool 目录留 README.md 说明。
2026-09-13 11:06:01 +08:00
77699e216b fix(webui): 新到的授权请求藏在折叠分组里 —— 徽标动了,内容看不见
用户报告:"我点到授权界面,才更新显示授权请求"。

先排除了推送本身:实测徽标是**实时**更新的(gui-lab 授权 7→8、jianf 1→2,
都没导航)。问题在内容:`PermissionList` 的展开状态
`const openSet = expanded ?? new Set(autoOpen)` —— 一旦手动点过一次,
`expanded` 就冻结成"点的那一刻"的快照,此后新到的待决请求落在一个折叠的分组里:
徽标数字变了,正文却看不见,直到离开再回到授权页(组件重挂载、`expanded`
回到 null、默认展开重算)才出现。

这违反代码自己的设计意图(注释写着「有待决策请求的会话默认展开:那些是在等人
动手的,藏起来等于没解决问题」)。

修法:加一条**状态迁移**判据 —— 新出现的待决邮件(`sessionId:mailId`)让它所在
的会话自动展开。用 mail_id 而不是"会话有没有待决"作判据,是因为实测撞到的正是
"会话早就有待决、用户把它折叠了,之后又来了一条";而用户在那之后再手动折叠同一
条不会被弹开(没有新 mail_id)。

验证:
  · 真浏览器复现:授权 2 → 3 而新请求正文不可见,重进页面才可见(复现成功)
  · 新增 `test/components/PermissionList-autopen.test.tsx`(3 条,含反向对照)
  · 扰动验证:撤掉修复 → 2 条目标判据红、对照判据仍绿;恢复 → 3/3
  · 部署后同一探针复验:折叠状态下新请求**立刻可见**,不再需要重进页面
  · 前端 239 测试全绿;桌面重打包与 WebUI 同源(index-jaRgHqX2.js)

顺带修掉一个**更严重的缺陷**(在做「用 zcode 写个网页」时被 agent 自己报出来的):

  fix(plugins): zcode 的 read_mail 永远返回空正文

agent 回信原话:「read_mail 返回的正文是空的,收件箱预览在「点击计数…」处被截断」
—— 它因此只看到前两条要求,写出来的页面漏了第 3 条(生成时间)。

根因在 `lib/inbox-format.js` 的渲染端:

    const body = m?.body_preview || m?.body || '';
    lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);

zcode 的 read_mail 用 `bodyLimit = 0` 表示"要全文"(HTTP 侧 `?body_limit=0`
也确实是这个语义,服务端返回了完整正文),但这里 `slice(0, 0)` 把正文渲染成
**空字符串** ⇒ 模型永远读不到全文,只能看收件箱里那段预览。

修法:`bodyLimit <= 0` 视为不截断;不截断时优先取 `body`(单封接口可能同时带
`body_preview`,那是短的那个)。四份副本逐字节同源(`check-shared-libs.sh`
通过),每个桥各加 2 条判据:0 = 不截断、不截断时优先全文。
扰动验证:退回旧写法 → 2 条红。

端到端验证:让 zcode 读全文并原样回报最后一行(一个随机标记)。
修复后它精确回出 `最后一行标记:ZTOKEN-2c7561fd` ✓ —— 修复前这不可能。

四家桥都已重新部署到新快照(pi/opencode/dsh/zcode),部署漂移检查:
「四个宿主都在跑当前代码」。测试基线:pi 417 / opencode 323 / dsh 372 / zcode 382。
2026-09-13 10:39:26 +08:00
f243355bd4 test(webui): 全流程演练(真浏览器 + 真后端 + 真 Agent 闭环)
test/manual/webui-full-flow.mjs —— 37 项判据,实测 37/37 全绿、0 失败
0 无法判定。覆盖:登录(含错密码负向对照)→ 收件箱 → 打开详情 → 写信带
附件 → **真 Agent 闭环**(Agent 请求 bash 授权 → 在界面批准 → 决策落库 →
Agent 继续 → 回信把附件原样带回,sha256 一致)→ 授权页 → 多账号添加/切换
→ 主题与背景(含自定义图片)刷新后保持 → 窄屏 → 全程无预期外 4xx/5xx。

判据要点(这轮踩过的坑都在里面):
- 授权邮件**不在收件箱列表里**(MailList 明确把 permission_request 滤到
  「授权」导航项)——在收件箱里找它必然"找不到同意按钮",看着像 UI 缺陷。
- 决策按钮必须用**精确文本**:`has-text("同意")` 会命中"已同意"/"一直同意",
  `.first()` 可能点到别处,表现是"点了但库里没有任何决策"(像后端失灵)。
- 一次「同意」只放行**一条**命令,多步任务会一条接一条地问(实测 pi 连问
  两次)——所以脚本首次用「同意」、之后用「一直同意」,两种选项都验到。
- 未登录时前端要问一次 `/auth/me`,那时 401 是**正常回答**;登录之后再 401
  才是会话丢失。判据按时间点区分,而不是无条件放过这个端点。
- 故意用错密码造的 401 也要断言"确实产生了",否则"被拒"那条可能是假绿。

顺带清掉本轮留下的测试数据:6 条待决权限决策为拒绝(剩 0),13 个测试会话
归档 12 个(余下 1 个属他人会话,服务端 403「无权归档他人的会话」——这是
对的,没绕过去)。
2026-09-13 09:30:44 +08:00
5804ba4f63 fix(webui): 自定义背景完全不可用 —— 「图片」档进不去
现象:点「图片」后背景反而被关掉,上传控件永远不出现 ⇒ 自定义图片在 UI 上
完全不可达(用户看到的正是"自定义背景不正常")。

根因:`normalizeBackground` 把「kind=image 但还没有图片数据」折叠成 `none`
(这条判据本身是对的 —— 读盘时那确实是脏数据),但 store 的 `commit()` 每次
patch 都要过一遍它,于是 `setKind('image')` 这一瞬间就被折叠回去;而上传控件
只在 `kind === 'image'` 下渲染 ⇒ 鸡生蛋问题,用户永远走不到选文件那一步。

修法:给归一化加 `keepEmptyImage`。
  - 读盘(`readStored`)保持严格:空图片状态是脏数据,退回 none。
  - 交互(`commit`)保留瞬态:允许"已选图片档、还没挑文件"这个中间状态存在。
静止态的不变量没有放松,放松的只是正在选图的那一瞬间;`applyBackground` 对
空图片本来就不铺开(不会出现 `url("")`)。

判据(都验过"修复前会红"):
  · 新增 `test/components/BackgroundPicker.test.tsx` —— 点「图片」后选文件控件
    必须出现、选完图背景必须亮、失败必须说原因、选「无」必须能关掉。
    **扰动验证**:把修复撤掉 → 组件 3 红 + store 1 红;恢复 → 22 全绿。
  · `test/stores/background.test.ts` 补 2 条:交互进入图片档要留住 /
    瞬态落盘后重读必须退回 none。

线上验证(真浏览器,部署后):线上 bundle 换成 index-CSFGa8wa.js 后 ——
点「图片」→ `kind=image` 保持 → 上传 16KB 小图与 9MB 大图都成功
(大图压缩到 1790KB)→ 刷新后仍在 → 全程无页面错误。

顺带:应用内**从未出现**过品牌图标。`BrandMarkIcon` 只用在登录页与首启页
(都是登录前界面),登录后的日常界面里一处都没有。侧栏顶端加上品牌标记
(点它回收件箱)。favicon 那条链本来就是好的(3 个 link 都 200、类型正确、
图标内容正确),已在验证中确认。
2026-09-13 08:55:55 +08:00
085e6384a0 test(electron): 多账号真实验收脚本(真起打包产物 + 两个真实账号)
test/manual/multi-account-verify.mjs:预置 accounts.json 后起打包产物,
判据落在**网络层**而不是"列表里有多少行" —— 收件箱按会话分组且默认折叠,
DOM 行数 ≠ 邮件数,用行数当判据只会得到一条随折叠状态变化的假绿/假红。

21 项判据,实测 21/21:
- 聚合 = 每个可用账号各一次收件箱请求、**各带自己的令牌**(抓请求头确认没串号)
- 徽标的存在与否是单/聚合视图的确定性差异(单账号视图不该有徽标)
- 切到单账号只问那一个账号、且带的是它的令牌
- 添加流程:无效密钥当场拒绝(不写进列表)、有效密钥写入并落盘
- 落盘 accounts.json 权限 600、内容与界面一致

★ 修一处**判据自伤**:无效密钥那步故意发 401,最初我把它算成"页面错误"导致
假红;改成"断言这个 401 确实是本步造出来的"(直接过滤 401 会把真实的鉴权故障
一起放过去)。
2026-09-13 06:38:58 +08:00
addde97600 feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与
写信账号切换留作下一轮)。

- `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并),
  16 条测试钉住每条判据(含反向对照)。
- 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+
  0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与
  http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。
- 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的
  令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。
- ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的
  服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。
- ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表
  少一整份邮件而界面看起来完全正常。
- `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它
  (`client.ts` 的 `const BASE` 快照已改成每次读)。
- UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页
  「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。
- 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、
  typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00
c7cb88d9aa chore(security): 轮换 verify-l2.sh 里那把曾进 git 历史的 user key
旧值(jianf 的 user key,1759666 起在历史里)已从库里删除,实测
`/auth/me` → **401**,即暴露在历史中的那串现在无效。新值只存在
/root/gotmp/verify-l2-token.txt(600),脚本 6/6 通过。

- 只删当前行**不等于**作废:git 历史里的密钥必须靠轮换才能失效。
- 轮换前先核对过使用面:全仓库只有这一处,/root 下的脚本与运行进程环境里都没有。
- 生产库里临时建的留档表已 DROP,那行旧记录挪到
  /root/gotmp/user-keys-rotation-backup-20260912.json(600)。
- 轮换过程本身也踩了一个小坑:sqlite3 CLI 里没有 gen_random_uuid()
  (那是网关 Go 驱动注册的函数),插入必须显式给 key_id —— 而当时旧行已经删掉了,
  于是出现了一小段「无可用 key」的空档(立刻补上并双向验证过)。
2026-09-12 23:58:10 +08:00
33e44074e7 fix(tooling): pi-lens 的项目防护其实一直没生效(JSON 里带注释 → 整份被忽略)
## 现场

跑 `pi --help` 时有一行不起眼的警告:

    [pi-lens] ignoring invalid project config …/.pi-lens.json:
    Expected double-quoted property name in JSON at position 60

`.pi-lens.json` 是 `d50c55d` 专门加的项目级防护(关掉 pi-lens 的格式化与
autofix),但它的说明写成了 `//` 注释 —— 读 pi-lens 的源码确认:

    PROJECT_CONFIG_BASENAMES = ['.pi-lens.json', 'pi-lens.json'];  // 没有 .jsonc
    raw = JSON.parse(text);                                        // 不去注释
    // 解析失败 → 打一行警告,然后**整份忽略**

所以「已经关掉了」这个结论是**错的**,防护从落地那天起就没生效过。
而它要防的是两次已发生的真实损失:一次是 `19a3161` 把约 7000 行 biome 重排
扫进功能提交(无法审查、还掩盖了一处删行),一次是 prettier 改坏
`client/electron/index.html` 并打破 `test/theme.test.mjs` 的两条断言。

失败方式是静默的:进程照常跑,只有启动时一行警告 —— 而我正是在
`pi --help` 的输出里才看见它。

## 修

- `.pi-lens.json` 改为**严格 JSON**(只有 `$schema` / `format` / `autofix`;
  `$comment` 也不行 —— pi-lens 校验未知键,会为它刷一条
  「unknown key … ignored」的警告,而噪声会训练人忽略告警)。
- 理由移到 `docs/DEV-TOOLING.md`(含 pi-lens 的源码依据、两次事故、
  以及「为什么不是把格式化器配成本仓库风格」——试过,仍改 17 个文件)。
- `biome.jsonc`(允许注释)里加指针:它只是两道闸中的一道,真正的总开关是
  `.pi-lens.json`,而那份**必须是严格 JSON**。
- 验证:`pi --help` 现在 **0 行** pi-lens 输出;反向对照(故意塞回一行注释)
  会重新出现 `ignoring invalid project config`。

## 顺带:仓库里有一个活的凭据

`scripts/verify-l2.sh` 把**管理员 user key 硬编码**在文件里(`1759666` 起就在
git 历史里)。实测它**仍然有效**(`/auth/me` → 200),属于用户 `jianf`。

- 已改为从 `VERIFY_L2_TOKEN` 或 `/root/gotmp/verify-l2-token.txt`(600)读取,
  两者都没有时**报错退出**(反向对照验过:EXIT=2,不静默跑);
- 跑通一次确认可用(6 通过 0 失败)。

**但删掉当前这行不能把它从历史里拿掉** —— 要真正作废必须轮换那把 key。
这需要你定(它是你账号的密钥,可能还有别的工具在用),见提交后的说明。
2026-09-12 23:49:55 +08:00
2e28696e74 fix(pi): 模型选择落到「宿主默认」是静默的,而且日志把它说成「平台默认」
## 现场

pi 每封来信都报 `模型 (平台默认) 失败: 402: Insufficient Balance`。
**把它读成「平台的模型没钱了」是错的** —— 真相是:

- `modelAttemptOrder(范围, env默认)` 在「平台没划范围 **且** env 没指定」时
  返回 `[undefined]`,语义是「交给宿主 SDK 用它自己的默认模型」;
- pi.env 里 `AGENTMAIL_REPLY_PROVIDER/MODEL` **都是空的**,而
  **opencode.env 里钉了 `llmsproxy`/`AUTO`** —— 这就是为什么其它三桥通、只有 pi 不通;
- 于是落到了宿主的默认:`/root/.pi/agent/settings.json` 的
  `defaultProvider: deepseek` + `defaultModel: deepseek-v4-flash`
  —— **直连 DeepSeek 云**(不是本地代理),那边的余额是零;
- 而那个名字连本地代理的目录里都没有(目录是 `deepseek-v4.1-flash`),
  所以即使指对了代理也会 403。

## 修

1. **配置**(`/etc/agentmail/pi.env`):按 opencode 的约定钉上
   `llmsproxy` + `AUTO`,并在注释里写明「留空的语义是交给宿主默认,平台管不着」
   —— 这个语义本身就是坑。
2. **代码**:把那句 `(平台默认)` 改成 `宿主默认(平台未指定模型)`,
   并在「平台未指定模型」时**显式告警**一次。一句话的日志差别决定了排查方向:
   「平台默认」把人引向平台配置,「宿主默认」直接指向 `~/.pi/agent/settings.json`。
3. **断言**:`modelAttemptOrder` 的 `[undefined]` 语义 + 「源码里不能把宿主默认
   写成平台默认」(只看字符串字面量,免得注释里的解释也被禁掉)。

## 验证

- 配置前:`env | grep -i zcode|agentmail` 那条待决请求被拒(它会把 worker 环境里的
  `AGENTMAIL_AGENT_KEY` 打进模型上下文);顺带清扫 9 条早前实验遗留的待决请求。
- 配置后真发一封进 pi 的**已有会话**:6 秒内收到回信,标记原样返回 ✓
- 四桥漂移检查全通过;pi 415 测试全绿;「平台未指定模型」告警在生产日志里出现 0 次
  (说明配置确实齐了)。

## 仍然待定(需要你定)

`/root/.pi/agent/settings.json` 的宿主默认 **仍指向 `deepseek/deepseek-v4-flash`**。
它影响**交互式 pi**(人工开着 pi 干活时用的就是它),而且那个模型名不在本地代理目录里。
桥这条路已经绕开它了,但要不要把宿主默认也改成 `llmsproxy/AUTO`
(与 opencode 一致)需要你拍板 —— 那会改变交互式会话的行为。
2026-09-12 23:31:44 +08:00
630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00
b53afd4523 chore(deploy): 部署漂移检查器 —— 快照是不是还等于仓库、进程到底在跑哪份代码
「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会
在意,直到出问题时才发现线上是几天前的行为:

  1. 仓库改了、快照没重新部署(快照是独立副本)
  2. **软链切了、进程没重启** —— `current` 只是个符号链接,切换它不会重载
     已在跑的进程,进程持有的是启动那一刻加载进内存的代码
  3. 单元/配置改了、没 daemon-reload 或没重装

`node deploy/check-deploy-drift.mjs`(`--self-check` / `--json`)把三件事变成
可判定的:逐字节比内容(**不用 diff** —— 本机 PATH 上那个是鸿蒙工具链里的,
对内容不同的文件仍返回 0)、读进程真实 argv、比进程启动时刻与软链切换时刻。

## 判据必须按宿主真实的加载方式分开写(这是踩出来的)

第一版对四个宿主用同一套判据(「单元 ExecStart 指向 current」+「进程 argv 里有
快照路径」),结果 **opencode 与 dsh 双双假红**:它们的插件由**宿主进程**加载,
argv 里永远只有宿主自己的可执行文件。永假条件在检查器里表现为「稳定的红灯」,
人会学会忽略它。

| 宿主 | 谁加载 | 从哪里读 | 切换后要重启吗 |
|---|---|---|---|
| pi / zcode | 自己的进程 | unit 的 ExecStart | 要(启动时加载) |
| dsh | dsh 宿主 | profile `link:` → `node_modules` 软链 | 要(服务启动时) |
| opencode | opencode 宿主 | `opencode.jsonc` 的 `plugin:` | 不要(会话创建时惰加载) |

## 判据自检也修了一次

第一版自检「宿主表:运行时加载的宿主必须标记需要重启」写的是
`h.restartOnSwitch !== false`,而 `undefined` 也被放过 —— 于是 pi/zcode 的
`restartOnSwitch` 缺省成 undefined、落进「惰加载」分支、判据**永真**。
现在这条判据抽成纯函数 `judgeRestart`,自检用**行为用例**盖住:
旧进程 + 启动时加载的宿主必须判红、惰加载的宿主不判红、切换后启动的一律放行、
读不到启动时刻时不据此判红、容差内不判红。

## 扰动实验(在真实对象上证明判据会红)

- 改一个仓库运行文件 → pi 的「① 运行文件与仓库一致」变红,恢复即绿 ✓
- 只把 pi 的软链 mtime 拨到刚刚(模拟「切了没重启」)→ 「④」变红并指出原因 ✓
- 把 dsh 与 opencode 的软链都拨动 → **dsh 红、opencode 绿**(惰加载该保持绿)✓
  全部拨动均已在实验后还原并复核为绿。

## 当前结论

四个宿主都在跑当前代码(pi/opencode/dsh 的快照自 11:45 起未变,其间无提交
动过它们的运行文件;zcode 是 18:57 的快照)。「三桥加载规范化」的切换本身
早已完成,缺的是这条可复跑的判据 —— 补齐了。

另外修了 `redeploy-plugin.sh` 的一个静默问题:`usage()` 按**行号范围**截取头部
注释(`sed -n '2,40p'`),我这次的注释把退出码那段挤到 40 行之后,`--help`
就少了半页。已同步范围并就地记下这个陷阱。
2026-09-12 20:39:22 +08:00
8b2206ed53 fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录
Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
2026-09-12 20:25:00 +08:00
d015d3694c test(zcode): 把门禁判决实验收进仓库(test/manual/gate-e2e.py)
它是「yolo + 自有工具面 + 我们自己的门禁」这个姿态**唯一**的决定性验证:
批了→命令真执行(比对文件内容,不只看回信);拒了→命令真没执行(文件不存在)
**且回信把成因说成「人拒绝」而不是「超时」**;同会话第三次调用仍产生新请求
并在获批后执行(幂等键按调用唯一)。之前只放在 /root 下,会随环境丢弃。

顺手修两处会骗人的东西:

1. **不要用 `python3 run.py | tee log` 再取 `$?`** —— 那是 tee 的退出码(恒 0)。
   实测踩过:脚本自己打印 5/6(有失败),后台任务通知却报 exit-code 0,
   日志里那句 `EXIT=0` 完全是噪声。现在脚本把**自己的** exit_code 写进证据文件,
   README 也改成 `set -o pipefail` 的调用方式。
   (同源问题第三次:PIPESTATUS 在 dash 下报错、`go build | head` 假绿、这次是 tee。)
2. **备注文字不再显示在通过项旁边** —— 通过的判据曾挂着「很可能又被当成重复请求
   丢弃了」这种失败提示,会把「全绿」读成「有问题」。

已从仓库路径连跑三次 13/13(不同标记),确认收进仓库后仍可用。
2026-09-12 19:11:58 +08:00
a00cbf36fc feat(zcode): yolo + 自有工具面 + 我们自己的执行门禁(headless 真正能干活了)
按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。

## 为什么必须走这条路(实测,不是推断)

MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。

于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。

## 新增

- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
  三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
  不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
  暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
  不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
  默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
  opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
  平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
  且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
  可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
  模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
  **不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
  最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
  产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
  会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。

## 修掉三个真缺陷(都是实测撞出来的)

1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
   网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
   不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
   砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
   错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
   并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
   而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
   (实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
   timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
   所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
   留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
   替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
   其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。

## 自报改成如实

detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。

## 验证

- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
  shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
  「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
  「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
  (「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
  批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
  且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
  并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
  备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
  驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
2026-09-12 19:05:54 +08:00
10ff50e899 feat(homeagent): 输出通道改造 —— 通道名 email + 注入通道对齐 + 附件走路径
修的是内核的交付判定与我们对不上的问题。

## 根因

内核判断「这一轮到底有没有交付」**只认工具名前缀** `output_send__*`
(internal/agent/core/process.go 的 isOutputDeliveryTool)。而 `send_mail`
是个普通工具,长得和 read_inbox 没有区别 —— 模型用它发完信,内核不知道
回复已经交付,照常补一句「请根据以上工具结果继续。」。模型把这句话读成
「还要再做一步」,而它手里唯一能做的「一步」往往又是再发一封信。
这个自我强化循环在 QQ 上有过真实事故(单轮 34 次 output_send__qq、514 秒)。

三处改动,缺一不可:

1. **通道名 `homeagent` → `email`**(按投递介质命名,与内核自带的
   qq/webui/cli/acp/a2a 一致),内核据此拼出 `output_send__email`。
   能力位声明必须与实现一致:原先声明了 CapFile 而 handler 不读 `type`,
   于是任何 type=file 都会静默变成一封「把路径当正文」的邮件 ——
   声明一个做不到的能力比不声明更糟(调用方无从发现)。现在 caps=7
   且真的实现 text/file/image。

2. **注入来信时用通道名,不再用插件名**。内核的约定是「用回复该走的通道名
   注入」:webui 传 "webui",clawhubadapter 传它自己的通道名。我们原先两个
   参数都传 `homeagent-mail-bridge` —— 那个名字**不是一个已注册的通道**,
   于是注入来源、模型被告知要用的通道名、实际注册的通道名三者对不上。

3. **提示词补上平台特有的投递指引**(channel.go 的 deliveryHint)。
   共用的 replyInstruction 对人来信说的是「回信不用你自己发,插件会替你转发」
   —— 那句在 dsh/opencode/pi 上完全正确,在 HomeAgent 上却与内核的 persona
   (「必须显式调用 output_send__{通道名}」)相反。实测:模型听我们的、
   返回纯文本、插件兜底转发,邮件确实到了,但内核不知道已交付。
   现在两条口径对齐:优先走通道,兜底 relay 仍然生效且**不会重复发**
   (通道 handler 先 noteExplicitChannelSend,自动 relay 据此让位)。

同时按新纪律在 plg.json 声明 `sdk: "1.2.0"`;工具链据此同步了 go.mod 的
require/replace(它自己写的,含 store 里的绝对路径 —— 换机器跑一次
hmapdev build 会重新同步)。

## 验证(真模型、真网关、真邮件)

进程构建:`hmapdev build` 报「SDK 1.2.0(项目声明 sdk=1.2.0)」、
子进程模式(协议 2);go vet 干净、go test 通过。

部署:经内核自己的 pluginmgr(POST 127.0.0.1:9876/plugins,overwrite=true)
0.1.0 → 0.2.1 → 0.2.2,每次 config_kept=true;重启后 loaded/alive/心跳齐全,
插件日志「注册完成(15 个工具 + 1 个输出通道)」。

行为端到端(两封真邮件):
- 让模型列出通道 → 回信里列出 `email | text / file / image`,与注册的
  caps=7 及描述逐字一致
- 让模型「原样重复标记」→ 内核日志 `executing tool: output_send__email`,
  回流**恰好 1 封**,插件日志「模型已自行回复,跳过自动 relay」
- 让模型建文件并以 type=file 发送 → `cmd_run` → `output_send__email_help`
  → `output_send__email`,附件 chan-file-*.txt(15 字节)原样到达,relay 让位

这两条正是改造的两个动因:内核认得交付(循环被切断),以及附件走
「一个本地路径字符串」而不是 `attachment_ids=[...]` 数组
(opencode 上模型把数组写成 JSON 字符串、连试 6 次放弃整个任务的那个失败模式,
在结构上就不存在了)。
2026-09-12 17:38:32 +08:00
3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00
42816b4d1e fix(zcode): 真模型跑通后发现的三处缺陷(register / 工具活动日志 / SSE 关停)
真模型端到端(场景 A 通过:6893 事件、175 秒、530 字回信)把三处只有真跑才
暴露的问题照了出来:

1. **register 调不通**:驱动按 pi 的客户端 API 写了 `client.register()`,
   而本插件的 GatewayClient 没有这个方法 —— 靠此前手工注册过才没暴露。
   补上后才发现第二个坑:`/agent/register` 的认证与其它接口**不同**,
   它只认 `Authorization: Bearer` 或 **body 里的 `secret`**,不认 `X-Agent-Secret`
   头(其它接口认)。实测报错:
     HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
   所以没密钥时把 secret 放进 body。

2. **一轮 6893 条事件,日志里什么也看不见**:邮件驱动的会话没有界面,
   「模型正在干什么」只能来自日志,否则一个五分钟的回合与一个卡死的回合
   在外部完全一样。新增 `describeRunEvent`,只记工具调用与权限事件
   (全记等于没有日志),并由 runTurn 通过 onEvent 逐个交出来。

3. **关停没真断 SSE**:驱动调的是 `client.stopSSE?.()`,而客户端没有这个方法
   (`?.` 让它静默变成空操作)。改成持有 createSSEClient 的句柄并在关停时 stop。

验证:单元 325/325、授权桥 e2e 5/5、驱动 e2e(桩)7/7、快照握手 12 项。
2026-09-12 16:16:37 +08:00
6a7356ebe7 fix(zcode): 软链部署下入口静默不执行 + 部署脚本支持 zcode 快照
## 缺陷:入口判断不解析软链 → 生产形态下 main() 从不执行

`mcp/server.mjs` 与 `src/index.mjs` 都这么判断是否被直接执行:

    import.meta.url === `file://${process.argv[1]}`

而 ESM 的 `import.meta.url` 是**解析过软链的真实路径**,argv 是命令行里写的那个。
生产布局是软链(`/opt/agentmail/plugins/<name>/current` → 时间戳目录),
于是两者不等、`main()` 从不执行:**没有输出、没有报错、退出码 0**。

它是被部署脚本的后置验证抓到的:第一次从快照起 MCP 服务器时
「握手 0 个工具、stderr 一个字都没有」,而同一个文件从仓库路径跑完全正常
(仓库路径没有软链)。这类缺陷只在部署形态下出现,本地怎么试都对;
表现(静默成功)又与「功能没被调用」一模一样。

修法:新增 `lib/is-main.mjs`,**两侧都 realpath** 后比较(只归一 != 「同一文件」)。
单测含目录软链、文件软链、文件不存在(保守判否,避免被 import 时误跑一遍)。

## 部署脚本:引入 HOST 概念,四个插件一条部署路径

pi/opencode/dsh 的宿主是 systemd 单元,zcode 的宿主是 ZCode 应用本身 ——
没有我们的单元可重启。于是:

- `HOST=systemd`:重启单元 + 看网关库里有没有新心跳(原有判据)
- `HOST=zcode`:**从快照起一次 MCP 服务器并走完握手**,这与 ZCode 加载插件
  走的是同一份入口代码;另查 `plugins list` 报告的路径是不是 current

后置验证带**判据自检**:握手函数对坏路径必须返回 0,否则判据本身失效就拒绝通过。
计数用 `grep -o | wc -l` 而不是 `grep -c` —— 每条响应只占一行,tools/list 的
11 个工具名全在同一行,用 -c 会得到 2,把好快照判失败(实测踩过)。

## 生产已切到快照

`/opt/agentmail/plugins/zcode-mail-bridge/current → 20260912-150547`,
`~/.zcode/cli/config.json` 的 `plugins.dirs` 已指向 current,
`zcode plugins list` 报的路径就是快照路径。仓库不再是生产代码。

验证:单测 325/325;快照握手 12 个 name 字段;官方 __zcode-plugin-host 从快照
启动正常;钩子从快照跑通 409 → block;坏路径能被握手判据发现(反向对照)。
2026-09-12 15:06:49 +08:00
c5e1d562eb feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。

## --mode 是必传的(不传等于关掉授权系统)

ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。

档位映射(依据是 CLI 产物里的规则表,不是猜):
  plan → --mode plan    (mode.plan.nonReadOnly:非只读一律拒)
  workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
  full → --mode yolo    (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。

## 一轮怎么跑

  node <zcode.cjs> --prompt <提示词> --output-format stream-json \
       --cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N

用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。

## 回信策略(与另三桥同源)

- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
  「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
  CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
  刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
  对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
  与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
  收件箱里两封说同一件事的邮件,311 与 342 字节)。

## 两处健壮性(都是实现时自己发现的真问题)

- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
  而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
  现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
  (unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
  而既没有驱动看着它、也没有本地界面看着它。

## 自报强制力只声明得出来的事

驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。

## 验证

- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
  继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
  SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
  stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
  反向验证:让 workspace.js 分叉会被抓住)

## 我自己写错并被测试抓出来的三处(值得记)

1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
   报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
   → 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
   必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
   同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
   另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
2026-09-12 14:58:36 +08:00
4b129b5f49 chore(deploy): 同源清单纳入 ZCode 授权桥用到的 4 个共用模块
permission-mode / relay-key / permission-grants / sse-client 的
实现与测试一并同步 —— 档位语义与决策判定若分叉,「同意」在 ZCode 上
会悄悄变成另一种意思。
2026-09-12 14:10:44 +08:00
c774904c0c feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人
第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。

钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
  多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
  (MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)

档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。

钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。

fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。

「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。

共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。

验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
  同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
  plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
  非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
  mcp: plugin:agentmail:agentmail

我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
   只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
   最后超时。第一版还把这个超时误报成「拒绝路径通过」。
   现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
   验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
2026-09-12 14:09:10 +08:00
e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
2026-09-12 13:47:51 +08:00
9204f019a1 fix(deploy): 故障告警按 invocation 分会话 —— 崩溃循环时告警不再被护栏挡下
# 问题(实测)

服务反复崩溃时,故障告警**送不出去**。

链路:告警邮件带 `relay:"summary"`(走免预算通道),而服务端按
`AutoAliasFor(收件人, 主题)` 派生会话别名 —— **主题相同即同一条会话**。于是崩溃
循环下多封告警全落进同一条会话,而网关对会话内**连续**的中继邮件有
`maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起返回 403:

    POST /api/v1/mail/send ... - 403 245B        (网关 NRestarts=0,一直在正常服务)

报告只能落 spool,等下次 ExecStartPost `--flush` 才补投。实测 dsh 崩溃循环
(我自己的部署缺陷所致)时的六条告警全部 spooled —— 而**服务反复崩溃正是最需要
告警送达的时刻**。

# 修法

主题带上本次启动的 `INVOCATION_ID` 短号:`[dsh] 桥服务异常终止 #5ce6a845`。
主题变了,别名与会话随之分开,每次崩溃各自可投递。

**护栏不削弱**:它针对的是 agent↔agent 互相唤醒,不是同一个人收多条故障通知。
副作用是崩溃循环产生多条会话而非一条线程 —— 对"服务在崩"这件事,分开计数比合并成
一条更容易发现问题。

`INVOCATION_ID` 缺失时退回 `Date.now()+randomUUID()` 的哈希:宁可每次不同,
也不能因为拿不到标识而退回"主题相同 → 告警又被挡下"。

# 验证(三条,含两个反向对照)

  不同 invocation      → 主题各不相同(每次崩溃各自成会话)
  无 invocation(兜底) → 每次仍各不相同(不会退回同主题)
  同一 invocation 重报 → 主题相同(一次崩溃仍是一条会话,不刷屏)

注:第二次验证第一次跑时"失败",是因为**我的测试假设错了** —— 父环境里本来就继承了
`INVOCATION_ID`,两次跑用的是同一个 id。用 `env -u INVOCATION_ID` 才真正走到兜底分支。
2026-09-12 11:56:48 +08:00
c9209e4adc fix(deploy): 失败通知不再把所有失败都说成「Gateway 不可达」
# 误导的来源

`reportFailure` 只在 `result.sent` 上分叉,而 sent 为假同时覆盖「fetch 抛」「HTTP 4xx/5xx」,
于是两种情况都打印同一句话。

# 实测代价

切换插件时 dsh 进了崩溃循环(我自己的部署缺陷所致),通知脚本连打六条
「Gateway 不可达」并写进 spool —— 而网关**一直在正常服务**(NRestarts=0),
日志里真实响应是 **HTTP 403**:

    POST http://127.0.0.1:8180/api/v1/mail/send ... - 403 245B

403 的原因是同一会话连续中继邮件撞上防「两个 Agent 互相唤醒」的跳数上限
(`maxRelayHops=5`):告警邮件的会话别名由主题派生,崩溃循环下六封落进同一条会话。

一句话把人送去查网络,而问题在策略层 —— **故障通知本身给出误导性诊断,
是「静默失败」的另一种形态**。

# 修法

新增 `describeSendError`:`Gateway HTTP <status>: <body>` 归类为「网关可达,但返回
HTTP xxx(附响应体)」;只有 fetch 本身失败 / AbortError 才说「不可达」/「超时」。

双向验证(真实走代码路径,都不实际发信):
  网关指向关闭端口     → 「网关不可达:fetch failed」
  真网关 + 坏密钥      → 「网关可达,但返回 HTTP 401:{"error":"密钥无效"}」
2026-09-12 11:52:52 +08:00
cbe5ef5cec fix(deploy): 快照改整包复制 + 存活判据改看网关心跳 —— 修两个会毁掉生产的缺陷
切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。

# 缺陷一:白名单拷贝漏文件 → 服务直接起不来

第一版 staging 用白名单:`package.json lib src index.js dist`,漏掉了 dsh 的
`cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是「少个文件」而是**服务崩溃循环**:

    Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT

白名单的失败模式天生如此:**默认不带**,漏一个就等启动时炸,而那时旧版本已经被换掉。
改为整包复制 + 剔除明确不需要的(`test/`、`.git`、`node_modules/.cache`、`*.log`)。

# 缺陷二:存活判据写成了某一个平台特有的措辞

第一版后置验证 grep 日志里的「已接入」。那句话**只有 pi 与 opencode 会打印**,
dsh 启动时只输出 `dsh web: http://127.0.0.1:3080` —— 于是**一次成功的 dsh 部署
被判成失败**,脚本按设计回滚……回滚到了缺 cordis.patch.yml 的坏快照,
把 dsh 推进崩溃循环。

改为查网关库(平台无关,且是真的端到端):

    SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT'

**时刻必须用 UTC**:`agents.last_seen` 是 UTC(CURRENT_TIMESTAMP 语义),
而 `date` 默认给本地时间 —— 拿 11:44 去比 03:44 会永远为假,于是每次部署都被判成
「桥没连上」并回滚,**门禁主动破坏生产**。已用 `date -u`。

判据双向验证过:以「3 分钟前」为重启时刻 → 命中 1;以未来时刻 → 命中 0。

# 本次切换结果

  pi       /opt/agentmail/plugins/pi-mail-bridge/current/src/index.mjs
  opencode /opt/agentmail/plugins/opencode-mail-bridge/current/index.js
  dsh      /opt/agentmail/plugins/dsh-mail-bridge/current/dist/index.js

三处配置已迁移(备份在 /root/config-backups/pre-snapshot-20260912-113826/):
pi 的 unit、opencode.jsonc 的 plugin 项、dsh profile 的 link:(含 pnpm install 重建软链)。

验证:三桥进程**打开仓库文件数均为 0**;快照与仓库文件 inode 不同(独立副本);
四个 Agent 心跳新鲜;dsh 读 cordis.patch.yml 走的就是该软链(坏快照时它起不来,
好快照时它 active —— 这条是最硬的证据)。
2026-09-12 11:47:32 +08:00
0549975555 feat(deploy): 插件规范化部署 —— 生产跑仓库外快照,可回滚
# 问题(实测的加载形态)

    pi        systemd: node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
    opencode  opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
    dsh       profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"

三个桥跑的都是**仓库工作区**。于是:一次编辑 + 重启就是上线(没有构建、没有评审、
没有版本);**没有回滚目标**(网关有 `.bak-<时间戳>`,三个桥一个都没有);
`git checkout` / `git stash` / 半成品编辑会静默改变线上行为;仓库同时兼作构建目录
(`dist/`、`node_modules/` 都在里面)。

对照:homeagent 插件本来就是这个规范形态(跑 `/home/newqqagent/plugins/
homeagent-mail-bridge/plugin.bin` 部署副本)—— 所以这里不是发明新办法,
而是把已有的那个形态推广到三个 JS 桥。

# 本提交只交付工具与门禁,**未切换生产**

`deploy/redeploy-plugin.sh <pi|opencode|dsh> [--stage-only]`:

  1 前置断言 → 2 staging(仓库外)→ 3 门禁 → 4 原子切换 `ln -sfn <ts> current`
  → 5 重启 → 6 后置验证 → 任一步失败即切回 `.prev` 并重启

后置验证不只看 `systemctl is-active`:桥可能进程活着却没连上 Gateway
(密钥失效、Gateway 未起、依赖在惰加载时才暴露),所以**必须以日志出现
「已接入」为准**,45 秒轮询。

需要编译的插件(dsh)**产出到 staging**,不写仓库的 `dist/`:先在仓库构建再拷贝
会有两个问题 —— 失败的构建也会 emit(tsc 默认 `noEmitOnError=false`)导致仓库产物
被半成品覆盖;以及生产产物与工作区之间多一条看不见的耦合。

# 依赖门禁:不能用 require.resolve

`deploy/check-plugin-snapshot.mjs` 从入口递归收集静态 import/export/动态
import/require 的说明符并逐个解析。

**判据用 ESM 而非 CJS 解析**——这是实测教训:`@earendil-works/pi-coding-agent`
的 `exports` 只声明 `"import"`,`require.resolve` 抛 ERR_PACKAGE_PATH_NOT_EXPORTED,
而插件是 `import` 它的、实际毫无问题。用错 API 会让门禁**报假缺陷**,
而假缺陷比不检查更糟(会让人去修一个没坏的东西)。

也不能只比对 `package.json` 的 dependencies:pi 的 dependencies 是 `{}`,
而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的,只查声明等于空跑。

# 已验证(干跑,未切换、未重启)

  pi       20/20 说明符可解析          opencode 23/23          dsh 26/26
  dsh 经 tsc 构建到 staging 后通过

反向验证:拿掉一个依赖 → 门禁 rc=1 并点名该说明符(不是空转)。
2026-09-12 11:36:25 +08:00
4b1946ccb4 fix(dsh): 补共用库类型声明 + 测试前强制构建 —— 堵住两个会静默通过的通道
# 1. 缺 .d.ts 导致构建失败(上一提交引入)

`lib/attachment-ids.js` 是上一提交新增的共用库,但没配 `.d.ts`。dsh 桥走
TypeScript 编译,于是:

    src/index.ts(65,40): error TS7016: Could not find a declaration file for
    module '../lib/attachment-ids.js'

pi 与 opencode 不做类型检查,所以只有 dsh 会在这里红 —— 很容易被当成偶发放过。
补上声明,类型刻意写成 `unknown`:这个函数的全部意义就是接收**不可靠的输入**,
用 `string[]` 收窄签名会让人误以为调用方本来就该给对形状。

# 2. 测试从 dist/ 导入,却不先构建 → 可以测到改动前的旧产物

dsh 的测试有两类导入:`../lib/*.js`(直连源码)与 `../dist/index.js`(编译产物)。
test 脚本原本是纯 `node --test`,**不先构建**。于是「改 src → npm test 全绿」
完全可能只验证了旧 dist —— 实测就踩到了:提交前那次 361 通过跑的是改动前的产物,
`lib` 本身有覆盖(attachment-ids.test.mjs 直连 lib),但**入口的接线没被测到**。

加 `pretest: tsc -p tsconfig.json`(npm 会在 test 前自动执行)。

反向验证:往 src 注入一个类型错误后 `npm test` **EXIT=2**(构建先失败),
而不是拿旧 dist 蒙过去;恢复后 361 通过 / 0 失败。
2026-09-12 11:36:03 +08:00
b374ce1f20 fix(plugins): 附件 id 归一 —— 修 opencode「做完全部活却发不出附件」
# 现象(全功能演练抓到,根因来自 opencode 自己的内部记录)

opencode 把四个步骤全做完了(2× download_attachment、read 读到内容、
upload_attachment 成功),却在最后一步卡死:`send_mail` 连续 **6 次**失败,
然后放弃整个任务,自述为「attachment_ids 参数有框架级序列化 bug」。

真实形状(从 opencode 的 part 表里取出的原始输入):

    input.attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]"   ← 字符串
    error: 字段 "attachment_ids" 类型不对:期望 string 数组,收到 string

模型把数组写成了 **JSON 字符串**,桥原样转发,服务端的严格解码器按契约拒收。

# 修在哪一层

**不在服务端放宽。** 那个「严格」是刻意的,挡的是字段名拼错、结构写错这类真
错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 200)。

**在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠,而适配器的职责就是把
不可靠的输入归一成契约要求的形状。对模型宽容、对服务端严格 —— 这与
homeagent 那个 Go 插件里的 `stringList` 是同一个判断(那边注释写着「也接受
单个字符串……拒绝它只会换来一次重试,而意图毫无歧义」)。

接受的形状:数组 / JSON 数组字符串 / 单个 id / 逗号或空白分隔 / 混进 null
与数字时丢掉坏的保留好的。空串与 null 一并丢掉,与服务端
`parseAttachmentIDs` 保持一致。

# 三桥同源

新增共用模块 `lib/attachment-ids.js` + 同名测试,已加入
`deploy/check-shared-libs.sh` 的两个清单(实现与测试都必须逐字节相同 ——
只同步实现不同步测试,等于允许一侧偷偷放宽约定)。
三份 md5 一致,检查脚本通过。

# 测试

`test/attachment-ids.test.mjs` 17 条,三桥各一份。含**反向对照**:把 JSON 字符串
分支去掉后必须变红(实测 3 条失败)—— 否则这条判据就是空转,事故会复发。

全量:pi 401 / dsh 361 / opencode 312,0 失败。
2026-09-12 11:20:13 +08:00