/** * 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'; import { createSSEClient } from '../lib/sse-client.js'; // 只用 isApproval:DSH 没有 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 { 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): Promise { 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 { 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 { 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(MAX_TRACKED_SESSIONS); const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号: // 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js), // 后者决定提示词说「新任务」还是「你上封信的回复到了」。 const mailContexts = new BoundedMap(MAX_TRACKED_SESSIONS); const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 // 空数组 = 不限定,回退到环境变量或平台默认。 let allowedModels: { provider: string; model: string }[] = []; const syncedTitles = new BoundedMap(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(); // 权限被插件主动拒绝时的真正原因 —— 键是 `${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(); /** 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 要求插件声明依赖的服务:没有 inject,ctx.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 持密钥主动连 // Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。 /** * 收集本机 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 { 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(); 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(MAX_TRACKED_MAILS); let caughtUp = false; /** * 补投离线期间积压的未读邮件。 * * SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。 * 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。 */ async function catchUp(pending: unknown): Promise { 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 { const body: Record = {}; // 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 { 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 { await new Promise((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>(); async function locked(dshSessionId: string, fn: () => Promise): Promise { const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve(); let release!: () => void; const next = new Promise((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-` —— 而那个 id 上一次 * 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。 * * 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验 * 不过时都会抛。三种情形里只有第一种适合 create,但后两种 resume 也一样 * 救不回来 —— 那就让 create 去报它自己的错。 */ async function persistedCwd(sessionId: string): Promise { 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 用同一套 API(ctx.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 = { 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 = { 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-` 换成平台自己那个。第一次投递走 // resume 分支装回上下文,之后与普通续谈完全一样(sessionMap 命中 → followup)。 const adoptedID = adoptedSessionID(data); if (!existing && adoptedID) { const onDisk = await persistedCwd(adoptedID); if (onDisk === undefined) { // 平台侧那条会话已不在磁盘上。 // // 不能落到「新开会话」那条路 —— 那会用 `mail-` 另开一条, // 人在 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 桥的语义对齐:那边每封邮件都重建 mailContext(worker 一封一进程), // 注释写的就是「一个会话里可能来过多封信,只留最近那封」。 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-` —— 每封邮件一个全新的空目录。 // 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; } // 先建映射再 followup:turn 可能在 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-`);而 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 { // 真实 cwd:create 路径下就是上面传的 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 才进 journalctl(ctx.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 监听 ─── // // 共用客户端(lib/sse-client.js):跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。 // 之前手写的解析把 evt/data 当 read() 的局部变量,一帧被切在两个 chunk 就整帧 // 静默丢弃(new_mail / permission_decision / session_update 都可能丢);也不带 // Last-Event-ID,断线期间的事件回放不出来。 let sseClient: { stop: () => void } | null = null; function startSSE(onEvent: (type: string, data: any) => void) { sseClient?.stop?.(); sseClient = createSSEClient({ authHeaders: () => client.authHeaders(), baseURL: GW.replace(/\/+$/, ''), path: '/api/v1/events/stream', onEvent, log: (msg: string) => console.error(`[dsh-mail-bridge] ${msg}`), }); return sseClient; } // ─── 注册模型工具 ─── 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 { // 改名提议以 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 { 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 { // 先 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 拆插件时重复注销(已经卸载 / 未注册过)不影响 teardown,忽略即可 } } }; }, '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.error:ctx.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) { // 模型 idle 但没有任何 assistant 文本:空回复/被中断在文本前。 // 此前静默返回 —— 发件人等不到任何回复(缺的是 B-6 的「模型全部失败」 // 同款兜底,只是这次没有失败可报却也没有产出)。给发件人一封失败通知。 // 只给人类来信发:Agent 之间不自动转发(与上面同一判据)。 try { await client.post('/mail/send', { to: mctx.replyTo, subject: `处理失败: ${mctx.subject || '(无主题)'}`, body: [ `这封邮件(主题:${mctx.subject || '(无主题)'})的处理轮次已结束,但没有产出任何回复文本。`, '', '可能原因:模型空响应、输出被中断、或工具调用异常耗尽轮次。', '请检查后重试。', ].join('\n'), reply_to: mctx.mailID || '', relay: 'summary', relay_key: clampRelayKey(`empty-reply:${mailSessionID}`), }); console.error(`[dsh-mail-bridge] 已发空回复失败通知给 ${mctx.replyTo}`); } catch (e: any) { console.error(`[dsh-mail-bridge] 空回复失败通知也发不出去: ${e?.message || e}`); } 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 就是它要的,因此可以真的等人。 ctx.on('approval/request', async (req: any, next: () => Promise) => { 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,而邮件驱动的会话 // 根本没有 UI,waterfall 跑到尾以后依旧无人应答 —— 这正是生产事故 // 的形状: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'; } // 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功, // 不能 `return next()` —— 下一个 answerer 是本地 UI,而邮件驱动的会话 // 没有 UI,waterfall 跑到尾仍旧无人应答。 // 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((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-execute(pre-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) => { 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 —— // 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。 // 因此这里的选项只有两个(见上面的 options),isApproval 就够用。 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.logger:DSH 的 logger 不进 journalctl, // 而投递失败是「发件人等不到回信」的唯一线索。 console.error(`[dsh-mail-bridge] ${type} 处理失败(mail ${data?.mail_id || '?'}): ${e?.message || e}`); }); break; case 'permission_decision': handlePermissionDecision(data); break; case 'session_update': { // 人类在 WebUI 切换权限档位后,DSH 运行时必须立刻更新 sandbox/mode + // approval/policy,否则模型仍按旧档位执行。 const sid = String(data?.session_id || ''); const pm = String(data?.permission_mode || ''); if (sid && pm) { const bound = sessionMap.peek(sid); if (bound) { const live = ctx.agents.get(bound.dshSessionId); if (live?.session) { applyPermissionMode(live.session, pm); console.error(`[dsh-mail-bridge] session_update ${sid} -> 权限档位 ${pm}`); } } } break; } case 'session_archived': // 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。 forgetSession(String(data?.session_id || '')); break; } }); return () => { sseClient?.stop?.(); sseClient = null; // 拆插件时没人再能回答待决询问,一律 fail closed, // 否则 DSH 侧那些 await 永远不会返回。 for (const [key, pending] of pendingApprovals) { pending.resolve('unavailable'); pendingApprovals.delete(key); } }; }, 'dsh-mail-bridge.sse'); }