Files
MailUI4Agents/plugins/opencode-mail-bridge/index.js
JianFeeeee ca64d12057 feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有
邮件会话既不属于任何项目、彼此也不同组。

而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload
只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在
SSE 事件里,插件即使想用也拿不到。

- SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
  不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
- 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到
  兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活)
- 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`

## 2. 会话别名列不出工作区下的历史会话(无法选择)

workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须
JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace
填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p`
在只剩 Agent 回信可匹配时两边都对不上。

- `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace
  为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失
- `FindOrCreateDefaultSession` 同步改用会话的 workspace

## 3. 平台侧会话在补全里根本不存在

人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。

新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。
**上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway,
Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。

- 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有
  本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断
  这条到底是不是真的本侧会话
- **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是
  三态语义,指向不存在的会话直接 404
- **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像),
  而不是传空数组把镜像抹掉
- **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是
  派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名;
  它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
  出现几个点哪个都不确定的候选
- DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着

补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与
第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是
「缓存选型」而不是 brisk-harbor 这种随机短名。

## 4. 对话树看不见抄送与转发产生的分支

旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是
它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到
另一个;挂在原件上的转发分支同理。

改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向,
因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点
为什么并列,唯一的解释就是父邮件抄送给了两个人。

## 5. DSH 插件(Phase 7.7)

卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是
`followup()` 的参数形状:DSH 要完整的 UserMessage(content + source),
而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。

- `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的
  session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道
- `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的
  permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall,
  可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回
- 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 ——
  留在别名里会让它自己被解析器切开)
- 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处
  炸一个无关错误」

## 其他

- `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup`
  而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的
  foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰
  systemd(演练时误停过一次生产服务)
- 插件 dist/ 不进版本库,install.sh 负责构建
- `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
2026-09-02 20:05:51 +08:00

881 lines
38 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";
// 自动转发去重的纯逻辑放在 lib/ 里opencode 会把入口模块的每一个导出
// 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。
import { snapshotOpencodeSessions } from "./lib/session-snapshot.js";
import {
explicitSends,
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
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("改名理由,一句话,展示给用户看"),
},
// context 带 sessionID用它记下「模型这一轮亲手发过信」
// 让 session.idle 的自动转发让位,避免同一件事发两封。
async execute(args, context) {
// 改名建议以 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 || [],
});
// 发成功后才记:失败的调用不该压掉自动转发 ——
// 那种情况下模型的结论还没送出去,自动转发正是兜底
noteExplicitSend(context?.sessionID, args.to, args.reply_to);
const alias = result.session_alias
? `,会话别名 ${result.session_alias}(续谈可用 ${args.to.split(".")[0]}.${result.session_alias}`
: "";
// 本任务的剩余往返必须回给模型:不然它只能撞到 403 才知道额度用完。
// 注意这是【这条线索】的预算,不是 Agent 的终身额度 ——
// 换一个任务就是另一份预算。
const budget =
typeof result.budget_remaining === "number"
? `\n本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` +
(result.budget_remaining <= 1
? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。"
: "")
: "";
// 回传规范化后的别名Agent 提的名字可能含非法字符被改写过
const proposed = result.rename_proposed
? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。`
: "";
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${budget}${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 "收件箱为空。";
const listed = 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");
// 读过就标掉。不标的话下次拉收件箱还是这一批,
// 处理过的信和新来的信混在一起,模型分不清哪封该回。
//
// 只标本次真正列出来的而不是全部未读limit 之外的还没看过,
// 一并标掉等于让它们凭空消失。
if (args.filter !== "all") {
const ids = data.mails.map(m => m.mail_id).filter(Boolean);
if (ids.length) {
// 标记失败不该让 read_inbox 失败 —— 正文已经取到了,
// 代价只是下次会重复看到,比丢掉这次读取轻。
apiPost("/mail/read", { mail_ids: ids }).catch(e =>
console.error("[mail-bridge] 标记已读失败:", e?.message || e)
);
}
}
return listed;
},
};
/** 人类可读的字节数,用于附件清单展示。 */
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 };
// 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。
//
// 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的
// directory 会让所有邮件会话都挤在同一个目录里,与地址写的完全无关;
// 而 opencode 按 directory 归属项目,写错了会话就归到别的项目下。
const wantDir = typeof data.to_workspace === "string" && data.to_workspace.trim()
? data.to_workspace.trim()
: directory;
// 故意不传 titleopencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。
const created = await client.session.create({
query: wantDir ? { directory: wantDir } : 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;
// 模型这一轮已经亲手回过这条线索 → 不再自动转发。
//
// 否则收件箱里会出现两封说同一件事的邮件生产实测311 字节与 342 字节各一封,
// 其中带附件的那封才是模型真正想发的)。判定看两点:
// - 收件人同名:它已经跟这个人说过了
// - reply_to 相同:它已经回过这封信了
// relay_key 的幂等管不了这个 —— 那个键保证「同一条消息不转两次」,
// 而这里是「模型已经自己发过了」。
if (shouldSkipAutoRelay(explicitSends.get(sessionID), ctx.replyTo, ctx.mailID)) {
explicitSends.delete(sessionID);
relayedSummaries.set(sessionID, last.id); // 记下这条已「处理」,别下次 idle 又转
console.error(`[mail-bridge] 本轮模型已主动回信 ${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);
explicitSends.delete(sessionID); // 一轮结束,窗口关闭
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);
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。
// 不清的话,上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
explicitSends.delete(sessionID);
// 记住这轮该回给谁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);
// 心跳。保活、取待处理邮件数,并上报平台侧的会话快照。
//
// 额度属于具体任务(会话),不属于 Agent所以这里没有「剩余额度」可报。
// 剩余往返随每次发信响应的 budget_remaining 回传,在那里才有意义。
//
// 会话快照解决的是「工作区下的历史会话在补全里选不到」Gateway 只看得见
// 邮件驱动的那部分,人直接在 opencode 界面上开的会话它一无所知。
// 让插件上报而不是让 Gateway 反向拉取 —— 当前架构是单向的,
// 反向拉取需要 Gateway 保存各平台的地址与凭证。
async function reportSessions() {
try {
const listed = await client.session.list({
query: directory ? { directory } : undefined,
});
const sessions = listed?.data ?? listed ?? [];
return snapshotOpencodeSessions(sessions, (id) => mailDrivenSessions.has(id));
} catch (e) {
// 拉不到列表就**省略**该字段,而不是传空数组:
// 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
console.error("[mail-bridge] 会话列表读取失败:", e?.message || e);
return undefined;
}
}
const beat = async () => {
const platform_sessions = await reportSessions();
const body = platform_sessions ? { platform_sessions } : {};
apiPost("/agent/heartbeat", body).catch(() => {});
};
beat();
const heartbeat = setInterval(beat, 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,
},
};
}