Files
MailUI4Agents/plugins/dsh-mail-bridge/src/index.ts
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

1485 lines
67 KiB
TypeScript
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.

/**
* dsh-mail-bridge — DeepSeek Harness ↔ AgentMail 桥接插件
*
* 与 opencode-mail-bridge 共享同一套 Gateway API。
* DSH 用 Cordis 插件框架(@deepseek-ai/cordis不是 opencode 的 @opencode-ai/plugin。
*
* 关键差异:
* - opencode: client.session.create() + client.session.promptAsync()
* - DSH: ctx.agents.create() + agent.followup()
* - opencode: tool 用 zod schema
* - DSH: tool 用 defineTool() + 参数 spec 格式
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import {
explicitSends,
noteExplicitSend,
shouldSkipAutoRelay,
} from '../lib/relay-dedup.js';
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
import {
userMessage,
replySubject,
lastAssistantText,
modelTitle,
} from '../lib/message.js';
import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js';
import {
snapshotDshModels,
modelAttemptOrder,
renderFailureReport,
} from '../lib/model-scope.js';
import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js';
import { selectCatchup } from '../lib/catchup.js';
import {
renderInbox,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
// ─── 凭证管理 ───
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
function readLocalKey(): string | null {
try {
if (!existsSync(KEY_FILE)) return null;
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
return typeof raw.key === 'string' ? raw.key : null;
} catch { return null; }
}
function saveLocalKey(key: string) {
try {
mkdirSync(dirname(KEY_FILE), { recursive: true });
writeFileSync(KEY_FILE, JSON.stringify({ key }, null, 2), 'utf8');
} catch { /* 忽略 */ }
}
function generateLocalKey(): string {
const key = 'ak_' + randomBytes(24).toString('hex');
saveLocalKey(key);
return key;
}
// ─── Gateway HTTP 客户端 ───
class GatewayClient {
baseURL: string;
agentName: string;
agentKey: string;
agentSecret: string;
constructor(gatewayURL: string, agentName: string, agentKey: string, agentSecret: string) {
this.baseURL = gatewayURL.replace(/\/+$/, '');
this.agentName = agentName;
this.agentKey = agentKey;
this.agentSecret = agentSecret;
}
/** 与 opencode-mail-bridge 的 authHeaders() 相同逻辑 */
authHeaders(): Record<string, string> {
if (this.agentKey) {
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
}
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
}
async post(path: string, body: Record<string, unknown>): Promise<any> {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify(body),
});
const data = await res.json() as any;
if (!res.ok) {
// 状态码与响应体挂在 error 上:调用方要区分「暂时失败」与「永远不会成功」。
// 权限询问碰到 409任务链上没有人类必须当场拒绝
// 而 502 应该重试 —— 只看 message 字符串分不出这两种。
const err: any = new Error(data?.error || `POST ${path} failed: ${res.status}`);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
async get(path: string): Promise<any> {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
const data = await res.json() as any;
if (!res.ok) throw new Error(data?.error || `GET ${path} failed: ${res.status}`);
return data;
}
async register(): Promise<void> {
await this.post('/agent/register', {
name: this.agentName,
secret: this.agentSecret || '',
workspaces: [],
platform: 'dsh',
});
}
}
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
const sessionMap = new Map<string, { dshSessionId: string; directory: string }>();
const reverseMap = new Map<string, string>();
const mailDrivenSessions = new Set<string>();
const mailContexts = new Map<string, { replyTo: string; subject: string; mailID: string }>();
const relayedSummaries = new Map<string, string>();
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels: { provider: string; model: string }[] = [];
const syncedTitles = new Map<string, string>();
// 权限询问DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
// 人类决策通过 SSE 回来后再 resolve 这个 promise让 DSH 自己恢复执行。
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id
// 而同一个 callId 的同一个工具只会问一次。
interface PendingApproval {
resolve: (outcome: string) => void;
sessionId: string;
}
const pendingApprovals = new Map<string, PendingApproval>();
// ─── 运行时导入 DSH 内部函数 ───
let _defineTool: any;
function defineTool(opts: any): any {
if (!_defineTool) {
try {
const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools');
_defineTool = require(dshToolsPath).defineTool;
} catch { return opts; }
}
return _defineTool(opts);
}
// ─── Cordis 插件入口 ───
// Cordis 要求插件声明依赖的服务:没有 injectctx.tools / ctx.agents 根本不存在
// (报 `cannot get property "tools" without inject`)。
//
// sessionQuery 不列在这里而用 ctx.get('sessionQuery') 取inject 是硬依赖,
// 列进去的服务没挂载时整个插件不会启动 —— 而会话上报只是补全体验,
// 不应该能把邮件投递整体拘死。
export const inject = ['agents', 'tools'];
export const name = 'dsh-mail-bridge';
interface PluginConfig {
gateway: { url: string; agentName: string; agentKey: string; agentSecret: string };
reply: { provider?: string; model?: string };
}
export function apply(ctx: any, config: PluginConfig): void {
const GW = config.gateway.url || 'http://127.0.0.1:8180';
const AGENT_NAME = config.gateway.agentName || 'dsh';
let AGENT_KEY = config.gateway.agentKey || '';
const AGENT_SECRET = config.gateway.agentSecret || '';
const REPLY_PROVIDER = config.reply.provider || '';
const REPLY_MODEL = config.reply.model || 'AUTO';
if (!AGENT_KEY && !AGENT_SECRET) {
AGENT_KEY = readLocalKey() || generateLocalKey();
}
const client = new GatewayClient(GW, AGENT_NAME, AGENT_KEY, AGENT_SECRET);
// 注册 Agent
(async () => {
try {
await client.register();
ctx.logger.info(`[dsh-mail-bridge] 已接入 ${GW},身份 ${AGENT_NAME}`);
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 注册失败: ${e?.message || e}`);
}
})();
// ─── 心跳 + 平台会话上报 ───
//
// 心跳两个职责:
// 1. 保活 —— Gateway 靠 last_seen 判在线,不发心跳就会被当成离线(之前真的没发)
// 2. 上报平台侧会话快照 —— 写信时想续谈某条会话,得先知道那个工作区下
// 有哪些会话可续Gateway 只看得见邮件驱动的那部分。
//
// 上报而不是让 Gateway 反向拉取当前架构是单向的Agent 持密钥主动连
// GatewayGateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。
/**
* 收集本机 DSH 的会话快照。
*
* 优先用 ctx.sessionQuery包含已落盘的历史会话它不可用时退到
* ctx.agents.list()只有当前活着的。base bundle 里 session-query-sqlite 的
* openAt 是 'never',但 listSessions/readTitle 这些精确读不依赖 SQLite
* —— 只有全文搜索会报 SESSION_QUERY_SEARCH_DISABLED。
*
* 返回 undefined 表示「本次拿不到列表」,调用方应当省略字段而不是传空数组:
* 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
*/
async function collectSessions(): Promise<any[] | undefined> {
const q = ctx.get('sessionQuery');
if (q?.listSessions) {
try {
const records = await q.listSessions();
const ids = records
.map((r: any) => r?.header?.id)
.filter((id: any): id is string => typeof id === 'string');
// 标题在日志里,需要单独 fold。批量读而不是逐个读
// readTitleSnapshots 共用一次 corpus 观测,而逐个 readTitle 会重复加载日志。
const titles = new Map<string, string>();
if (q.readTitleSnapshots && ids.length > 0) {
const results = await q.readTitleSnapshots(ids);
results.forEach((res: any, i: number) => {
// 单个会话读失败不该拘到其他会话(接口本身就是逐条隔离的)
if (res?.status === 'rejected') return;
const title = res?.value?.title?.title ?? res?.title?.title;
if (typeof title === 'string' && title) titles.set(ids[i], title);
});
}
return records.map((r: any) => ({
id: r?.header?.id,
cwd: r?.header?.cwd ?? '',
title: titles.get(r?.header?.id) ?? '',
updatedAt: r?.header?.createdAt,
// subagent 子会话要被过滤掉,判据在 header 上
origin: r?.header?.origin,
delegationDepth: r?.header?.delegationDepth,
}));
} catch (e: any) {
ctx.logger.warn(`[dsh-mail-bridge] sessionQuery 不可用,退到活会话列表: ${e?.message || e}`);
}
}
// 退路:只报当前活着的 agent。比什么都不报强 ——
// 它们恰好是正在进行的任务,也是最可能被续谈的那批。
try {
const live = ctx.agents?.list?.() ?? [];
return live.map((a: any) => ({
id: String(a?.id ?? ''),
cwd: a?.session?.header?.cwd ?? '',
title: modelTitle(a?.session?.events ?? []),
updatedAt: a?.session?.header?.createdAt,
origin: a?.session?.header?.origin,
delegationDepth: a?.session?.header?.delegationDepth,
}));
} catch {
return undefined;
}
}
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
const deliveredMails = new Set<string>();
let caughtUp = false;
/**
* 补投离线期间积压的未读邮件。
*
* SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。
* 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。
*/
async function catchUp(pending: unknown): Promise<void> {
if (!pending) return;
try {
const box = await client.get('/mail/inbox?status=unread&limit=20');
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (tasks.length === 0) return;
console.error(`[dsh-mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
for (const ev of tasks) {
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
if (deliveredMails.has(ev.mail_id)) continue;
deliveredMails.add(ev.mail_id);
try {
await deliverMail(ev, 'mail');
} catch (e: any) {
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
}
}
} catch (e: any) {
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
}
}
async function beat(): Promise<void> {
const body: Record<string, unknown> = {};
const [entries, models] = await Promise.all([collectSessions(), collectModels()]);
if (entries) {
body.platform_sessions = snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id));
}
if (models) body.models = models;
try {
const res = await client.post('/agent/heartbeat', body);
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
// 插件最多一个周期30 秒)就能看到新值,不需要重启。
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
// 只在首个成功的心跳后补投一次:之后的积压都由 SSE 覆盖,
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
if (!caughtUp) {
caughtUp = true;
await catchUp(res?.pending_mails);
}
} catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
}
}
ctx.effect(() => {
void beat();
const timer = setInterval(() => { void beat(); }, 30_000);
return () => clearInterval(timer);
}, 'dsh-mail-bridge.heartbeat');
// ─── 获取默认模型 ───
/**
* 这一轮按什么顺序尝试模型。
*
* 优先级:管理员划定的范围 > 环境变量指定 > 平台自己的默认选择。
* 范围是运行时可改的策略,环境变量是部署时的兜底,因此前者优先。
*/
function attemptOrder(): ({ provider: string; model: string } | undefined)[] {
const envDefault = REPLY_PROVIDER && REPLY_MODEL
? { provider: REPLY_PROVIDER, model: REPLY_MODEL }
: ctx.get('agentDefaultModel')?.currentSelection?.();
return modelAttemptOrder(allowedModels, envDefault);
}
/** 收集本机 DSH 看得见的模型目录,供配置页勾选。 */
async function collectModels(): Promise<any[] | undefined> {
const llm = ctx.get('llm');
if (!llm?.listProviders || !llm?.listModels) return undefined;
try {
const flat: any[] = [];
for (const p of llm.listProviders()) {
const id = p?.provider ?? p?.id;
if (typeof id !== 'string' || !id) continue;
try {
const models = await llm.listModels(id);
for (const m of models) flat.push(m);
} catch {
// 单个 provider 拉不到不该拖掉其他 provider ——
// 上游故障通常只影响一家
}
}
return snapshotDshModels(flat);
} catch {
// 拉不到就省略该字段,而不是上报空数组把配置页清成空白
return undefined;
}
}
/**
* 等这个会话的首轮真正跑起来,或者失败。
*
* **`ctx.agents.create()` 不会因为模型无效而失败** —— 它只是记下 agentOptions。
* 真正的失败发生在之后的 turn 里,异步抛出:
*
* request/context {provider: "nonexistent", model: "x"}
* turn/end {reason: {kind: "error", error: {code: "NO_ADAPTER", ...}}}
*
* 因此降级尝试不能只包一个 try/catch —— 那样第二个模型永远不会被试到。
* 这里用 `session/event` 观察 turn 的走向。
*
* **`assistant/chunk` 本身不是成功信号**:它的 `finish` 子类型也带错误 ——
*
* {chunk: {type: 'finish', reason: {kind: 'error', failure: {code: 'NO_ADAPTER'}}}}
*
* 见过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。
* 判据要落在 chunk 的类型上:`finish` 看 reason其余`block-start`、
* `text-delta`、`tool-call-delta`…)才意味着模型真的在产出。
*
* 超时按「成功」处理:模型可能只是很慢(首 token 前要装载上下文),
* 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。
*
* @param agent 刚建好的 agent
* @param timeoutMs 判定窗口
*/
function awaitFirstTurn(agent: any, timeoutMs = 60_000): Promise<{ ok: true } | { ok: false; error: string }> {
return new Promise((resolve) => {
let done = false;
const finish = (r: { ok: true } | { ok: false; error: string }) => {
if (done) return;
done = true;
clearTimeout(timer);
dispose?.();
resolve(r);
};
const timer = setTimeout(() => finish({ ok: true }), timeoutMs);
/** 把 DSH 的错误对象拼成一行可读文本。 */
const describe = (err: any): string =>
[err?.code, err?.message].filter(Boolean).join(': ') || '未知错误';
const dispose = ctx.on('session/event', (session: any, event: any) => {
if (session !== agent.session) return;
if (event?.type === 'assistant/chunk') {
const chunk = event.data?.chunk;
if (chunk?.type === 'finish') {
if (chunk.reason?.kind === 'error') {
return finish({ ok: false, error: describe(chunk.reason.failure) });
}
return; // finish 收尾,等 turn/end
}
// assistant/chunk 模型在产出内容 → 模型确实活着,算 OK
return finish({ ok: true });
}
if (event?.type === 'turn/end') {
const reason = event.data?.reason;
if (reason?.kind === 'error') {
return finish({ ok: false, error: describe(reason.error) });
}
return finish({ ok: true });
}
});
});
}
/**
* 等待当前轮次结束后再投递下一封。
*
* 原来 catchUp 用 awaitFirstTurn首 token 即放行),同一会话的多封邮件在
* 补投时全部撞进同一个 turn → 「message undefined is already pending」。
* 这个函数等 turn/end且对同一会话串行化杜绝并发 followup。
*/
async function waitForTurnEnd(agent: any, timeoutMs = 120_000): Promise<void> {
await new Promise<void>((resolve) => {
let done = false;
const finish = () => { if (!done) { done = true; clearTimeout(timer); dispose?.(); resolve(); } };
const timer = setTimeout(finish, timeoutMs);
const dispose = ctx.on('session/event', (session: any, event: any) => {
if (session !== agent.session) return;
if (event?.type === 'turn/end') finish();
});
});
}
/** 按会话锁串行化 followup同一会话同一时刻只跑一轮。 */
const sessionLocks = new Map<string, Promise<void>>();
async function locked<T>(dshSessionId: string, fn: () => Promise<T>): Promise<T> {
const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve();
let release!: () => void;
const next = new Promise<void>((r) => { release = r; });
sessionLocks.set(dshSessionId, next);
try {
await prev;
return await fn();
} finally {
release();
if (sessionLocks.get(dshSessionId) === next) sessionLocks.delete(dshSessionId);
}
}
// ─── 建会话(磁盘上已有则 resume───
/**
* 问持久化层:磁盘上是否已经有这条会话?
*
* `sessionMap` 是纯内存的,插件重启后为空,于是同一封邮件的续谈会走
* 「新开会话」那条路,用回同一个 `mail-<session_id>` —— 而那个 id 上一次
* 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。
*
* 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验
* 不过时都会抛。三种情形里只有第一种适合 create但后两种 resume 也一样
* 救不回来 —— 那就让 create 去报它自己的错。
*/
async function persistedCwd(sessionId: string): Promise<string | undefined> {
const q: any = (ctx as any).get?.('sessionQuery');
if (!q?.readSession) return undefined;
try {
const snap = await q.readSession(sessionId);
return snap?.header?.cwd ?? '';
} catch {
return undefined;
}
}
/**
* 启一个 agent磁盘上没有这个 id 就 create有就 resume。
*
* # 为何必须先探测,不能靠 try/catch
*
* id 冲突不是 `create` 报的:持久化是在**轮次进行中** flush 的,所以
* `create` 会正常返回,错误到 `turn/end` 才以 `reason.kind === 'error'`
* 冲出来(实测:`UNKNOWN: session "..." already has a persisted log on disk`)。
* 把修法写成 catch 里改 resume 完全不会生效 —— 这与「模型失败不是同步抛出的」
* 是同一类陷阱,只是上了一层。
*
* # 为何 resume 而不是换一个新 id
*
* 换 id 等于把之前的往来上下文丢掉,模型会重新问一遍已经问过的问题。
* resume 把磁盘上那条会话装回来接着谈,这同时修掉了一个已知取舍:
* 插件重启后续谈的邮件不再另开一条平台会话。
*
* resume 不接受 `meta`cwd 取自持久化的 header。这正是想要的 —— 上一次在哪个
* 目录,就继续在那儿;传一个不同的 cwd 只会得到
* `is already persisted at a different cwd` 而不是“改目录”。
*/
async function startAgent(
sessionId: string, cwd: string, route: any,
): Promise<{ handle: any; resumed: boolean }> {
// route 为 undefined 表示不指定模型,交给平台自己选
const agentOptions = route ? { provider: route.provider, model: route.model } : {};
const onDisk = await persistedCwd(sessionId);
if (onDisk !== undefined) {
// 用 console.error 而不是 ctx.logger.info后者不进 journalctl实测
// 而这条是排查「邮件投不进去」时唯一能看到的线索。
console.error(`[dsh-mail-bridge] 会话 ${sessionId} 已在磁盘上cwd=${onDisk || '未记录'}),改为 resume 续谈`);
const handle = await ctx.agents.resume({
resumeSessionId: sessionId as any,
agentOptions,
setup: undefined,
});
return { handle, resumed: true };
}
const handle = await ctx.agents.create({
sessionId,
meta: { cwd },
agentOptions,
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
return { handle, resumed: false };
}
// ─── 投递邮件到 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) {
return locked(existing.dshSessionId, async () => {
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
: [
`本会话收到一封新邮件AgentMail 续谈)。`,
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
`回信不用你自己发:把这一轮做完、把结论说出来就行。`,
].join('\n');
live.followup(userMessage(promptText));
// 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封,
// 会让 DSH 报 "message already pending"。串行化靠 locked() 保证
// 同一时刻只有一个 followup 在跑,两个锁互斥 —— 即使 turn/end
// 未出现比如模型完全没响应120s 超时兜底不会把后续邮件永久卡住。
await waitForTurnEnd(live);
return { sessionID: existing.dshSessionId, reused: true };
});
}
}
// 新开会话
const sessionId = `mail-${mailSessionID || Date.now()}`;
// cwd 取寻址里的 path 位to_workspace
//
// 之前这里硬拼 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
// DSH 按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、彼此也不同组,
// 界面上全落进「未分组」。path 位本来就是「希望它在哪儿干活」。
const { cwd, grouped } = resolveWorkspaceCwd(
data.to_workspace, mailSessionFallback(sessionId));
ensureCwd(cwd, grouped);
if (!grouped && data.to_workspace) {
ctx.logger.warn(
`[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
}
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。`
: [
`你收到一封新邮件AgentMail`,
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
`身份:你是 ${AGENT_NAME}`,
``,
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
``,
`**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`,
`插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || '发件人'}(不消耗你的发信配额)。`,
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
].join('\n');
// 按管理员划定的范围逐个尝试建 agent全部失败才回一封说明原因的邮件。
//
// 必须发那封信agent 一次都没建起来时会话里没有任何 assistant 消息,
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
//
// DSH 与 opencode 的差异:这里失败在**建 agent** 时就暴露(模型路由是
// agentOptions 的一部分),而 opencode 是在 promptAsync 时。
const attempts = attemptOrder();
const failures: { provider?: string; model?: string; error: string }[] = [];
for (let i = 0; i < attempts.length; i++) {
const route = attempts[i];
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
// 每次尝试用不同的会话 id失败那次的日志里已经有 turn/end error
// 复用同一个 id 会让重试接在一条已经出错的会话后面。
const attemptSessionId = i === 0 ? sessionId : `${sessionId}-r${i}`;
let handle: any;
try {
const started = await startAgent(attemptSessionId, cwd, route);
handle = started.handle;
} catch (e: any) {
// create/resume 本身很少失败create 不校验模型),
// 但 cwd 不符、日志 replay 不过之类仍会抛
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
continue;
}
// 先建映射再 followupturn 可能在 followup 返回前就产出事件,
// 而 agent/status 的处理要靠这些映射找到回信地址。
if (mailSessionID) {
sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd });
reverseMap.set(attemptSessionId, mailSessionID);
mailDrivenSessions.add(attemptSessionId);
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
}
// 把会话挂进工作区注册表 —— 这是让 DSH GUI 把它从「未分组」挪进对应项目
// 的唯一路径。不挂的后果:会话永远落在 Ungrouped人类在侧栏看不到它
// 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。
//
// **cwd 必须取自会话 header不能用上面那个 `cwd` 变量。**
//
// 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值**
// `~/.dsh/mail-sessions/mail-<uuid>`);而 resume 路径下会话的真实 cwd 取自
// 持久化的 header两者不一致时 `attachSession` 的校验直接拒绝:
//
// cannot attach session 'mail-f3d824ce…' to workspace
// '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy'
//
// 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里
// 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。
//
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。
if (handle) {
try {
// 真实 cwdcreate 路径下就是上面传的 meta.cwd
// resume 路径下是持久化 header 里那个。两种情形都从 session 读。
const actualCwd = String(handle.agent?.session?.header?.cwd ?? '');
const wr: any = (ctx as any).get?.('workspaceRegistry');
if (actualCwd && wr?.create) {
const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd);
if (ws?.attachSession) {
await ws.attachSession(attemptSessionId as any);
}
}
} catch (e: any) {
// 工作区注册失败不影响会话功能:只有 GUI 分组会受影响。
// 日志用 console.error 才进 journalctlctx.logger 不进)。
console.error(`[dsh-mail-bridge] 工作区注册失败(不影响功能): ${e?.message || e}`);
}
}
const watching = awaitFirstTurn(handle.agent);
handle.agent.followup(userMessage(promptText));
const outcome = await watching;
if (outcome.ok) {
if (failures.length > 0) {
console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID: attemptSessionId, reused: false };
}
failures.push({ ...(route ?? {}), error: outcome.error });
console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
// 拆掉这一路的 agent 与映射,否则它会占着会话 id
// 而 agent/status 还会为这个死会话触发一次自动转发
reverseMap.delete(attemptSessionId);
mailDrivenSessions.delete(attemptSessionId);
try {
await handle.dispose();
} catch {
// dispose 失败不影响换下一个模型
}
}
if (mailSessionID) sessionMap.delete(mailSessionID);
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
// 这是插件的故障报告,不是模型的自主发信。
if (kind === 'mail' && data.from_name) {
try {
await client.post('/mail/send', {
to: data.from_name,
subject: `处理失败: ${data.subject || '(无主题)'}`,
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || '',
relay: 'summary',
relay_key: `model-failure:${data.mail_id || sessionId}`,
});
ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 失败回报也发不出去: ${e?.message || e}`);
}
}
throw new Error(
`划定范围内的 ${failures.length} 个模型全部失败:` +
failures.map(f => f.error).join(' | '));
}
// ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)───
let sseAbort: AbortController | null = null;
function startSSE(onEvent: (type: string, data: any) => void) {
sseAbort?.abort();
sseAbort = new AbortController();
const reconnect = () => {
if (sseAbort?.signal.aborted) return;
fetch(`${GW}/api/v1/events/stream`, {
headers: client.authHeaders(),
signal: sseAbort?.signal ?? new AbortController().signal,
}).then((res) => {
const reader = res.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buf = '';
const read = () => {
reader.read().then(({ done, value }) => {
if (done) { setTimeout(reconnect, 3000); return; }
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() || '';
let evt = '', data = '';
for (const line of lines) {
if (line.startsWith('event: ')) evt = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
else if (line === '' && evt) {
try { onEvent(evt, JSON.parse(data)); } catch {}
evt = ''; data = '';
}
}
read();
}).catch(() => setTimeout(reconnect, 5000));
};
read();
}).catch(() => setTimeout(reconnect, 5000));
};
reconnect();
}
// ─── 注册模型工具 ───
ctx.effect(() => {
// send_mail
ctx.tools.register(defineTool({
name: 'send_mail',
description: '发送邮件。三维地址 name@path.session省略 session 投递到默认会话,.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
parameters: {
to: { type: 'string', required: true, description: '收件人三维地址' },
subject: { type: 'string', required: true, description: '邮件主题' },
body: { type: 'string', required: true, description: '邮件正文Markdown' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名' },
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
propose_alias: {
type: 'string',
description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak。这只是建议别名是人的寻址入口实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new',
},
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 拼接收在 lib/rename-proposal.js三平台共用标记格式是服务端正则的
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
const { body, proposed } = appendRenameProposal(
args.body, args.propose_alias, args.propose_reason);
const result = await client.post('/mail/send', {
to: args.to, subject: args.subject, body,
cc: args.cc || '', reply_to: args.reply_to || '',
session_alias: args.session_alias || '',
attachment_ids: args.attachment_ids || [],
});
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
return `邮件已发送ID: ${result.mail_id}${budget}` + (note ? `\n${note}` : '');
},
}));
// read_inbox
ctx.tools.register(defineTool({
name: 'read_inbox',
description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单带 attachment_id。',
parameters: {
status: { type: 'string', description: '过滤条件 unread|all默认 unread' },
limit: { type: 'number', description: '返回数量,默认 5' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const status = args.status || DEFAULT_INBOX_STATUS;
const { mails } = await client.get(
`/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}`
);
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
//
// 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。
const listed = renderInbox(mails, 200, AGENT_NAME);
// 读过就标掉,否则每次拉收件箱都重复捞同一批,
// 处理过的和新来的混在一起,模型分不清哪封该回。
const ids = idsToMarkRead(args.status, mails);
if (ids.length) {
// 标记失败不该让 read_inbox 失败:正文已经取到了,
// 代价只是下次重复看到,比丢掉这次读取轻。
client.post('/mail/read', { mail_ids: ids }).catch((e: any) =>
ctx.logger.error(`[dsh-mail-bridge] 标记已读失败: ${e?.message || e}`));
}
return listed;
},
}));
// upload_attachment
ctx.tools.register(defineTool({
name: 'upload_attachment',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
'未随邮件发出的附件 24 小时后自动清理。',
parameters: {
file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' },
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
// 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。
let st;
try {
st = await stat(args.file_path);
} catch {
return `文件不存在或不可读: ${args.file_path}`;
}
if (!st.isFile()) return `不是普通文件: ${args.file_path}`;
const data = await readFile(args.file_path);
const filename = args.filename || args.file_path.split('/').pop() || 'file';
// **必须发真正的 multipart。**
//
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
// `X-Filename` 头,而服务端走 `ParseMultipartForm` + `FormFile("file")` ——
// 于是 **这个工具从来没成功过一次**,每次都回「解析 multipart 失败」。
// 模型甚至把它当成了文件存在性探针(存在→报 multipart 错、
// 不存在→ENOENT那是对症状的准确利用但不是它应该做的事。
//
// 不设 Content-Type交给 FormData 自己带 boundary手写的一定对不上。
const form = new FormData();
form.append('file', new Blob([data]), filename);
const res = await fetch(`${client.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: client.authHeaders(),
body: form,
});
const json = await res.json() as any;
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
const a = json.attachment;
return `已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
},
}));
// download_attachment
ctx.tools.register(defineTool({
name: 'download_attachment',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
parameters: {
attachment_id: { type: 'string', required: true, description: '附件 ID' },
save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const res = await fetch(`${client.baseURL}/api/v1/attachments/${args.attachment_id}`, {
headers: client.authHeaders(),
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
// 不建的话 writeFile 抛 ENOENT而那个错误看起来像「附件不存在」。
await mkdir(dirname(args.save_path), { recursive: true });
await writeFile(args.save_path, buf);
return `已保存到 ${args.save_path}${formatSize(buf.length)}`;
},
}));
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上本插件猜了 `opencode@/home`,投递成功,
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
ctx.tools.register(defineTool({
name: 'suggest_address',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;' +
'name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**' +
'不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
parameters: {
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const name = String(args.name || '').trim();
const path = String(args.path || '').trim();
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case 'name': return renderNameSuggestions(data.suggestions);
case 'path': return renderPathSuggestions(data.suggestions, name);
default: return renderSessionSuggestions(data, name, path);
}
},
}));
ctx.tools.register(defineTool({
name: 'list_contacts',
description:
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
parameters: {
limit: { type: 'number', description: '最多列出多少条,默认 20' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get('/agent/contacts');
return renderContacts(data, args.limit || 20);
},
}));
ctx.tools.register(defineTool({
name: 'session_participants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
parameters: {
session_id: { type: 'string', required: true, description: '会话 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/sessions/${args.session_id}/participants`);
return renderParticipants(data);
},
}));
ctx.tools.register(defineTool({
name: 'read_thread',
description:
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
parameters: {
mail_id: { type: 'string', required: true, description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const qs = args.offset ? `?offset=${args.offset}` : '';
const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`);
return renderThread(data, AGENT_NAME);
},
}));
ctx.tools.register(defineTool({
name: 'read_mail',
description:
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
parameters: {
mail_id: { type: 'string', required: true, description: '邮件 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/mail/${args.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || '?'}`,
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
`主题: ${m.subject || '(无主题)'}`,
`会话: #${data.session_alias || '未命名'}session_id: ${m.session_id || '?'}`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map((c: any) => c?.raw || c?.name).join('、')}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(`附件: ${m.attachments
.map((a: any) => `${a.filename}${formatSize(a.size_bytes)}, id=${a.attachment_id}`)
.join('、')}`);
}
lines.push('', m.body || '(空正文)', '');
if (Array.isArray(data.participants) && data.participants.length) {
lines.push('可投递地址: ' + data.participants
.filter((p: any) => p.address && p.name !== AGENT_NAME)
.map((p: any) => `${p.address}${p.role}`)
.join('、'));
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return lines.join('\n');
},
}));
// forward_mail —— 转发给新收件人。
//
// 之前 DSH 侧缺这个工具opencode 侧一直有),于是本平台上「把这封信
// 转给某人」只能退化成 send_mail 重抄一遍正文 —— 丢掉附件、丢掉
// parent_mail_id对话树上也看不出这条新线索从何而来。
ctx.tools.register(defineTool({
name: 'forward_mail',
description:
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,' +
'转发按目标地址另行定位会话(它是一条新线索)。只能转发自己参与过的邮件。',
parameters: {
mail_id: { type: 'string', required: true, description: '要转发的邮件 ID' },
to: { type: 'string', required: true, description: '新收件人的三维地址(先用 suggest_address 确认)' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
session_alias: { type: 'string', description: '仅当目标地址以 .new 结尾时生效:给新会话命名' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
const result = await client.post(`/mail/${args.mail_id}/forward`, {
to: args.to,
comment: args.comment || '',
cc: args.cc || '',
subject: args.subject || '',
session_alias: args.session_alias || '',
});
// 转发也是一次「模型亲手发信」,要计入 explicitSends
// 否则本轮结束时自动转发会再把同一段话发一遍。
noteExplicitSend(toolCtx?.sessionID, args.to, '');
return `已转发。新 Mail ID: ${result.mail_id}Session: ${result.session_id}`;
},
}));
// connect_to_server —— 连接自愈。
//
// 之前只有 opencode 侧有这个工具。后果是Gateway 换了地址、或密钥需要
// 重新登记时opencode 里的模型能自己修好,而 DSH 里的模型只能干等
// systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无,
// 等于让人记住哪个平台能自己修。
//
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
ctx.tools.register(defineTool({
name: 'connect_to_server',
description:
'连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
parameters: {
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
let key = client.agentKey;
if (args.key_token) {
key = String(args.key_token).trim();
// 管理员给的密钥落盘,重启后仍然可用
saveLocalKey(key);
} else if (!key) {
key = generateLocalKey();
}
const url = String(args.gateway_url || client.baseURL).replace(/\/+$/, '');
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: 'dsh' }),
});
const data = await res.json().catch(() => ({})) as any;
if (!res.ok) {
return [
`连接失败HTTP ${res.status}${data?.error || '未知错误'}`,
``,
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
key,
``,
`密钥文件:${KEY_FILE}`,
].join('\n');
}
// 成功后把新坐标写回客户端,当场生效(不用等重启)
client.baseURL = url;
client.agentKey = key;
return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}`;
},
}));
return () => {
for (const n of [
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
'upload_attachment', 'download_attachment',
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
'connect_to_server',
]) {
try { ctx.tools.unregister(n); } catch {}
}
};
}, 'dsh-mail-bridge.tools');
// ─── turn 完成后自动转发回复 ───
//
// 与 opencode-mail-bridge 的 session.idle 同一职责:模型把话说完了,
// 插件把它最后那段话搬到邮件里 —— 不该让模型自己记得调 send_mail。
ctx.on('agent/status', async (payload: any) => {
if (payload?.status !== 'idle') return;
const agent = payload.agent;
if (!agent?.id) return;
const mailSessionID = reverseMap.get(String(agent.id));
if (!mailSessionID) return;
const mctx = mailContexts.get(mailSessionID);
if (!mctx?.replyTo) return;
// 取最后一条 assistant 消息的文本
const events = agent.session?.events ?? [];
const lastText = lastAssistantText(events);
if (!lastText) return;
// 本轮模型已亲手回过这条线索 → 不再自动转发(与 opencode 侧同一取舍)
if (shouldSkipAutoRelay(explicitSends.get(String(agent.id)), mctx.replyTo, mctx.mailID)) {
explicitSends.delete(String(agent.id));
return;
}
// 同一条消息只转一次
if (relayedSummaries.get(String(agent.id)) === lastText) return;
try {
await client.post('/mail/send', {
to: mctx.replyTo,
subject: replySubject(mctx.subject),
body: lastText,
reply_to: mctx.mailID || '',
// relay + relay_key走免配额通道harness 的搬运不该收费)
relay: 'summary',
relay_key: `${agent.id}:${events.length}`,
});
relayedSummaries.set(String(agent.id), lastText);
explicitSends.delete(String(agent.id));
ctx.logger.info(`[dsh-mail-bridge] 总结已回信 ${mctx.replyTo}(不计配额)`);
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 转发回复失败: ${e?.message || e}`);
}
// 会话命名回写DSH 首轮结束后由模型生成摘要标题,把它同步回 AgentMail。
// 与 opencode 侧同一决定:不另造一套命名,平台叫什么这边就叫什么。
//
// DSH 没有 opencode 那样的 slug别名由标题派生slugFromTitle 会去掉
// `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。
// 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。
const title = modelTitle(events);
if (title && syncedTitles.get(String(agent.id)) !== title) {
syncedTitles.set(String(agent.id), title);
const alias = slugFromTitle(title);
try {
await client.post(`/sessions/${mailSessionID}/sync`,
alias ? { title, alias } : { title });
ctx.logger.info(
`[dsh-mail-bridge] 会话命名已同步: title=${title}${alias ? ` alias=${alias}` : ''}`);
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 命名同步失败: ${e?.message || e}`);
}
}
});
// ─── 平台原生权限询问 → 转成邮件问人 ───
//
// 这是 harness 的职责,不该让模型自己调一个 request_permission 工具:
// 模型可能忘了调,也可能在不需要时乱调,而真正被 DSH 拦下的那次询问反而没人看见。
//
// 与 opencode 的差异opencode 的 permission.ask 是同步钩子,卡在里面会把整个
// 请求挂住,所以那边只能“转出去就返回 ask”而 DSH 的 approval/request 是
// **异步 waterfall**,返回 Promise<ApprovalOutcome> 就是它要的,因此可以真的等人。
ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
const agentId = String(req?.agent?.id ?? '');
if (!mailDrivenSessions.has(agentId)) return next(); // 非邮件驱动的会话不接管
const mailSessionID = reverseMap.get(agentId);
if (!mailSessionID) return next();
// DSH 不给询问发 id用 (会话, 工具, callId) 做幂等键。
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
const mctx = mailContexts.get(mailSessionID);
try {
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 →
// 无人可问则 409。插件若把来信人当决策人Agent 之间转派任务时
// A 把活分给 B权限邮件会发给 Agent 自己 —— Agent 不可能在界面上点
// 「同意」,于是下面那个 await 永不 resolve会话无声挂死。
await client.post('/permission/request', {
question: `请求执行 ${req.toolName}`,
options: ['同意', '拒绝'],
context: [
`工具:${req.toolName}`,
req.callId ? `调用 ID${req.callId}` : '',
req.reason ? `理由:${req.reason}` : '',
// 决策人未必是这条会话的参与者Agent 转派出来的会话,人从没见过它),
// 只给工具名无从判断得说明这活是谁派的、为的什么事B-8.4)。
mctx?.subject ? `触发任务:${mctx.subject}` : '',
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
].filter(Boolean).join('\n'),
session_id: mailSessionID,
relay_key: relayKey,
});
} catch (e: any) {
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。
//
// 不能 `return next()`:下一个 answerer 是本地 UI而邮件驱动的会话
// 根本没有 UIwaterfall 跑到尾以后依旧无人应答 —— 这正是生产事故
// 的形状pi 把任务派给自己的另一条会话,那条要跑 bash会话永久挂死。
//
// 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到
// 拒绝后会自己换方式,而挂死时它连重试的机会都没有。
if (e?.status === 409) {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}${hint}`);
// 用 'rejected' 而不是 'denied'DSH 的 ApprovalOutcome 只认
// allowed-once / rejected / cancelled写错了它不报错而是当成未知值处理。
return 'rejected';
}
// 其余失败网络抖动、Gateway 重启)是暂时的,交给下一个 answerer本地 UI
console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
return next();
}
console.error(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
// 等人类决策DSH 撤销询问signal abort时结算为 cancelled。
return new Promise<string>((resolve) => {
pendingApprovals.set(relayKey, { resolve, sessionId: agentId });
req.signal?.addEventListener('abort', () => {
if (pendingApprovals.delete(relayKey)) resolve('cancelled');
}, { once: true });
});
});
/** 人类决策回来:先看是不是在等的那条 approval否则当普通通知投给会话。 */
function handlePermissionDecision(data: any): void {
const relayKey = String(data?.relay_key ?? '');
const pending = relayKey ? pendingApprovals.get(relayKey) : undefined;
if (pending) {
pendingApprovals.delete(relayKey);
// AgentMail 的选项文本 → DSH 的 ApprovalOutcome。
// 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。
//
// **DSH 不提供「一直同意」**:它的 ApprovalOutcome 只有
// allowed-once / rejected / cancelled / unavailable没有 always 语义
// (见 @deepseek-ai/dsh-user-approval 的类型定义)。桥自己记免批的话,
// DSH 侧仍会每次调 approval/request而桥直接答 allowed-once ——
// 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。
// 因此这里的选项只有两个(见上面的 optionsisApproval 就够用。
const decision = String(data?.decision ?? '');
const outcome = isApproval(decision) ? 'allowed-once' : 'rejected';
pending.resolve(outcome);
ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`);
return;
}
// 没在等(插件重启后丢了 pendingApprovals或历史数据→ 当一封通知投进会话。
deliverMail(data, 'permission')
.catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 权限决策投递失败: ${e?.message || e}`));
}
// ─── 启动 SSE与 opencode-mail-bridge 相同的事件处理)───
ctx.effect(() => {
startSSE((type, data) => {
switch (type) {
case 'new_mail':
if (data?.mail_id) deliveredMails.add(data.mail_id);
deliverMail(data, 'mail')
.then(({ sessionID, reused }) => {
console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`);
})
.catch((e: any) => {
ctx.logger.error(`[dsh-mail-bridge] ${type} 处理失败: ${e?.message || e}`);
});
break;
case 'permission_decision':
handlePermissionDecision(data);
break;
}
});
return () => {
sseAbort?.abort();
sseAbort = null;
// 拆插件时没人再能回答待决询问,一律 fail closed
// 否则 DSH 侧那些 await 永远不会返回。
for (const [key, pending] of pendingApprovals) {
pending.resolve('unavailable');
pendingApprovals.delete(key);
}
};
}, 'dsh-mail-bridge.sse');
}