Commit Graph

53 Commits

Author SHA1 Message Date
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
16df8f44c9 PLAN.md: L2 (P1/P2/P3) 权限档位传播链路 + PUT 端点 + 24 例测试全部完成 2026-09-06 17:36:59 +08:00
1dc6223631 L2: permission mode propagation across forward/calendar/adopt + PUT endpoint + tests
P1 - forward inherits mode:
- doForward: when creating a new target session, use InheritedMode from
  the source session (plan parent → plan child, cannot escalate)
- enforcement snapshot set from receiving agent

P1 - calendar_events gets permission_mode column:
- Added to 3 migration sites (sqlite init, pg init, incremental alter)
- CalendarEvent model gains PermissionMode field
- CreateCalendarEvent / UpdateCalendarEvent normalize + persist the field
- resolveCalendarSession: on new session → write event's mode;
  on reuse → ModeAtMost(cur, eventMode), prevents escalation
  (plan Agent's reminder fires into a workspace session = bypass)
- SendCalendarMail now takes permMode and threads it through
- fireEvent passes event.PermissionMode to all delivery paths

P1 - AdoptPlatformSession explicitly writes default mode:
- Writes DefaultPermissionMode + enforcement on adopt, instead of
  relying on DB column default (avoids silent drift on schema changes)

P2 - PUT /sessions/{id}/permission endpoint:
- New handler UpdateSessionPermission (auth required, access check)
- Registers PUT route alongside existing budget/alias endpoints
- Broadcasts session_update on change
- Does NOT refresh enforcement (design: snapshot at creation)

P3 - Tests (24 new cases):
- permission_mode_test.go: InheritedMode (6 cases), SetSessionPermissionMode
  roundtrip, dirty value fail-closed, NormalizePermissionMode, calendar event
  roundtrip/dirty/update, adopt writes default mode, plan escalation guard,
  3-level inheritance chain
- defaultsession_test.go: budget regression, permission mode regression
  (pins created=false → no reset on reuse)

Deploys with: bash deploy/redeploy-gateway.sh --skip-tests
Schema migration: auto via addMissingColumns (new column default 'workspace')
2026-09-06 17:32:47 +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
a101c2fada 停用/恢复文案说清密钥不会自动回来 + 原子部署脚本
## 起因

本会话踩到一次:浏览器测试点了 opencode 的「停用」按钮验证确认流程,
停用连带撤销全部密钥。插件从此拿着已撤销的密钥重试了 18 小时
(gateway 日志 2690 次 401),而 UI 只说了「已停用」。

恢复时同样没提示「密钥不会自动回来」,点完恢复以为就完事了。

## 文案

后端 `AdminSetAgentStatus`:
- 停用 detail 补一句「停用期间别人发信给它会收到 409」
- 恢复 detail 改成「停用时撤销的密钥不会自动回来 —— 必须在密钥面板
  重新签发一把并写进该插件的配置,否则它会一直拿旧密钥重试并被拒(401)」
- 恢复响应加 `needs_new_key: true` 字段,前端可据此做更强的提示

前端 QuotaPanel:恢复成功的 notice 不再是「已恢复」四个字,
把重新签发这一步说全;面板说明与按钮 title 同步。

## AdminDeleteAgent 两处修正

- 硬编码 `name == "jianf"` 改成按 `repo.IsHumanUser` 判定 —— 换管理员时
  硬编码会失效,而人类账号不该走 Agent 删除端点
- detail 原来说「日历事件已保留」,实际 `DeleteAgent` 把 active 事件置为
  cancelled(留着会由调度器一直触发,而发信人已不存在)。文案改成实际行为

## deploy/redeploy-gateway.sh(新)

日常改后端不必重跑 install.sh(它重装 npm 依赖、重写 systemd 单元、
重新生成 env)。这个脚本做手工 `stop → cp → start` 不做的四件事:

- `sqlite3 .backup` 备份数据库 + 立即 `PRAGMA integrity_check` 复核。
  不用 cp:WAL 模式下 cp 会拿到主库与 -wal 不同步的快照
- `install -m 0755` 原子替换二进制。install 本质是 rename,要么完整
  换掉要么原样不动;cp 是就地写入,中途失败会留下半截文件且旧的已被覆盖
- 旧二进制留档并打印可直接粘贴的回滚命令
- 后置验证清单:服务 active / /health 可达 / 近 2 分钟无 panic /
  SSE 重连计数。任一项不过**自动回滚**,不「先上着再修」

`--dry-run` 只打印动作,`--skip-tests` 急救用,`--skip-web` 跳过前端同步。
纪律来自 git-release-discipline skill 第五章。

## 验证

- 脚本 dry-run + 真实跑通一次:备份 integrity_check=ok、原子替换、
  9 个 SSE 客户端重连、验证四项全绿
- 停用/恢复文案线上实测;`DELETE /admin/agents/jianf` → 403「是人类用户」
- 端到端:jianf → opencode「部署脚本验收」→ 回信「部署验收 OK」
- 全量测试:gateway 全包 / web 176 / opencode 217 / pi 288 / dsh 241 /
  homeagent go ok;三方共用模块同源检查通过
2026-09-05 10:10:13 +08:00
344f970353 修死信黑洞:发信前校验收件人可达性
## 事故

发给已彻底删除的 Agent 返回 200:邮件入库、分配 20 个来回预算、
建好会话,而那一端永远不会有人读。发件人看到 200 和一个 session_id,
以为送出去了。

实测(修复前):
  POST /me/mail/send to=remotebot@/tmp  → 200
  mail_id 53e4c9ea… session dc8a41c3… budget_max 20

remotebot 的 agents 行在本会话早前已被 DELETE /admin/agents 删掉。

根因:三个发信入口的检查链只有「地址语法 / 调用权限 / 会话别名」,
从不问「这个名字存在吗」。`AgentDisabled` 那个函数只在注册路径被调用,
发信路径压根不查——它的注释甚至写着「Agent 不存在时返回 false」。

静默丢件比报错严重:报错能立刻改,静默丢件要等对方追问才发现。
这与之前修过的「relay_key 400 被当暂时失败导致静默挂死」同类。

## 修法

`repo.RecipientDeliverable(ctx, name)` 作为唯一判据:
- 人类用户 → 放行(人的收件箱一直在,不受 Agent 停用影响)
- Agent 在册且未停用 → 放行
- Agent 不存在 → ErrRecipientUnknown → 404
- Agent 已停用 → ErrRecipientDisabled → 409

`handler.checkDeliverable` 把它接到三个入口,**收件人与抄送位一起查**:
不查 cc 的话抄送位就成了绕过口,而且因为不是主收件人更不容易被发现。

- me.go   MeSendMail   (人类发信)
- mail.go SendMail     (Agent 发信)
- forward.go ForwardMail(人与 Agent 两条转发路径共用)

停用选择「当场拒收」而非「入库等恢复后补投」:停用的语义就是这个
Agent 现在不干活,让发件人以为信已送达更坏——它会照常等回信。

## 测试

`internal/repo/deliverable_test.go` 8 例:人类 / 在线 Agent / 不存在 /
删除后不可达 / 停用 409 / 恢复后重新可达 / 空名放行 /
同名人类优先于已停用 Agent。

负向对照:让 RecipientDeliverable 无条件 return nil(还原事故前行为),
UnknownName、AfterDelete、Disabled 三例如期失败。

## 线上验证

  发给已删除 remotebot        → 404「收件人不存在」
  发给在线 pi                → 200,pi 回信「可达性 OK」
  cc 位放已删除 remotebot     → 404(绕过口已封)
  停用 pi 后发信             → 409「已被管理员停用」
  恢复 pi 后发信             → 200

## 顺带

- 部署改用 sqlite3 .backup + install -m 0755(原子 rename,不写坏
  运行中进程镜像),来自 git-release-discipline skill 的运维纪律
- 清理本会话测试残留:误登记的 opencode 密钥、remotebot 两把残留密钥、
  死信测试邮件与会话
2026-09-05 09:58:36 +08:00
9ebb8dfb41 Agent 删除:后端 DELETE /admin/agents/{name} + 名字退役保护
## 新增端点

DELETE /api/v1/admin/agents/{name} → 清 Agent 全部运行态,保留邮件历史。

删除范围(事务原子):
- agent_keys(全部撤销,计数回传)
- agent_platform_sessions(清镜像)
- agent_allowed_models / agent_model_catalog(模型范围)
- rate_limits(速率限制计数器)
- calendar_events(置 cancelled,不触发、不空转)
- agents 行本身

保留范围(审计凭据,不删):
- mails(历史邮件)
- sessions(线索与邮件一起组成线索)

内置管理员 jianf 不可删(二次保险)。

## 名字退役保护

`IsRetiredAgentName`:agents 表里没有 + mails 表里有引用 = 已退役。

注册路径(RegisterAgent)+ 密钥登记路径(CreateAgentKey)两处都拦。
后者原来会级联建 agents 行,绕过注册检查——现在名字退役时
CreateAgentKey 也返回 409。

## 错误信息

`writeKeyErr` 加 `strings.Contains(err, "已退役")` 分支,返回 409
而不是通用的「密钥操作失败」。

## 前端

QuotaPanel:每个 Agent 行右侧加「删除」按钮,二次确认里
明确说明「邮件保留,此名不可再用」。恢复与删除共用一套
confirming 状态,通过 confirmAction 区分。

## 测试

gateway build + vet + go test 全绿(预存农历 bug 不是本轮引入)。
生产验证:remotebot 删除后数据库四张表清空、-mails 保留;
同名建密钥被 409 拦截;jianf 删除被 403 拒绝。
2026-09-05 00:28:05 +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
30b78ef2cd fix: SuggestPaths 从镜像取路径 — 清库后路径补全不再为空
SuggestPaths 原来只查 mails.to_workspace(清库后为空)和
agents.workspaces(标准插件永远为空) → 清库后路径补全对所有
Agent 都空,界面提示「没有可用路径」。

加第三个来源:agent_platform_sessions.workspace(镜像,心跳上报)。
清库后镜像仍在(心跳重新上报),补全立即恢复。

按使用频率降序排列,常用路径(/root, /home/program/llmsproxy)
排在前,测试路径(/tmp/pi-bridge-e2e/ws)排在后。

顺带修了 SQL:SELECT DISTINCT + ORDER BY MAX() 不加 GROUP BY
在 SQLite 上语法错误(静默返回空集而非报错)。
2026-09-04 15:50:27 +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
390fef8941 fix(web): 补齐强调色的 CSS 变量 —— 红/绿/橙/黄按钮此前不可见但可点
## 症状

所有界面的「确认」类按钮看不见,但对应位置点击照样生效。
归档确认、删除、危险操作、状态徽标全部受影响;蓝色主按钮正常。

## 根因

`tailwind.config.js` 的 `colors` 里对 red/green/amber/orange/yellow
**同时写了两份定义**:先是固定 hex,紧接着又是 `accent('red')`。
JS 对象字面量重复键**后者胜出**,不报错、不警告 —— 读代码的人看到上面那份
hex 以为在用它,实际生效的是下面那份变量引用。

而 `index.css` 里当时只有 20 个变量(white / on-accent / gray / chrome),
没有任何 `--c-red-*`。CSS 里变量未定义会让**整条声明失效**:

    .bg-red-600 { background-color: rgb(var(--c-red-600) / 1) }   ← 整条被丢弃

于是 `bg-red-600` 退回透明,而 `text-white`(走 `--c-on-accent`,浅色下是纯白)
照常生效 → 白字落在白卡片上。按钮的盒子、padding、点击区域全都在。

实测部署产物里 32 个变量被引用但从未定义。blue 逃过一劫只因为它没有第二份
`accent('blue')` 定义,编译成了固定值。

## 修法(用户选 B:补齐变量,让强调色也参与主题)

`index.css` 新增 96 个变量,`tailwind.config.js` 去掉重复定义。

**强调色是两段语义色阶,深色下走向相反**:
- `50`–`300` = 表面(chip 底、提示条底、边框)→ 深色下**变暗**。
  照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
  那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
- `400`–`900` = 前景(文字、图标)→ 深色下**变亮**。
  照搬时 red-700 只有 2.67:1、amber-900 只有 1.90:1。现在每档 ≥4.5
  (最低 red-400 = 5.93)。

**实心按钮底另立一组 `--s-*`,两种模式同值。**
那六档在深色下被提亮是为了 `text-red-600` 读得动,而 `bg-red-600 text-white`
的白字落在提亮后的浅红上只有 1.6:1。一个名字服务两种语义必然坏掉一头 ——
与此前 text-white/bg-white 那次同理。只覆盖 `backgroundColor`,
`text-*`/`border-*`/`ring-*` 仍走 `accent()`。

顺带把浅色 red-600 从官方的 220 38 38 压到 213 37 37:官方值落在 red-50 上
只有 4.41:1,而 `bg-red-50 text-red-600` 正是错误提示条。

## 防复发

`test/theme.test.mjs` 20 → 26 条,新增 6 条针对这次的:
- **Tailwind 实际使用的每个变量都在 index.css 有定义**。判据走 resolveConfig
  而不是正则扫配置文本:出问题的变量名是 `accent('red')` 模板拼出来的,
  源码里没有 `--c-red-600` 这个字面量,扫文本会漏掉正是要防的那一类
- colors 里没有重复的颜色名(这次 bug 的成因)
- 表面段深色下变暗 / 前景段在深色卡片上 ≥4.5:1(逐档断言,72 项)
- 实心底走 `--s-*` 且未被 `.dark` 覆盖
- 白字在实心底上 ≥3:1

新增 `test/manual/accent-verify.mjs`:真浏览器渲染 17 组配色 × 两模式,
读 `getComputedStyle` 量实际值,**把「背景透明」单独判为失败**(那正是本次
bug 的指纹)。只以 `hover:` 变体出现的档不能放进探针 —— Tailwind 不生成
未使用的基础类,探它必然透明,是假阳性。

## 验收

- theme.test.mjs 26 条全过;web 160 例;tsc 无错
- accent-verify 两模式各 17 项全过(浅色最低 4.51、深色最低 3.05)
- theme-verify 9 项全过;wide-regression 5 项全过
- 截图逐像素核对:浅色侧栏 (15,23,42) / 卡片 (255,255,255) / 页面底 (249,250,251);
  深色 (12,14,18) / (24,27,33) / (17,19,24) —— 层次关系两模式一致
2026-09-04 11:57:42 +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
d74f356f41 feat(web): 深色主题 —— 反转灰阶而非逐处 dark: 前缀
**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里,
漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现,
且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍,
那种约定活不过三次改动。

改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身
就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线,
gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成
深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。

变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS ——
那些半透明高亮会静默失效(不报错,只是不透明)。

---

实测撞了三个必须分离的语义,每一个共用变量就坏:

**1. text-white 不能跟 bg-white 走。**
`white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字
(深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在
bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。

**2. 侧栏与底部导航不能跟 gray 走。**
它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。
并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区
(rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、
保持「框架比内容更沉」。

**3. 强调色不能反转。**
blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量,
而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。

---

顺带修的三处真实对比度不足(实测量出来的,不是猜的):
- 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1
  (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急)
- 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**,
  不是次要装饰,读不动等于页面空白
- 列表头计数:同上

---

主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话,
白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时
才跟随系统,显式选了的人不该因为日落被切换。

index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到
React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。
外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键
(不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。

body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。

入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以
表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」——
那是个回不去的单向门。

测试:test/theme.test.mjs 20 条结构性断言(已进 npm test),
test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG
比值,往上找第一个不透明背景)。两种模式各 4 项全过。
2026-09-04 06:30:26 +08:00
7f28552440 feat(web): 日历前端 —— 月/周/日三粒度 + 事件编辑器 + 农历显示
布局与全站一致:**内容区自己再分两栏**。

第一版是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
而点「新建」时编辑器**顶掉**整个日历,人失去正在看的那个月的上下文。
两个问题同源:日历没有「详情栏」这个位置。

现在左边网格、右边常驻面板:默认显示选中那天的日程(所以永远不空),
新建/编辑时同一位置变编辑器 —— 与「新建邮件是右侧整页」同一套语言。
窄屏退回覆盖式(NarrowStack,与收件箱详情同一组件同一段动画)。

lib/calendar.ts 里三个易错点(都有测试钉住):

**周首必须是周一。** getDay() 把周日算作 0,直接减它会让周日归到上一周
末尾,月视图第一行整体错位。

**分桶用本地日期串而不是 toISOString().slice(0,10)。** 后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。

**月视图固定 42 格。** 按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。

另有一个 TS 陷阱:replaceAll 在 tsconfig 的 target: ES2020 下不存在
(TS2550)。改用 split/join —— **不能**退回 replace,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。

编辑器:
- 农历规则预览**接下来三次的公历日期**。规则名(「每农历月廿二」)看不出
  公历日子,而那日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
- 变量按钮 + 实时渲染预览。「模板里写了什么」和「Agent 收到什么」不是一个
  东西,不给预览人只能发一次试试看。
- 收件人列表可增删调序(together 模式下首个是主收件人,顺序有语义)。
  编辑旧事件时初值走与后端相同的兜底链 —— 不做归一化的话,编辑一条老事件
  再保存会把收件人清空(列表是空的,保存就覆盖了)。
- remindAt 单独显示:调度器比较的是「事件时间 − 提前分钟」那个点,
  不显示的话人设了「提前 30 分钟」却在事件时间才反应过来。

日历格子标农历日(初一显示月名,纸质日历惯例)。暂停/取消的事件加删除线
与图标 —— 否则人以为设好了,实际到点什么都不会发生。

exportCalendarICS 不走 request()(那个假定 JSON,这里是 text/calendar),
返回文本让调用方用 Blob 触发下载而不是让浏览器导航 —— 导航会丢掉 cookie
之外的认证头。

测试 35 例。
2026-09-04 06:29:50 +08:00
e504eccf3a fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定
**授权请求不是「一封信」而是「一件待办」。**

生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各
3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件
事在等我)。

第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 →
决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的,
授权项只放要批的。

`groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、
至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部,
而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。

未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是
「有内容没看」,待决策是「有 Agent 卡着等我」。

---

**回复发给自己的 bug(用户报)。**

根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留
(当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假,
于是取 from_name(自己)。

生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的),
`8e519925 jianf→jianf` 错(锚点是自己发的)。

两处修正:
1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处:
   对端解析 / replyAll 去自己 / ThreadCard 图标。
2. **会话视图的回复对端是会话的属性,不看任何单封邮件。**
   人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时,
   自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个
   非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。

ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有
多个人类参与方。

测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、
replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。
手工验收脚本 inbox-group-verify.mjs 六项全过。
2026-09-04 06:29:21 +08:00
5b76ac5c57 feat(homeagent): 四个日程工具 + 工具计数不再硬编码
create_schedule / list_schedules / update_schedule / delete_schedule,
插件从 11 个工具变 15 个。

时间格式是这组工具最容易出错的地方,parseEventTime 接受三种形态:
完整 RFC3339(推荐)、无时区(按本机时区解释)、只有日期(当天 9 点,
比零点有用 —— 零点的提醒没人看)。

**刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天,
而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。
解析失败的报文里带上当前本机时间,让它能自己算。

其他细节:
- recipients 用逗号分隔的 string 而非数组:几个平台对数组参数的 schema
  支持不一致,而逗号分隔在所有平台上都是普通 string。中英文逗号都收 ——
  中文输入法下打出「,」是常态。
- argInt 同时收 float64 与字符串:"30" 带引号会让「提前 30 分钟」静默失效。
- update 的工具描述里写明「只传要改的字段」:模型的默认倾向是回传它记得的
  全部字段,记错一个就覆盖掉原有的提醒正文或收件方。
- list 在接近上限(>=75%)时主动提示清理,而不是等撞墙 —— 撞墙那一刻
  模型往往正在做别的事,没有余裕去清理。
- scheduleEvent.effectiveRecipients() 与服务端同一条兜底链:只读
  recipients 会让旧数据显示成「发给:」后面空白。

**工具计数从 RegisterTool 的调用数派生,不硬编码。**
之前写死 13 而实际注册 11 —— 排查「工具没生效」时日志说 13、平台说 11,
两个数字都不可信,白花了一轮时间。加工具时忘改常量是必然的,
所以让它没有机会写错。

plugin.go 另补 put/delete 两个 HTTP 辅助(原来只有 get/post)。

已部署验证:homed 日志「注册完成(15 个工具 + 1 个输出通道)」,
模型实际调用 list_schedules 成功。
2026-09-04 06:28:55 +08:00
2ae4df0e98 feat(agent-calendar): Agent 侧日历端点(可写,只能动自己建的)
在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。
于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启
定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与
调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。

更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来
某刻醒来。

三处收紧:

**1. 只看/只改自己建的。**
别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404**
而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。

**2. 不能设给人类。**
理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。
发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下**
(提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。
一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益
(它其实可以直接 send_mail)。

**3. 速率 + 总量双闸。**
速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、
连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。
攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。
所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit:
模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。

其他设计点:
- **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次
  回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。
  全部字段用 *T,nil = 没传 = 保持原值。
- 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错);
  重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。
- 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让
  模型忘记然后拿到 400。

顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数),
统一成 strconv.Itoa。

测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、
速率桶与会话桶独立、按 Agent 隔离、失败归还名额。
生产实测 11 项全过(含 403/400/404/429 各条边界)。
2026-09-04 06:28:33 +08:00
069bf03ae2 feat(calendar): 日历后端 —— 事件/提醒/重复规则 + iCal + 多收件人
三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。

日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。

**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together        = 首个为主收件人、其余进 cc_list、共享一条线索

「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。

抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。

recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。

---

修掉的七个真问题:

**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。

**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。

**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。

**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。

**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。

**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。

**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。

---

其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
  在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
  时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
  「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
  它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
  兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
  无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
  不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)

测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
2026-09-04 06:28:03 +08:00
4d211c84d6 feat(lunar): 双端农历换算层(Go + TS,同作者同算法)
日历要支持「每农历月十五」「农历生日」这类规则。公历与农历的换算不能
自己算,两端各引一个库:Go 用 6tail/lunar-go v1.4.6,前端用同作者的
lunar-javascript 1.7.7 —— 同算法保证两端结果一致(前端要在格子上显示
农历日、在编辑器里预览接下来几次触发)。

为什么要包一层而不直接用库:

**1. 库在非法日期上 panic 而不是返回 error。**
`NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar year
2027 month 9")。农历月是 29 或 30 天不定,「每月农历三十」这条规则必然
撞上短月份。调度器里一次 panic 就让那条提醒永久卡住。
修法是夹到该月实际天数并返回 clamped 标记 —— 夹而不滚:「每月三十」的
语义是「月末那天」,滚到下月初一会让提醒与前一次只隔一天。

**2. 闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
2025 有闰六月、2028 有闰五月,2026/2027 没有。AddYears 从闰月出发而
目标年没有同一闰月时退回正月份 —— 静默让重复事件消失更糟。

**3. 按农历推进不能加固定天数。**
农历月 29~30 天、农历年 353~385 天(闰年多一整月),AddDate 近似一年
能偏半个月。

前端另有一个 TS 陷阱:日名有五种前缀形态(初一/十一/二十/廿一/三十),
原来用正则从 toString() 截取时漏了「二十」,20 号会显示整串「七月二十」。
改成查表。

测试:Go 12 例 / TS 37 例。含「同一农历日在六年公历里落到至少 4 个不同
月日上」—— 那正是农历重复存在的理由(公历 yearly 会固定在同一天)。
2026-09-04 06:27:15 +08:00
473c46659a fix(homeagent): 删除 plugin.go 里与 tools.go 重复的 6 个方法
plugin.go 与 tools.go 各自声明了同名工具处理器。逐个 diff 后保留
tools.go 的版本 —— 它严格更丰富:read_thread 多 ParentHid → parent_hidden
并区分「父邮件无权查看」与「父邮件尚未加载」,suggest_address 多解析
candidates,connect_to_server 多一条注释。

plugin.go 删除 handleConnectToServer / handleForwardMail /
handleSuggestAddress / handleListContacts / handleSessionParticipants /
handleReadThread + minInt。

tools.go 删除本地 min():go.mod 是 go 1.25.0,内置 min 可用,
本地定义只是遮蔽。

部署验证:11 项工具注册(日志里硬编码的「13 个工具」是错的数字)。
2026-09-03 21:11:15 +08:00
a2f3db143b fix(pi): 移除 additionalProperties: false —— 与 pi 的 _ref 注入冲突
现场:一条会话 23 次 toolCall 只有 2 次成功,21 次报
`Validation failed for tool "read_inbox": - root: must not have additional
properties`,Received arguments: { "_ref": "CAIStw4..." }。

pi 在 toolCall.arguments 里注入 `_ref`(thinking 上下文引用句柄),
而 11 个工具全声明了 additionalProperties: false,typebox 判它非法。

判据:builtin bash 同样收到 _ref 却成功;opencode/dsh/homeagent
三平台 additionalProperties 出现次数都是 0,只有 pi 是 11。
它不是「更严格更好」,而是与 pi 的参数传递机制直接冲突。

**是活锁不是死锁**:模型收到校验失败 → 重试 → 又被拒;每次 toolu_bdrk_*
都是新的,幂等键各不相同,每一次都生成新权限邮件(实测 35 → 39 封)。

链条在第一环就断了:validator 在 consent 之前先拒了 tool_call,
权限钩子根本没机会跑 —— 不是「同意了被忽略」而是**从未被问过**。

踩坑理由写进 createMailTools() 的文档注释(下一个加工具的人很可能顺手写回去)。
新增 tool-schema.test.mjs 5 例,含「注入 _ref 不被拒」。
2026-09-03 21:11:15 +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
edb4bd94fd fix(permission): 权限决策者必须是人类,顺会话树查找
死锁现场:pi 的权限邮件 to_name = "pi"(决策者被解析成 Agent 自己),
SendToUser 找不到收件人 —— 请求发出去后无人可答,会话无声挂死。

新增 repo.NearestHumanInThread 三级查找:会话 owner → 最近人类发件人 →
最近人类收件/抄送方。发件人优先于收件人:主动说话的人更可能在关注这条线索。

不用递归 CTE 沿 parent_mail_id 上溯而按会话扫:权限请求常常不挂父邮件,
上溯会在第一跳就断。

没有人类可路由时**自动拒绝并给 Agent 可执行的替代建议**,而不是转给管理员
—— 管理员对这条 Agent 链的上下文一无所知,收到也无法判断。
409 返回结构化的 error/detail/suggestion/decider_was。

nearesthuman_test.go 4 子测试。
2026-09-03 21:08:39 +08:00
1767953331 fix(db): SQLite 时间绑定改用 sqlite 格式 + UTC 时区
modernc.org/sqlite 在 writeTimeFormat 为空时回落到 t.String(),写出
`2026-09-03 16:45:38.5 +0800 HKT m=+0.009` —— 带单调时钟后缀,
datetime() 返回 NULL,与 NOW() 的字符串比较必然错。

后果是安全 bug:user_sessions.expires_at 无法被 datetime() 解析,
过期令牌照样通过校验 —— 会话永不过期。

修法是一行 DSN(`_time_format=sqlite&_timezone=UTC`)而不是改 40 处
db.Ts() 绑定点:DSN 是全局的、不可能漏,辅助函数要求每个新绑定点都记得调用。

排除 `_time_format=datetime`:它把亚秒截断,会重新引入「同秒插入多封邮件
排序不确定」——「会话里最早那封」(决定联系人身份)与「最后那封」
(决定最新进展)都会取错行。

新增 usersession_time_test.go 5 例。反向验证:摘掉 DSN 修复后 4 个失败,
报文即「过期令牌竟然解析成功 —— 会话永不过期」。
2026-09-03 21:08:23 +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
fc33087982 feat: homeagent-mail-bridge —— TrueAgent 接入 AgentMail(MVP)
## 架构

TrueAgent 是单一常驻 agent 事件循环,没有 per-conversation session。
插件像 QQ 插件一样:所有邮件注入同一个事件循环,靠中断消息文本传递 context。
不做 platform_sessions 上报(无 sessions 可映射)。

## 工具

- read_inbox: 收件箱查询(渲染发件人/主题/正文/抄送/附件)
- send_mail: 三维地址发信(支持 reply_to)
- output_send__homeagent 输出通道(agent 可主动发信)

## 自动回信

InjectInputSync(阻塞)等待 agent 处理完毕 → 自动把 FinalText
发回给发件人(reply_to 指向原邮件)。agent 不需要记得调 send_mail。

试过 StageAfterOutput hook,但 before_output 看不到 ToolCalls
(read_inbox 调用在 turn 中间就被清掉了),导致自动回信不触发。
InjectInputSync 更可靠:TrueAgent 保证不重入。

## 基础设施

- 心跳 25 秒
- SSE 长连 + 自动重连(3 秒退避)
- 注册时自动注册密钥(AGENTMAIL_AGENT_KEY 环境变量)
- systemd drop-in 注入 AgentMail 环境变量

## 构建

plugindev build → dist/homeagent_linux_amd64.hmap
安装到 /home/newqqagent/plugins/homeagent-mail-bridge/
homeagent.service drop-in: /etc/systemd/system/homeagent.service.d/agentmail.conf

## 端到端验证

admin → homeagent@/tmp/homeagent.new(自动回信验证)
→ homeagent 读取邮件 + 自动回信「收到确认」→ 25 秒往返完成
2026-09-03 12:57:43 +08:00
e892980373 feat: 管理页 Agent 停用/恢复按钮
QuotaPanel 改名为「Agent 管理」,每行新增:
- 状态徽章(在线/离线/已停用)
- 停用/恢复按钮(点一下出确认,确认后执行)

adminSetAgentStatus API 调用 PUT /admin/agents/{name}/status
AgentStats.status 字段加入前端类型定义

同步部署:前端产物已构建并覆盖 gateway/internal/static/static/

go test 全绿,tsc --noEmit 通过
2026-09-03 12:26:34 +08:00
c900b4e1da dsh: 工作区注册 —— 邮件会话挂进 DSH GUI 的项目分组
ctx.get('workspaceRegistry') 可选注入:workspaceRegistry 不存在时
(非 GUI 模式/headless profile)跳过,不影响会话功能。
create(cwd) 先注册目录,attachSession(sessionId) 再绑定会话。
失败只影响 GUI 分组,不影响邮件收发。
顺带清掉上一个 commit 里的重复代码块。
2026-09-03 12:12:48 +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
514f443e54 fix: 「我的」页无法滚动 —— 缺滚动容器;顺带修矮屏登录页
用户反馈「我的页面点击进入后无法滑动」。

## 根因:AccountPage 根本没有滚动容器

窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
`flex-1 min-w-0 flex flex-col`,中间缺一层 `overflow-y-auto` ——
内容超出的部分**直接被裁**,滚不到也点不到。

实测 390px 下内容(资料 + 权限 + 改密码 + 密钥 + 退出)需 860px、容器只有
795px,「退出登录」按钮连同下面 65px 一起消失;1280x800 的桌面上同样看不到。

其余六个页面级组件(AdminUsersPage / MailView / ComposePage / ThreadView /
ContactPanel / MailList)都有这一层,只有它漏了 —— 上一轮把退出登录搬进这页
之后内容变高,问题才显形。

## 顺带:登录页与初始化页在矮屏滚不到底

卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出,
溢出到顶部那段是滚不到的(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机,
或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto`
也够不着。

改用卡片自己的 `my-auto` 而不是容器的 `items-center`:auto margin 在空间不足时
自动退化为 0,矮屏变成正常的顶对齐可滚布局,高屏仍然垂直居中
(390x844 与 1280x800 实测 centered=true,568x280 下滚到底能看到按钮)。

## 两侧都加了检查

- 结构断言:七个页面级组件都必须含 `overflow-y-auto`;
  登录/初始化页必须有 `my-auto` 且不用 `h-full ... items-center`
- 实测脚本新增 `scrollHealth()`:每页都有滚动容器,且没有内容被
  `overflow-hidden` 的父级裁掉

判据刻意是「**有**滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者
为假,但页面是健康的;真正的 bug 是根本没有那一层。

## 验证

窄屏实测 18 项 + 宽屏回归 5 项全通过;结构断言从 28 条扩到 37 条。
生产已部署。
2026-09-03 07:39:52 +08:00
342282b92c fix: 窄屏实测修复 —— 删抽屉、44px 命中区、触摸设备可见的次要动作
用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px
(iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现
一个功能性 bug 加五处可用性问题。

## 抽屉式侧栏遮挡底部导航(真 bug)

抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有
z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的
SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试
才能发现。

**删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/
管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed
层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。

退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本
没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。

## 触摸命中区:新增 .tap

详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」
54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。

直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素
扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度
足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。

覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage /
ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。

## 看不见却按得动的按钮:新增 .reveal

`opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0,
**但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的
「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片,
实际归档了一条会话。

改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。
单看 hover 会把带触摸板的平板算进去。

## 对话树

- 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要
  去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate
  吃掉。窄屏改为每级 10px、上限 5 级
- 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表,
  关闭只收起树、留在这封邮件上

## 把实测脚本留进仓库

`web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test ——
要一个跑着的浏览器加一个活的 Gateway。

留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」,
而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门
为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox),
hitTest() 验每个元素点下去是否命中自己。

## 验证

- 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事:
  .tap 伪元素 content 为 none、没有返回按钮
- narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复
- 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth
- 生产已部署
2026-09-02 23:53:59 +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
9d4718a412 feat: SSE Last-Event-ID 补投 + 连接状态指示 + 限速器 DB 化
## SSE Last-Event-ID 补投

EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了——
所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。

改为 per-user 事件环形缓冲区(500 条,~100KB/用户,20 在线 ≈ 2MB):
每次 Broadcast/SendToUser/SendToAgent 同时写入对应用户的缓冲区;
AddClient 时取 Last-Event-ID 头,找到该 ID 的位置后从下一条回放。
找不到 ID 说明事件已被覆盖(缓冲区溢出),从头回放全部。

事件 ID 用全局递增序列号(非 UUID),EventSource 的 Last-Event-ID
就是靠这个 ID 记住断点的。

新增测试:缓冲区回放、溢出行为、并发安全(10 goroutine × 200 次 push)、
端到端重连验证(SendToUser → 带 Last-Event-ID 的 AddClient → 补投)。

## 连接状态指示器

Sidebar 用户头像右下角的小圆点:绿=已连接,黄=连接中,橙=重连中,红=断开。
NarrowNav 底栏也有(移动端)。

SSE 模块新增 onSSEStatus/getSSEStatus 接口,onerror/onopen 驱动状态变化。
状态点用 absolute 定位在头像边缘,不遮挡文字。

## 限速器 DB 化(解决多实例部署时的计数漂移)

原实现:LoginLimiter 与 sessionRateLimiter 都是进程内内存计数器。
多实例部署时各自独立计数,等效上限变成 N 倍。

改为 rate_limits 表(bucket + ts),两个限速器共享同一套基础设施:
- LoginLimiter:bucket="login:<username>",COUNT(*) >= 5 → 锁定 5 分钟
- sessionRateLimiter:bucket="session:<agent_name>",COUNT(*) >= 20/h → 拒绝

判断与写入在同一个 BEGIN IMMEDIATE 事务里——SQLite 的 IMMEDIATE
在事务开始时获取 RESERVED 锁,防并发写事务同时进入 COMMIT 阶段。
实测 80 并发下恰好放行 20 次(旧内存版同样通过,但 DB 版才能多实例共享)。

DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
新建 rate_limits 表迁移(SQLite + PG 两版)。
2026-09-02 14:33:41 +08:00
07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00