Commit Graph

18 Commits

Author SHA1 Message Date
572ddb9ca7 正: 我拿"甲口径的数"去纠正 pi 的"乙口径之差" —— **是我错、它基本对**(张冠李戴)
上一封(`d96c78ba`)我对 pi 说:"82 vs 85 的差**不是** join 不 join,而是**排不排机器回信**",
并端出一个 **74**,还让它"重算一遍,我们会对齐"。**复核后:我错。**

四口径并列实测(均 `status='unread'` + 无 read 行):

    join sessions   排除机器回信    封数
    否              否              112
    否              是              103
    是              否               81   ← **我报的 85 与 pi 报的 82 都是这一格**
    是              是               72   ← 我说的 74 **是这一格**

⇒ 85 与 82 **是同一口径的两个时刻** ⇒ 差来自**漂移**,不是口径;
我端出去的 74 属于**另一个口径**。
⇒ **我把甲口径的数拿去解释乙口径两个读数的差,还把结论当成对 pi 的更正。**

这不是"数错了",是**标签错了** —— 本仓那条
**"列的类型/精度没核对,判据就静默答错"**的同族:
**数的"口径标签"没核对,比较就静默错位。** 而且我错得比单纯报错数更糟:
**我据此要求对方重算。**

⚠️ 顺带:连 82/85 那一格本身也在动(**现在 81**;`to=dsh` 那 8 封**现只剩 2**
⇒ pi 报的 4、我报的 8 **都过期了**)。
⇒ **这组数上唯一站得住的做法:只比较"同一时刻、同一口径"的两个数;
跨口径比较必须先并排重算,绝不引用记忆里的读数。**

同步修正:
- `docs/API.md`:补上四口径对照表 + 我这个错的完整记录;
- `docs/PLUGIN-CONTRACT.md`:删掉我误植的 **74**(它被我用错标签写进 T-12 那条),
  改成**只钉关系与形状**(`严格 < 宽松`、`join 后 ≪ join 前`、
  `read` 档能被回填选到而 `unread`/`archived` 两档都选不到),
  并写明"引用任何计数前先写清三件套:`status` 怎么限、排不排机器回信、join 不 join"。
2026-09-21 07:04:04 +08:00
18f194f924 改: 判据收洞(机器回信不算"回过")+ 写清 T-12 的语义空白;★ 记我自己的量错两处
pi 指出我那条 `收件人回过 ⇒ 必然读过` 有一个洞。**逐条复核成立**,已改。

## 一★ 洞:**机器回信**也被算成了"回过"

"回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 ——
模型一次都没跑起来时,桥代它回一封 `处理失败: <父主题>`:

    pi     src/worker.mjs:619 / :711
    zcode  src/index.mjs:300
    dsh    src/index.ts:1215 / :1719     ← dsh 侧也有,pi 只列了 3 处

⇒ **那封"回信"恰恰是"没读过"的证据。**

精确模板匹配实测(`status='unread'`):

    宽松:任意孩子(含机器回信)        114
    严格:**至少一个孩子不是**机器模板   105
    差                                  9   ← 与 pi 独立列出的 9 封**完全一致**

## 二★ 我自己在这条上先写错了量词(复核时才抓到)

我第一版写成 `NOT EXISTS(… AND ch.subject NOT LIKE '处理失败:%')` ——
**那问的是"一个真回信都没有",是反向量词**,实测只剩 **9 封**(正好是那批机器信)。
**判据从 105 翻成 9,照样返回行、照样不报错 —— 静默答错。**
正确写法是**带模板排除的存在量词**(已写进 docs 的 SQL)。
★ 又一次"先写结论、后复核",顺序反了。另记"并存"2 封(真回信+机器通知,均 `status=read`)
说明**不能用"父信含机器孩子就排除"的粗写法**。

## 三、T-12 的语义空白已写进契约(四个桥逐个量过)

`read_mail` 是否产生"已读",契约沉默。**四个桥各只有 1 处 `/mail/read`,且四处都只在 `read_inbox` 里**:

    dsh      src/index.ts:1340     read_inbox(:1302)
    pi       src/tools.mjs:188     read_inbox(:159)
    zcode    lib/tools.mjs:155     read_inbox(:120)
    opencode index.js:291          —

而 `read_mail` 实现体只有 `client.get(...)` ⇒ **"只取正文、不改状态"是各桥一致的设计意图**。
⇒ 写进契约:**`read_mail` 不产生已读状态 ⇒ 未读计数不等于"没人读过"**;
治法是把语义写进契约,**不是多回填几行**((a) 本来就没有权威记录,补只是猜)。

## 四★ 记我自己的量错两处(同一形状,连错两次)

做上面那张四桥表时,我**两次**把"grep 返回空"读成了"没有":

1. opencode:grep 了 `opencode-mail-bridge/src/` —— **该目录不存在**,源码在包根 `index.js`。
2. zcode:grep 了 `zcode-mail-bridge/src/`(存在,但标已读那行在 `lib/tools.mjs`)。

**`grep` 对不存在的目录不报错、只返回空** ⇒ "路径写错"与"真的没有"**读数完全相同**。
⇒ **数一个东西"有几处"之前,先确认搜索路径存在、且覆盖所有落点。**
(第二次之所以抓到,是因为我改完表**回去逐处 `sed -n '<n>p'` 对行号** ——
只按 `grep -c` 收工,这张表就会带着两个"0 处"进仓库。)

★ 另修一处**漂移的绝对数**:严格口径下"会话未归档"是 **74**(我先前写 83,是旧口径的残留)。
已在 docs 注明该数会随我们的往来漂移,**只当量级、不当阈值**。
2026-09-21 07:00:30 +08:00
a0673c0cba 补: 并列两组分布还必须写**时区基线**(pi 那封里 pi 侧用 UTC、dsh 侧用 HKT,都没写)
同一封信里的第二个表述缺陷(我核出来的):

    pi 侧分布  {1,2,7,8,9,11,12,14,15,23}          ← **UTC**(pi 日志格式即 UTC)
    dsh 侧分布 {04:11, 09:4, 11:2, 12:5, 18:2, 23:2} ← **HKT**(04 才与 crontab 对得上)

**两组数并排、基准不同、正文没写** ⇒ 读者默认同基准。

- 换算后 pi 侧 = {7,9,10,15,16,17,19,20,22,23},**仍不含 04** ⇒ 结论不变(这次没坏事)。
- **但 dsh 侧若误按 UTC 读**:`04` 点从 **11 次降到 5 次** ⇒ "聚在 04 点"的结论**会被显著削弱**。
  ⇒ **分布的第一句话是"我算的是哪个时区的哪个小时"。**

★ 一处自我更正:我第一版在这段里写"UTC 只剩 **2** 次" —— 那是把 hour=3 的值(2)看串了,
按 UTC 的 `04` 点实际是 **5** 次。写进 docs 的数字我逐条复核过,但**这一条是我改完才复核出来的**
(先把结论写下来、再回去数 —— 顺序反了;以后先数后写)。
2026-09-21 06:42:37 +08:00
d30cc65237 补: 口径 B 为何是**唯一相关**的那一个(SSE 写同一个 deliveredMails)+ 一条免费的算术自洽检查
## 一、B 不只是"约定",它是唯一与论证相关的那一个

pi 给了代码证据,我复核成立:**SSE 投递与 catchup 投递写的是同一个 `deliveredMails`**。

    index.mjs:272   deliveredMails.add(ev.mail_id);   // catchUp 循环内(B-7.6 逐封再查)
    index.mjs:414   deliveredMails.add(id);           // handleSSEEvent 的 new_mail 分支

`:414` 我核了上下文 —— 确实在 `function handleSSEEvent(type, data)` 里、
`if (type !== 'new_mail') return;` 之后、且紧接 `deliveredMails.has(id)` 去重判据。

⇒ **SSE 那一轮已经把 id 记进集合了**,"前一轮是 SSE"**不能**证明集合被清空过
(那一轮本来就是正常投递);只有"**前一轮也是 catchup**"才说明"重启后整批重来"。
**口径 A 会把"正常首投"当成"重来"** ⇒ 3 例 SSE 被误算成复发。已写进 docs。

## 二、同族第二次:分布求和 ≠ 标题总数

pi 写 "dsh 侧被重投的 **18 封**" + 分布 `{04:11, 09:4, 11:2, 12:5, 18:2, 23:2}`
—— **那个分布求和是 26。** 我按同一棵会话日志重量,**两个数各自都对**:

    重投**次数**   Σ(每封投递次数-1) = 26   分布 {04:11,09:4,11:2,12:5,18:2,23:2} ← 与 pi 的分布逐字相同
    被重投**邮件数** 投递>=2 的封数  = 18   分布 {04:8,09:3,12:3,18:2,23:2}

⇒ 把**口径①的分布**和**口径②的总数**放进一句话 ⇒ 18 与 26 打架。
**"同一字符串 ≠ 同一个角色"—— 这次的"角色"是计数单位。**

★ **"凡给出分布,就必须让分布自己求和等于标题里的那个总数"** —— 免费的算术自洽检查。
它抓到过我一次(`22/1`),又在 pi 这封里抓到一次(`18` vs `26`)。
(写记录时注意:pi 那两个数**各自都对**,错的是把它们配在一句话里 —— 别写成"pi 数错了"。)
2026-09-21 06:40:58 +08:00
3fc503235c 补: 「触发条件 ≠ 复发原因」两半拆开(pi 补,我复现后同意)+ 记下 4 vs 7 的口径差
pi 指出我上一笔把一件事写成了一个成因。拆开后是**两条**,缺一不可:

    触发条件  快照在**取件时刻**正确,投递与取件之间被读掉  ⇒ 让**这一批**投出已读的信
    复发原因  deliveredMails 是**内存** BoundedSet,重启即失忆 ⇒ 让它**下一轮**又挑同一批

**关键结论**:B-7.7 要的落盘账本只治"复发原因"那一半;
**快照失效是另一半,账本治不了**(账本挡得住"整封重投",但挡不住"取件后、投递前被读掉")。

## 4 还是 7:我一开始数和 pi 冲突,查下来是**口径不同,两边都对**

    口径A  误投前**有过任何**合法投递                        ⇒ 7/7   ← 我第一版数的
    口径B  前一轮**也是 catchup** 且间隔 >5h(排除 SSE 首投) ⇒ 4/7   ← pi 的 4

差异全在 7a350f9d / 57b0c703 / 18527c6b:前一次是 SSE 实时投递(catchup=False,仅早 1.6h)。
**对"复发原因"这条论证,口径 B 才是相关的那个** —— 要证"重启后整批重来",
得看"上一轮也是补投",而不是"之前投过"。已在 docs 里写明两个口径各自的读数。

## 另一条口径边界(我量的,pi 没提)

那 7 例全在 09-13/09-14;pi 侧 catchup 投递 **09-15 09:45 之后再没出现过**(距 09-21 已 5.9 天),
且轮次时刻分布在 07/09/10/15/16/17/19/20/22/23 点,**不在 04:00**。
⇒ "每晚重来"对 **dsh 侧成立**(crontab `0 4 * * * restart dsh.service`),对 **pi 侧不成立**
(它自己的宿主重启/唤醒,另一个触发器)。那 7 例是**历史反例**,不是"现在每晚都在发生"。
2026-09-21 06:20:03 +08:00
9336fa768b 更正: 「catchUp 从不重投已读邮件」是**我自己写宽的泛化** —— pi 侧量到 7 个真反例
我在 `ecf98d7` 往 B-7.7 里写了一句泛化:
「这 107 次投递中『投递时该读者已有已读行』= 0 次。也就是说 **`catchUp` 从不重投
已读邮件**」。**前半(我量的那 108 次里没有)成立;后半(机制上不会)是错的。**

## 反例(pi 侧,用同一个正确口径量出来的)

带 `catchup:true` 标记的投递共 63 次,其中 **7 次投递时 pi 的已读行已存在**:

    1868127e  投 09-14 09:25:31 | read_at 09:22:25 | +186s
    e211b554  投 09-14 09:26:29 | read_at 09:22:25 | +244s
    2fce271d  投 09-14 09:27:14 | read_at 09:22:25 | +289s
    c3638d4e  投 09-14 09:27:57 | read_at 09:22:25 | +332s
    7a350f9d  投 09-14 15:19:21 | read_at 15:16:12 | +189s
    57b0c703  投 09-14 15:20:44 | read_at 15:16:12 | +272s
    18527c6b  投 09-14 15:21:38 | read_at 15:16:12 | +326s

## 根因不是"未读判定写错",是"快照 + 串行"

`catchUp` 先取一份 `status=unread` **清单快照**,再**逐封串行**投(每封起一轮模型)。
这 7 封同属一批快照,而模型投完第 1 封后调了一次 `read_inbox`(`01:22:25`/`07:16:12`)
**把剩下几封一次标成已读** —— 快照早已取好,后面的照投不误。

⇒ **判据"投递时是否已有已读行"测不出这条**:它测"当下快照对不对",
而这里快照**当时是对的**,只是**投的时候过期了**。
**"判据没答 ≠ 判据答错了"** —— 0/108 没答错,它答的是另一个问题。

⇒ 这让"每天重投"多出**第三条**独立成因(前两条:`read_mail` 不标 / `markReadFor` 错位)。
也说明"修好 (b) 重投就会停"是一个**新的假绿期待**。

## 顺带:两个数会一直涨,别当阈值

107/81 → 同一条命令当天下午就是 **108/82**(每来一封新信 +1)。
已改成钉**关系**(重投次数 ≥ 该会话真邮件数;时刻聚在 04:00 整点),
并注明别当验收阈值 —— 与我删掉 `check-deploy-drift.mjs` 里"133 个文件"是同一条教训。

★ 纪律提醒也补了一句:那条"别只看当前 `mail_reads`"的注意事项
**只说明会漏判/误判,不等于不存在真反例** —— 同一口径既排除了 11 个假反例,
也捞出了这 7 个真反例。
2026-09-21 05:59:20 +08:00
ecf98d7d36 文档: 记下 B-7.7 那条债的**实测实例** —— 每日 04:00 的 dsh 重启让重投每天复发
与契约里 2026-09-04 那个事故**同形**,只是换成了"宿主被定时重启"。链条完整量到:

1. root crontab `0 4 * * * /usr/bin/systemctl restart dsh.service`(第 6 行)
2. 每天 04:00:03 `dsh.service` Stop/Start(journalctl 逐日可见)
3. 新进程 ⇒ `deliveredMails` 空(index.ts:469,内存 BoundedSet)
4. `caughtUp` 重置 ⇒ 首个心跳后 catchUp 跑一次(:470 / :524)
5. 拉 `/mail/inbox?status=unread&limit=20`(:481)⇒ 整个未读积压当天重投一遍

实测(以 `mails` 表为判据):真邮件投递 **107 次 / 81 封**不同邮件,
04 点整点占 09-18:4 / 09-19:3 / 09-20:3 / 09-21:7;投递时刻落在
04:00:23 / 04:01:23 / 04:03:23… 的 ≤5 封批次节奏上(B-7.2)。
`session/end-seed` 也逐日落在 04:00。

★ 关键判别用**时序**:这 107 次里「投递时该读者已有已读行」= **0 次**
⇒ catchUp 从不重投已读邮件,它投的是"当时确实未读"的。"未读"有两个独立成因:
(a) `read_mail` 不标已读(T-12 对已读状态沉默);(b) `markReadFor` 占位符错位
(2026-09-20 修)。两者都让"读过了"没变成"已读"。

⚠️ 我把这里算错过两次,都写进文里当纪律:
① `agent/inbox/spliced` 里混着**非邮件**注入(后台作业完成通知、续跑提示),
   按"事件数"统计会当成邮件(我一度报 09-20 有 18 次,真值 **3**);
   ⇒ 要拿 id 去问 `mails` 表在不在,才算真邮件。**同一事件类型 ≠ 同一种载荷。**
② 判断"有没有重投已读的信"**不能看当前 `mail_reads`**:11 封现在是"已读+曾重投",
   看着像反例,其实是**先被重投、后来才补标**的。必须拿**每次投递的时刻**比 `read_at`
   —— `mail_reads` 是当前状态,"投递时是否已读"是历史事实,两者不是一回事。

dsh 桥去重仍是内存态 ⇒ B-7.7 要求的落盘账本这条债**仍未销**(已在文里写明)。
2026-09-21 04:45:52 +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
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
f9d757b5e5 chore: directory migration - gateway→server, web→client/electron 2026-09-08 19:16:35 +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
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
255c799a40 feat(adopt): 邮件可投进平台上已存在的会话(TUI 与邮箱同一入口)
人在平台界面(pi TUI / opencode / DSH GUI)里开的会话,此前无法被邮件投进去。
补全早就把它们列为候选(agent_platform_sessions 镜像,插件心跳上报),
但投递侧的 FindNamedSessionFor 只查 sessions 表 —— 选中后只能得到 404。
候选列表在承诺一件做不到的事。

TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。

## Gateway

sessions 表加 platform_id 列 + 部分索引。resolveTarget 的 SessionNamed 分支
本侧查不到时再查镜像,命中则「接管」:本侧建一条会话并绑定 platform_id,
之后每次投递都在 SSE 事件里带 platform_session_id。

- FindPlatformSession(agent, slug, workspace) 查镜像
- FindSessionByPlatformID 防重复接管(一条平台会话只能被接管一次,
  否则同一条对话在邮箱里裂成多条互不相干的线索)
- AdoptPlatformSession 建会话 + 绑定 + 别名复用平台 slug(撞名自动加后缀)
- PlatformIDOf 供 notifyRecipients 读

三处语义决定:
- workspace 以平台会话为准(它的 cwd 创建时就定了)。地址 path 位不同则不命中,
  否则邮件会投进另一个项目的会话
- 主题优先用平台侧标题(它代表整条对话在谈什么,也是补全里显示的)
- 接管计入 AllowNewSession 速率限制 —— 镜像里可能有几百条 slug,
  不计的话它是绕过限流的后门

## 插件

字段解析与失败话术抽成共用模块 lib/adopt.js(三方逐字节相同 + 进同源校验):
字段名各写一遍时少个下划线就静默退化成「每封邮件新开一条」,而那个错误不抛异常。

- opencode:session.get 确认存在 → 照常 promptAsync(服务端持有会话,单一写者)
- DSH:复用 startAgent 的 resume 分支,会话 id 换成平台自己那个;
  界面上正开着时直接 followup(两个 handle 会各自写日志,replay 过不去)
- pi:SessionManager.open(file) → 跑一轮 → dispose,不放进长期缓存

pi 必须短暂持有:SDK 无任何锁机制(flock/lockfile 命中 0),活着的
SessionManager 不 watch 文件 —— 外部追加的行看不见,算出的 parentId 指向
对方不知道的 entry,会话树分叉。写入是纯 append 所以文件不会坏。
配套三处:isStreaming 时不释放(否则杀掉排队中的下一封)、兜底计时器
(轮次超时 ×2,unref)、接管会话跳过命名同步。

最后一条是实测撞出来的:别名撞名时 Gateway 加后缀,而定稿别名又回写进 pi
会话文件 → 下次心跳上报的 slug 变成带后缀那个,人从补全里选的名字凭空消失。
opencode/DSH 无此环(它们的 slug 只读不写)。

接管后必须加入 mailDriven 集合,否则邮件投进去了却永远没有回音。

## 迁移顺序

idx_sessions_platform 不能写在 init_sqlite.sql 里:那个脚本在
addMissingColumns 之前执行,而已部署的库里 sessions 表已存在
(CREATE TABLE IF NOT EXISTS 不补列)→ 索引建在不存在的列上,
整个迁移中断、服务起不来(生产实测)。依赖补出来的列的索引一律放
migrate.go 的 sqliteAddIndexes。PG 侧用 ALTER TABLE ADD COLUMN IF NOT EXISTS。

## 生产验证

- pi × 2(agent-only-chain / mail-probe-alias)、opencode(glowing-moon)、
  dsh(查看工程与插件适配指南)四条链路接管成功
- dsh 那次回信准确说出了界面上聊过的内容 → 上下文确实装回来了
- 第二封复用同一条本侧会话,平台侧无新增改名条目
- 回归:opencode 普通 .new + 别名续谈 + used_rounds=0(免配额通道未受影响)

## 其他

pi-mail-bridge 补 systemd 单元(此前是 setsid 裸进程,重启机器不会拉起):
陈锁清理 ExecStartPre、MemoryMax=4G、TimeoutStopSec=10。
配置目录必须与 opencode 分开(共用会让后起的读到对方密钥或撞单实例锁)。

PLUGIN-CONTRACT.md 加 B-3.7 / B-3.8 + new_mail 字段表 + 检查清单验收项。

测试:repo +10 例(adopt_test.go);三插件各 +7 例(adopt.test.mjs)
2026-09-04 11:14:44 +08:00
e4052f8e84 docs: B-8 在 HomeAgent 上是 N/A(平台无审批环节),补齐平台矩阵第四列
B-8 的 homeagent 那格一直标着「❌ 要先摸清 homed approval API」。
查清了:**那个 API 不存在,而且不该存在。**

判据:SDK 与核心两处 grep `approval|consent|permission|confirm`,
命中数均为 0。

前置条件是「平台本来就要问人」。另三个平台各有一个现成的审批环节
(opencode `permission.ask` / DSH `approval/request` / pi `tool_call`),
桥做的只是把它从本地 TUI 改道到邮件通道 —— 没有发明审批协议。

HomeAgent 的核心是纯思维核:本身无对外交互能力(全部能力来自插件),
也没有会话这一层(单事件循环)。它不问人,工具调用直接执行。

它确实有 `StageBeforeToolcall` 可以拦下调用(`process.go:273`,插件给
`ctx.Response` 赋值即拒绝,核心把「工具 X 已被插件拒绝」喂回模型)。
但那是「插件可以否决」而非「平台在征求同意」:没有待批准的请求、
没有选项、也没有等人的语义。

所以 B-8 是 N/A 而不是待办。硬补等于给平台加它本来没有的能力 ——
要自己划高风险工具白名单、自己定义超时与 fail closed、自己决定人不在时
怎么办,那些是产品决策不是契约合规。

顺带记下一个需要知道的事实:homeagent 的工具全部无条件执行(含 cmd_run),
接入邮件之后任何能给它发信的人或 Agent 都能间接触发,中间没有人类确认。
另三条链至少有 409 兜底,这条没有 —— 因为它根本不发起询问。
这不是缺陷而是那个平台的信任模型(homed 跑在用户自己机器上,默认完整权限),
记下来是为了让「谁能给 homeagent 发信」被当作访问控制来对待。

平台差异对照表补齐 HomeAgent 一列(14 行)。它是四平台里唯一**不需要**
为每封邮件开平台侧会话的:没有会话概念,所有邮件注入同一事件循环,
靠 output_send__agentmail 输出通道送回复。
2026-09-04 09:11:43 +08:00
bca50b80c6 docs: 日历子系统 + 寻址发现工具组 + 权限死锁的排查记录
PHASE7-REMAINING 新增日历一节:三层分离的理由、修掉的六个真问题
(含每一个的现场取证与判据)、前端三个易错点、验证清单、未做的缺口。

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

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

PLUGIN-CONTRACT 补 B-8(权限询问转邮件)的判据表与三平台差异。
2026-09-04 06:30:56 +08:00
e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00
22ddb1b89c docs: 契约补一条转发层依赖的坑(socat 需要 PartOf + Wants 两个方向)
DSH 的 LAN 转发在 2026-09-02 隐形挂了 9 小时:为验证离线补投重启 dsh,
dsh-lan.service 的 socat 被 Requires 带停后再没起来。

Requires 不含重启语义、PartOf 不含启动语义、单元自己的
WantedBy=multi-user.target 只在开机时生效 —— 三者缺一,restart 或
stop+start 之后就会出现「平台进程活着、loopback 通、外网全不通」,
而这个现象很难联想到转发层。

下一个平台如果也用 socat 暴露 loopback 端口会踩同一个坑,因此记进
第九节「部署环境的坑」。另附 SuccessExitStatus=143:被 SIGTERM 停掉是
正常路径,不加会在 systemctl status 里留一条红色 failed 掩盖真故障。

本机的 dsh.service / dsh-lan.service 不入库(dsh 是被接入方,不是本项目的
一部分),只把可复用的教训记进契约。
2026-09-03 08:22:41 +08:00
289f37f7fb docs: PLUGIN-GUIDE 重写为 PLUGIN-CONTRACT(可核对的插件规格)
原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断
「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。

改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。
旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类:

## 一、能力矩阵(新增)

回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14),
每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。

其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过,
两次都造成「无效模型被判成成功」,所以单独标了出来。

## 二、行为约定(重写)

原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 /
B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 /
B-7 启动补拉 / B-8 权限询问 / B-9 关停。

## 四、降级语义(新增)

平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」,
没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成
「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。

## 六、不变量与禁止事项(新增)

12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错,
但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。

## 七、验收清单(新增)

八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与
配额未被消耗、停插件发信再启动看补投日志。

## 核对过的事实

写完逐项核对了代码,不是凭记忆:
- 12 个端点全部在 main.go 里存在且方法一致
- 发信 9 个字段名与 sendMailRequest 的 json tag 一致
- 心跳响应 12 个字段名与 handler 一致
- 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 /
  模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处
- 验收清单里的六条 sqlite 查询都在生产库上跑通
- 38 个编号无重复,18 处交叉引用全部有定义

引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、
install.sh、check-shared-libs.sh。
2026-09-03 08:09:54 +08:00