Files
MailUI4Agents/plugins/dsh-mail-bridge/src/index.ts
JianFeeeee be9f46cf73 dsh: resume 续谈也挂载 standard preset —— 修复旧会话没有文件工具
根因:startAgent 的 resume 分支 setup: undefined,注释说
「resume 从磁盘恢复,工具已在」—— 实际上工具是通过 setup 回调
presets.mount 注册的,resume 不传 setup 就没有任何文件工具。
presets.mount 修复之前创建的旧会话(setup:undefined 时代)从此
没有 read/write/edit/bash/glob/grep,用户反馈「dsh 无法看到工作区文件」。

修复:把 presets.mount 抽成 setupPreset 函数,create 与 resume 共用。
resume 恢复的是会话历史,不是工具注册 —— 两者必须都挂。

实测:旧会话 318f0703 resume 续谈后 setup 回调触发、mount 成功,
模型用 glob 列出工作区文件、read 读取、write/edit 可写,完整回复。
2026-09-07 06:31:19 +08:00

1786 lines
82 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 {
autoRelayDecision,
replyInstruction,
inboundHeadline,
} from '../lib/relay-policy.js';
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.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 相同结构)───
//
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
// 上限淘汰(兜底)。没有它们,插件跟着 dsh 跑几周之后表里会躺着几十万条再也
// 不会被查到的条目,而 GC 收不掉(还被强引用着)。
const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS);
const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS);
// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号:
// 前者决定要不要自动转发Agent 间不转,见 lib/relay-policy.js
// 后者决定提示词说「新任务」还是「你上封信的回复到了」。
const mailContexts = new BoundedMap<string, {
replyTo: string; subject: string; mailID: string;
fromHuman: boolean; inReplyTo: string;
}>(MAX_TRACKED_SESSIONS);
const relayedSummaries = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels: { provider: string; model: string }[] = [];
const syncedTitles = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
/**
* 会话归档 → 忘掉它的全部映射。
*
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有新邮件
* 投进来,`agent/status` 也不该再把总结转回去(会话已经收不了信)。留着这些条目
* 只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
*
* DSH 侧那个 agent **不 dispose**:人可能还在界面上看它,而且它正在跑的那一轮
* 不该被归档打断。这里只解除邮件绑定。
*/
function forgetSession(mailSessionID: string): void {
if (!mailSessionID) return;
// peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。
const bound = sessionMap.peek(mailSessionID);
mailContexts.delete(mailSessionID);
sessionMap.delete(mailSessionID);
const dshSessionId = bound?.dshSessionId;
if (!dshSessionId) return;
reverseMap.delete(dshSessionId);
mailDrivenSessions.delete(dshSessionId);
relayedSummaries.delete(dshSessionId);
syncedTitles.delete(dshSessionId);
}
// 权限询问DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
// 人类决策通过 SSE 回来后再 resolve 这个 promise让 DSH 自己恢复执行。
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id
// 而同一个 callId 的同一个工具只会问一次。
//
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。静默淘汰
// 一条会让 DSH 侧那个 `await` 永远不返回 —— 那次工具调用直接挂死。它有确定的
// 清理路径(决策到达 / 询问被 abort / 拆插件时 fail closed不需要靠猜。
interface PendingApproval {
resolve: (outcome: string) => void;
sessionId: string;
}
const pendingApprovals = new Map<string, PendingApproval>();
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
//
// 为什么需要这张表DSH 把 `approval/request` 的返回值翻译成模型可见文本时
// 用的是 **dsh-tools 里写死的句子**`node_modules/@deepseek-ai/dsh-tools/lib/index.js`
//
// case "rejected": reason = `the user rejected tool "${exec.name}"`
// case "unavailable": reason = `... no approval channel is available`
//
// 于是插件回 'rejected' 时模型看到的是「the user rejected tool bash」——
// 而实际上**没有任何用户拒绝它**,是「这条链上没有人类可问」或「转发遇到
// 4xx 永久失败」。服务端给的 suggestion换不需要权限的方式 / 在回信里请上游
// 转达)根本没有出口,只进了 journalctl。模型得到的信息既是错的
// 也不含任何可行动的提示 —— 它只会以为人在拒绝它,而不会改道。
//
// pi`{block:true, reason}`)与 opencode`output.reason`)的 reason 直达模型,
// 只有 DSH 把它吞了。出路是 `tools/post-execute`:门禁拒绝的调用**也会**走
// post-execute源码里 `{kind:"post-result"}` → finalizeScheduledExecution
// → postExecute而 `{kind:'block', feedback}` 能换掉模型看到的内容。
interface DeniedReason {
text: string;
at: number;
}
const deniedReasons = new Map<string, DeniedReason>();
/** 10 分钟前的条目不可能还有对应的 post-execute清掉以免无限增长。 */
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
function denialKey(agentId: string, callId: unknown): string {
return `${agentId}:${String(callId ?? 'nocall')}`;
}
function noteDenial(agentId: string, callId: unknown, text: string): void {
const now = Date.now();
for (const [k, v] of deniedReasons) {
if (now - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
}
deniedReasons.set(denialKey(agentId, callId), { text, at: now });
}
function takeDenial(agentId: string, callId: unknown): string | undefined {
const key = denialKey(agentId, callId);
const hit = deniedReasons.get(key);
if (!hit) return undefined;
deniedReasons.delete(key); // 一次性:同一次调用只能被换一次
if (Date.now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
return hit.text;
}
// ─── 运行时导入 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 推一次 —— 不去重就会投两遍。
//
// 有界:插件跟着 dsh 长期活着,这里会攒下每一封处理过的邮件 id 而永远没有
// 出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
const deliveredMails = new BoundedSet<string>(MAX_TRACKED_MAILS);
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> = {};
// DSH 有真沙箱read-only / workspace-write / danger-full-access
// 档位在这里是被强制执行的,不是 advisory。
body.mode_enforcement = 'native';
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 } : {};
// 注册 standard preset 的工具bash、fs、fs-search 等)。
// dsh-a2a 用同一套 APIctx.get('agentPresets') + presets.mount并稳定运行。
//
// create 与 resume 都必须挂resume 从磁盘恢复的是**会话历史**
// 不是工具注册 —— 工具是 setup 回调里 mount 的resume 不传 setup
// 就等于回到 setup:undefined 时代(邮件会话没有任何文件工具)。
const setupPreset = async (agentCtx: any) => {
console.error(`[dsh-mail-bridge] setup 回调触发agentCtx keys=${JSON.stringify(Object.keys(agentCtx || {}))}`);
try {
const presets = ctx.get('agentPresets');
console.error(`[dsh-mail-bridge] agentPresets: ${presets ? '存在' : '不存在'}`);
if (presets) {
const preset = await presets.resolve('standard');
console.error(`[dsh-mail-bridge] standard preset: id=${preset?.id} keys=${JSON.stringify(Object.keys(preset || {}))}`);
if (preset?.id) {
await presets.mount(agentCtx, preset.id);
console.error(`[dsh-mail-bridge] presets.mount 成功`);
} else {
console.error(`[dsh-mail-bridge] standard preset 无 id跳过 mount`);
}
}
} catch (e: any) {
console.error(`[dsh-mail-bridge] presets.mount 失败(工具注册降级): ${e?.message || e}\n${e?.stack?.slice(0,300)}`);
}
};
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: setupPreset,
});
return { handle, resumed: true };
}
const handle = await ctx.agents.create({
sessionId,
meta: { cwd },
agentOptions,
setup: setupPreset,
});
return { handle, resumed: false };
}
// ─── 权限档位 ───
//
// DSH 的权限由两个独立旋钮控制:
// sandbox/mode → 控制文件系统和命令执行的边界
// approval/policy → 控制是否需要人类审批
//
// 三档映射(见 lib/permission-mode.js dshSandboxMode / dshApprovalPolicy
// plan → read-only + ask只读遇到权限询问转邮件
// workspace → workspace-write + ask目录内可写越界转邮件
// full → danger-full-access + never完全放开不问人
//
// 直接往 session 上 append 事件(与 permissionPresets.set 同一底层)。
// 不走 permissionPresets 服务:它要求 preset 名在配置表里,
// 而我们的三档映射需要完全自主控制。
function applyPermissionMode(session: any, mode: string): void {
if (!session?.append) return;
const m = (mode || 'workspace').trim();
// sandbox mode
const sandboxMap: Record<string, string> = {
plan: 'read-only',
workspace: 'workspace-write',
full: 'danger-full-access',
};
const sandbox = sandboxMap[m] ?? 'workspace-write';
session.append('sandbox/mode', { mode: sandbox });
// approval policy
const approvalMap: Record<string, string> = {
plan: 'ask',
workspace: 'ask',
full: 'never',
};
const approval = approvalMap[m] ?? 'ask';
session.append('approval/policy', { policy: approval });
}
// ─── 投递邮件到 DSH 会话 ───
/**
* 投进「被接管的平台会话」时给模型的提示词。
*
* 与新建会话那份的差别:不自我介绍身份、不解释邮件系统 —— 这条会话里人已经
* 在谈别的事了,一段「你是 dsh你收到一封邮件」的开场白会让模型以为上下文
* 被重置。只说「有封邮件进来了」。
*/
function adoptPrompt(data: any, kind: string): string {
if (kind === 'permission') {
return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`;
}
const fromHuman = data.from_human === true;
return [
inboundHeadline({ inReplyTo: data.in_reply_to, fromHuman, reused: true }),
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
...replyInstruction({ fromHuman, replyAddress: data.reply_address }),
].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 || '',
fromHuman: data.from_human === true,
inReplyTo: data.in_reply_to || '',
});
}
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 界面上看不到这封邮件带来的对话而那正是接管的目的N-8
//
// 用 console.error 而不是仅靠抛异常:调用方那层的 catch 走
// `ctx.logger.error`,而 DSH 的 logger **不进 journalctl**。邮件因此会
// 静默消失:发件人以为送到了,而日志里一个字都没有(实测过)。
console.error(`[dsh-mail-bridge] 接管失败:会话 ${adoptedID} 不在本机磁盘上`
+ `mail ${data.mail_id},发件人 ${data.from_name || '?'})。`
+ `若这个 id 属于另一个平台,说明 Gateway 把别人的 platform_session_id `
+ `推给了本插件。`);
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]);
applyPermissionMode(handle.agent?.session, data.permission_mode);
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 () => {
// 回信上下文必须刷成**这一封**。
//
// 续谈分支原来不刷mailContexts 只在 bindAdopted 与新开会话时写一次,
// 于是同一条会话的第二封邮件跑完后,自动转发用的还是**第一封**的
// subject / mailID —— 回信主题与 parent_mail_id 都指向上一封。
// 实测撞出过:回「全流程回归」那封的信,主题写的是上一封
// 「platformID 归属验证」parent 也挂在那封上。
//
// 与 pi 桥的语义对齐:那边每封邮件都重建 mailContextworker 一封一进程),
// 注释写的就是「一个会话里可能来过多封信,只留最近那封」。
if (kind === 'mail' && mailSessionID) {
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
fromHuman: data.from_human === true,
inReplyTo: data.in_reply_to || '',
});
}
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
: [
inboundHeadline({
inReplyTo: data.in_reply_to,
fromHuman: data.from_human === true,
reused: true,
}),
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
...replyInstruction({
fromHuman: data.from_human === true,
replyAddress: data.reply_address,
}),
].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 || '用户'})。请据此继续。`
: [
inboundHeadline({
inReplyTo: data.in_reply_to,
fromHuman: data.from_human === true,
catchup: data.catchup,
reused: false,
}),
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
`身份:你是 ${AGENT_NAME}`,
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
``,
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
``,
...replyInstruction({
fromHuman: data.from_human === true,
replyAddress: data.reply_address,
}),
].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;
// 设定权限档位sandbox/mode 决定文件与命令边界,
// approval/policy 决定越界时是否转邮件问人。
applyPermissionMode(handle.agent?.session, data.permission_mode);
} 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 || '',
fromHuman: data.from_human === true,
inReplyTo: data.in_reply_to || '',
});
}
// 把会话挂进工作区注册表 —— 这是让 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: clampRelayKey(`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 || [],
from_session_id: toolCtx?.sessionID || '',
});
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);
// **只给人类来信自动转发**(见 lib/relay-policy.js
//
// 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为「我只要
// 把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮直到
// 撞上连续 relay 跳数上限。日志走 console.errorctx.logger 不进 journalctl
// 而「本轮为何没有回信」必须能查到。
const policy = autoRelayDecision({
fromHuman: mctx?.fromHuman === true,
replyTo: mctx?.replyTo,
});
if (!policy.relay) {
console.error(`[dsh-mail-bridge] 本轮不自动转发:${policy.reason}`);
return;
}
// policy.relay 为真已经蕴含 mctx?.replyTo 非空autoRelayDecision 的第一道判定),
// 但 TS 推不出那层关系 —— 显式窄一下,而不是给后面每处加非空断言。
if (!mctx) 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: clampRelayKey(`${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) 做幂等键。
// clampRelayKey 收尾callId 的长度由上游模型决定pi 侧实测过带思考签名的
// 13601 字节 id超服务端 160 字节列宽直接 400。
const relayKey = clampRelayKey(`${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}`);
// 把真正的原因存起来post-execute 会用它换掉 dsh-tools 写死的
// 「the user rejected tool X」—— 没有任何用户拒绝过它。
noteDenial(agentId, req.callId, [
e?.body?.error || `权限询问无法送达:这条任务链上没有人类用户`,
e?.body?.detail || '',
e?.body?.suggestion || '',
].filter(Boolean).join('\n'));
// 用 'rejected' 而不是 'denied'DSH 的 ApprovalOutcome 只认
// allowed-once / rejected / cancelled / unavailable写错了它不报错
// 而是归一化成 'unavailable'。
return 'rejected';
}
// 其余 4xx400 / 401 / 403 / 404 / 422…同样永远不会因重试成功
// 不能 `return next()` —— 下一个 answerer 是本地 UI而邮件驱动的会话
// 没有 UIwaterfall 跑到尾仍旧无人应答。
// pi 侧实测relay_key 过长报 400 被当暂时失败让位,
// 那条 bash 在无人批准的情况下执行了 —— fail closed 才安全。
if (isPermanentFailure(e)) {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(
`[dsh-mail-bridge] 权限询问遇到永久失败HTTP ${e?.status}),当场拒绝 ${relayKey}${hint || e?.message || ''}`,
);
noteDenial(agentId, req.callId, [
`无法把 ${req.toolName} 的授权请求送达给人类HTTP ${e?.status}${e?.body?.error || e?.message || '请求被服务端拒绝'}`,
e?.body?.detail || '',
e?.body?.suggestion || '这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
].filter(Boolean).join('\n'));
return 'rejected';
}
// 暂时失败5xx / 408 / 429 / 网络抖动)交给下一个 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 });
});
});
// 把插件主动拒绝的真正原因递给模型。
//
// DSH 将 approval/request 的 'rejected' 翻译成写死的
// `the user rejected tool "X"`dsh-tools/lib/index.js—— 而当插件因为
// 「没人可问」或「转发遇 4xx」主动拒绝时**没有任何用户拒绝过它**。
// 模型看到一句不存在的拒绝,只会以为人不同意,不会去换一条路;
// 服务端给的 suggestion换不需要权限的方式 / 在回信里请上游转达)则只进了日志。
//
// 门禁拒绝的调用也会进 post-executepre-execute 的 deny 走
// `{kind:"post-result"}` → finalizeScheduledExecution → postExecute
// 而 `{kind:'block', feedback}` 能换掉模型看到的内容 —— 这是 DSH 上
// 唯一能把真实原因送到模型眼前的口子。
//
// pi`{block:true, reason}`)与 opencode`output.reason`)的 reason 直达模型,
// 不需要这道绕行。
ctx.on('tools/post-execute', async (exec: any, result: any, next: () => Promise<any>) => {
const agentId = String(exec?.agent?.id ?? '');
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
// 只管失败的结果:成功的调用不可能是被我们拒绝的那一次。
if (!result?.isError) return next();
const reason = takeDenial(agentId, exec?.callId);
if (!reason) return next();
console.error(`[dsh-mail-bridge] 已把拒绝原因递给模型(${exec?.name}`);
return {
kind: 'block',
feedback: [{
type: 'text',
text: `无法执行 ${exec?.name}${reason}`,
}],
};
});
/** 人类决策回来:先看是不是在等的那条 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) => {
// console.error 而不是 ctx.loggerDSH 的 logger 不进 journalctl
// 而投递失败是「发件人等不到回信」的唯一线索。
console.error(`[dsh-mail-bridge] ${type} 处理失败mail ${data?.mail_id || '?'}: ${e?.message || e}`);
});
break;
case 'permission_decision':
handlePermissionDecision(data);
break;
case 'session_archived':
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
forgetSession(String(data?.session_id || ''));
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');
}