Commit Graph

43 Commits

Author SHA1 Message Date
519bdf7093 fix(dsh)★: 模型写好回信却被当成「空回复」丢弃 —— lastAssistantText 取错那条事件
## 现象(用户实测)

用户看到 dsh 的回复**内容完全正常**(一段完整的「你好 jianf!收到你的问候了 👋…」),
但 AgentMail 里收到的是 dsh 发来的 `处理失败: 打招呼`,正文写着:

    这封邮件的处理轮次已结束,但没有产出任何回复文本。

## 根因(从真实会话日志取证,不是推断)

取那次会话的 `session.v4.jsonl.zstd`(zstd 解压 84KB),事件统计:

    assistant/message: 2 条        ← 两条!

    最后一条 content = [{"type":"tool-call","name":"read_mail",...}]   ← 无 text
    往前一条        = [{"type":"text","text":"邮件已读取 —— …你好的问候了 👋…"}]

dsh 的一次 step 里,模型先出文本、再发工具调用,会落成**两条**
`assistant/message`;最后那条往往只有 tool-call 块。

而 `lastAssistantText` 是「从后往前找,取到**第一条** assistant/message
就 return —— 不管那条里有没有 text 块」:

    if (!Array.isArray(blocks)) return '';      ← 直接判空
    return blocks.filter(b => b?.type === 'text')…   ← 无条件 return

⇒ 过滤后是空串 ⇒ 判定空回复 ⇒ **静默丢弃模型已经写好的回信**,
改发一封「处理失败」通知给发件人。

## 修法

`return ''` 改 `continue`;只在**真的取到文本**时才 `return`。

## 为什么既有测试全绿

原有 5 格测的**全是单条** `assistant/message`(或只有 reasoning/tool-call 的
单条),与本缺陷正交。新增 2 格用的是**从真实日志取的事件形状**:

* 最后一条只有 tool-call ⇒ 必须往前找到有文本的那条
* **反向对照**:全部无文本时**仍**返回空串,且 `content` 不是数组时应
  `continue` 而非当成空回复 —— 保证「空回复」判定没有被放宽成
  「几乎总有回复」,否则那封失败通知就没有存在意义了

**变异验证**:把实现还原成原写法 → 21 pass / **2 fail**(正是新增那两格)。

## 影响面

仅 dsh:pi / opencode 走各自宿主的 API 取回复,不共用这个函数
(实测两边的 `lib/` 里没有 `assistant/message` 字面量)。
实测那次只有 34 秒就走到失败通知,是个高频路径而非边缘情况。

dsh 435 格全绿、tsc 零错。
2026-10-03 22:24:32 +08:00
86e05e7027 feat(dsh桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
`reverseMap` 只在邮件投递时填 ⇒ 在 dsh 界面里直接对话时 `mailSessionOf(exec)` 为空
⇒ withScope「取不到就原样返回」⇒ 请求不带 session_id ⇒ 服务端 403
(AgentMayReadSession 收严后,2026-09-15 的迁移期放行已作废)。

不改的后果很具体:**在 dsh 界面里读不到任何信**。

回落答案只能向服务端问 —— dsh 侧给不出 session_id:
opencode 有宿主 session id 可反查(`sessionWorkspace`),pi 有 worker 闭包,
而 dsh 的 exec 里只有 dsh 自己的会话 id,与 AgentMail 会话 uuid 无映射。
这与 workspace 不同(workspace 能从目录/cwd 得到)。

装配期问一次(`loadDefaultSession`),不塞进 withScope:
withScope 是取值函数,IO 混进来就变成「拼 URL 时顺带发 HTTP」,
判据也无法用裸对象驱动(同 homeagent 那次的教训)。

问不到就当没有:不带 session_id → 服务端 403(可见的错误),
而不是静默放行成越权。

判据 4 格(接线形状 + 常量一致性 + 不得伪造 id + 失败要留日志)。
三个变异各红 3 格(判据之间交叉命中,说明它们各自都在钉不同的东西)。
tsc --noEmit 通过;桥全量 429/429 绿。
2026-10-02 01:12:35 +08:00
dsh
b0c87192b0 fix(notify+四桥): ★ 投递通知带 parent_from(方向判据)—— in-reply-to-ignores-direction 转绿
为什么这次顺手修:网关部署门禁(redeploy-gateway.sh 跑全量 Go 测试)被
TestInReplyToCarriesParentSender 拦下 —— 那是 2026-09-28 判据先行的债,
死锁修复本身无涉,但不修它网关换不上去。已用 git worktree 在修复前的
HEAD(16bf474)上验证过该测试原本就红,不是本次改动引入。

服务端(数据本来就在手,零新增查询):
  · resolveTarget 的 reply_to 分支原本把父邮件整行读进内存、只用 SessionID
    就丢掉;现在把 mail.FromName 一并返回。
  · notify.Mail 增 ParentFrom;payload 增 "parent_from"。
  · 转发 / 人类发信(me.go)路径如实传 ""(转发本就是新线索)。

四桥(relay-policy.js 四份逐字相同的拷贝 + 各自调用点):
  · inboundHeadline 增方向判据:parentFrom === selfName 才说
    「你上一封信的回复到了」;parentFrom 非空但≠自己 ⇒ 明说
    「多方线索里的续谈(回的那封是 X 发的)」;服务端未升级(无
    parent_from)⇒ 退回旧行为(含糊的「回复到了」强于把真回复当新任务
    —— 那是互相客套的起点,回退语义被既有判据钉死)。
  · 「回的是你那封:<id>」一行同样只在父邮件确为本方发出时才输出。
  · 四份 lib + 四份 test 逐一 md5 相同(cross-bridge-prompt 1/2/3/4 继续绿),
    判据 5 转绿。

红绿:
  · 服务端 TestInReplyToCarriesParentSender 修复前红(16bf474 实测)、修复后绿;
  · 四桥新增 3 条方向判据测试(别人发的 / 自己发的 / 未升级回退);
  · client/electron 聚合套件 cross-bridge-prompt 5/5 绿。
2026-09-30 17:30:27 +08:00
093c4dd06e fix(桥接): 模型清单过期时**起会话前**就剔掉(opencode 每轮白烧一次)
## 现象(线上实测,不是推演)

    [mail-bridge] 模型 opencode/mimo-v2.5-free 失败:
      Model not found. Did you mean: mimo-v2.6-flash-free, ling-3.0-flash-fin-free...?
    [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败)

30 分钟内 9 次 —— **每一封邮件**的第一轮尝试都是它。

## 根因

`agent_allowed_models` 里 opencode 的 rank=0 仍是 `mimo-v2.5-free`,
而**上游已改名** `mimo-v2.6-flash-free`。查证:225 个 provider 的实时目录里
确有 `mimo-v2.6-flash-free`、无 v2.5;库里 `agent_model_catalog`
(心跳上报的快照,今天 01:28)也已含新名字。

⇒ 清单是管理员存下来的,**没有任何失效检测**。降级逻辑救了它(信还是回了),
但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。

## 为什么不是「在服务端过滤掉」

`ListAllowedModels` 的注释明确否掉了这条路:
「不与目录做 JOIN:目录是平台上次注册时的快照……在这里用目录过滤,
只会把『目录暂时没上报但其实可用』的模型挡掉」。**这个判断是对的**,不改。

## 改法:桥侧预检(pi 桥早就有)

`pi-mail-bridge/src/worker.mjs:674-680` 同一件事已经做了,注释写着
「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。
本提交把那条纪律提到共用层 `model-scope.js` 的 `partitionByCatalog`,
让 opencode / dsh / pi 共用。

**实测对比**(同一封信,修复前后):

    修复前:模型 mimo-v2.5-free 失败: Model not found…   ← 起了一轮会话才失败
    修复后:跳过 opencode/mimo-v2.5-free:平台目录里没有…  ← 同步拦下,零会话
            llmsproxy/AUTO 成功

## ★ 最要紧的一条:拿不到目录时**全部放行**

心跳还没跑过 / 拉取失败 ⇒ 目录是 `undefined`。此时若照样剔除,
Agent 会**彻底哑掉** —— 而失效方向恰是「什么都收不到」。

与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一方向。
判据里对 `undefined / null / [] / 'not-an-array' / 42` 五种输入逐个断言。

`routes` 被剔空时**退回平台默认**并打日志说明「请到管理页重新划定范围」——
否则发件人只看到「本次未能处理」,而管理员看不出自己的选择已过期。

## 判据(并入共用测试,三桥同源,33/33 过)

7 格,含:线上那个 case、拿不到目录必须全放行、`undefined` 路由不归目录管、
全失效时 kept 为空(退路是策略决定不是过滤职责)、provider 同名 model 不同不算数。

变异验证两向都打红:
  ① 去掉「拿不到目录就全放行」的保护 ⇒ ★那格红
  ② 键只用 provider(半匹配)  ⇒ 4 格红
2026-09-28 09:40:15 +08:00
c851bef5cb fix(4 bridges): 投递提示词改指向 read_mail,不再引 read_inbox
投递时 `markDelivered` 就把信标成已读(dsh `src/index.ts:505` 定义,
`:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
2026-09-26 为治"重启重投→回声"定的性)。而提示词却要求"先调
read_inbox 读正文",read_inbox 默认 `status=unread` ⇒ **看不到刚投递的
那封信**。

危险的不是"白费一次调用",是模型看到空之后以为"没有新邮件"就结束
回合 —— 那会**静默丢掉一个真实请求**。mail_id 就在同一段提示词里。

## 改了什么

投递提示词(8 处)与 read_inbox 工具描述(4 处):

    请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文……
    这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;
    read_inbox 只用来看本会话的其它未读。

工具描述那句「收到新邮件通知后应立即调用此工具」是**模型看到的第一句话**,
只改提示词不改它,模型照样走偏,所以一并改。另给 read_mail 的描述补
一句「刚投递到本会话的那封邮件就读这个」。

落点(dsh 桥是**三处**,不是一处 —— adoptPrompt / resume 复用 / 新开会话
三条建会话路径各带一份文案):

- dsh `index.ts:1047`、`:1163`、`:1219` + 工具描述 `:1428`/`:1680`
- opencode `index.js:1016` + `:261`/`:473`
- pi `turn.mjs:170` + `tools.mjs:197`/`:430`
- zcode `prompt.mjs:152` + `lib/tools.mjs:123`

## 为什么指向 read_mail 是安全的

`workspace` 必需只加在**两个**端点上:GET /mail/inbox
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)与全部标已读(`:806`)。`read_mail` 走
`GET /agent/mail/{id}` → `AgentGetMail`(`agent_discovery.go:306`),
该路径**没有 workspace 参数**,鉴权走 `canReadSession()` 按会话归属判定。

桥侧也印证:`read_mail` 走 `withScope()`(只拼 session_id),
`read_inbox` 的 scope 单独拼 `&workspace=`。**两条路分开,400 搬不过去。**

## 不动默认行为

`read_inbox` 的默认 `status=unread` 保持不变(`lib/inbox-format.js:157`
刻意如此:默认 all 会让模型每轮重读旧邮件),`idsToMarkRead` 在 `all`
下不标已读与"投递即标已读"配套。改默认值等于把 2026-09-26 定的性放松
回去,不是另一种修法。

## homeagent 故意不动

`plugin.go:673,1005,250` 三处留着。改 Go 源码需重出 `plugin.bin`,产物在
**跨机器**的 `/home/newqqagent/plugins/`,你我都碰不到 —— 源码改了线上
没生效,仓库与线上分叉比不改更难排查。待跨机重出。

## 测试

新增 test/inbound-prompt-reads-mail.test.mjs(5 项),钉住:旧文案不存在、
新文案三处齐全、工具描述改过、read_mail 描述指过、附件指引保留。同时
断言 src 与 **dist**(dist 是 dsh 真正加载的那份,忘了 build 就是
"源码对、线上旧代码")。

四桥测试:dsh 419 / opencode 344 / pi 517 / zcode 394,全绿。
(改前 407/344/517/394,无回归。)

## dist 变更(不在 diff 里,.gitignore:20 忽略 plugins/*/dist/)

重出后 `请先调用 read_inbox` 由 **3 处 → 0 处**,新文案 3 处;
旧工具描述 1 处 → 0 处。已用 `npx tsc -p tsconfig.json` 重出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-28 09:29:26 +08:00
9985c41b13 fix(dsh-bridge): 新开会话登记工作区,workspaceOf 加淘汰兜底
read_inbox 在**新开会话**里恒 400("缺少 workspace"),而 read_mail /
read_thread / list_contacts / session_participants 都不受影响。

## 根因

`workspaceOf()` 取不到工作区就返空,URL 不带 `&workspace=`,服务端 400
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)。而 `sessionWorkspace` 只有两个写入点 —— adopt(bindAdopted)
与确定性 id 恢复 —— **新开会话分支漏了**。

漏了之后只有两种情况能发现:那条会话后来恰好走过另外两条绑定路径,
或者重启后 `sessionWorkspace`(500 条上限)把它淘汰掉。

## 改法

1. 新开会话分支补 `sessionWorkspace.set(attemptSessionId, sessionCwd)`。

   ★ 取值必须是 `header.cwd`,**不是那个 `cwd`**:后者来自
   `resolveWorkspaceCwd`,`to_workspace` 不可用时回退到
   `mailSessionFallback` → `~/.dsh/mail-sessions/mail-<uuid>`,每次邮件
   都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 ——
   比 400 更坏,因为 400 至少是可见的错误。同一文件下方工作区注册那段
   (`actualCwd`)也是从 header 取,两处保持同源。

2. `workspaceOf()` 读侧兜底:反查 `sessionMap` 的 `directory`。

   补在读侧而不是写侧,因为淘汰是**事后**发生的:补写站点只能覆盖
   「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory`
   在两条绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘
   header),不是兜底目录。

## 影响面

`read_inbox` 的两维收窄(session_id + workspace)是**防越界读**的机制
(2026-09-14 用户报的越界)。这个改动让更多会话能拿到 workspace,因此
**可见范围确实变宽**了 —— 这是有意的(原本是读不到,不是读得少),
但复核时应当盯住这一条。

## 测试

新增 test/workspace-of-fallback.test.mjs(7 项)。其中 5 项把
`workspaceOf` 的真函数体抠出来在真 BoundedMap 上跑(不复制一份实现,
否则源文件改了测试还在绿)。已做变异验证:删掉读侧兜底 → 3 项红;
删掉写入点 → 2 项红。

dsh 桥 414 项全绿(改前 407,无回归)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-28 09:26:15 +08:00
4175c0ba45 fix(bridge): ★ 投递即标已读 —— 修「桥重启 → 重投 → 回声」
用户 2026-09-26 原话:
  「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
  「就是你的错误的重投机制造成了回声」

# 我上一轮把因果搞反了

我先认定是「两个 Agent 自发辩论」,还为此写了第三道防线(数 Agent↔Agent
连续往返)。**方向错了** —— 是**桥把同一封信反复投递**,每次投递起一个
worker 回信,回信又触发下一轮。模型在做什么?它在回答一封被重复投进来的
旧信。用户根本不知道有这回事。

# 根因:deliveredMails 只在内存,库里的 status 从没被写

投递路径(SSE `new_mail` / 心跳补投 / 决策回执)只做两件事:起 worker、
把 id 记进 `deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一
那处标已读在 `read_inbox` 工具里,要等模型自己去读收件箱。

于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉
⇒ 桥一重启(**每次部署都会**),积压的"未读"被当成离线漏投**再投一遍**。

# 实证(不是推断)

· 5 个 mail_id 各出现在**两条不同 pi 会话**里:
    f06129f4 → 04:54:45 投进 01a0a2bd
             → 08:01:20 投进 01a0daf0
  (而那封信库里已有 1 封回信 —— 它早就被处理过)
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
· pi 收件箱 287 封 unread 中 **187 封已经回过信了**
  (`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
· 两条会话各烧到 463 / 268 封
· 桥侧:同一邮件会话 id 前缀 `01a0a2bd` 出现在 **4 个** pi 会话文件里
  (投了两次 + 别的历史残留)

# 修法:内存与库必须同时写

`deliveredMails` 是**内存**集合,重启即丢;数据库的 status 才是跨重启的
"我接管过了"记录。两者只写其一 ⇒ 口径不一致 ⇒ 重投。

新增 `markDelivered(id)`:**凡是标记"我接管了这封"的地方都走它**
(SSE / 补投 / 决策回执三个投递点),同时写内存与库。漏一处就是一条重投
路径 —— 这正是缺陷的形状(四处各自 add,没有一处标已读)。

标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 照常可见。
而"已交给一个 worker 处理"正是那封信此刻的真实状态 —— 库里本来就该记这件事,
而不是"模型有没有顺手调过 read_inbox"。

# 四个桥:三个有缺陷,第四个早已修过

| 桥 | 投递标已读 | 说明 |
| --- | --- | --- |
| pi | ✗ → ✓ | 三处 add 都不标 |
| opencode | ✗ → ✓ | 同上 |
| dsh | ✗ → ✓ | 同上 |
| **homeagent** | **✓ 早有** | `ledger` 落盘,跨进程 |

homeagent 不用这个修法:它的 `ledger` 记 `delivered`/`completed` 两个状态,
只有 `completed` 才跳过(投过但被中断的**仍然重投**并带说明)—— 那份设计的
注释里就写着 18:59:38 那次实测,比我今天这个修法更早也更完整。
所以对它只做了「补投按工作区收窄」那一半(见下条)。

# 附带修:homeagent 的 workspace 收窄(我今天打破了它)

我先部署服务端(缺 workspace 直接 400)并修了三个桥,**漏了 homeagent**
⇒ 线上 07:42 起持续报 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`。
这是我造成的,靠自己的日志发现的(pid 还是重启前的旧进程 2291455)。

修法与另三个同源:`currentWorkspace`(信封的 `to_workspace`)在回合期间暂存
(与 `currentSessionID` 同一形状、同一生命周期),`inboxURL`/`scopeQuery` 带上它,
补投从"读一次全局收件箱"改为逐工作区(清单来自心跳的 `pending_workspaces`)。

# 清理重投燃料

151 封归档(80 封回声:会话全程无人类 + 71 封 `permission_decision` 不可投)。
★ 用 `archived` 而不是 `read` —— `read` 还能被 `status=all` 拉出来重投。
判据用服务端自己的口径(`unreadFor` = `m.status<>'archived'` 且
`mail_reads` 无该读者),不手写 SQL 猜语义。

后置:pi / dsh / opencode / homeagent 在**所有工作区**的 unread 全部为 0。

# 判据

· `delivery-marks-read.test.mjs` × 3(pi / opencode / dsh)各 4 条:
  核心那条钉的是「`deliveredMails.add` **只允许**出现在 markDelivered 内部」——
  任何别处直接 add 就是绕过标已读的重投路径。另加自检反例。
  变异验证:绕过投递点 / markDelivered 不写库 / 补投绕过,三处全判红。
· `inbox_workspace_test.go`(homeagent)5 条:URL 带 workspace、带不到时不带
  (让服务端 400:错误可见好过静默越界)、补投逐工作区、两处投递路径都设工作区
  且都清空。变异 3 处全判红。
· 改了两条既有判据(pi / dsh 的 permission-note):原来钉
  `deliveredMails.add(decisionMailID)` —— 那个形状**就是**缺陷载体。
  语义没变(仍"不再当新任务"),载体变了。

全量:pi 517 / opencode 344 / dsh 407 / homeagent 除一条既有的
`TestSDKPinMatchesBuildMachinePointer`(依赖构建机路径,改动前后同样红)全绿。
2026-09-26 09:18:39 +08:00
7634be8966 fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
2026-09-26 07:44:33 +08:00
73065664dd test(桥): 判据从「片段存在」收紧到「结构正确」—— 对抗性变异④找出我自己的洞
pi 复跑变异①报 `pass=1 fail=4`(我报 0/5)。两个数**都对**,但说的是**两种不同变异**:
  · 变体A(我上封跑的)整段替换成 `catch { return undefined }` ⇒ 0/6
  · 变体B(pi 跑的)保留 `catch (e: any)` 外形、只删 code 判断 ⇒ 1/5
两者的差别正是"catch 外形" —— 我上一封没把变异**写清楚**,这是我的表述问题。
事故版本的原文(`1de93fe^`)是 `} catch {\n return undefined;`,即**变体A**。

★ 更重要:顺着 pi 的复跑做**对抗性变异**,我找到了自己判据的一个真洞 ——

    } catch (e: any) {
      if (e?.code === ABSENT) { /* 什么都不做 */ }   ← 片段"在"
      return undefined;                              ← 读失败被降级 = **事故本身**
      throw e;                                       ← 不可达
    }

旧的 `distinguishes() && orderHolds()` 对**全绿**:code 比较在、`throw e` 在、
且 ABSENT 排在 throw 之前 —— 三条"**片段存在**"判据全过,而语义已经是事故。
⇒ "片段在不在"与"结构对不对"是**两种性质**(与 orderHolds 同族),必须分开表达。

本次收紧:
- 新增 `catchInner()`(按花括号取 catch 内层正文,不会误取函数体);
- 新增 `absentBranchReturnsUndefined()`:「不存在」那支必须**真的** `return undefined`
  (空转守卫 `{}` 不算);
- 新增 `unreachableThrowFree()`:`throw e` 必须**可达**(它前面与"不存在"之前
  不许有无条件的 `return undefined`);
- `criterionPasses` = 区分 && 顺序 && 上面两条结构判据;
- 新增测试 ④,并**先断言它骗得过旧判据**(`distinguishes` 真、`orderHolds` 真)——
  否则这条自检就不证明那个洞存在。

验证(对**真源码**改、逐字节还原):
  基线 6/6 | 变体A 0/6 | 变体B 1/5 | **变体C(洞)1/5**(旧判据下全绿)| 还原 6/6
门禁 `npx tsc && npm test`:403/403。
2026-09-25 04:45:44 +08:00
753310d12f test(桥): 把「三个变异逐个验过」从**手跑**变成**判据**(原来文件里只钉了变异①)
pi 复核我那笔 `1de93fe` 时指出:提交信息里写了"三个变异逐个验过",
但判据文件里**只给变异①配了自检** —— 那三个是手跑的,手跑过一次 ≠ 以后还会红;
谁把断言改松,另两种变异不会有任何人发现。

原文件的问题(这次才看清):
- 变异②(`if (true) return undefined`:catch 形状不变、也"看"了 code,只是判断写反)
  与变异①在**判据层面完全同形** —— 只用"有没有 code 比较"去判,两者都抓不到;
- 变异③(`throw e` 提到 code 判断之前)**根本不改"是否区分"**:
  `distinguishes()` 对它恒为真。它测的是**顺序**,所以必须单独一条 `orderHolds()`。
  这正是我原来漏掉③的真正原因 —— 不是"忘了写",是**当时没有能表达它的判据**。

本次改动:
- 把判据抽成 `distinguishes()`(区分)与 `orderHolds()`(顺序)两条,`criterionPasses` 取合取;
- 三条变异各一条断言 + 一条**前置**(原样源码必须通过 ⇒ 否则三条自检恒假,等于没写);
- 每条变异都断言"真的落在 persistedCwd 内"(全局正则会命中文件里第一个无关的
  `catch (e: any)`,变异没落下而判据全绿 —— 本仓踩过,`assert.notEqual` 守住);
- 变异③额外断言 `distinguishes(mutated) === true`:**证明它测的是顺序而不是退化成①**。

验证(对**真源码**做变异,不是只喂文本):
  基线 pass=5 fail=0 | ①pass=0 fail=5 | ②pass=1 fail=4 | ③pass=3 fail=2 | 还原 pass=5 fail=0
  源码逐字节还原(cmp 过)。门禁 `npx tsc && npm test`:402/402。
2026-09-25 04:30:53 +08:00
1de93feabc fix(桥): 「读不出来」被当成「不存在」—— 这一行把 dsh 的邮件通道整条弄断
`persistedCwd()` 原来是 `catch { return undefined }`,把 readSession 的**三种**
抛出情形压成同一个「磁盘上没有」。调用方只把 `undefined` 读作"可以 create":

  读失败(格式迁移拒绝 / 日志损坏)→ 当成不存在 → 走 create
  → 磁盘上**确实有**那个 id ⇒ `session "…" already exists`
  ⇒ 该会话的邮件全投不进去,而日志里只有 create 的错,
     **真正的读失败被那个 catch 吃掉了**。

2026-09-19 DSH 升到 0.1.5-rc.2 后 40 个 `mail-*` 会话全部命中。

修法:`readSession` 的报错**本来就带可区分的 code**
(`dsh-session-query` 的 `notFound()` 给 `SESSION_QUERY_SESSION_NOT_FOUND`;
格式/损坏给 `SESSION_QUERY_CORRUPT_SESSION` / `SESSION_QUERY_PERSISTENCE_FAILED`)。
现在**只有 `SESSION_QUERY_SESSION_NOT_FOUND` 返回 `undefined`**,其余一律抛出,
让原文错误浮到调用方 —— 不再降级成"不存在"。

判据 `test/persisted-cwd-not-found.test.mjs`(2 条,已进 `npm test` 门禁)钉的是
**区分本身**,不是"有没有 try/catch"。三个变异逐个验过:
  ① catch 改回无条件 `return undefined` ⇒ 红
  ② 任何抛错都返回 undefined ⇒ 红
  ③ `throw e` 提到 code 判断之前("不存在"也抛 ⇒ create 不可达)⇒ 红

★ 变异③第一次**没落在目标上**:全局正则命中了文件里第一个无关的
`catch (e: any)`,判据全绿 —— 于是把它写成自检里的一条断言(变异必须真的落下),
避免这条自检本身变成恒真的假判据。

顺带记两个事实:
- `src/index.ts` 是桥的真源,`dist/` 是部署产物(`.gitignore` 忽略);已 `tsc` 重建并在产物里复验。
- 姊妹桥(pi/opencode/zcode)不含 `persistedCwd`,本缺陷只在 dsh 这条链上。
2026-09-25 04:15:37 +08:00
b4a8f74ae5 修复: dsh 邮件通道全断的**两侧**根因(桥侧不产 message id 是真正在写的那一处)
现象:dsh 的邮件通道全断。老会话读不出来 ⇒ 桥报 SessionQueryError ⇒ 按"不在磁盘"
处理 ⇒ 再 create 撞 `already exists`。修好读路径之后又立刻暴露下一层
`message "undefined" is already pending`。

根因一(历史数据,dsh 侧):v0 会话的 `agent/inbox/spliced.inserted[]` 缺 `id`/`role`,
v0→v1 迁移第一步就拒绝。40 个真 mail-* 会话全部命中。

根因二(**仍在写**,本仓侧):`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。DSH 0.1.5 的 inbox 按 `message.id` 去重
(`dsh-agent-loop` 的投影 apply() 与 mutate() 各维护一个 Set),id 全是 undefined
⇒ **第二条消息必挂**。日志里最早的同类记录在 2026-09-07,累计 50+ 次。
官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`({id, role, content, source}),
同一份 dsh 里其它插件都用官方的 createUserMessage(),只有这个桥手搓。
以前没炸是因为读路径先坏,根本走不到 followup。

本次改动
- message.js/.d.ts: userMessage() 补 id: randomUUID() 与 role:'user'
- test/message.test.mjs: 钉住「id 非空」「两条消息 id 必须不同」,用官方 inbox
  去重逻辑逐字复刻验证(修复前 message "undefined" is already pending,修复后 20 封全唯一)
- scripts/: repair-legacy-spliced-ids.mjs(v0,默认 dry-run)、
  repair-v3-usermessage-ids.mjs(v3)、verify-mail-sessions-readable.mjs
  (走生产真读路径 JsonlSessionPersistence.open,而非解码器口径)、两个 apply driver
- docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md: 补执行结果与两处新事实

执行与验收(详见文档 §9-§15)
- v0 修 40 个、v3 修 2 个;逐文件解压后与备份 `cmp` **逐字节相等**,事件数 40/40 一致,
  零丢失(25.2MB→12.5MB 是单帧改 500 行/帧的重压缩,不是丢数据)
- 真 mail-* 会话最终 **41/41 可读**
- journal 里同一会话从 `already exists` 变为 `resume 续谈`,且持续增长
  (22647→22685 事件),最新 user/message 带真实 UUID;修复上线后 already pending 计数为 0
- 已在生产部署(deploy/redeploy-plugin.sh dsh,快照+原子软链+重启+后置验证全绿)

两个必须记住的坑
1. **校验与落盘不能共用同一批对象**:createRestore().decodeRow() 会原地改写入参
   (补全 dt 数组),污染后写出去会报 `released Session row N has seq gap`。
   这曾让 dry-run 说"40 个可修"、apply 只说"3 个"。
2. **判定磁盘健康只认 open()**:readSession() 走 SessionCorpus.load,命中有 live 会话时
   直接返回内存快照、不校验磁盘;open() 才走 validateStoredEvents。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00
2a5e3d7d15 fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)
第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家,
并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容,
与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。

四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源):
- dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上
- opencode:`reverseMap.get(context.sessionID)`
- zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程)
- homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构)

判据(每条两侧都钉:包住了 / 没包住的不存在):
- dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js),
  src 改了忘了 build 就是"源码对、线上旧代码"
- opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量")
- homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带
  session_id;对照侧:不在回合里(currentSessionID 为空)时不许带
- pi:把 post 的 URL 也纳入记录,forward 进用例表

★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是
`withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以
"恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。

变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。

(工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由
redeploy-plugin.sh 在 staging 里构建。)
2026-09-14 23:18:12 +08:00
d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00
7028c244fd dsh 桥同一处缺陷:409 带 full 档时也当场拒绝(并且把会话降级了)
pi 报的是它自己的桥,但**同一处缺陷 dsh 桥也有**(`src/index.ts` 的 409 分支无条件
`return 'rejected'`),而且后果多一层 —— dsh 的档位是通过 `applyPermissionMode()`
写进会话的(沙箱 + 审批策略)。补投漏传档位时 `applyPermissionMode(session, '')`
把会话**降级**成 workspace:`danger-full-access → workspace-write`、
`never → ask`,于是每个受守卫的工具调用都去问一次,再被 409 拒绝 ——
一条 full 档会话只要有一封补投邮件,这一轮的工具调用全被自己人拦死,
**顺带把自己的权限也降了**。

修法同 pi 桥:409 分支先认回包里的 `permission_mode`,是 full 就
`return 'allowed-once'`(DSH 的 ApprovalOutcome 只认 allowed-once/rejected/cancelled/unavailable);
plan 档与"链上没有人类"照旧 `return 'rejected'`。放行分支排在普通 409 之前,否则不可达。

判据 `test/permission-409-full.test.mjs`(含自检:拿掉放行分支必须红)。
自检那步发现我第一版判据又踩了同一个坑:「普通 409 分支里不许出现 allowed-once」
读的是**含注释**的正文,而那段的注释正好写着 "ApprovalOutcome 只认
allowed-once / rejected / …" → 误报。改成读剥注释的源码(规范里那条:
判"代码里有什么"读剥离版,判"理由写清了没"读原文)。

zcode / opencode 不转发权限询问(没有 409 分支),无需改。

验证:dsh 套件 383 通过(+2);变异(拿掉放行分支)→ 自检红。
2026-09-14 15:51:38 +08:00
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
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
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
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
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
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
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
2996f9af9c fix(plugins): 409 时当场表态 + DSH 补投按会话串行
**409 = 永远不会成功**(没有人类可路由)。原来三个插件都在失败时让位给
平台本地 UI —— 但邮件驱动的会话**没有 TUI**,让位之后 waterfall 跑到尾
依旧无人应答,仍是无声挂死。

HTTP 客户端必须把 err.status 与 err.body 挂到 error 上:只看 message
字符串分不出「暂时失败(502,该重试)」与「永远不会成功(409)」,
两种都会被当成前者,而前者会永久挂住会话。

三平台表态方式不同但语义统一:
- opencode: output.status = "deny" + output.reason 带服务端原文
- dsh: return 'rejected'(ApprovalOutcome 只认 allowed-once/rejected/
  cancelled,写 'denied' 不报错而是被当未知值静默失效)
- pi: return { block: true, reason }

其余失败(502 等)保持原行为,让位本地 UI。

---

**DSH 补投并发**(同一文件,故并入本次提交)

生产日志:`补投 5 封(共 16 封未读)`,9 秒后三封失败
`message "undefined" is already pending`。串行 for...of 并未真正串行 ——
awaitFirstTurn 在**首个 token** 就放行,turn 尚未结束下一封已 followup。

新增 waitForTurnEnd(等 turn/end 而非首 chunk)与 sessionLocks/locked()
按会话串行化。live-agent 路径原来直接 followup 就返回,现在也进锁。
120s 超时兜底,模型完全无响应时不会把后续邮件永久卡住。

权限场景下锁会持有到人类决策完 —— 这是正确行为:两封都需要授权时
第二封排队,比同时弹两个授权请求更合理。

顺带把 rename-proposal 纳入 check-shared-libs.sh 的同源校验。
2026-09-03 21:10:48 +08:00
f321380fa3 fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
2026-09-03 15:01:52 +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
9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00
89356d4a9b feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。

## 目录上报走心跳,不另设端点

模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。

心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。

与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。

## 目录与选择分两张表

模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。

## 最难的一点:模型失败不是同步抛出的

两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。

必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
  因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`

DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。

超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。

DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。

## 其他决策

- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
  的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
  而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
  (如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段

## 验证

- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
  确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
  opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
2026-09-02 21:34:55 +08:00
7c9be9fd58 docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和
代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的
逻辑提到共用模块。

## docs/PLUGIN-GUIDE.md

八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、
平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。

三条设计原则贯穿全文,后面每一节都是它们的推论:

1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供
   request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」
   的平台信号上自动转发)
2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key
3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制)

「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。

## 共用模块提取

`lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为,
而它们与平台 SDK 无关:

- 附件必须带 attachment_id(只说「有附件」模型无从下载)
- 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方)
- 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读
  会让下一轮的新邮件混在里面认不出来)

顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批),
且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。

`lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为
(workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory,
DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。
opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」
这两条保护。

## deploy/check-shared-libs.sh

`lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。

一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现,
只能靠 diff。

## 文档同步

- PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与
  7.7.2(平台会话快照)两节,记录根因而非只记改法
- API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与
  permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途)
- PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展
  (repo 层已就绪,handler/插件/前端待做)
- README 文档索引与项目结构

验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿;
端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
2026-09-02 20:28:19 +08:00
ca64d12057 feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有
邮件会话既不属于任何项目、彼此也不同组。

而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload
只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在
SSE 事件里,插件即使想用也拿不到。

- SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
  不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
- 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到
  兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活)
- 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`

## 2. 会话别名列不出工作区下的历史会话(无法选择)

workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须
JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace
填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p`
在只剩 Agent 回信可匹配时两边都对不上。

- `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace
  为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失
- `FindOrCreateDefaultSession` 同步改用会话的 workspace

## 3. 平台侧会话在补全里根本不存在

人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。

新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。
**上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway,
Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。

- 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有
  本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断
  这条到底是不是真的本侧会话
- **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是
  三态语义,指向不存在的会话直接 404
- **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像),
  而不是传空数组把镜像抹掉
- **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是
  派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名;
  它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
  出现几个点哪个都不确定的候选
- DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着

补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与
第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是
「缓存选型」而不是 brisk-harbor 这种随机短名。

## 4. 对话树看不见抄送与转发产生的分支

旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是
它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到
另一个;挂在原件上的转发分支同理。

改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向,
因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点
为什么并列,唯一的解释就是父邮件抄送给了两个人。

## 5. DSH 插件(Phase 7.7)

卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是
`followup()` 的参数形状:DSH 要完整的 UserMessage(content + source),
而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。

- `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的
  session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道
- `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的
  permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall,
  可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回
- 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 ——
  留在别名里会让它自己被解析器切开)
- 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处
  炸一个无关错误」

## 其他

- `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup`
  而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的
  foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰
  systemd(演练时误停过一次生产服务)
- 插件 dist/ 不进版本库,install.sh 负责构建
- `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
2026-09-02 20:05:51 +08:00