Files
MailUI4Agents/deploy/pi-mail-bridge.service
JianFeeeee 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

57 lines
2.5 KiB
Desktop File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[Unit]
Description=pi mail-bridge (AgentMail ↔ @earendil-works/pi-coding-agent)
After=network-online.target agentmail-gateway.service
Wants=network-online.target
[Service]
Type=simple
# 桥自己不需要工作目录 —— 每条会话的 cwd 来自邮件寻址的 path 位。
# 但 systemd 要求一个存在的目录,且 pi 的 SettingsManager 会在这里找
# 项目级配置,因此指向仓库而不是 /。
WorkingDirectory=/home/program/agentmail/plugins/pi-mail-bridge
ExecStart=/usr/bin/node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
# pi 靠 HOME 定位 ~/.pi/agentsettings.json、auth.json、models.json、sessions/)。
# systemd 不会自动注入 HOME不显式给就
# - 读不到 provider 凭证 → 每封邮件都"没有可用模型"
# - 会话落到 /.pi 或直接失败 → C-8 的会话快照永远是空的
Environment=HOME=/root
# pi 的全局扩展 pi-a2a / pi-acp 绑死 127.0.0.1:12010 / 12011。
# 桥用 noExtensions:true 起会话,本进程不会去 bind 那两个端口;
# 这两个变量是给「同机还跑着 pi CLI」的情况留的隔离位
# 万一将来放开扩展加载,端口也不会和交互式 pi 撞。
Environment=PI_A2A_PORT=13010
Environment=PI_ACP_PORT=13011
EnvironmentFile=/etc/agentmail/pi.env
Restart=always
RestartSec=10
# 桥用 O_EXCL 建 <AGENTMAIL_CONFIG_DIR>/pi-bridge.lock 保证单实例
# (两个实例各自订阅 SSE 会让同一封邮件各起一条会话,发件人收到两封回信,
# 而去重集合在各自内存里、彼此看不到)。
#
# 被 SIGKILL 或主机掉电时锁文件留在盘上 —— 桥自己会检查锁里的 pid 是否还活着,
# 但那要求 /proc 可读且 pid 未被复用。systemd 重启前清一次更直接:
# ExecStartPre 里的 pid 必然已经死了systemd 确认进程退出后才重启)。
#
# 用 `-` 前缀:文件不存在时 rm 失败不该拦住启动。
ExecStartPre=-/bin/rm -f /root/.agentmail-pi/pi-bridge.lock
# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSEB-1
# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。
# pi 会话在内存里持有整条对话,长跑之后常驻几百 MB。给一个上限让它被
# OOM killer 挑中而不是拖垮整机Restart=always 会把它拉回来。
MemoryMax=4G
# 优雅停机:桥收到 SIGTERM 后要注销 SSE、清锁文件。
# 默认 90 秒太长停机时人在等10 秒足够那两件事。
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target