import { z } from "zod"; import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; import { join, dirname, basename } from "node:path"; const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180"; const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode"; // 收到邮件后自动开会话处理时使用的模型 const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || "llmsproxy"; const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || "AUTO"; // ─── 凭证 ─── // // 优先用 Agent 密钥(Authorization: Bearer)。密钥来源按优先级: // 1. AGENTMAIL_AGENT_KEY 环境变量(systemd 部署走这条) // 2. ~/.agentmail/agent.key(首次安装时本地生成并落盘) // 没有密钥时退回旧的 name/secret 方式,保证老配置不被这次改动打断。 const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail"); const KEY_FILE = join(CONFIG_DIR, "agent.key"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || ""; /** 读取本地密钥文件;不存在或损坏时返回 null。 */ function readLocalKey() { try { if (!existsSync(KEY_FILE)) return null; const raw = JSON.parse(readFileSync(KEY_FILE, "utf8")); return typeof raw?.key_token === "string" && raw.key_token ? raw.key_token : null; } catch { return null; } } /** 首次安装时本地生成密钥并落盘(0600)。 */ function generateLocalKey() { const token = randomBytes(32).toString("hex"); mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync( KEY_FILE, JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), { mode: 0o600 } ); console.error(`[mail-bridge] 已在 ${KEY_FILE} 生成本地密钥。`); console.error(`[mail-bridge] 该密钥需管理员在 AgentMail 后台登记后才能接入:`); console.error(`[mail-bridge] ${token}`); return token; } /** 把 gateway 地址与密钥记到 config.json,便于换机时人工核对。 */ function saveConfig(extra) { try { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); let cur = {}; if (existsSync(CONFIG_FILE)) { try { cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { /* 损坏就重写 */ } } writeFileSync( CONFIG_FILE, JSON.stringify({ ...cur, gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, ...extra }, null, 2), { mode: 0o600 } ); } catch (e) { console.error("[mail-bridge] 写 config.json 失败:", e?.message || e); } } // 当前生效的密钥:环境变量 > 本地文件 > 无(退回 name/secret) let AGENT_KEY = process.env.AGENTMAIL_AGENT_KEY || readLocalKey() || ""; /** 认证头:有密钥走 Bearer,否则退回 name/secret。 */ function authHeaders() { if (AGENT_KEY) { return { Authorization: `Bearer ${AGENT_KEY}`, "X-Agent-Name": AGENT_NAME }; } return { "X-Agent-Name": AGENT_NAME, "X-Agent-Secret": AGENT_SECRET }; } // ─── HTTP ─── async function apiGet(path) { const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() }); if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); return res.json(); } async function apiPost(path, body) { const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `POST ${path} failed: ${res.status}`); return data; } // 把 opencode 侧的会话标题/slug 回写到 AgentMail。 // opencode 会在首轮对话后由模型生成会话标题,并配一个短 slug(如 jolly-cactus)—— // 不在 AgentMail 侧另造一套命名,平台那边叫什么,这边的 session_alias 就叫什么。 async function syncSessionNaming(mailSessionID, { alias, title }) { if (!mailSessionID) return null; if (!alias && !title) return null; try { const res = await apiPost(`/sessions/${mailSessionID}/sync`, { alias: alias || "", title: title || "", }); return res; } catch (e) { // 同步是后台润色,失败不该影响邮件主流程,但必须留痕 console.error("[mail-bridge] 会话命名同步失败:", e?.message || e); return null; } } // ─── Tools(直接用 zod 定义,不依赖 @opencode-ai/plugin) ─── const sendMailTool = { description: "发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建会话,.具体别名 必须是已存在的会话(否则无法送达)。回复来信请传 reply_to。", args: { to: z.string().describe("收件人:name / name@path(默认会话)/ name@path.new(新建)/ name@path.别名(已有会话)"), subject: z.string().describe("邮件主题"), body: z.string().describe("邮件正文(Markdown)"), cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"), reply_to: z.string().optional().describe("回复某封邮件时传其 mail_id,回信会落回同一会话"), session_alias: z.string().optional().describe("仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈。别名全局唯一,不可含 . / @ 空白,不可为 new"), attachment_ids: z.array(z.string()).optional().describe("附件 ID 列表,先用 upload_attachment 上传取得"), propose_alias: z.string() .optional() .describe( "建议把当前会话改名成这个别名(例如摸清问题后从 witty-planet 改成 fix-login-leak)。" + "这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new" ), propose_reason: z.string().optional().describe("改名理由,一句话,展示给用户看"), }, async execute(args) { // 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 // 选注释而不是自造标记:react-markdown 不解析 raw HTML, // 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。 let body = args.body; if (args.propose_alias) { const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符 const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : ""; body += `\n\n`; } const result = await apiPost("/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 || [], }); const alias = result.session_alias ? `,会话别名 ${result.session_alias}(续谈可用 ${args.to.split(".")[0]}.${result.session_alias})` : ""; // 配额剩余必须回给模型:不然它只能撞到 403 才知道额度用完, // 那时已经没有配额发最终总结了。 const quota = typeof result.quota_remaining === "number" ? `\n发信配额剩余 ${result.quota_remaining}/${result.quota_max}。` + (result.quota_remaining <= 1 ? "配额即将用尽,请尽快向人类发送最终总结。" : "") : ""; // 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过 const proposed = result.rename_proposed ? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。` : ""; return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${quota}${proposed}`; }, }; const forwardMailTool = { description: "转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。" + "只能转发自己参与过的邮件。", args: { mail_id: z.string().describe("要转发的邮件 ID(从 read_inbox 获得)"), to: z.string().describe("新收件人的三维地址"), comment: z.string().optional().describe("转发说明,置于引用原文之前"), cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"), subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"), session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"), }, async execute(args) { const result = await apiPost(`/mail/${args.mail_id}/forward`, { to: args.to, comment: args.comment || "", cc: args.cc || "", subject: args.subject || "", session_alias: args.session_alias || "", }); return `已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`; }, }; const readInboxTool = { description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。", args: { filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"), limit: z.number().optional().describe("返回数量,默认 5"), }, async execute(args) { const filter = args.filter || "unread"; const limit = args.limit || 5; const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`); if (!data.mails || data.mails.length === 0) return "收件箱为空。"; return data.mails.map((m) => { const lines = [ `[${m.status}] ${m.from_name}: ${m.subject}`, `邮件 ID: ${m.mail_id}`, `会话: #${m.session_alias || "未命名"}`, ]; // 必须把 attachment_id 一起给出:不然模型知道「有附件」却无从下载 if (m.attachments?.length) { lines.push( "附件: " + m.attachments .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) .join("、") ); lines.push("下载附件请用 download_attachment 工具。"); } lines.push(`内容: ${(m.body_preview || m.body || "").substring(0, 200)}`); return lines.join("\n"); }).join("\n\n"); }, }; /** 人类可读的字节数,用于附件清单展示。 */ function formatSize(n) { if (typeof n !== "number") return "?"; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const uploadAttachmentTool = { description: "上传本地文件作为邮件附件,返回 attachment_id。" + "拿到 id 后在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。" + "未随邮件发出的附件 24 小时后自动清理。", args: { path: z.string().describe("要上传的本地文件绝对路径"), filename: z.string().optional().describe("自定义展示文件名,默认取路径的最后一段"), }, async execute(args) { const filePath = args.path; let stat; try { stat = statSync(filePath); } catch { return `文件不存在或不可读: ${filePath}`; } if (!stat.isFile()) return `不是普通文件: ${filePath}`; const name = args.filename || basename(filePath); // Node 的 Blob 需要完整读入内存。附件上限 25MB,一次性读入可接受; // 若将来放宽上限,这里要换成流式 multipart。 const buf = readFileSync(filePath); const form = new FormData(); form.append("file", new Blob([buf]), name); const res = await fetch(`${GATEWAY_URL}/api/v1/attachments`, { method: "POST", headers: authHeaders(), // 不设 Content-Type,交给 FormData 自己带 boundary body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `上传失败: ${res.status}`); const a = data.attachment; return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`; }, }; const downloadAttachmentTool = { description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。", args: { attachment_id: z.string().describe("附件 ID"), save_to: z.string().describe("保存到的本地绝对路径"), }, async execute(args) { const res = await fetch(`${GATEWAY_URL}/api/v1/attachments/${args.attachment_id}`, { headers: authHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `下载失败: ${res.status}`); } const buf = Buffer.from(await res.arrayBuffer()); mkdirSync(dirname(args.save_to), { recursive: true }); writeFileSync(args.save_to, buf); return `已保存到 ${args.save_to}(${formatSize(buf.length)})`; }, }; // 平台原生权限询问 → 邮件。 // // **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 / // permission.updated 事件),模型该做的是正常调工具,由 harness 决定要不要问人。 // 让模型主动调一个 request_permission 工具是把 harness 的职责推给模型 —— // 它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。 // // relay_key 用 opencode 的 permission.id 做幂等键:permission.updated 会重复触发, // 插件重连也会重放,没有它同一次询问会生成好几封邮件。 async function relayPermission({ question, options, context, relayKey }) { return apiPost("/permission/request", { question, options: options && options.length ? options : ["同意", "拒绝"], context: context || "", relay_key: relayKey || "", }); } const connectToServerTool = { description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" + "密钥若未在后台登记过,此处会返回需要登记的密钥全文。", args: { gateway_url: z.string().optional().describe("Gateway 地址,如 https://mail.example.com;省略则用当前配置"), key_token: z.string().optional().describe("管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)"), }, async execute(args) { if (args.key_token) { AGENT_KEY = args.key_token.trim(); // 管理员给的密钥落盘,重启后仍然可用 mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync( KEY_FILE, JSON.stringify({ key_token: AGENT_KEY, created_at: new Date().toISOString() }, null, 2), { mode: 0o600 } ); } else if (!AGENT_KEY) { AGENT_KEY = generateLocalKey(); } const url = (args.gateway_url || GATEWAY_URL).replace(/\/+$/, ""); const res = await fetch(`${url}/api/v1/agent/register`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${AGENT_KEY}` }, body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: "opencode" }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { // 密钥没登记是最常见的失败,直接把要登记的值给出来,省一轮来回 return [ `连接失败(HTTP ${res.status}):${data.error || "未知错误"}`, ``, `若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`, AGENT_KEY, ``, `密钥文件:${KEY_FILE}`, ].join("\n"); } saveConfig({ gateway_url: url, registered_at: new Date().toISOString() }); return `已连接 ${url},注册为 ${data.agent_name || AGENT_NAME}。`; }, }; // ─── SSE ─── let sseAbort = null; function startSSE(onEvent) { if (sseAbort) sseAbort.abort(); sseAbort = new AbortController(); const reconnect = () => { if (sseAbort?.signal.aborted) return; fetch(`${GATEWAY_URL}/api/v1/events/stream`, { headers: authHeaders(), signal: sseAbort.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(); } // ─── Plugin ─── // AgentMail 会话 ↔ opencode 会话的绑定。 // 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定, // 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。 const sessionMap = new Map(); // agentmail session_id -> opencode session id const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名) const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) // 权限询问的双向定位。 // // opencode 的 permission.id 与 AgentMail 的 mail_id 是两个 id 空间: // 转出去时要记住 permission 属于哪个会话(回信地址从那里来), // 人类决策回来时要用 permission.id 去回复 opencode。 // 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。 const pendingPermissions = new Map(); // permission.id -> { sessionID, callID } // 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。 // 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。 const relayedSummaries = new Map(); // opencode session id -> assistant message id // 收到邮件后建立的会话,才需要在 idle 时把总结转回去。 // 用户在 TUI 里自己开的会话不该被搬进邮件系统。 const mailDrivenSessions = new Set(); // opencode session id async function resolveSessionForMail(client, directory, data, kind) { const mailSessionID = data.session_id; const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined; if (bound) return { sessionID: bound, reused: true }; // 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题, // 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。 const created = await client.session.create({ query: directory ? { directory } : undefined, }); const session = created?.data ?? created; const sessionID = session?.id; if (!sessionID) throw new Error("session.create 未返回 id"); if (mailSessionID) { sessionMap.set(mailSessionID, sessionID); reverseMap.set(sessionID, mailSessionID); mailDrivenSessions.add(sessionID); // slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写; // 标题要等模型生成,走 session.updated 事件。 if (session.slug) { syncSessionNaming(mailSessionID, { alias: session.slug }).then((res) => { if (res?.alias) console.error(`[mail-bridge] 别名同步 ${sessionID} -> ${res.alias}`); }); } } return { sessionID, reused: false }; } // 把本轮的最终总结转成邮件发回给对方。 // // **不消耗配额**(基本原则:配额约束的是模型的自主发信,不是 harness 的转发): // 模型已经把话说完了,插件只是把它搬到邮件里。对搬运收费会导致配额用尽时 // Agent 连交代都做不了 —— 而那正是最需要它说话的时刻。 // // 触发点是 session.idle:opencode 在一轮跑完(不再有工具调用与生成)时发这个事件, // 此刻的最后一条 assistant 消息就是本轮结论。 // 不用 message.updated:那会在流式生成过程中反复触发,转出去的是半截话。 async function relaySummary(client, directory, sessionID) { const mailSessionID = reverseMap.get(sessionID); if (!mailSessionID) return null; // 不是邮件驱动的会话,不碰 if (!mailDrivenSessions.has(sessionID)) return null; // 取最后一条 assistant 文本消息 const listed = await client.session.messages({ path: { id: sessionID }, query: directory ? { directory } : undefined, }); const msgs = listed?.data ?? listed ?? []; let last = null; for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i]; if (m?.info?.role !== "assistant") continue; // 未完成的消息(还在生成/被中断)不转:转出去是半截话 if (!m.info.time?.completed) continue; const text = (m.parts || []) .filter(p => p.type === "text" && !p.synthetic && !p.ignored && p.text) .map(p => p.text) .join("\n") .trim(); if (text) { last = { id: m.info.id, text }; } break; } if (!last) return null; // 本地去重(服务端另有 relay_key 幂等兜底,这里只是少打一次网关) if (relayedSummaries.get(sessionID) === last.id) return null; // 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。 const ctx = mailContexts.get(mailSessionID); if (!ctx?.replyTo) return null; const res = await apiPost("/mail/send", { to: ctx.replyTo, subject: ctx.subject ? `Re: ${stripRe(ctx.subject)}` : "本轮工作总结", body: last.text, reply_to: ctx.mailID || "", // relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次 relay: "summary", relay_key: last.id, }); relayedSummaries.set(sessionID, last.id); return res; } /** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */ function stripRe(subject) { return String(subject).replace(/^(\s*Re:\s*)+/i, ""); } // 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。 // 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。 const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID } // relaySummary 需要 client/directory,而 event 钩子拿不到它们 // (只在插件初始化时给一次)。插件启动时把它们闭包进来。 let relaySummaryRef = async () => null; // 人类决策回来 → 回复 opencode 的原生权限询问。 // // 决策语义映射回 opencode 的三态: // 同意 → once (仅这一次) // 一直同意 → always (后续同类不再问) // 拒绝 → reject // // relay_key(= opencode 的 permission.id)由服务端随决策事件回传, // 所以插件重启丢了 pendingPermissions 也能续上 —— 这个映射不能只存在内存里。 async function replyPermission(client, directory, data) { const permID = data.relay_key || ""; if (!permID) { // 没有上游 id 说明这条权限请求不是插件转发的(例如模型直接调过老的 // request_permission,或历史数据)。此时没有可回复的 opencode permission, // 只能把结论作为一段话送进会话。 return deliverMail(client, directory, data, "permission"); } const pending = pendingPermissions.get(permID); const sessionID = pending?.sessionID || sessionMap.get(data.session_id || ""); if (!sessionID) { console.error(`[mail-bridge] 权限 ${permID} 找不到对应会话,跳过`); return null; } const decision = String(data.decision || ""); const response = decision === "一直同意" || decision === "always" ? "always" : decision === "拒绝" || decision === "reject" ? "reject" : "once"; await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: permID }, query: directory ? { directory } : undefined, body: { response }, }); pendingPermissions.delete(permID); console.error(`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`); return { sessionID }; } // 把一封来信投递给对应的 opencode 会话(已绑定则续谈,未绑定则新开)。 async function deliverMail(client, directory, data, kind) { const { sessionID, reused } = await resolveSessionForMail(client, directory, data, kind); // 记住这轮该回给谁:idle 时 relaySummary 靠它决定收件人与 reply_to。 // 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。 if (kind === "mail" && data.session_id) { mailContexts.set(data.session_id, { replyTo: data.from_name || "", subject: data.subject || "", mailID: data.mail_id || "", }); } const text = kind === "permission" ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。` : [ reused ? `本会话收到一封新邮件(AgentMail 续谈)。` : `你收到一封新邮件(AgentMail)。`, ``, `发件人:${data.from_name || "unknown"}`, `主题:${data.subject || "(无主题)"}`, `邮件 ID:${data.mail_id || "unknown"}`, `身份:你是 ${AGENT_NAME}`, ``, `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, ``, `**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`, `插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || "发件人"}(不消耗你的发信配额)。`, `只有在需要主动联系其他人、或要带附件时才调用 send_mail。`, ].join("\n"); await client.session.promptAsync({ path: { id: sessionID }, query: directory ? { directory } : undefined, body: { model: { providerID: REPLY_PROVIDER, modelID: REPLY_MODEL }, parts: [{ type: "text", text }], }, }); return { sessionID, reused }; } export default async function mailBridge(input) { const { client, directory } = input; // 注册 Agent。无密钥也无 secret 时先本地生成一把密钥, // 等管理员在后台登记后即可接入(无需重装插件)。 if (!AGENT_KEY && !AGENT_SECRET) { AGENT_KEY = generateLocalKey(); } try { await apiPost("/agent/register", { name: AGENT_NAME, secret: AGENT_KEY ? "" : AGENT_SECRET, workspaces: [], platform: "opencode", }); saveConfig({ registered_at: new Date().toISOString() }); console.error( `[mail-bridge] 已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}` + `(${AGENT_KEY ? "密钥认证" : "name/secret 认证"})。` ); } catch (e) { // 密钥未登记时这里会报「密钥无效」——必须说清楚该做什么, // 否则用户只看到一句 401 不知道要拿密钥去后台登记。 console.error("[mail-bridge] 注册失败:", e?.message); if (AGENT_KEY) { console.error(`[mail-bridge] 若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥(见 ${KEY_FILE})。`); } } // event 钩子里拿不到 client/directory(它们只在插件初始化时给), // 所以用一个闭包把 relaySummary 需要的两个参数固定下来。 relaySummaryRef = (sid) => relaySummary(client, directory, sid); // 心跳。响应带回配额,配额将要用尽时留一条日志。 // 注意插件代劳的转发(权限询问、最终总结)不占配额, // 所以这条警告只关系到模型主动调 send_mail 的次数。 let lastQuotaWarn = -1; const heartbeat = setInterval(() => { apiPost("/agent/heartbeat", {}) .then(res => { const q = res?.quota; if (!q || q.unlimited) return; if (q.remaining <= 2 && q.remaining !== lastQuotaWarn) { lastQuotaWarn = q.remaining; console.error( `[mail-bridge] 主动发信配额剩余 ${q.remaining}/${q.max_rounds}` + `(自动转发的总结与权限询问不占配额)。` ); } }) .catch(() => {}); }, 30000); startSSE((type, data) => { // 人类决策了一条权限请求 → 回复 opencode 的原生 permission,让它自己恢复执行。 // 这条路径不走 deliverMail:opencode 的权限机制会在收到回复后继续原来的工具调用, // 再往会话里塞一段「你的请求已批准」的文字只会干扰它。 if (type === "permission_decision") { replyPermission(client, directory, data).catch((e) => { console.error("[mail-bridge] 权限决策回传失败:", e?.message || e); }); return; } if (type !== "new_mail") return; deliverMail(client, directory, data, "mail") .then(({ sessionID, reused }) => { console.error(`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`); }) .catch((e) => { // 失败必须可见,否则邮件会静默丢失 console.error(`[mail-bridge] ${type} 处理失败:`, e?.message || e); }); }); process.on("SIGINT", () => { clearInterval(heartbeat); if (sseAbort) sseAbort.abort(); }); return { // 平台原生的权限询问 → 转成邮件问人。 // // 这是 harness 的职责,不该让模型自己调一个 request_permission 工具: // 模型可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。 // // 钩子里只**记下**待决策项并转出邮件,status 保持 "ask" —— 不在这里阻塞等人回复: // permission.ask 是同步钩子,卡在这里会把整个 opencode 请求挂住。 // 人类决策通过 SSE 回来后,再用 SDK 回复这条 permission。 async "permission.ask"(input, output) { if (!mailDrivenSessions.has(input.sessionID)) return; // 非邮件驱动的会话不接管 const mailSessionID = reverseMap.get(input.sessionID); if (!mailSessionID) return; pendingPermissions.set(input.id, { sessionID: input.sessionID, callID: input.callID || "", }); try { // opencode 的权限语义是三态,映射成人类看得懂的选项: // 「同意」= once(仅这次),「一直同意」= always(后续同类不再问),「拒绝」= reject await relayPermission({ question: input.title || `请求执行 ${input.type}`, options: ["同意", "一直同意", "拒绝"], context: [ `类型:${input.type}`, input.pattern ? `目标:${Array.isArray(input.pattern) ? input.pattern.join(", ") : input.pattern}` : "", Object.keys(input.metadata || {}).length ? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```" : "", ].filter(Boolean).join("\n"), relayKey: input.id, }); console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`); } catch (e) { // 转不出去就别让 opencode 挂在那儿等:保持 ask 让本地机制接管(TUI 弹窗) console.error("[mail-bridge] 权限询问转发失败:", e?.message || e); pendingPermissions.delete(input.id); return; } output.status = "ask"; }, async event({ event }) { // 1) opencode 生成/更新会话标题时,把标题与 slug 回写成 AgentMail 的会话命名。 // 首轮对话结束后 opencode 才由模型定标题,所以只能靠事件而非创建时刻拿到。 if (event?.type === "session.updated") { const info = event.properties?.info; if (!info?.id) return; const mailSessionID = reverseMap.get(info.id); if (!mailSessionID) return; // 不是邮件驱动的会话,不碰 // "New session - <时间>" 是 opencode 的占位标题,等模型生成真摘要再回写 const title = typeof info.title === "string" ? info.title : ""; if (!title || title.startsWith("New session")) return; // 同一标题只回写一次,避免 session.updated 高频触发时反复打网关 if (syncedTitles.get(info.id) === title) return; syncedTitles.set(info.id, title); const res = await syncSessionNaming(mailSessionID, { alias: info.slug || "", title }); if (res) { console.error(`[mail-bridge] 会话命名同步 ${info.id} -> alias=${res.alias || "-"} title=${res.title || "-"}`); } return; } // 2) 一轮跑完 → 把最后那段话作为回信转出去(不消耗配额)。 // 用 session.idle 而不是 message.updated:后者在流式生成中反复触发, // 转出去的会是半截话。 if (event?.type === "session.idle") { const sid = event.properties?.sessionID; if (!sid || !mailDrivenSessions.has(sid)) return; try { const res = await relaySummaryRef(sid); if (res?.mail_id) { console.error(`[mail-bridge] 总结已回信 ${res.mail_id}(不计配额)`); } } catch (e) { console.error("[mail-bridge] 总结回信失败:", e?.message || e); } return; } // 3) 权限被本地机制(TUI)处理掉时,清掉待决策记录, // 免得之后邮件决策回来又去回复一条已经结案的 permission。 if (event?.type === "permission.replied") { const pid = event.properties?.permissionID; if (pid) pendingPermissions.delete(pid); return; } }, tool: { send_mail: sendMailTool, read_inbox: readInboxTool, forward_mail: forwardMailTool, upload_attachment: uploadAttachmentTool, download_attachment: downloadAttachmentTool, connect_to_server: connectToServerTool, }, }; }