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)
This commit is contained in:
2026-09-04 11:14:44 +08:00
parent e4052f8e84
commit 255c799a40
20 changed files with 1176 additions and 22 deletions

View File

@ -0,0 +1,2 @@
export function adoptedSessionID(data: any): string;
export function adoptMissingMessage(platformID: string, detail?: string): string;

View File

@ -0,0 +1,54 @@
/**
* 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。
*
* # 这件事是什么
*
* TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上
* 开的会话opencode 的 session、DSH 的 agent、pi 的 .jsonl早就被
* session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳,
* 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
*
* 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在
* 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈,
* **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。
*
* # 为什么这两个函数要三平台共用
*
* 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成
* 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个
* 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。
*
* 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不
* 相同),只有这两件事共用。
*/
/**
* 从投递事件里取出被接管的平台会话 id。
*
* @param {any} data new_mail / permission_decided 事件的 payload
* @returns {string} 平台会话 id空串 = 不是接管,照旧按邮件新开一条
*/
export function adoptedSessionID(data) {
const raw = data?.platform_session_id;
return typeof raw === 'string' ? raw.trim() : '';
}
/**
* 平台侧那条会话已经不在了时的错误话术。
*
* 镜像是快照,可以过期:人可能已经在界面上删了那条会话。
*
* **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话,
* 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用
* .new 是禁止的」同一条原则)。
*
* 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。
*
* @param {string} platformID
* @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」
* @returns {string}
*/
export function adoptMissingMessage(platformID, detail = '可能已被删除') {
return `平台会话 ${platformID} 已不存在(${detail})。` +
`请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -21,6 +21,7 @@ import {
noteExplicitSend,
shouldSkipAutoRelay,
} from '../lib/relay-dedup.js';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
import {
userMessage,
replySubject,
@ -570,10 +571,89 @@ export function apply(ctx: any, config: PluginConfig): void {
// ─── 投递邮件到 DSH 会话 ───
/**
* 投进「被接管的平台会话」时给模型的提示词。
*
* 与新建会话那份的差别:不自我介绍身份、不解释邮件系统 —— 这条会话里人已经
* 在谈别的事了,一段「你是 dsh你收到一封邮件」的开场白会让模型以为上下文
* 被重置。只说「有封邮件进来了」。
*/
function adoptPrompt(data: any, kind: string): string {
if (kind === 'permission') {
return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`;
}
return [
`本会话收到一封新邮件AgentMail`,
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
`回信不用你自己发:把这一轮做完、把结论说出来就行,`,
`插件会在轮次结束时把你最后那段话发回给 ${data.from_name || '发件人'}(不消耗配额)。`,
].join('\n');
}
/**
* 记下「这条邮件会话 ↔ 这条平台会话」的绑定与回信上下文。
*
* mailDrivenSessions 必须加:接管之后这条会话**开始**参与邮件往来,轮次结束
* 要把总结转回发件人。不加的话邮件投进去了却永远没有回音。
*/
function bindAdopted(mailSessionID: string, dshSessionId: string, cwd: string, data: any): void {
if (!mailSessionID) return;
sessionMap.set(mailSessionID, { dshSessionId, directory: cwd });
reverseMap.set(dshSessionId, mailSessionID);
mailDrivenSessions.add(dshSessionId);
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
}
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
const mailSessionID = data.session_id;
const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 DSH 界面上
// 开的那种)—— 投进它而不是新建。
//
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。补全早就把平台
// 会话列为候选session-snapshot 上报的那批),这一跳补上投递侧。
//
// DSH 上不需要新代码路径:`startAgent` 本来就「磁盘上有就 resume」
// 接管只是把会话 id 从 `mail-<uuid>` 换成平台自己那个。第一次投递走
// resume 分支装回上下文之后与普通续谈完全一样sessionMap 命中 → followup
const adoptedID = adoptedSessionID(data);
if (!existing && adoptedID) {
const onDisk = await persistedCwd(adoptedID);
if (onDisk === undefined) {
// 镜像是快照,可以过期:平台侧那条会话可能已经被人删了。
// 不能落到「新开会话」那条路 —— 那会用 `mail-<uuid>` 另开一条,
// 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的。
throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志'));
}
return locked(adoptedID, async () => {
const promptText = adoptPrompt(data, kind);
const live = ctx.agents.get(adoptedID);
if (live) {
// 界面上正开着这条会话 —— 直接 followup不要再 resume 一次:
// 同一条会话两个 handle 会各自往日志里写replay 校验过不去。
live.followup(userMessage(promptText));
await waitForTurnEnd(live);
bindAdopted(mailSessionID, adoptedID, onDisk, data);
return { sessionID: adoptedID, reused: true };
}
const { handle } = await startAgent(adoptedID, onDisk, attemptOrder()[0]);
bindAdopted(mailSessionID, adoptedID, onDisk, data);
handle.agent.followup(userMessage(promptText));
await waitForTurnEnd(handle.agent);
return { sessionID: adoptedID, reused: true };
});
}
if (existing) {
const live = ctx.agents.get(existing.dshSessionId);
if (live) {

View File

@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」,
// 而那个错误不报任何异常。这组测试锁住字段名本身。
test('adoptedSessionID: 取 platform_session_id', () => {
assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123');
});
test('adoptedSessionID: 去首尾空白', () => {
assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc');
});
// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常
test('adoptedSessionID: 空串表示不是接管', () => {
assert.equal(adoptedSessionID({ platform_session_id: '' }), '');
assert.equal(adoptedSessionID({ platform_session_id: ' ' }), '');
});
test('adoptedSessionID: 字段缺失返回空串', () => {
assert.equal(adoptedSessionID({}), '');
assert.equal(adoptedSessionID({ session_id: 'x' }), '');
});
// 老版本服务端不发这个字段;插件不能因此崩掉整条投递
test('adoptedSessionID: 非字符串与空输入都退回空串', () => {
assert.equal(adoptedSessionID({ platform_session_id: 123 }), '');
assert.equal(adoptedSessionID({ platform_session_id: null }), '');
assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), '');
assert.equal(adoptedSessionID(undefined), '');
assert.equal(adoptedSessionID(null), '');
});
// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址
test('adoptMissingMessage: 带上 id 与 .new 的指引', () => {
const msg = adoptMissingMessage('ses_gone');
assert.match(msg, /ses_gone/);
assert.match(msg, /\.new/);
assert.match(msg, /可能已被删除/);
});
test('adoptMissingMessage: detail 可按平台定制', () => {
const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话');
assert.match(msg, /磁盘上已无这条会话/);
assert.match(msg, /\.new/);
});

View File

@ -33,6 +33,7 @@ import {
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
// opencode 原生支持三态权限免批由它自己记response:"always"
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
@ -599,8 +600,13 @@ const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
const relayedSummaries = new Map(); // opencode session id -> assistant message id
// 收到邮件后建立的会话,才需要在 idle 时把总结转回去。
// 用户在 TUI 里自己开的会话不该被搬进邮件系统。
// 哪些会话参与邮件往来,idle 时把总结转回去。
//
// 两个来源:① 收到邮件后新建的会话 ② **被接管的平台会话**(人在 TUI 里开的,
// 但已经有邮件投进来了)。后者从接管那一刻起加入 —— 不加的话邮件投进去了
// 却永远没有回音,发件人只看到信发出去后再无音讯。
//
// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。
const mailDrivenSessions = new Set(); // opencode session id
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
@ -612,6 +618,41 @@ async function resolveSessionForMail(client, directory, data, kind) {
const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
if (bound) return { sessionID: bound, reused: true };
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的
// 那种)—— 投进它而不是新建。
//
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界:人在界面上聊了
// 一半想转到邮件继续,或者想把一封邮件投进正在谈的那条会话。补全早就把平台
// 会话列为候选,这一跳补上投递侧。
//
// 新建会让人在 TUI 里看不到这封邮件带来的对话,而那正是接管的目的。
//
// opencode 上这件事最省力会话由服务端持有单一写者promptAsync 本来
// 就是「给这个 session id 发一轮」,不区分谁建的。**不需要**校验它是否活着。
const adoptedID = adoptedSessionID(data);
if (adoptedID) {
// 平台侧那条会话可能已经被人删了(镜像是快照,可以过期)。
// 校验一次:直接 prompt 一个不存在的 id 会得到一个语焉不详的 HTTP 错误,
// 而这里能给出「它没了,去 .new」这种可操作的话。
let ok = false;
try {
const got = await client.session.get({ path: { id: adoptedID } });
ok = Boolean((got?.data ?? got)?.id);
} catch {
ok = false;
}
if (!ok) throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除"));
if (mailSessionID) {
sessionMap.set(mailSessionID, adoptedID);
reverseMap.set(adoptedID, mailSessionID);
// 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来,
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
mailDrivenSessions.add(adoptedID);
}
console.error(`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID}`);
return { sessionID: adoptedID, reused: true, adopted: true };
}
// 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。
//
// 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的

View File

@ -0,0 +1,54 @@
/**
* 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。
*
* # 这件事是什么
*
* TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上
* 开的会话opencode 的 session、DSH 的 agent、pi 的 .jsonl早就被
* session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳,
* 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
*
* 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在
* 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈,
* **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。
*
* # 为什么这两个函数要三平台共用
*
* 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成
* 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个
* 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。
*
* 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不
* 相同),只有这两件事共用。
*/
/**
* 从投递事件里取出被接管的平台会话 id。
*
* @param {any} data new_mail / permission_decided 事件的 payload
* @returns {string} 平台会话 id空串 = 不是接管,照旧按邮件新开一条
*/
export function adoptedSessionID(data) {
const raw = data?.platform_session_id;
return typeof raw === 'string' ? raw.trim() : '';
}
/**
* 平台侧那条会话已经不在了时的错误话术。
*
* 镜像是快照,可以过期:人可能已经在界面上删了那条会话。
*
* **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话,
* 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用
* .new 是禁止的」同一条原则)。
*
* 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。
*
* @param {string} platformID
* @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」
* @returns {string}
*/
export function adoptMissingMessage(platformID, detail = '可能已被删除') {
return `平台会话 ${platformID} 已不存在(${detail})。` +
`请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」,
// 而那个错误不报任何异常。这组测试锁住字段名本身。
test('adoptedSessionID: 取 platform_session_id', () => {
assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123');
});
test('adoptedSessionID: 去首尾空白', () => {
assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc');
});
// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常
test('adoptedSessionID: 空串表示不是接管', () => {
assert.equal(adoptedSessionID({ platform_session_id: '' }), '');
assert.equal(adoptedSessionID({ platform_session_id: ' ' }), '');
});
test('adoptedSessionID: 字段缺失返回空串', () => {
assert.equal(adoptedSessionID({}), '');
assert.equal(adoptedSessionID({ session_id: 'x' }), '');
});
// 老版本服务端不发这个字段;插件不能因此崩掉整条投递
test('adoptedSessionID: 非字符串与空输入都退回空串', () => {
assert.equal(adoptedSessionID({ platform_session_id: 123 }), '');
assert.equal(adoptedSessionID({ platform_session_id: null }), '');
assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), '');
assert.equal(adoptedSessionID(undefined), '');
assert.equal(adoptedSessionID(null), '');
});
// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址
test('adoptMissingMessage: 带上 id 与 .new 的指引', () => {
const msg = adoptMissingMessage('ses_gone');
assert.match(msg, /ses_gone/);
assert.match(msg, /\.new/);
assert.match(msg, /可能已被删除/);
});
test('adoptMissingMessage: detail 可按平台定制', () => {
const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话');
assert.match(msg, /磁盘上已无这条会话/);
assert.match(msg, /\.new/);
});

View File

@ -0,0 +1,54 @@
/**
* 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。
*
* # 这件事是什么
*
* TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上
* 开的会话opencode 的 session、DSH 的 agent、pi 的 .jsonl早就被
* session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳,
* 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
*
* 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在
* 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈,
* **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。
*
* # 为什么这两个函数要三平台共用
*
* 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成
* 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个
* 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。
*
* 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不
* 相同),只有这两件事共用。
*/
/**
* 从投递事件里取出被接管的平台会话 id。
*
* @param {any} data new_mail / permission_decided 事件的 payload
* @returns {string} 平台会话 id空串 = 不是接管,照旧按邮件新开一条
*/
export function adoptedSessionID(data) {
const raw = data?.platform_session_id;
return typeof raw === 'string' ? raw.trim() : '';
}
/**
* 平台侧那条会话已经不在了时的错误话术。
*
* 镜像是快照,可以过期:人可能已经在界面上删了那条会话。
*
* **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话,
* 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用
* .new 是禁止的」同一条原则)。
*
* 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。
*
* @param {string} platformID
* @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」
* @returns {string}
*/
export function adoptMissingMessage(platformID, detail = '可能已被删除') {
return `平台会话 ${platformID} 已不存在(${detail})。` +
`请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -34,6 +34,7 @@ import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib
import { snapshotPiSessions } from '../lib/session-snapshot.js';
import { selectCatchup } from '../lib/catchup.js';
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
import { createGrantStore, isApproval } from '../lib/permission-grants.js';
// ─── 配置 ───
@ -254,6 +255,159 @@ function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 接管过的 pi 会话pi session id。用完即释放见 releaseAdopted。
*
* 与 `sessions` 的区别:那里存的是桥自己起的、长期持有的会话;这里是
* 「借用一下磁盘上人家的会话」,一轮结束就还回去。
*/
const adopted = new Set();
/** 接管会话的兜底释放计时器pi session id -> Timeout。 */
const adoptTimers = new Map();
/**
* 接管会话最长持有多久。
*
* 取轮次超时的两倍:`runTurn` 60 秒就按成功返回(长任务很正常,判成失败会
* 换模型重跑一遍),但会话仍在跑。正常结束走 agent_end 提前释放,这个数字
* 只兜「事件永远不来」的底。
*/
const ADOPT_MAX_HOLD_MS = TURN_TIMEOUT_MS * 2;
/**
* 接管一条磁盘上已经存在的 pi 会话,把这封邮件投进去。
*
* # 为什么必须**短暂持有**
*
* pi 没有任何锁机制,它假定「一个文件一个持有者」。活着的 SessionManager
* 不 watch 文件外部TUI追加的行它看不见之后它自己的写入算出的 parentId
* 指向一个对方不知道的 entry —— 文件不会坏(写入是纯 append但会话树分叉。
*
* 所以这里 open → 跑一轮 → 丢弃,**不放进 sessions 长期缓存**。下一封邮件
* 再来时重新 open那一次读到的就是 TUI 期间写的全部内容。
*
* 窗口是一轮对话的时长。人正好在这期间也在 TUI 里发消息仍会分叉,但那需要
* 两边同时动手,且后果是历史看起来少一段,不是数据损坏。
*
* # 为什么不校验「TUI 是否正开着这条会话」
*
* pi 不提供这个信息(没有 lockfile、没有 pid 记录)。能做的只有猜 mtime
* 而任何阈值都是猜。与其用一个猜出来的数字拒掉合法投递,不如让窗口尽量短。
*/
async function adoptSession(platformID, data, mailTools) {
const mailSessionID = data.session_id;
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
// listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关。
const all = await SessionManager.listAll();
const info = all.find((e) => e?.id === platformID);
if (!info?.path) {
// 镜像是快照,可以过期:那条会话可能已经被删了。
// **不能**退回「新建一条」—— 那会让人在 TUI 里看不到这封邮件带来的对话,
// 而那正是接管的目的N-8 同理:静默改语义比报错糟)。
throw new Error(adoptMissingMessage(platformID, '磁盘上已无这个会话文件'));
}
// cwd 取会话自己的SessionInfo.cwd 来自持久化 header
// 老会话的 cwd 是空串,那种情况退回地址里的 path 位。
const { cwd } = resolveWorkspaceCwd(
info.cwd || data.to_workspace, piMailFallback(mailSessionID));
const opened = await openSession({
cwd,
modelRuntime,
customTools: mailTools,
extension: permissionExtension((id) => mailContexts.get(id)),
sessionFile: info.path,
});
for (const d of opened.diagnostics) {
log(`扩展诊断: ${d?.message ?? JSON.stringify(d)}`);
}
const piSessionId = opened.session.sessionId;
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
if (mailSessionID) {
sessions.set(mailSessionID, entry);
reverseMap.set(piSessionId, mailSessionID);
// 加进 mailDriven接管之后这条会话**开始**参与邮件往来,轮次结束要把
// 总结转回发件人。不加的话邮件投进去了却永远没有回音。
mailDriven.add(piSessionId);
adopted.add(piSessionId);
}
// 兜底释放:`agent_end` 不来就永远握着这个文件,而握着它的期间 TUI 那边
// 的写入对我们不可见 —— 正是要避免的分叉窗口。会话跑挂、事件丢失、
// 模型一直不结束都属于这种情形。
//
// 时长取轮次超时的两倍runTurn 自己 60 秒就按成功返回了(长任务很正常),
// 那之后会话仍在跑,正常结束时 agent_end 会照常触发并提前释放。
const safety = setTimeout(() => {
if (!adopted.has(piSessionId)) return;
log(`接管会话 ${piSessionId} 超过 ${ADOPT_MAX_HOLD_MS / 1000}s 未结束,强制释放`);
releaseAdopted(piSessionId, mailSessionID);
}, ADOPT_MAX_HOLD_MS);
if (typeof safety.unref === 'function') safety.unref();
adoptTimers.set(piSessionId, safety);
// 只挂 agent_end不挂 session_info_changed改名同步会把 Gateway 侧的别名
// 覆盖成 pi 的标题,而接管会话的别名是人从补全里选的那个 slug ——
// 改掉会让他找不到自己刚发的信。
opened.session.subscribe((event) => {
if (event?.type !== 'agent_end') return;
if (event.willRetry) return;
// 转发完再释放relaySummary 要读 sessions 里的 entry。
relaySummary(piSessionId)
.catch((e) => log(`自动转发失败: ${describeError(e)}`))
.finally(() => {
// **还在跑就不能释放。**
//
// 同一条会话可能已经排了下一封邮件runTurn 在 isStreaming 时走
// `streamingBehavior: 'followUp'`,那封信排在当轮之后。此时 dispose
// 会把排着的那一轮一起杀掉 —— 发件人只看到信发出去后再无音讯。
// 排着的那轮结束时会再触发一次 agent_end由它来释放。
if (opened.session.isStreaming) {
log(`接管会话 ${piSessionId} 仍有排队轮次,暂不释放`);
return;
}
releaseAdopted(piSessionId, mailSessionID);
});
});
log(`接管 pi 会话 ${piSessionId}cwd=${cwd},文件 ${info.path}`);
// reused: true —— 这条会话有历史,提示词不该重新自我介绍,
// 且 deliverMail 的续谈支不做模型降级(换模型要换会话,会丢掉整条上下文)。
return { ...entry, reused: true };
}
/**
* 还回一条接管来的会话dispose + 清缓存。
*
* mailDriven 不清:它同时喂给心跳快照的 mail_driven 标记,那条平台会话
* 确实已经在邮件往来里了。reverseMap 也不清 —— 留着让迟到的事件能找到线索,
* 而 relaySummary 在 entry 缺失时会自己早退。
*/
function releaseAdopted(piSessionId, mailSessionID) {
if (!adopted.has(piSessionId)) return;
adopted.delete(piSessionId);
const timer = adoptTimers.get(piSessionId);
if (timer) {
clearTimeout(timer);
adoptTimers.delete(piSessionId);
}
const entry = mailSessionID ? sessions.get(mailSessionID) : undefined;
if (entry?.session?.sessionId === piSessionId) {
sessions.delete(mailSessionID);
}
try {
entry?.session?.dispose?.();
} catch (e) {
log(`释放接管会话失败(不影响后续): ${describeError(e)}`);
}
log(`释放接管会话 ${piSessionId}(文件已交还,下一封邮件重新打开)`);
}
/**
* 找到(或建立)这封邮件该落进的 pi 会话。
*
@ -266,6 +420,13 @@ async function resolveSession(data, mailTools) {
const bound = mailSessionID ? sessions.get(mailSessionID) : undefined;
if (bound) return { ...bound, reused: true };
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的
// 那种)—— 投进它而不是新建。TUI 与邮箱是同一个 Agent 的两个入口。
const adoptedID = adoptedSessionID(data);
if (adoptedID) {
return await adoptSession(adoptedID, data, mailTools);
}
// cwd 取寻址里的 path 位B-3.1)。校验走共用模块:目录不存在时**不创建**
// N-2笔误会在磁盘上落下真目录而 Agent 在里面一无所获拒绝相对路径N-3
//
@ -387,8 +548,21 @@ async function relaySummary(piSessionId) {
// 实测过第一封邮件跑通了sessions.session_alias 仍是空串)。
//
// 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。
await syncNaming(piSessionId, entry.session.sessionName)
.catch((e) => log(`命名同步失败: ${describeError(e)}`));
//
// **接管来的会话跳过这一步。**
//
// 它的名字是人在 TUI 里定的,也是他从补全里选中的那个 slug。同步会双向改坏它
// 别名撞上本侧已有会话时 Gateway 加后缀(`agent-only-chain` →
// `agent-only-chain-2`),而定稿别名又会**回写进 pi 的会话文件** ——
// 于是下一次心跳上报的 slug 变成加了后缀那个,人从补全里选的名字凭空消失。
// 实测撞出来过一次。
//
// 接管会话的别名由服务端在接管时按 slug 定好AdoptPlatformSession
// 这里不需要也不应该再动它。
if (!adopted.has(piSessionId)) {
await syncNaming(piSessionId, entry.session.sessionName)
.catch((e) => log(`命名同步失败: ${describeError(e)}`));
}
// 只取 type==='text' 的块B-5.1 / N-6thinking 是思考过程,不是结论
const text = lastAssistantText(entry.session.messages);

View File

@ -24,7 +24,7 @@ import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoa
* @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子
* @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>}
*/
export async function openSession({ cwd, modelRuntime, model, customTools, extension }) {
export async function openSession({ cwd, modelRuntime, model, customTools, extension, sessionFile }) {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
@ -45,7 +45,28 @@ export async function openSession({ cwd, modelRuntime, model, customTools, exten
});
await resourceLoader.reload();
const sessionManager = SessionManager.create(cwd);
// sessionFile 非空 = **接管一条磁盘上已经存在的会话**(人在 TUI 里开的那种)。
//
// `SessionManager.open` 把整条会话装回内存(历史消息、分支、标签都在),
// 之后 prompt 就是在那条对话后面接着谈 —— 人在 TUI 里再打开它能看到
// 邮件带来的这一轮。TUI 与邮箱是同一个 Agent 的两个入口。
//
// # 双写风险与它的边界
//
// pi 没有任何锁机制SDK 里 flock/lockfile 命中为 0它假定「一个文件
// 一个持有者」。写入本身是纯 append`_persist` → `appendFileSync`),所以
// 两个持有者不会把文件截断;坏的是**各自的内存索引**:对方追加的行自己看不见,
// 于是算出的 parentId 指向一个对方不知道的 entry会话树分叉。
//
// 取舍是「短暂持有」open → 跑一轮 → 丢弃这个 manager调用方不缓存它
// 窗口是一轮对话的时长。人正好在那一刻也在 TUI 里发消息仍会分叉 ——
// 但那需要两边同时动手,而分叉的后果是历史看起来少了一段,不是数据损坏。
//
// cwd 用会话 header 里的open 的第三参不传即取 header不是外面传进来的
// 会话的工作目录在它创建时就定了,传一个不同的只会让项目级配置错位。
const sessionManager = sessionFile
? SessionManager.open(sessionFile)
: SessionManager.create(cwd);
const created = await createAgentSession({
cwd,
agentDir,

View File

@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」,
// 而那个错误不报任何异常。这组测试锁住字段名本身。
test('adoptedSessionID: 取 platform_session_id', () => {
assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123');
});
test('adoptedSessionID: 去首尾空白', () => {
assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc');
});
// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常
test('adoptedSessionID: 空串表示不是接管', () => {
assert.equal(adoptedSessionID({ platform_session_id: '' }), '');
assert.equal(adoptedSessionID({ platform_session_id: ' ' }), '');
});
test('adoptedSessionID: 字段缺失返回空串', () => {
assert.equal(adoptedSessionID({}), '');
assert.equal(adoptedSessionID({ session_id: 'x' }), '');
});
// 老版本服务端不发这个字段;插件不能因此崩掉整条投递
test('adoptedSessionID: 非字符串与空输入都退回空串', () => {
assert.equal(adoptedSessionID({ platform_session_id: 123 }), '');
assert.equal(adoptedSessionID({ platform_session_id: null }), '');
assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), '');
assert.equal(adoptedSessionID(undefined), '');
assert.equal(adoptedSessionID(null), '');
});
// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址
test('adoptMissingMessage: 带上 id 与 .new 的指引', () => {
const msg = adoptMissingMessage('ses_gone');
assert.match(msg, /ses_gone/);
assert.match(msg, /\.new/);
assert.match(msg, /可能已被删除/);
});
test('adoptMissingMessage: detail 可按平台定制', () => {
const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话');
assert.match(msg, /磁盘上已无这条会话/);
assert.match(msg, /\.new/);
});