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),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -96,11 +96,20 @@ name@path.session
| **C-8** | 权限/审批钩子 | `permission_hook` | 不转发权限询问 | 危险操作只能靠平台本地 UI 决策 |
| **C-9** | 钩子可异步等待 | `async_permission` | 转出去后立即返回「待决」,决策经 SSE 回来后再补 | 模型会先被拒一次再重试 |
| **C-10** | 列出会话 | `list_sessions` | 不上报平台会话快照 | 写信时只能续谈邮件驱动的会话 |
| **C-11** | 模型标题 | `model_title` | 会话别名退化为随机短名或平台 id | 补全列表里分不清哪条在谈什么 |
| **C-11** | 模型标题 | `model_title` | `D-5` 阶梯:退到邮件主题派生别名 | 补全列表里的名字来自人写的主题,而非模型对内容的概括 |
| **C-12** | 列出可用模型 | `list_models` | 不上报模型目录 | 管理员无法在配置页划定模型范围 |
| **C-13** | 指定单轮模型 | `per_turn_model` | 不做降级尝试 | 主力模型故障时该 Agent 整体不可用 |
| **C-14** | 会话内文件读写工具 | `file_tools` | 附件下载后模型看不到 | 附件功能形同虚设 |
> **C-11 可能是「部分具备」。** pi 是实例:它确实会生成会话标题,但生成器在
> **pi-web** 包里,不在 `pi-coding-agent` 内核里。人在 pi-web 界面上开的会话有标题,
> 桥用 SDK 起的会话没有。判定的依据只能是**桥自己起的那条会话**上
> `sessionName` 到底有没有值,不是「这个平台有没有这个功能」。
>
> 具备 `C-11` 的平台还要回答第二个问题:**别名由谁定稿**。别名负有寻址唯一性义务
> (撞名追 `-2`、`manual` 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉,
> 因此必须由服务端定稿、插件把响应里的值**回写**进平台(见 `D-5`、`W-7`)。
### 能力自检脚本
接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
@ -113,10 +122,15 @@ name@path.session
5. 怎么取到最后一条 assistant 消息的纯文本? → C-5
6. 注册工具时 execute 能拿到 session id 吗? → C-6
7. 权限钩子是同步还是异步?能不能真的等人? → C-8/C-9
8. 往一条**空闲**会话里注入一轮,用的是哪个方法? → C-1见 9.11
```
> 问题 4 在次适配里被漏掉过,两次都造成「无效模型被判成成功」——
> 问题 4 在次适配里被漏掉过两次,两次都造成「无效模型被判成成功」——
> 详见 `D-3` 与第九节。
>
> 问题 8 是 pi 适配加上的:平台常常有两个注入入口(一个起新轮、一个往正在跑的轮次里
> 排队),而排队那个在会话空闲时**静默什么也不做** —— 不报错、不超时、没有回信。
> 见 9.11。
---
@ -443,17 +457,68 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
心跳里**省略** `platform_sessions`(不是传 `[]`,见 `W-3`)。
后果:写信时的会话补全只能看到邮件驱动的那些。
### D-5 无模型标题(缺 `C-11`
### D-5 别名来源阶梯(`C-11` 缺失或不可用时
| 优先级 | 别名来源 |
|---|---|
| 1 | 平台自带的 slug`witty-planet` |
| 2 | 由模型标题派生(`slugFromTitle` |
| 3 | 不回写,保持服务端自动生成的别名 |
| 优先级 | 别名来源 | 何时用 |
|---|---|---|
| 1 | 平台自带的 slug`witty-planet` | 平台创建会话时就给 |
| 2 | 由平台标题派生(`slugFromTitle` | 有标题且标题可用 |
| 3 | 由**邮件主题**派生 | 平台没有标题,或标题不可用(见下) |
| 4 | 不回写,保持服务端自动生成的别名 | 以上都没有 |
**不得**用占位标题回写(`"新会话"``"处理邮件"` 之类):那会覆盖掉一个
本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。
> **第 3 级是 pi 适配加上的。** 原先的阶梯假定「平台要么有标题,要么完全没有命名机制」。
> pi 是第三种情况:内核不生成标题(生成器在 pi-web 里),于是 SDK 起的会话
> `sessionName` 恒为 `undefined`。只走前两级的话别名永远是空的 ——
> `name@path.<别名>` 续谈无从下手,`.new` 又是一次性的,那条会话事实上只能收一封信。
>
> **「标题不可用」是真实存在的一类。** pi-web 的标题生成器会泄漏思维链,
> 本机 82 条会话里捞到过这两条真实样本:
>
> ```
> The user is asking me to generate a title for a coding-agent
> 我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短
> ```
>
> 它们语法上是合法标题(`cleanSessionName` 只取首行 + 截 60 字符),
> 派生出的别名却是一句废话。判废逻辑在 `lib/session-snapshot.js` 的
> `isUnusableName()`:命中就降到下一级,**不是**改写成别的。
#### 别名必须由服务端定稿
具备 `C-11` 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写:
```
插件观察到平台的名字
→ POST /sessions/{id}/sync { alias: slug, title: 原文 }
→ 读**响应里**的 alias可能被改写过
→ 与平台当前名字不同 → 写回平台
```
两个原因让单向推送无法收敛:
| 服务端行为 | 后果 |
|---|---|
| 别名撞名时追 `-2``-3``SyncSessionAlias` | 平台侧没有唯一性约束,不会跟着改 |
| `alias_source='manual'`(人手工改过)永远优先 | 服务端**原样返回当前别名**,忽略提议 |
不回写的话:邮箱里显示 `fix-leak-2`、平台界面上显示 `fix-leak`
用户按界面上看到的名字发信会收到「无法送达」。
回写有三条实测约束pi 的形态,其它平台需各自确认):
| # | 约束 | 违反的后果 |
|---|---|---|
| 1 | 只能用平台的改名 API不能自己写会话文件 | 首条 assistant 消息落盘前文件不存在pi 首次落盘用 `openSync(file,"wx")`,抢先创建让它抛 `EEXIST` |
| 2 | 活着的会话管理器从不重读文件 | 它自己后续的 `session_info` 会覆盖外部改名 |
| 3 | 空名字是**清除**语义 | 「没拿到定稿值」绝不能落成一次空写入 |
还要防自激循环:改名通常会触发「名字变了」事件,而那个事件正是同步的触发源。
插件必须记住**上次提交的内容指纹**并在相同时跳过。指纹不能用平台名字本身 ——
名字为空时(上面 pi 的常态)它无法区分「还没提交过」与「提交过、内容没变」。
### D-6 工作目录不存在
| # | 要求 | 强度 |
@ -584,7 +649,10 @@ Agent 侧只会收到两个事件:
"mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...",
"from_name": "admin", "subject": "重构导入路径",
"mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail"
"to_workspace": "/home/program/agentmail",
"session_alias": "refactor-imports",
"reply_address": "admin@.refactor-imports",
"self_address": "pi@/home/program/agentmail.refactor-imports"
}
```
@ -593,6 +661,13 @@ Agent 侧只会收到两个事件:
| `role` | `to``cc` |
| `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) |
| `mail_type` | `normal` / `permission_request` / … |
| `session_alias` | 这条会话今后的寻址名 |
| `reply_address` | 「把回信发回这条会话」的现成地址 |
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
> 的话,`.new` 会被拼进去,于是回信静默开出一条**新**会话,原来的线索里再无下文。
**`permission_decision`**
@ -911,21 +986,38 @@ GET /api/v1/attachments/{id}
## 八、平台差异对照 / Platform Matrix
次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
| 关注点 | opencode | DeepSeek Harness |
|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` |
| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 |
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl |
| 关注点 | opencode | DeepSeek Harness | pi |
|---|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` |
| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema |
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` | `prompt()` 的 promise resolve事件是 `agent_end` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** | `tool_call` 扩展事件(**能 await**,实测) |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`**不传参**,传字符串会被当自定义目录) |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`**不是** `getModels()`1221 条里只有 1 条能用) |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**SDK 会话没有平台标题,见下) |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl | `console.error` |
> **pi 为什么是守护进程而不是扩展**pi 扩展被加载进**一条已经存在的**会话,
> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace`
> 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。
> 桥因此用 SDK 起会话,一个进程里并存多条不同 cwd 的会话(实测可行)。
> 代价是它需要自己的 systemd 单元(`deploy/pi-mail-bridge.service`)。
>
> **pi 的 `C-11` 是有条件的**pi 会生成会话标题,但生成器在 **pi-web** 包里
> `sessionNameGenerator.js`),不在 `pi-coding-agent` 内核里。桥用 SDK 起的会话
> 走不到那条路径,`session.sessionName` 一直是 `undefined`。因此 pi 侧的别名走
> `D-5` 阶梯的第二级(邮件主题派生),并把 Gateway 的定稿值**回写**进
> `session.setSessionName()` —— 这一步让 pi-web 界面上显示的名字与邮箱里一致。
>
> **回写只能用 `setSessionName()`**,三条实测约束:首条 assistant 消息落盘前
> 会话文件还不存在pi 首次落盘用 `openSync(file,"wx")`,外部抢先创建会让它抛
> `EEXIST`;活着的 `SessionManager` 从不重读文件,它自己后续的 `session_info`
> 会覆盖外部改名;空名字是**清除**语义,不是「不改」。
---
@ -1038,6 +1130,39 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
它把目录名当模块解析。必须写 glob`node --test 'test/*.test.mjs'`
### 9.11 pi 的 `followUp()` 在会话空闲时什么也不做(静默丢邮件)
`session.followUp(text)` 只往 `followUpQueue` 里塞消息,而那个队列**只在运行中的
轮次末尾**被 drain`pi-agent-core/agent.js` 的 run 循环,以及 `continue()`)。
会话空闲时(上一轮早已结束)塞进去的消息永远没人取。
表现极具欺骗性:日志打了「续谈成功」,`prompt()` 没报错,收件箱里却只有来信、
没有回复。既没有异常也没有超时。
> **正确做法**:按 `session.isStreaming` 分流 —— 空闲用 `prompt(text)` 直接起一轮,
> 正在跑用 `prompt(text, { streamingBehavior: 'followUp' })` 排到当轮之后
> (缺 `streamingBehavior` 时 pi 会抛 `Agent is already processing.`)。
> 不要用 `steer`:那会打断当前轮,而当前轮正在处理**上一封邮件**。
### 9.12 pi 的全局扩展会 `listen` 固定端口
`~/.pi/agent/extensions/` 下的 `pi-a2a` / `pi-acp` 在加载时就 bind
`127.0.0.1:12010` / `12011`。同机已有 pi 在跑时,任何新起的 pi 进程(包括
`pi --help`)都会 `EADDRINUSE` 并把整条会话拖死。
> 桥用 `noExtensions: true` + 内联 `extensionFactories` 起会话:既避开端口冲突,
> 也不继承那套给人类交互用的扩展TUI 命令、快捷键、状态栏对邮件没有意义)。
> 邮件工具走 `customTools`,权限钩子走内联工厂。
### 9.13 `SessionManager.listAll(dir)` 的参数不是 agentDir
它的字符串参数是**自定义会话目录**,会直接在里面找 `.jsonl`。传
`getAgentDir()``~/.pi/agent`)得到的是空列表 —— 会话在它的 `sessions/`
子目录下按 cwd 分目录存放。**不传参数**才会走默认的逐 cwd 扫描。
同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉
所有真正在干活的会话。
---
## 附:文档关系
@ -1050,4 +1175,4 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
| [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 |
参照实现:`plugins/opencode-mail-bridge/``plugins/dsh-mail-bridge/`
`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。
`plugins/pi-mail-bridge/``deploy/remote-agent-demo.py`(纯标准库的协议层对照)。