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:插件重启丢了待决映射时要靠它定位会话
This commit is contained in:
2026-09-02 20:05:51 +08:00
parent 9d4718a412
commit ca64d12057
43 changed files with 3940 additions and 290 deletions

View File

@ -0,0 +1,10 @@
export interface DshUserMessage {
content: { type: 'text'; text: string }[];
source: { kind: 'user' };
}
export function userMessage(text: string): DshUserMessage;
export function stripRe(subject: string): string;
export function replySubject(subject: string, fallback?: string): string;
export function lastAssistantText(events: readonly any[]): string;
export function modelTitle(events: readonly any[]): string;

View File

@ -0,0 +1,88 @@
/**
* DSH 消息与会话日志的纯函数工具。
*
* 单独放一个模块是为了能被单测覆盖 —— 其中 userMessage() 的形状曾让整个插件
* 卡了一下午(见该函数注释),这种「错了不报错、只在深处炸一个无关的类型错误」
* 的约定必须被测试钉住。
*/
/**
* 构造 DSH 的 UserMessage。
*
* **这是 followup()/steer() 唯一接受的形状。** DSH 的 `agent.followup(message)`
* 要的是完整的 UserMessage(`content` + `source`),不是 opencode 那种 parts 数组。
* 传数组进去不会当场报错:agent-loop 会一路走到 preStep 里读 `message.source.kind`,
* 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在
* agent-loop 内部,完全不指向调用点。
*
* @param {string} text 正文
* @returns {{content: {type: 'text', text: string}[], source: {kind: 'user'}}}
*/
export function userMessage(text) {
return {
content: [{ type: 'text', text: String(text) }],
source: { kind: 'user' },
};
}
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 无限叠加。 */
export function stripRe(subject) {
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
}
/**
* 回信主题:原主题前加一个 Re:,空主题给一个兜底。
* @param {string} subject 来信主题
* @param {string} fallback 主题为空时用的标题
*/
export function replySubject(subject, fallback = 'DSH 回复') {
const base = stripRe(subject).trim();
return base ? `Re: ${base}` : fallback;
}
/**
* 从会话事件日志里取最后一条 assistant 消息的可见文本。
*
* 只取 `type === 'text'` 的块:reasoning 块是模型的思考过程,不该出现在邮件里。
*
* @param {readonly any[]} events session.events
* @returns {string} 文本,找不到时为空串
*/
export function lastAssistantText(events) {
const list = Array.isArray(events) ? events : [];
for (let i = list.length - 1; i >= 0; i--) {
const ev = list[i];
if (ev?.type !== 'assistant/message') continue;
const blocks = ev.data?.message?.content;
if (!Array.isArray(blocks)) return '';
return blocks
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim();
}
return '';
}
/**
* 从会话事件日志里取最后一次 session/title 的标题。
*
* DSH 首轮结束后由模型生成摘要标题,之前是 `source.kind === 'fallback'` 的占位
* (内容就是用户第一句话的截断)。占位标题不值得回写给 AgentMail,会把
* 「你收到一封新邮件(AgentMail)」这种插件自己的提示词当成会话标题。
*
* @param {readonly any[]} events session.events
* @returns {string} 模型生成的标题,没有则空串
*/
export function modelTitle(events) {
const list = Array.isArray(events) ? events : [];
for (let i = list.length - 1; i >= 0; i--) {
const ev = list[i];
if (ev?.type !== 'session/title') continue;
const kind = ev.data?.source?.kind;
if (kind === 'fallback') return '';
const title = ev.data?.title;
return typeof title === 'string' ? title.trim() : '';
}
return '';
}

View File

@ -0,0 +1,14 @@
export interface RelayRecord {
names: Set<string>;
replyTos: Set<string>;
}
export declare const explicitSends: Map<string, RelayRecord>;
export function addrName(addr: string): string;
export function noteExplicitSend(sessionID: string | undefined, to: string, replyTo: string): void;
export function shouldSkipAutoRelay(
sent: RelayRecord | undefined,
replyTo: string,
mailID?: string
): boolean;

View File

@ -0,0 +1,58 @@
// 自动转发去重的纯逻辑。
//
// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的
// 每一个导出都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),
// 多导出一个 Map 就会让整个插件加载失败:
// ERROR message="failed to load plugin" error="Plugin export is not a function"
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
/** 取三维地址的名字段:admin@root.alias -> admin */
export function addrName(addr) {
return String(addr || "").split("@")[0].trim();
}
/**
* 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。
*
* session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了,
* 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。
* 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封,
* 说的是同一件事,其中带附件的那封才是模型真正想发的。
*
* 为什么不靠 relay_key 幂等:那个键是 assistant message id,
* 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。
*
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
* relaySummary 用完即清。
*/
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
export function noteExplicitSend(sessionID, to, replyTo) {
if (!sessionID) return;
let rec = explicitSends.get(sessionID);
if (!rec) {
rec = { names: new Set(), replyTos: new Set() };
explicitSends.set(sessionID, rec);
}
const name = addrName(to);
if (name) rec.names.add(name);
if (replyTo) rec.replyTos.add(String(replyTo));
}
/**
* 本轮是否该跳过自动转发。
*
* @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空
* @param replyTo 自动转发本来要发给谁(三维地址或纯名字)
* @param mailID 自动转发本来要 reply_to 的邮件 id
*/
export function shouldSkipAutoRelay(sent, replyTo, mailID) {
if (!sent) return false;
// 收件人同名:模型已经跟这个人说过了
if (sent.names.has(addrName(replyTo))) return true;
// 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过
if (mailID && sent.replyTos.has(String(mailID))) return true;
return false;
}

View File

@ -0,0 +1,22 @@
export interface PlatformSessionReport {
platform_id: string;
workspace: string;
slug: string;
title: string;
mail_driven: boolean;
updated_at?: string;
}
export declare const MAX_REPORTED: number;
export function snapshotOpencodeSessions(
sessions: readonly any[],
isMailDriven?: (id: string) => boolean
): PlatformSessionReport[];
export function snapshotDshSessions(
entries: readonly any[],
isMailDriven?: (id: string) => boolean
): PlatformSessionReport[];
export function slugFromTitle(title: string): string;

View File

@ -0,0 +1,151 @@
/**
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
*
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
*
* 为什么是插件上报而不是 Gateway 拉取:当前架构是单向的(Agent 持密钥主动连
* Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证,
* 那是另一套信任模型。
*/
/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */
export const MAX_REPORTED = 200;
/**
* 把 opencode 的 session 列表整理成上报格式。
*
* @param {any[]} sessions client.session.list() 的结果
* @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动
* @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项
*/
export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) {
const list = Array.isArray(sessions) ? sessions : [];
const out = [];
for (const s of list) {
const id = typeof s?.id === 'string' ? s.id : '';
if (!id) continue;
// 没有 slug 的会话不报:slug 是填进 session 位的值,
// 没有它这一项在补全里点下去只能得到一个空的 session 段。
const slug = typeof s?.slug === 'string' ? s.slug : '';
if (!slug) continue;
out.push({
platform_id: id,
// opencode 的工作目录在 directory 上(path 是项目内的子路径,不是 cwd)
workspace: typeof s?.directory === 'string' ? s.directory : '',
slug,
title: typeof s?.title === 'string' ? s.title : '',
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(s?.time?.updated ?? s?.time?.created),
});
}
return sortAndCap(out);
}
/**
* 把 DSH 的 agent 列表整理成上报格式。
*
* DSH 没有 opencode 那样的 slug,别名由**模型生成的会话标题**派生
* (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名:
* DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断,
* 而那句话是插件自己拼的提示词。
*
* @param {any[]} entries [{ id, cwd, title, updatedAt }]
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotDshSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
// subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
// 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫
// "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。
if (isSubagent(e)) continue;
const title = typeof e?.title === 'string' ? e.title : '';
const slug = slugFromTitle(title);
if (!slug) continue;
out.push({
platform_id: id,
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.updatedAt),
});
}
// slug 撞名的只留最近那条:别名是**寻址**用的,
// 同一个 slug 对应多条会话时服务端只能取其中一条(updated_at DESC LIMIT 1),
// 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。
return dedupeBySlug(sortAndCap(out));
}
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
function isSubagent(e) {
if (e?.origin === 'subagent') return true;
const depth = e?.delegationDepth;
return typeof depth === 'number' && depth > 0;
}
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
function dedupeBySlug(list) {
const seen = new Set();
const out = [];
for (const item of list) {
if (seen.has(item.slug)) continue;
seen.add(item.slug);
out.push(item);
}
return out;
}
/**
* 把模型生成的会话标题转成可寻址的 slug。
*
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
* `.` 切分,中文不影响解析)。
*
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
*
* @param {string} title
* @returns {string} slug,无法派生时为空串
*/
export function slugFromTitle(title) {
const raw = String(title ?? '').trim();
if (!raw) return '';
const slug = raw
.replace(/[\s\u3000]+/g, '-')
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
// 截断可能又切出尾部的 -
.replace(/-+$/g, '');
// 纯符号标题清干净后会剩空串
return slug;
}
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
function toISO(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v).toISOString();
}
if (typeof v === 'string' && v) {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
return undefined;
}
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
function sortAndCap(list) {
return list
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
.slice(0, MAX_REPORTED);
}

View File

@ -0,0 +1,6 @@
export function resolveWorkspaceCwd(
workspace: string | undefined,
fallbackKey: string
): { cwd: string; grouped: boolean };
export function ensureCwd(cwd: string, grouped: boolean): void;

View File

@ -0,0 +1,65 @@
/**
* 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。
*
* 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的
* `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
* DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、
* 彼此也不同组,界面上全落进「未分组」。
*
* path 位本来就是「希望它在哪儿干活」,插件只需照用。
*/
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
/**
* 把 new_mail 事件里的 to_workspace 解析成一个可用的 cwd。
*
* 决策顺序:
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
* 2. path 位非空但目录不存在 → **不创建**,回退到兜底目录
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底目录
*
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`)
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
*
* 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,
* 那是个与邮件语义无关的量(systemd 下通常是 `/`)。
*
* @param {string} workspace 事件里的 to_workspace
* @param {string} fallbackKey 兜底目录名(通常是会话 id)
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
*/
export function resolveWorkspaceCwd(workspace, fallbackKey) {
const raw = typeof workspace === 'string' ? workspace.trim() : '';
const fallback = join(homedir(), '.dsh', 'mail-sessions', String(fallbackKey || 'default'));
if (!raw || !isAbsolute(raw)) return { cwd: fallback, grouped: false };
const abs = resolve(raw);
try {
if (existsSync(abs) && statSync(abs).isDirectory()) {
return { cwd: abs, grouped: true };
}
} catch {
// 权限不足等:当作不可用
}
return { cwd: fallback, grouped: false };
}
/**
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
* 只有兜底目录需要现建。
* @param {string} cwd resolveWorkspaceCwd 的结果
* @param {boolean} grouped 是否落在寻址指定的目录里
*/
export function ensureCwd(cwd, grouped) {
if (grouped) return;
try {
mkdirSync(cwd, { recursive: true });
} catch {
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
}
}