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 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

View File

@ -0,0 +1,806 @@
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,
},
};
}

View File

@ -0,0 +1,448 @@
{
"name": "opencode-mail-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencode-mail-bridge",
"version": "0.1.0",
"dependencies": {
"zod": "^3.25.76"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.15.0"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@opencode-ai/plugin": {
"version": "1.18.25",
"resolved": "https://registry.npmmirror.com/@opencode-ai/plugin/-/plugin-1.18.25.tgz",
"integrity": "sha512-Kb34zFqYosFNiMd1IuYiZGjX17z+18Srm7tHZMCz+uMVRTYNkEw1FTrfAK2FLbggwYdgzifGwKMNF1slLT8eLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "1.18.25",
"effect": "4.0.0-beta.83",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/plugin/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.18.25",
"resolved": "https://registry.npmmirror.com/@opencode-ai/sdk/-/sdk-1.18.25.tgz",
"integrity": "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT",
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.83",
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.83.tgz",
"integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.8.0",
"find-my-way-ts": "^0.1.6",
"ini": "^7.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^2.0.1",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^14.0.0",
"yaml": "^2.9.0"
}
},
"node_modules/fast-check": {
"version": "4.9.0",
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.9.0.tgz",
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT",
"peer": true
},
"node_modules/ini": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/ini/-/ini-7.0.0.tgz",
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
"license": "ISC",
"peer": true,
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)",
"peer": true
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"peer": true,
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.8",
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.8.tgz",
"integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==",
"license": "MIT",
"peer": true
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"peer": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "14.0.2",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"peer": true,
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "opencode-mail-bridge",
"version": "0.1.0",
"description": "Opencode plugin: 邮件驱动多智能体协作平台桥接",
"type": "module",
"main": "index.js",
"dependencies": {
"zod": "^3.25.76"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.15.0"
}
}