|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
0e754617a4
|
feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。
核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过
后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
附件挂载),并发下不会刷穿
前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts
插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
session.idle 时转发本轮总结)
|
2026-09-02 10:29:26 +08:00 |
|