|
|
bbddee26b9
|
feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口
建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**
追下去是三件事叠在一起:
1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。
于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。
# 设计:邮件上给「时刻」,不给「是否失效」的布尔值
服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。
关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。
`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。
# 改动
- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
`PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。
# 验证
- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
- `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
- 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
- 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
|
2026-09-11 22:02:25 +08:00 |
|
|
|
d50c55da4f
|
fix(format): 关掉 pi-lens 的项目级自动改写;修复它被 prettier 改坏的 index.html
# 我上次的修复只做了一半
上一次(429149e)我加了根 biome.jsonc,把 biome 的 formatter 关掉,并撤销了它造成的
约 7000 行重排。那时我以为问题解决了 —— **没有**。
pi-lens 编辑文件后会「安全格式化」它,格式化器是**按扩展名各自挑**的
(FORMATTER_POLICY_BY_EXTENSION)。仓库里没有任何格式化器配置时它走 smart-default 回退:
.ts/.tsx/.js/.mjs/.json/.css → biome
.html → prettier ← 这一条我漏了
biome.jsonc 挡得住 biome,对 prettier 完全无效。于是 a404cba(图标提交)里我改了
index.html 加 favicon,prettier 顺手把它改写了:
-<!DOCTYPE html> +<!doctype html>
-classList.add('dark') +classList.add("dark") ← 单引号变双引号
-<meta name="viewport" …> +多行折行
而 test/theme.test.mjs 恰好逐字符断言那一段里是 `classList.add('dark')`(单引号),
两条断言当场变红。**更糟的是我没看见**:验证时我用 `grep -E 'Test Files|Tests |FAIL'`
过滤输出,而这个测试的摘要是中文的(「主题:28 通过,2 失败」),被整行滤掉了,
于是我在提交信息里写了「196 项测试全绿」—— 一句不成立的结论。
(顺带厘清两起事故的元凶不同:19a3161 那次是 **biome**(tab 缩进是它的默认),
a404cba 这次是 **prettier**。我先前把它们当成同一个问题。)
# 根因级修法
不再逐个格式化器打补丁,而是关掉 pi-lens 对本仓库的改写路径。
项目级 `.pi-lens.json` 支持 format.enabled / autofix.enabled(文档
docs/settings.md 的 mutation controls),由当前目录向上查找,覆盖整个仓库:
{"format": {"enabled": false}, "autofix": {"enabled": false}}
autofix 一并关掉:它同样会在与本次改动无关的行上动手。
biome.jsonc 保留(纵深防御,且它顺带关掉了 biome 的自动修复)。
# 验证
- 修好 index.html:DOCTYPE 恢复大写、viewport 回单行、内联脚本回单引号,
与 19a3161^(未受污染的原版)逐字节一致,只多出 favicon 四行。
实验证据:把原始版喂给 prettier,输出与 a404cba 里的版本**逐字节相同** —— 确认元凶。
- `node test/theme.test.mjs` → 主题:30 通过,exit 0
- 加注释后再编辑一次 index.html:单引号与 DOCTYPE 均未被改写(改写路径已断)
- 顺带在注释里写明「引号别统一成双引号,theme.test.mjs 逐字符断言」,防止后人"清理"
|
2026-09-11 22:01:56 +08:00 |
|
|
|
a404cbad54
|
feat(branding): 确定项目图标,并接入 Web / Electron / HarmonyOS
# 唯一源
`client/electron/src/icons/agentmail.svg` 是图标唯一源(24×24 视图框,`currentColor`
跟随文字色)。此前各端用的都是占位物:Electron 的窗口/托盘指向一个**不存在**的
`src/icons/tray-icon.png`(`nativeImage` 拿到空图,托盘不可见),
HarmonyOS 的 `startIcon/foreground/background` 是 1×1 PNG,
Web 端根本没有 favicon。
# 为什么带生成脚本
PNG/ICO 是二进制的,换一次配色要重出十几个尺寸,手工做必然出现
「Web 是旧的、Harmony 是新的」这种不一致,而且没人能复核。
`generate.py` 只认上面那一份源,所有变体都由它推导(本机无 rsvg/ImageMagick,
用 cairosvg + Pillow)。改图标只需改源文件再跑一次脚本。
# 各端产物
- **Web**:`public/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}` + `index.html` 引用。
放 `assets/` 下而非根目录,是因为 Gateway 只把 `/assets/*` 与 `/` 交给静态处理器
(`server/cmd/server/main.go`),放根下会 404。已实测本机与 LAN 均 200。
- **Electron**:应用图标 `icon.png`(512) / `icon.ico`(16–256) / 各尺寸 PNG /
托盘 `tray-icon.png`(32),`package.json` 里 `win.icon` 与 `linux.icon` 指过去。
托盘用品牌色字形而非白色 —— 浅色面板下白色会消失。
- **HarmonyOS**:`startIcon.png`(512) 用完整应用图标(启动页底色浅 `#FFF` /
深 `#000`,白底蓝图标两套都立得住);分层图标的 `background` 是品牌色整块、
`foreground` 是白色字形并留 12% 安全区,避免被系统圆角裁掉。
- **应用内**:新增 `BrandMarkIcon`(fill 型,与现有描边图标集不同族),
替换登录页与初始化页品牌位的占位 `MailboxIcon`;后者已无引用,一并删除。
图标色 `#2563eb` 与门户 Dashy 主题主色一致。
# 验证
- 前端 typecheck 与 196 项测试全绿;`npm run build` 产物含三个图标文件
- Gateway 重新部署后 `/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}`
在本机与 `192.168.2.60:8180` 都返回 200,Content-Type 正确
- 所有 PNG/ICO 用 Pillow 复核尺寸与 alpha 边界(合成失败会表现为全透明,
已用 getbbox 排除)
|
2026-09-11 15:49:41 +08:00 |
|
|
|
4186ad4784
|
fix(setup): 首个管理员的创建只允许 Gateway 本机
# 之前的缺口
`POST /api/v1/setup/admin` 是公开路由,唯一的门是「系统还没有任何用户」。
Gateway 监听 `*:8180`,于是局域网里任何人可以绕开 nginx 直接调它。
本机已初始化时它只回 409,所以这条是纵深防御;但在**尚未初始化**的部署上,
它是「谁先提交谁成为管理员」——一个可被抢注的管理员入口。
# 为什么不是收紧监听地址
`.106` 上的反代(公网 `mail.jianfgit.xyz`)与本机鸿蒙客户端都直连
`192.168.2.60:8180`,把监听收到 127.0.0.1 会把这两条入口一起切断。
缺口在端点本身,不在监听面,所以只收紧端点。
# 改动
- 新增 `middleware.LocalOnly`:只有真实 TCP 对端为回环地址才放行。
- 新增 `middleware.CapturePeerAddress`,**注册在 `chimw.RealIP` 之前**。
RealIP 会信任 `X-Forwarded-For` 并改写 `RemoteAddr`,直接读它等于让外部
调用者用一个请求头冒充本机;所以先存原始连接地址,安全判断只认那份。
- `SetupAdmin` 的注释同步:本机限制在路由层,`NeedsSetup` 保留为第二道防线。
- 测试(`localonly_test.go`)按生产中间件顺序组装链,覆盖:
IPv4/IPv6 回环放行、局网拒绝、**伪造 X-Forwarded-For 仍拒绝**、
非法地址拒绝,以及未装 CapturePeerAddress 时 fail closed。
# 验证
- 回环 `/setup/admin` → 409(进入处理器,系统已初始化)
- LAN `/setup/admin` → 403
- LAN + `X-Forwarded-For: 127.0.0.1` → 403
- LAN `/setup/status` → 200(登录页判断是否显示向导仍正常)
- 登录 + 收件箱 → 200;Go 全量测试与 vet 通过;已部署,四桥/SSE 正常
|
2026-09-11 15:49:26 +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 |
|
|
|
fd9f99a3f9
|
UI 修复: 回复页(ReplyBar)加往返预算配置入口
jianf 反馈:回复页面/对话页面/发件页面无法配置对话预算。
- 发件页(ComposePage):已有 maxRounds 输入框 ✅
- 对话页(MailView 会话头部):已有 BudgetEditor ✅
- 回复页(ReplyBar):缺失 ← 本轮补上
ReplyBar 底部按钮区加预算徽标(点击展开编辑):
- 显示 已用/上限 来回 或「预算不限」,用尽时红色
- 点击 → 数字输入 + 保存/取消
- 复用 useSessionStore.setBudget(同一会话)
- 177 测试全过,已部署
|
2026-09-07 18:15:26 +08:00 |
|
|
|
83d3c2a51a
|
docs: HMS-PUSH-PLAN.md 华为统一推送服务端集成方案
|
2026-09-07 17:00:06 +08:00 |
|
|
|
7278fcdf9a
|
docs: MULTI-ACCOUNT-PLAN.md 多账号架构方案 v0.1(单一事实源)
dsh+pi 共同遵守的多账号设计文档:
- 账号数据结构(displayName/gateway/token/username)
- 聚合/单独视图交互(账号选择器)
- 发信账号选择
- SSE 每账号一连接架构
- 配置页 UI
- Gateway API 映射
- 安全要点(已审计)
|
2026-09-07 16:57:55 +08:00 |
|
|
|
fe96e197d3
|
pi-ele Phase 2: token/API 基地址注入 —— web/src 零改动成为独立桌面客户端
- main.cjs: 从 env 读 AGENTMAIL_GATEWAY_URL / AGENTMAIL_TOKEN(USER_KEY),
通过 additionalArguments 传给 preload;API_BASE = <gateway>/api/v1
- preload.cjs: 从 process.argv 读注入参数,contextBridge 暴露
window.__AGENTMAIL_API_BASE__ / __AGENTMAIL_TOKEN__
→ web/src 的 config.ts 自动读取,client.ts/sse.ts 零改动
- 效果:Electron 启动后 api.me() 带 Bearer 返回用户,直接进入主界面
(无登录页),收件箱等功能立即可用
验证(xvfb headless):
- 注入链路:渲染进程拿到 apiBase=192.168.2.60:8180 + token ✅
- 端到端:gui-lab key 注入 → 界面呈现 收件/授权/发件/日历/联系 导航 + 空收件箱,
无登录表单(inputCount=0, hasLogin=false)✅
|
2026-09-07 07:49:30 +08:00 |
|
|
|
dc5b9e440f
|
pi-ele Phase 1: Electron 骨架(主进程+托盘+preload,可复用 web/src)
- web/electron/main.cjs: BrowserWindow 加载 dist/index.html(prod)或 vite dev server
托盘(进托盘/退出/单击恢复),关闭按钮隐藏到托盘而非退出(用户要求)
- web/electron/preload.cjs: contextBridge 暴露 gatewayUrl/versions/platform
- web/package.json: 加 main 指向 main.cjs、dev:electron / build:win / build:linux 脚本、
electron 44.2.0 + electron-builder 26.15.3 依赖、build 配置(NSIS/AppImage/deb)
- .gitignore: /release/
验证(headless + xvfb):
- Electron 加载 Gateway WebUI 成功(title=AgentMail, body 渲染)
- Tray API 可用,BrowserWindow 正常
- prod 模式真实 main.cjs 无报错
|
2026-09-07 07:46:07 +08:00 |
|
|
|
bf32369ebd
|
docs/API.md: 补权限档位端点与字段(dsh 反馈的文档缺口)
dsh 在鸿蒙计划反馈中指出 API.md 未收录权限档位相关:
- PUT /sessions/{id}/permission 端点
- GET /sessions/{id} 返回的 permission_mode/permission_enforcement
- 发信请求体的 permission_mode 字段
- new_mail SSE 事件的档位字段
补:
- 会话端点清单加 PUT /sessions/{id}/permission
- 会话对象字段表(permission_mode 三档 + permission_enforcement native/advisory)
- 新增「权限档位」节:三档语义、新建/续谈均可设定、Agent 继承约束
- 发信请求体 JSON 示例加 permission_mode + 说明(两种情形都生效)
- new_mail SSE payload 示例补档位字段(含 from_human/to_human/in_reply_to)
注:后端只有 PUT 没有 GET /sessions/{id}/permission,文档已注明读取走会话详情。
|
2026-09-07 07:32:00 +08:00 |
|
|
|
314c3224ce
|
docs: 鸿蒙计划补权限档位(plan/workspace/full)与SSE档位字段(据 pi API 梳理)
|
2026-09-07 07:28:39 +08:00 |
|
|
|
9cdf4b9e3f
|
docs: 鸿蒙客户端(ArkUI)构筑计划 GUI-PLAN-HARMONY.md
|
2026-09-07 07:26:04 +08:00 |
|
|
|
89a4784c10
|
opencode+dsh: 空回复兜底 —— idle 但无 assistant 文本时回失败通知
缺口:模型 idle(轮次正常结束)但没有产出任何 assistant 文本时,
两个插件此前静默 return —— 发件人等不到任何回复也得不到交代。
(模型「全部失败」已有 renderFailureReport 兜底,但「跑完却没产出」
不等于失败,走不到那条。)
补:
- opencode relaySummary: if (!last) → 发一封「处理失败:无回复文本」通知
- dsh agent/status idle: if (!lastText) → 同款通知
- 都走 relay:summary 免配额 + relay_key 幂等
- 都只给人类来信发(Agent 间不自动转发)
|
2026-09-07 07:05:45 +08:00 |
|
|
|
be9f46cf73
|
dsh: resume 续谈也挂载 standard preset —— 修复旧会话没有文件工具
根因:startAgent 的 resume 分支 setup: undefined,注释说
「resume 从磁盘恢复,工具已在」—— 实际上工具是通过 setup 回调
presets.mount 注册的,resume 不传 setup 就没有任何文件工具。
presets.mount 修复之前创建的旧会话(setup:undefined 时代)从此
没有 read/write/edit/bash/glob/grep,用户反馈「dsh 无法看到工作区文件」。
修复:把 presets.mount 抽成 setupPreset 函数,create 与 resume 共用。
resume 恢复的是会话历史,不是工具注册 —— 两者必须都挂。
实测:旧会话 318f0703 resume 续谈后 setup 回调触发、mount 成功,
模型用 glob 列出工作区文件、read 读取、write/edit 可写,完整回复。
|
2026-09-07 06:31:19 +08:00 |
|
|
|
c440df6537
|
修复:收件箱 ListInbox 漏算 to_human 导致人类地址被拼上 workspace/session
根因:用户从收件箱点开邮件看到 。
收件箱路径 GET /me/mail/inbox 走 repo.ListInbox,它的 SQL 只算了
from_human,漏了 to_human(EXISTS users 子查询)—— 于是返回的
mail.to_human 恒为 false,前端 participantAddress 把人类 jianf 当成
Agent 拼成三段地址。
修复:ListInbox SQL 补 to_human 子查询 + Scan 补 &m.ToHuman。
这是 C 部分「人/Agent 区分」遗漏的最后一条路径(其余 mails 读路径
GetMailByID/GetSessionMails/GetSessionMailByID/ListSentBy 均已填)。
验证:GET /me/mail/inbox 返回 to_human: True,前端正确渲染 jianf。
|
2026-09-07 06:17:25 +08:00 |
|
|
|
7e9327a78b
|
发件页档位每次发信都能改(含续谈已有会话)
- me.go: SendMail 续谈时若显式传 permission_mode,也更新该会话档位
(人是权限的源头,可以任改三档,不受继承约束)
- ComposePage: 权限档位按钮始终可用(去 disabled/opacity),不再只限 .new
- hint 文案:新建→'Agent 在这类任务里被允许动手的程度',
续谈→'改了即刻生效(该会话的档位会更新)'
- 默认 workspace(不再从空字符串开始)
- sendMail 始终传 permission_mode(不再只限 isNewSession)
|
2026-09-06 22:00:33 +08:00 |
|
|
|
1759666d38
|
chore: L2 验证脚本
|
2026-09-06 21:37:13 +08:00 |
|
|
|
da67aae2dd
|
PLAN.md: P5 前端档位选择器完成
|
2026-09-06 21:25:12 +08:00 |
|
|
|
be61724314
|
P5 前端:权限档位选择器 + 卡片徽标 + 对话页改档
前端完整实现:
- types: Session/HumanSession/Mail/Contact 加 permission_mode + permission_enforcement 字段
- api/client: sendMail 支持 permission_mode 参数;新增 updateSessionPermission API
- PermissionChip 组件:plan=蓝/只读, workspace=绿/目录内, full=橙/全权
native 实心点=平台强制, advisory 空心点=仅提示;hover 显示 tooltip
- ComposePage: 新建会话时显示三档按钮(plan/workspace/full),传入 sendMail
- MailView: 会话头部加 PermissionEditor(点击徽标展开三档选择,点保存调 API 改档)
- WorkCard: 卡片底部与 BudgetChip 并排显示 PermissionChip(compact 模式)
- sessionStore: 新增 setPermissionMode action
- 177 前端测试全过,gateway 8 包全绿,已部署
|
2026-09-06 21:24:41 +08:00 |
|
|
|
36f1099c02
|
PLAN.md: L5 补充实测(dsh 写文件真拦,pi workspace 一律问人,homeagent advisory 不遵守)
|
2026-09-06 20:44:46 +08:00 |
|
|
|
b67c33c352
|
PLAN.md: L5 实测结果修正(opencode = advisory 自愿遵守,dsh = Landlock partial)
|
2026-09-06 20:40:44 +08:00 |
|
|
|
8960085152
|
opencode: mode_enforcement 改为 advisory(permission 参数不被 API 支持)
|
2026-09-06 20:39:48 +08:00 |
|
|
|
fc958f8809
|
opencode: 修正权限档位接线——session.create 不支持 permission,改用提示词 advisory
根因:opencode 1.18.29 的 session.create API 只接受 {parentID, title} + query.directory,
permission 字段被静默丢弃(SDK types.gen.d.ts 证实 SessionCreateData 无此字段)。
之前传入的规则不报错也不生效,plan 档下 bash 仍执行。
修复:
- 移除 session.create({permission: ...}) 调用(已被 API 忽略)
- 在 deliverMail 的 prompt 构造里注入 permBriefing(modeBriefing advisory 路径)
- permBriefing 与 homeagent 同理:如实说「这个平台无法强制这一档」
- 修正 catch (e: any) 语法错误(.js 文件不支持 TS 类型注解)
- 补充 import modeBriefing
L5 实测:opencode plan 档回信「由于当前权限档位为 plan(只读),我无法直接执行 bash 命令」
模型自愿遵守 advisory 约束(行为正确,但非平台强制)
|
2026-09-06 20:38:33 +08:00 |
|
|
|
b76d0d6c96
|
PLAN.md: P4 四桥档位接线全部完成(dsh/opencode/pi/homeagent)
|
2026-09-06 19:26:17 +08:00 |
|
|
|
ed3703295c
|
homeagent: advisory 档位提示词 + 心跳报 advisory + permission_mode.go
- 新建 permission_mode.go:NormalizeMode / ModeBriefing(advisory 版本)
homeagent 无工具拦截点,档位只能在提示词里告知模型,措辞如实说
「这个平台无法强制这一档」—— 假装强制会让模型以为越界会被拦
- mailEvent 加 PermissionMode 字段(从 SSE new_mail payload 读入)
- handleNewMail 提示词追加 ModeBriefing(plan/workspace/full 三档说明)
- 心跳上报 mode_enforcement: 'advisory'
- go vet + 68 测试全过
|
2026-09-06 19:23:42 +08:00 |
|
|
|
0c98fab4d5
|
pi: 档位判定 tool_call hook + 心跳报 native
- worker.mjs: mailContext 加 permissionMode 字段(从 SSE payload 读入)
- tool_call hook 增加档位判定:
full → 不拦截任何工具(直接 return)
plan → 被守卫工具(bash/write/edit)一律 block + 返回原因说明
workspace → 走原有问人流程(不变)
- index.mjs 心跳上报 mode_enforcement: 'native'
- 导入 normalizeMode/MODE_FULL/MODE_PLAN 从 lib/permission-mode.js
- 377 测试全过
|
2026-09-06 19:19:42 +08:00 |
|
|
|
3bb419f2f2
|
opencode: session.create 传入 permission 规则 + 心跳报 native
按 PLAN 7.11 P4 + 六条实测结论:
- 导入 opencodePermissions/normalizeMode 从 lib/permission-mode.js
- session.create 时根据 data.permission_mode 生成规则数组传入 permission 字段
plan: edit/bash/task 全 deny(工具从清单消失)
workspace: edit deny→allow(目录内) + bash ask + task deny
full: 空数组(用平台默认配置)
- 心跳上报 mode_enforcement: 'native'(opencode 有原生 permission.ask 钩子)
- 290 测试全过
|
2026-09-06 19:14:19 +08:00 |
|
|
|
af61a37d9a
|
dsh: 权限档位接线 — presets.mount 注册工具 + 三档 sandbox/approval 映射 + 心跳报 native
L3 dsh 适配:
- startAgent 新建会话时用 agentPresets.mount('standard') 注册 bash/fs/fs-search 等工具
(与 dsh-a2a 同一套 API,经 a2a server 实测可靠)
- 新增 applyPermissionMode(session, mode):
plan → read-only + ask(只读,越界转邮件)
workspace → workspace-write + ask(目录内可写)
full → danger-full-access + never(完全放开)
直接 session.append sandbox/mode + approval/policy 事件(与 permissionPresets.set 同底层)
- deliverMail 两条投递路径(新建 + 接管)加 applyPermissionMode 调用
- 已有 session 路径不动:档位在首次投递时已设,后续邮件不改
- 心跳上报 mode_enforcement: 'native'(dsh 有真沙箱,不是 advisory)
- 323 测试全过
|
2026-09-06 19:10:35 +08:00 |
|
|
|
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 |
|