Commit Graph

65 Commits

Author SHA1 Message Date
be0693821b fix(agents): 四家桥的 read_inbox 一律按会话收窄(dsh/opencode/zcode/homeagent)
用户:「你还是没修好不同 session agent 收件箱隔离的问题」。上一轮我只修了 **pi**,
另外四家还漏着 —— 它们是**每一家各自实现** read_inbox,不修就还是漏。

## 缺陷

列表按 Agent 列(整个收件箱),而 read_inbox 按契约把**列出来的都标成已读**
⇒ A 会话的回合会把 B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时
再也看不到那封信(静默丢信,不是"少看一封")。用户是在别的 Agent 上看到它的。

## 四家的修法(各自平台能力不同,但都要"并发安全")

| 桥 | 会话来源 | 为什么这样做 |
|---|---|---|
| dsh | 工具第二参数 `exec.agent.id` → `reverseMap` | 平台就在上下文里给了会话;**不能用模块级"当前会话"变量**(同进程可能同时跑多条会话的回合,会互相覆盖) |
| opencode | 工具第二参数 `context.sessionID` → `reverseMap` | 同上 |
| zcode | `AGENTMAIL_SESSION_ID`(在**调用时**读) | 一轮一个进程,驱动本来就注入它给授权钩子用;调用时读,避免将来复用进程拿到旧值 |
| homeagent | `p.currentSessionID`(回合开始设、结束清) | Go 插件,本来就有这个状态 |

取不到会话一律**退回整体收件箱**(历史行为),不猜 —— 猜错就是静默丢信。

## 判据

- 服务端语义:`server/internal/repo/session_scope_test.go`(读 A 不动 B、列表收窄、
  计数与列表口径一致)。
- 桥侧接线:dsh 4 条、opencode 3 条、zcode 3 条、homeagent Go 1 条
  (`TestInboxURLScopedBySession`,直接断言拼出来的 URL)。
  每家都带**判据自检**:拿旧写法喂进来必须判红;dsh/opencode 还专门断言
  "不得用模块级当前会话变量"。
- **部署件**(不是仓库):四家的部署快照里都能 grep 到 `session_id=`。
- **线上实测**:用 opencode 自己的 Agent 身份请求收窄列表 —— 会话 A 3 封、
  会话 B 0 封、两者无交集、且都是全量的子集。

套件:opencode **331**、dsh **381**、zcode **385**、homeagent ok,全绿。
四家桥已重新部署(dsh/opencode/zcode 快照切换 + homeagent 新 plugin.bin 并重启),
四个服务均 active。
2026-09-14 12:07:45 +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
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
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
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
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
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
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
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
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
a60ab66a40 refactor(plugins): 删掉「摆了一套权限规则却没人调用、且形状没人能消费」的死代码
# 问题

`lib/permission-mode.js` 里的 `opencodePermissions(mode)` 实现完整、注释详实
(含 6 条实测结论)、还有 9 条测试把行为钉住;opencode 的 `index.js` 第 45 行
确实 import 了它 —— 然后**全文件再没有第二次出现**。

静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为
「opencode 的 plan 档由这套规则拦着」。实际 opencode 是 advisory。

比 9.17(给了按钮不实现)更深一层:那只是没实现,这个是**看起来像实现**。

# 它不只是没接上,而是接不上

查 1.18.29 的 SDK 类型定义,三条都排除:

  SessionCreateData.body 只有 { parentID, title };query 只有 { directory }
      → 会话级根本没有 permission,也没有 agent
  permission 只存在于 Config / AgentConfig,且是 map 形状
      → { edit, bash, webfetch, doom_loop, external_directory } → ask|allow|deny
  Permission 事件(permission.updated 的 properties)没有 action 字段
      → { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }

而函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**,
并且 deny 了 `task`(本版本 permission 的合法键里没有 task)。

所以不是「加一行调用就生效」,而是**输出没有任何消费者**。

# 为什么 opencode 的 per-session 强制做不到(如实说明)

Config / AgentConfig 是配置文件级(全局或项目级)。邮件桥若靠改配置给某条会话
加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件
把人身兼的其他工作一起禁掉,不可接受。

opencode 目前唯一的拦截路径是「它自己先问 → 桥转发 → 服务端按档位 409 →
桥当场 block」,**前提是它的配置恰好是 ask**;若配置直接 allow,桥连
permission.updated 都看不到。这就是它只能是 advisory 的原因,不是缺工作量。

# 改动

- 删除 `opencodePermissions` 及其 doc 注释、`OpencodePermissionRule` 接口声明
  (三份共用库同时改,改后 md5 仍逐字节相同)
- 删除 opencode/index.js 里那行未使用的 import
- 删除 9 条针对该函数的断言(三桥各 9 条)
- **6 条实测结论没有丢** —— 搬进 `docs/PLUGIN-CONTRACT.md` 新增的 §9.18,
  连同上面那三条类型证据与「为什么 per-session 做不到」

删掉而非保留,是因为留下的就是陷阱:函数存在、注释写着「实测过」、
测试还全绿,唯一缺的是调用点 —— 下一个人会以为档位在这里被强制。

# 验证

- `deploy/check-shared-libs.sh` → 共用模块三方同源
- 三桥 `npm test` 全绿:pi 400 / dsh 360 / opencode 311,0 失败
  (删除前 pi 409 / opencode 320,各 −9 即被删断言;dsh 另有并发提交新增测试,
  净 −9 后为 360)
- `node --check` 四个改动文件全过;ESM 动态 import 该 lib 成功,
  导出里已无 `opencodePermissions`,index.js 只引用仍存在的符号
- 本改动不影响运行时行为(删的是一个从未执行的函数与一个未使用的 import)
2026-09-11 23:59:24 +08:00
f11415834a test(dsh-mail-bridge): 断言真实注册的工具都带 type:object
上一个 commit 只断言了编译函数的输出;那个测试对一个**运行期** bug 是无效的:
bug 的本质是 defineTool 在 ESM 下静默降级,源码看着没问题、注册出去的是裸映射。

所以这里跑**真实的 apply()**,用假 ctx 截下 ctx.tools.register 的入参逐个断言 ——
源码怎么写都不算数,注册出去的东西才算数。

要点:
- 工具注册包在 ctx.effect() 里,假 ctx 必须真的执行该回调
- effect 里会起 SSE,故放子进程跑并在拿到结果后主动退出
- 断言前先确认注册数 >= 11,避免在空集合上假通过

实测该测试能抓住修复前的形态(parameters 顶层键就是 gateway_url/key_token,
没有 type/properties),并单独覆盖被上游拒过的 connect_to_server。
2026-09-11 22:28:47 +08:00
e9df62a565 fix(dsh-mail-bridge): ESM 下 defineTool 静默降级导致工具 schema 非法
现象:dsh 经 llmsproxy AUTO 走到 gozen 时 400:
  Invalid schema for function 'connect_to_server':
  schema must be a JSON Schema of 'type: "object"', got 'type: null'.

根因:插件 package.json 是 "type": "module"(ESM),而源码用裸
require.resolve / require 载入 @deepseek-ai/dsh-tools。ESM 里 require
是 undefined,require.resolve 抛 ReferenceError,被 catch { return opts; }
**静默吞掉** —— defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**
注册:

    { gateway_url: {type:'string'}, key_token: {type:'string'} }   // 

而不是合法形态:

    { type:'object', properties:{ gateway_url:…, key_token:… } }    // 

后果波及全部 11 个 mail-bridge 工具。严格的上游直接 400(实测 OpenCode Go),
宽松的(Claude 系)不校验 —— 所以只在特定 AUTO 档位暴露,表现为「某个模型
突然不能用了」。

修法:
1. 用 createRequire(import.meta.url) 取得合法的 require(ESM 标准做法)。
2. 兜底也必须产出**合法** schema —— 新增 parametersToJsonSchema,在
   dsh-tools 不可用时自己编译:required:true 提升到根级 required 数组
   (JSON Schema 不允许属性自带 required),type:object 必补。
   绝不再静默把裸映射发出去 —— 那比直接报错更难查。

实测 11 个 mail-bridge 工具全部产出合法 schema,包括真机被拒的那个
connect_to_server。测试 test/tool-schema.test.mjs 覆盖:裸映射编译、
required 提升、数组 items、空 spec、被上游拒过的实际工具。
2026-09-11 22:12:01 +08:00
4050827e5c feat(permission): 新增第三种强制力 partial —— DSH 如实自报,不再冒充 native
# 问题

审计发现 DSH 自报 mode_enforcement=native,而实测它的 Landlock 沙箱受内核 ABI
版本限制、拦截覆盖不完整(PLAN.md L5 自己写的就是 dsh = Landlock partial)。

只有 native / advisory 两个取值时,这个平台无论标哪个都是在说假话:
  - 标 native → 人会以为 plan 档是硬保证,把它当安全边界依赖;
  - 标 advisory → 又低估了它(确实在拦),而「平台无法强制」会让模型
    在本可依赖的边界上过度保守。
多一个取值比多说一句假话便宜。

# 改动

- Go models:EnforcementPartial = "partial",ValidEnforcement 接受它;
  NormalizeEnforcement 对显式自报值一律原样保留(partial 降级到任一极端都是假话),
  未知值仍然 fail-closed 到 advisory。
- 三桥共用 lib/permission-mode.js(逐字节同源):ENFORCE_PARTIAL +
  modeBriefing 三态措辞。partial 版必须同时做到两件事:
  说清「覆盖不完整」,并收回 native 那句「都会被平台拦下」的承诺
  —— 否则模型会以为越界一定被拦,于是不必自己小心。
- 前端 PermissionChip:三个点形区分(实心 / 靶心 / 空心)+ 三套 tooltip 文案;
  认不出的强制力按 advisory(与后端同方向)。
- DSH 插件心跳改报 partial。
- 顺带修正活跃 DSH 会话的历史快照:那批 native 是插件当时的**误报**,
  不是能力变化,因此把 status<>'archived' 的 dsh 会话改为 partial;
  归档会话按设计保留(不重写已结束的历史)。改前已 sqlite3 .backup 备份。

# 验证

- agents.mode_enforcement:dsh 由 native 变为 partial(心跳生效)
- Go 全量、三桥插件 320/362/409、前端 196 全绿(新增 PermissionChip 11 例)
- 三桥共用模块同源校验通过
- 关键判据:partial 的措辞与 native/advisory 两两不同,且不含「无法强制」
2026-09-11 12:04:06 +08:00
429149e118 chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么

pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑
`biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的
默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。

我在 19a3161 那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去:
约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了
server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。

# 为什么是「关掉」而不是「配置成我们的风格」

试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/
lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按
biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行,
这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的
大面积 diff,把真正的改动埋掉。

因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由
tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的
自动修复。

(pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关,
所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write`
对这些文件零改动。)

# 本提交内容

把 19a3161 里除「有意改动」外的 20 个文件还原到重排前的样子。
19a3161 中真正有意的改动是 deploy/install.sh 的扩展注册与
plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。

验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿;
`biome format --write` 对还原后的文件零改动。
2026-09-11 12:03:51 +08:00
19a3161ee4 feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
2026-09-11 11:32:47 +08:00
1692c615c6 fix(dsh): session_update 在插件重启后仍能定位活会话(权限热更新不再静默失效)
sessionMap 是纯内存表,插件重启后为空。而 session_update(人在 WebUI 改权限
档位)原来只查这张表 —— 于是「插件刚重启 + 那条会话还没收到新邮件」时,
那条更新被静默忽略:人在界面上把 full 改回 workspace,DSH 运行时仍按 full 执行。
人以为自己收紧了权限,实际上没有。

邮件新建的会话 id 是确定性的(mail-<邮件会话 id>,模型降级重试时带 -r<i>),
所以重启后可以按前缀在活会话里定位,并顺手把 sessionMap/reverseMap 补回去
—— 不补的话这条会话后续的自动转发也会一起失效。

- lib/mail-session-id.js(dsh 专用,9 例测试):id 派生与匹配的纯函数。
  放宽成前缀匹配会把 mail-abcdef 误判成 mail-abc 的会话;非数字后缀
  (-retry / -rx)也不匹配,避免误改别的会话的档位。
- 接管会话(adopted)仍是例外:其 DSH 会话 id 由平台生成、推不出来,
  重启后无法热更新 —— 已知取舍;下次投递会按邮件里的 permission_mode 重设。
- tsconfig 清理:allowJs 原先写在根层(无效位置),移入 compilerOptions 后
  会让 lib/*.js 进入 TS 程序并违反 rootDir;这些模块已有 .d.ts 提供类型,
  该选项本就不需要,直接移除。

测试:dsh 358(新增 9)/ opencode 316 / pi 405 全绿。
2026-09-11 10:47:30 +08:00
f91efd2d8d feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
2026-09-11 10:44:18 +08:00
c401eb2da2 fix(bridges): SSE 跨分片保帧 + Last-Event-ID;pi worker 有界重投;systemd 故障上报;清理误提交二进制
三个平台桥原本各自手写 SSE 解析,有两个共同的静默丢事件缺陷:
  1. evt/data 是每次 read() 的局部变量 —— TCP 把一帧
     'event: x\ndata: {...}\n\n' 切在换行处时,前半段的 event 名被丢掉、
     后半段只剩 data,整帧静默丢弃。表现为「新邮件偶尔收不到」
     「权限决策点了没反应」,日志里一个字都没有。
  2. 重连不带 Last-Event-ID —— 断线期间的事件留在服务端 per-agent 环形
     缓冲里永远回放不出来(pi 与 homeagent 已正确使用,DSH/opencode 没有)。

修法:抽出共用 lib/sse-client.js(三桥逐字节同源,check-shared-libs 校验),
把「跨 chunk 保帧状态」与「Last-Event-ID 断点续传」写对一次。pi 桥的
gateway.mjs 也改为复用同一实现(保留 reconfigure 时清断点的语义)。

pi worker 丢任务:worker 未回报 done 就退出(SIGKILL/OOM/崩溃)时,
主进程原来只记一行日志就 pump() —— 那封邮件永远没有回音。改为按
1s/2s 退避有界重投(默认 3 次),到上限记「放弃」并可观测。

systemd 故障上报:四个宿主服务接入 service-failure-notify.mjs 的
ExecStopPost/--report 与 ExecStartPost/--flush。进程内 uncaughtException
捕获不了 SIGKILL/OOM,只能由 systemd 统一覆盖。正常 stop/restart 不发信。

仓库卫生:server/server(24MB 构建产物,f9d757b 误提交)移出版本库。

测试:opencode 302 / dsh 335 / pi 391 全绿(新增 12 例 SSE 帧解析 +
2 例 worker 重投);Go 全量通过;四平台重启后在线且无错误。
2026-09-11 10:27:41 +08:00
f9d757b5e5 chore: directory migration - gateway→server, web→client/electron 2026-09-08 19:16:35 +08:00
89a4784c10 opencode+dsh: 空回复兜底 —— idle 但无 assistant 文本时回失败通知
缺口:模型 idle(轮次正常结束)但没有产出任何 assistant 文本时,
两个插件此前静默 return —— 发件人等不到任何回复也得不到交代。
(模型「全部失败」已有 renderFailureReport 兜底,但「跑完却没产出」
不等于失败,走不到那条。)

补:
- opencode relaySummary: if (!last) → 发一封「处理失败:无回复文本」通知
- dsh agent/status idle: if (!lastText) → 同款通知
- 都走 relay:summary 免配额 + relay_key 幂等
- 都只给人类来信发(Agent 间不自动转发)
2026-09-07 07:05:45 +08:00
be9f46cf73 dsh: resume 续谈也挂载 standard preset —— 修复旧会话没有文件工具
根因:startAgent 的 resume 分支 setup: undefined,注释说
「resume 从磁盘恢复,工具已在」—— 实际上工具是通过 setup 回调
presets.mount 注册的,resume 不传 setup 就没有任何文件工具。
presets.mount 修复之前创建的旧会话(setup:undefined 时代)从此
没有 read/write/edit/bash/glob/grep,用户反馈「dsh 无法看到工作区文件」。

修复:把 presets.mount 抽成 setupPreset 函数,create 与 resume 共用。
resume 恢复的是会话历史,不是工具注册 —— 两者必须都挂。

实测:旧会话 318f0703 resume 续谈后 setup 回调触发、mount 成功,
模型用 glob 列出工作区文件、read 读取、write/edit 可写,完整回复。
2026-09-07 06:31:19 +08:00
8960085152 opencode: mode_enforcement 改为 advisory(permission 参数不被 API 支持) 2026-09-06 20:39:48 +08:00
fc958f8809 opencode: 修正权限档位接线——session.create 不支持 permission,改用提示词 advisory
根因:opencode 1.18.29 的 session.create API 只接受 {parentID, title} + query.directory,
permission 字段被静默丢弃(SDK types.gen.d.ts 证实 SessionCreateData 无此字段)。
之前传入的规则不报错也不生效,plan 档下 bash 仍执行。

修复:
- 移除 session.create({permission: ...}) 调用(已被 API 忽略)
- 在 deliverMail 的 prompt 构造里注入 permBriefing(modeBriefing advisory 路径)
- permBriefing 与 homeagent 同理:如实说「这个平台无法强制这一档」
- 修正 catch (e: any) 语法错误(.js 文件不支持 TS 类型注解)
- 补充 import modeBriefing

L5 实测:opencode plan 档回信「由于当前权限档位为 plan(只读),我无法直接执行 bash 命令」
模型自愿遵守 advisory 约束(行为正确,但非平台强制)
2026-09-06 20:38:33 +08:00
ed3703295c homeagent: advisory 档位提示词 + 心跳报 advisory + permission_mode.go
- 新建 permission_mode.go:NormalizeMode / ModeBriefing(advisory 版本)
  homeagent 无工具拦截点,档位只能在提示词里告知模型,措辞如实说
  「这个平台无法强制这一档」—— 假装强制会让模型以为越界会被拦
- mailEvent 加 PermissionMode 字段(从 SSE new_mail payload 读入)
- handleNewMail 提示词追加 ModeBriefing(plan/workspace/full 三档说明)
- 心跳上报 mode_enforcement: 'advisory'
- go vet + 68 测试全过
2026-09-06 19:23:42 +08:00
0c98fab4d5 pi: 档位判定 tool_call hook + 心跳报 native
- worker.mjs: mailContext 加 permissionMode 字段(从 SSE payload 读入)
- tool_call hook 增加档位判定:
  full → 不拦截任何工具(直接 return)
  plan → 被守卫工具(bash/write/edit)一律 block + 返回原因说明
  workspace → 走原有问人流程(不变)
- index.mjs 心跳上报 mode_enforcement: 'native'
- 导入 normalizeMode/MODE_FULL/MODE_PLAN 从 lib/permission-mode.js
- 377 测试全过
2026-09-06 19:19:42 +08:00
3bb419f2f2 opencode: session.create 传入 permission 规则 + 心跳报 native
按 PLAN 7.11 P4 + 六条实测结论:
- 导入 opencodePermissions/normalizeMode 从 lib/permission-mode.js
- session.create 时根据 data.permission_mode 生成规则数组传入 permission 字段
  plan: edit/bash/task 全 deny(工具从清单消失)
  workspace: edit deny→allow(目录内) + bash ask + task deny
  full: 空数组(用平台默认配置)
- 心跳上报 mode_enforcement: 'native'(opencode 有原生 permission.ask 钩子)
- 290 测试全过
2026-09-06 19:14:19 +08:00
af61a37d9a dsh: 权限档位接线 — presets.mount 注册工具 + 三档 sandbox/approval 映射 + 心跳报 native
L3 dsh 适配:
- startAgent 新建会话时用 agentPresets.mount('standard') 注册 bash/fs/fs-search 等工具
  (与 dsh-a2a 同一套 API,经 a2a server 实测可靠)
- 新增 applyPermissionMode(session, mode):
  plan     → read-only + ask(只读,越界转邮件)
  workspace → workspace-write + ask(目录内可写)
  full     → danger-full-access + never(完全放开)
  直接 session.append sandbox/mode + approval/policy 事件(与 permissionPresets.set 同底层)
- deliverMail 两条投递路径(新建 + 接管)加 applyPermissionMode 调用
- 已有 session 路径不动:档位在首次投递时已设,后续邮件不改
- 心跳上报 mode_enforcement: 'native'(dsh 有真沙箱,不是 advisory)
- 323 测试全过
2026-09-06 19:10:35 +08:00
47fe9a5e74 test: session-scan 内存泄漏测试 + ui-sweep 界面验收脚本 2026-09-06 15:18:30 +08:00
79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00
a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00
13fcb00acc feat: relay-key 共用模块(三桥 + homeagent)
Sha256 clamp relay_key 过 160 字节上限,避免服务端 400
被 worker 当暂时失败让位,导致邮件驱动会话无本地 UI 静默挂死。

新增 relay_key.go / relay-key.js + 15 个纯函数测试。
2026-09-06 15:16:34 +08:00
784192d8c4 Agent→Agent 不自动转发 + 提示词区分新活/回复/补投
## 设计规则:Agent 之间不自动转发

自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。

规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投

`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。

## Gateway 侧:`in_reply_to` + `from_human`

- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
  resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
  因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
  → `models.Mail.FromHuman`,让补拉路径也有这个信号

## 提示词分流

三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
  回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」

## homeagent 特殊处理

Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。

## `mailEvent` 命名类型

homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。

## 测试

- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
  autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
  回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
2026-09-04 23:52:52 +08:00
375578cf9b 补 dsh waitForTurnEnd / locked 的语义测试(6 例)
全流程逐项验证时唯一没有测试覆盖的一处:两个函数都是 apply() 内的闭包,
import 不到,于是把结构原样复刻进 test/turnwait.test.mjs 验语义不变量。

锁住的六条:
- turn/end 到了立刻返回,**且注销监听** —— 不注销的话每封邮件泄漏一个监听器
- 别的会话的 turn/end 不该让本会话提前返回(session 身份判据)
- 事件永不到来时超时兜底返回,不永久挂起(模型崩了不发 turn/end 的情形)
- 超时路径也要注销监听
- locked 严格串行(交错会让 DSH 报 message already pending)
- 前一个任务抛错不让后续卡死(release 在 finally 里)
- 不同会话不互相串行

dsh 219 → 225。
2026-09-04 21:39:50 +08:00
c941fa0f87 DSH 续谈分支不刷回信上下文 → 第二封的回信挂在第一封上
## 症状

全流程回归时发现:同一条 dsh 会话的第二封邮件,回信主题写的是**第一封**的主题,
`parent_mail_id` 也指向第一封。实测(旧版负向对照):

  jianf  负向对照 第一封
  dsh    Re: 负向对照 第一封   parent=48c4fedc  ← 对
  jianf  负向对照 第二封
  dsh    Re: 负向对照 第一封   parent=48c4fedc  ← 错,应为「第二封」

模型答的内容是对的(收到甲 / 收到乙),坏的是回信的主题与线索归属 ——
在收件箱里看起来像「同一封信被回了两遍」,而第二封的回复无处可寻。

## 根因

`mailContexts` 只在两处写入:`bindAdopted`(接管时)与新开会话分支。
`deliverMail` 的**续谈分支**(`existing` 且 agent 还活着)不写 —— 于是自动转发
用的还是第一封的 subject / mailID。

pi 与 opencode 都没有这个问题:pi 的 worker 一封一进程,每次重建 mailContext;
opencode 在 `deliverMail` 开头统一刷,注释写的就是「一个会话里可能来过多封信,
只保留最近那封」。DSH 漏了这一处,语义与另两个平台不一致。

## 修法

续谈分支进入 `locked()` 后先刷 `mailContexts`(`kind === 'mail'` 才刷 ——
权限通知不是新来信,不该改回信目标)。

## 顺带:pi 主进程删掉不会被调用的 createMailTools

工具是给模型调的,而重构后主进程没有会话。`connect_to_server` 换坐标的闭环在
pool 的 `onReconfigure` 里(工具跑在 worker,worker 回报给主进程)。
schema 约束由 `test/tool-schema.test.mjs` 直接验 `createMailTools`,
不需要在主进程建一份没人用的副本。

## 验证

- 旧版负向对照:确认第二封的回信 parent 指向第一封(复现)
- 修复后:`Re: 修复确认 甲` parent=d879f429 / `Re: 修复确认 乙` parent=e8f216a2,
  各自归位
- 四平台同发一封(pi 接管会话 + dsh/opencode/homeagent 抄送):四封回信全部到位
- dsh 219 / pi 269 / tsc 0
2026-09-04 21:00:11 +08:00
8e501f041e pi 桥改为工作进程池 + homeagent 落盘投递账本
## pi 桥:模型工作下到子进程(并发模型重构)

主进程原来自己跑模型,而 pi 的会话装载是同步的:`SessionManager.open()` 走
`openSync` + `readSync` 循环把整个 `.jsonl` 读进内存并逐行 JSON.parse。实测本机
最大那条会话 23MB,`open` 一次**阻塞事件循环 118ms**;模型跑起来后 SDK 内部还有
更多同步工作。SSE 读循环在那期间完全停住 → 后续邮件卡在 TCP 缓冲区 → 久到
Gateway 认为连接死了 → 重连 → 重放。

上一轮我在几个调用点前加 `setImmediate` 是无效的仪式(让出一次之后同步工作照样
占满线程),已回退。这一轮把模型工作整体搬进子进程:实测同样的活在 fork 出的
子进程里跑,主进程事件循环阻塞 **0ms**。

- 新增 `src/worker.mjs`:一封邮件一个进程,跑完就退。权限询问期间的挂起只影响
  那一个 worker(原来 `await new Promise(...)` 等人决策,整座桥不再收信)。
- 新增 `src/pool.mjs`:**不同会话并发**(上限 3,每个 worker 约 140MB RSS)、
  **同一会话严格串行**(pi 假定「一文件一持有者」,两个进程同时装载同一条会话
  文件会让各自的内存索引看不见对方追加的行 → 会话树分叉)、满载排队不丢邮件、
  硬超时 SIGKILL 回收卡死进程。
- `src/index.mjs` 只剩 I/O 与调度:SSE、心跳、去重、分派。
- 选进程而不是 `worker_threads`:模型会跑 bash/write/edit,一次 OOM 不该带走
  整座桥。两者实测都能建起 AgentSession,但线程与主线程共享堆和生命周期。
- 「接管会话短暂持有」那套机制(adopted / adoptTimers / releaseAdopted + 兜底
  计时器)整个删掉 —— worker 退出**就是**释放,且普通会话与接管会话一视同仁。
- 轮次超时 60s → 10 分钟:60s 那个数字是「主进程要腾出手收下一封」的产物,
  worker 没有这个理由,等真结论更准(带工具调用的一轮跑几分钟很正常)。
- IPC 只传路径与标量(sessionFile / cwd / grants / 命名指纹)—— AgentSession
  跨不了进程边界,worker 每次从 sessionFile 重新装载。

`test/pool.test.mjs` +19 例,真 fork 子进程、用桩 worker(不装 SDK)跑毫秒级:
并发上限、同会话串行、不同会话真并发(判据是两个进程的心跳交错,不是 running
map 里有两个条目)、sessionFile/grants/命名指纹跨 worker 传递、config() 每次重取、
硬超时回收、权限决策路由、决策原文透传、worker 退出后清路由、mailDrivenIDs、
kind 透传、stop 先发 shutdown 再杀。跑过三组负向对照确认用例真能抓回归:
拆掉串行守卫 / 不传 sessionFile+grants / 硬超时不杀,对应用例分别失败。

## homeagent:投递去重必须落盘

用户报的重复投递不是上一轮那个 bug。两段提示词的措辞差异指出了来源:
SSE 那段写「你把本轮工作做完」,补投那段写「你把结论说出来就行」。

`deliveredMails` 是进程内的 map,而 homeagent 的插件跑在**子进程**里:

  1. 18:59:38 邮件落库,旧插件进程的 SSE 收到,注入第一次
  2. 同一秒 homed 被重启,那一轮被掐断(`context canceled`)
  3. 18:59:45 新进程起来,`deliveredMails` 是空的
  4. 心跳报 `pending_mails: 1`(第一轮没跑完 → read_inbox 没执行 → 仍未读)
     → catchUp 注入第二次

**不能只记「投过没有」**:那会把「重复」换成「丢件」—— 第 2 步里发件人没收到
回信,而记录说「已投过」→ 永远跳过。丢件比重复严重,重复至少人能看出来。

新增 `ledger.go`:JSONL 账本记两个状态。`completed` 才跳过;`delivered` 但未
`completed` 的仍然重投,但提示词前面插一段说明「上一轮被中断,别把同一件事做
两次」。落在 SDK 的 `Settings().DataDir()`;拿不到时退回 key 文件目录;目录不可
写时退化为纯内存(不比修复前差,也不该让插件起不来)。

- 判定与记录在同一把锁里:SSE 与 catchUp 两个 goroutine 的竞态
- 每行写完 fsync:这个文件的全部意义就是「进程死了之后还算数」
- 坏行跳过而不是报错退出(崩溃时最后一行可能写残)→ 那封退化为重投,安全
- 14 天保留期;过期过半时「临时文件 + rename」压实
- `shortID()` 替代 `id[:8]`:日志不该有能力 panic 掉投递协程

`ledger_test.go` +14 例,含两组负向对照(只记「投过」→ 丢件用例失败;不读账本
→ 跨进程用例失败)。

## 契约文档

`B-7.7`(MUST):子进程形式的插件去重必须落盘且区分「投过」与「跑完」,含事故
时序、两状态表、何时标 completed。已知取舍那节标注投递账本是唯一必须落盘的状态。
验收清单加「模型跑到一半重启宿主」一项。

## 生产验证

- pi 三封 → 三条会话:三个 worker PID 并存,回信「收到 1/2/3」各落自己线索
- pi 同一会话两封:严格串行(收到A 19:26:39 → 收到B 19:26:48,全程单 worker)
- pi 主进程事件循环阻塞 1ms(旧版单进程 open 23MB 一次就 118ms)
- homeagent 正常一封:账本 `c:false` → `c:true`,一封回信
- homeagent 处理中重启:日志「上一轮被中断,带说明重投」,**只有一封 Re:**
- homeagent 再次重启:账本 2 条 completed,不再投递,会话邮件数不变
- gateway 7 包 / web 176+26 / pi 269 / dsh 219 / opencode 201 / homeagent 14
2026-09-04 20:33:44 +08:00
c297468819 修 platform_session_id 无差别下发导致抄送方邮件静默消失 + homeagent 补投漏去重
## platform_session_id 只发给归属方(Gateway)

`notify.Recipients` 原来对所有参与方推同一个 `platform_session_id`,
而那是**会话级**的一个值。生产实测:会话 16845133 接管了 pi 的平台会话
`01a05a5e-…`,那封邮件抄送了 dsh@/home/program/agentmail.new。DSH 收到
同一个 id,在 ~/.dsh/sessions/ 里查不到(那是 /root/.pi/agent/sessions/
下的文件),于是走进「平台侧会话已删」那道防线抛错。

那道防线本身是对的(N-8:不能退回新建,否则人在界面上看不到这封邮件带来
的对话),它拦下的却是「别人的会话」。异常被 ctx.logger.error 吞掉,而
DSH 的 logger 不进 journalctl —— 邮件静默消失,日志里一个字都没有。

- 新增 `repo.PlatformSessionFor` 一并返回归属 Agent:以镜像
  `agent_platform_sessions.agent_name` 为准,镜像整表替换后退回
  `sessions.from_agent`(AdoptPlatformSession 写在那里)
- `PlatformIDOf` 变薄封装,保留原签名
- `notify.Recipients` 加 `platformFor(forName)`:归属方以外一律空串;
  归属抽不到时(owner 空)也不下发 —— 宁可退回当普通会话处理,
  也不让一个抽不到归属的 id 把邮件弄丢
- 归属与收件角色无关:归属方在抄送位上同样拿到

## homeagent catchUp 漏 deliveredMails 去重

`go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时窗口
重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。homeagent 的回信正文
印证了这一点(「之前的对话时序中已经收到并确认过多次了」)。另三个插件的
catchUp 都有这层双查,只有这里漏了。

去重放在循环内逐封查而不是拉完一批再筛:InjectInputSync 一封要跑几十秒,
那期间 SSE 完全可能已经投过后面那几封。

## DSH 接管失败改用 console.error

DSH 的 ctx.logger 不进 journalctl,投递失败是「发件人等不到回信」的唯一
线索。接管失败点与 SSE 分发的 catch 都改走 console.error,并带上 mail_id
与发件人。

## 前端 ccAddress 移除(收尾上一轮未提交的改动)

cc_list 里的 `.new` 是**原始意图**,不该被替换成主收件人的别名:每个抄送
方的 `.new` 是独立的 —— pi@/x.new 给 pi 开一条、dsh@/x.new 给 dsh 开另一
条,各有自己的别名。数据库存的就是原文。删掉 ccAddress,MailView /
ThreadView 直接显示 c.raw。

## 测试

- `internal/notify/notify_test.go` +3 例:挂真实 SSE 客户端读帧,验
  归属方拿到 / 抄送方为空 / 归属方在抄送位也拿到 / 普通会话全空。
  负向对照跑过:platformFor 无条件返回时两条用例失败
- `internal/repo/platform_owner_test.go` +3 例:镜像取归属、普通会话、
  镜像被清后退回 from_agent
- 修好 web/test/components/replyTarget.test.tsx(上一轮遗留的语法损坏),
  三条 .new 用例改成断言原样保留
- gateway 7 包全绿;web 176 例 + 主题 26;dsh 219 / pi 250 / opencode 201

## 生产验证

- 抄送验证:jianf → pi(接管会话)cc dsh。DSH 正常建会话并回信「收到」,
  pi 走接管续谈 —— 两封回信都落在同一条线索上(此前 DSH 那封不存在)
- homeagent 去重:连发两轮,其中一轮在邮件未处理完时重启 homeagent 造出
  SSE/catchUp 并发窗口,两轮都只产生一封 Re:
- homeagent SSE:换新 plugin.bin 后连续 89 分钟零断连(此前 2 小时 102 次
  deadline exceeded 自激振荡)
2026-09-04 19:06:36 +08:00
e8583ecd41 fix: 事故全链路修复 — 调度器合流 + 接管保护 + 补全去重 + 权限显示 + homeagent SSE 振荡
## 事故现场

用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是
自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。

## 四处因果链

**① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler)
与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了
handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 →
命名同步冲掉接管会话的别名。

修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。
handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。

**② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug,
任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。

**③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话
出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。
改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。

**④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 →
FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。

## 权限页

**CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 →
前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录
哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。

**SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人
不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出
`pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。

**mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是
Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址
`agent@path.alias`。

## NarrowStack z-index

窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层
auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。

修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文;
覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的
z-index 又会复现。

## homeagent SSE 自激振荡

根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。

1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时)
2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)`
3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID`
4. 无邮件级去重 → 补 `deliveredMails map[string]bool`
5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap
   → Read 零长切片 → 满速空转。改 `bufio.Reader`。

## 清库

保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/
calendar_events/attachments/agent_platform_sessions/relayed_mails/
permission_requests/rate_limits。测试数据已全部清零。

## 测试

- gateway 7 包全过;repo + 7 例(adopt_alias_test.go)
- web 182 例(mailGroups 新增 session_workspace 断言)
- 前端构建通过
2026-09-04 15:35:48 +08:00
55b3f9bc4e fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状

单封邮件的元信息三行都不对(生产实测那封 12:12:12):

    发件  jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
    收件  pi@/home/program/agentmail
    抄送  pi@/home/program/agentmail.new

人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。

## 三处错

**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。

**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。

**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。

## 修法

`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:

    Agent → pi@/home/program/agentmail.日程提醒:…    三段齐全
    人    → jianf                                     裸名字

`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。

## 顺带修掉 `dsh@dsh`

改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。

会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。

## formatAddress 与后端对齐

第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:

    jianf@.任务  → name=jianf path="" session=任务   ✓
    jianf.任务   → name="jianf.任务"                  ✗

现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。

## 取舍

列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。

## homeagent 日程工具的两个修复(同批)

**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。

**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。

## 验收

- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
    人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
    Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
  判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
2026-09-04 13:49:16 +08:00