Files
MailUI4Agents/plugins/opencode-mail-bridge/index.js
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

807 lines
34 KiB
JavaScript
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.

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<!-- agentmail:rename-session alias="${esc(args.propose_alias)}"${reason} -->`;
}
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 };
// 故意不传 titleopencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 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.idleopencode 在一轮跑完(不再有工具调用与生成)时发这个事件,
// 此刻的最后一条 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让它自己恢复执行。
// 这条路径不走 deliverMailopencode 的权限机制会在收到回复后继续原来的工具调用,
// 再往会话里塞一段「你的请求已批准」的文字只会干扰它。
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,
},
};
}