Files
MailUI4Agents/plugins/opencode-mail-bridge/index.js
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

1455 lines
66 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 { normalizeAttachmentIDs } from "./lib/attachment-ids.js";
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 { resolveWorkspaceCwd } from "./lib/workspace.js";
import { selectCatchup } from "./lib/catchup.js";
import {
snapshotOpencodeModels,
modelAttemptOrder,
renderFailureReport,
} from "./lib/model-scope.js";
import {
renderInbox,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from "./lib/inbox-format.js";
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from "./lib/discovery.js";
import {
explicitSends,
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js";
import { clampRelayKey, isPermanentFailure, isDuplicateRelay } from "./lib/relay-key.js";
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js";
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
import { createSSEClient } from "./lib/sse-client.js";
// opencode 原生支持三态权限免批由它自己记response:"always"
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
import { normalizeMode, modeBriefing } from "./lib/permission-mode.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) {
// 把状态码与响应体挂在 error 上:调用方需要区分「暂时失败」与
// 「永远不会成功」。具体例子:权限询问碰到 409任务链上没有人类
// 必须当场 deny而 502 应该保持 ask 等重试 —— 只看 message 字符串
// 分不出这两种,于是两种都会被当成后者,而前者会永久挂住会话。
const err = new Error(data.error || `POST ${path} failed: ${res.status}`);
err.status = res.status;
err.body = data;
throw err;
}
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 注释形式附在正文末尾,由网关解析后剥离。
// 标记格式是服务端正则的镜像,因此拼接收在 lib/rename-proposal.js
// (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出,
// 提议凭空消失,而模型以为自己提过了。
const { body, proposed } = appendRenameProposal(
args.body, args.propose_alias, args.propose_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: normalizeAttachmentIDs(args.attachment_ids),
from_session_id: context?.sessionID || "",
});
// 发成功后才记:失败的调用不该压掉自动转发 ——
// 那种情况下模型的结论还没送出去,自动转发正是兜底
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
? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。"
: "")
: "";
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${budget}` +
(note ? `\n${note}` : "");
},
};
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"),
},
// ★ 第二个参数是平台给的上下文:`context.sessionID` 是这次调用所属的 opencode 会话,
// 经 reverseMap 换成邮件会话(并发安全,不依赖模块级"当前会话")。
//
// 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列,
// 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按
// ?status=unread 补投时再也看不到那封信(静默丢信)。
async execute(args, context) {
const filter = args.filter || DEFAULT_INBOX_STATUS;
const limit = args.limit || DEFAULT_INBOX_LIMIT;
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "";
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`);
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
//
// 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出
// 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是
// 一次性的,回过去只会再建一条平行会话。
const listed = renderInbox(data.mails, 200, AGENT_NAME);
const ids = idsToMarkRead(args.filter, data.mails);
if (ids.length) {
// 标记失败不该让 read_inbox 失败 —— 正文已经取到了,
// 代价只是下次会重复看到,比丢掉这次读取轻。
apiPost("/mail/read", { mail_ids: ids }).catch(e =>
console.error("[mail-bridge] 标记已读失败:", e?.message || e)
);
}
return listed;
},
};
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)}`;
},
};
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧
// 从来不是这样三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 ——
// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
// 那个错误路径静默变成了新会话的 workspace。
//
// 渲染逻辑在 lib/discovery.js与平台 SDK 无关,三平台共用)。
const suggestAddressTool = {
description:
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
"带 name 给它可用的工作目录name+path 都带则给该目录下可续谈的会话别名与现成地址。" +
"**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
args: {
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
},
async execute(args) {
const name = (args.name || "").trim();
const path = (args.path || "").trim();
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (path) qs.set("path", path);
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
// 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case "name":
return renderNameSuggestions(data.suggestions);
case "path":
return renderPathSuggestions(data.suggestions, name);
default:
return renderSessionSuggestions(data, name, path);
}
},
};
const listContactsTool = {
description:
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
args: {
limit: z.number().optional().describe("最多列出多少条,默认 20"),
},
async execute(args) {
const data = await apiGet("/agent/contacts");
return renderContacts(data, args.limit || 20);
},
};
const sessionParticipantsTool = {
description:
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
args: {
session_id: z.string().describe("会话 IDread_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
},
async execute(args) {
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
return renderParticipants(data);
},
};
const readThreadTool = {
description:
"查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" +
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
args: {
mail_id: z.string().describe("线索中任一封邮件的 ID"),
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
},
async execute(args) {
const qs = args.offset ? `?offset=${args.offset}` : "";
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
return renderThread(data, AGENT_NAME);
},
};
const readMailTool = {
description:
"读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" +
"收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。",
args: {
mail_id: z.string().describe("邮件 ID"),
},
async execute(args) {
const data = await apiGet(`/agent/mail/${args.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || "?"}`,
`收件人: ${m.to_name || "?"}${m.to_workspace ? "@" + m.to_workspace : ""}`,
`主题: ${m.subject || "(无主题)"}`,
`会话: #${data.session_alias || "未命名"}session_id: ${m.session_id || "?"}`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(
`附件: ${m.attachments
.map(a => `${a.filename}${formatSize(a.size_bytes)}, id=${a.attachment_id}`)
.join("、")}`
);
}
lines.push("", m.body || "(空正文)", "");
// 参与方地址由服务端拼好session 位已是真实别名,不是 .new
if (Array.isArray(data.participants) && data.participants.length) {
lines.push(
"可投递地址: " +
data.participants
.filter(p => p.address && p.name !== AGENT_NAME)
.map(p => `${p.address}${p.role}`)
.join("、")
);
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return lines.join("\n");
},
};
// 平台原生权限询问 → 邮件。
//
// **不作为工具暴露给模型**opencode 自己就有权限机制permission.ask 钩子 /
// permission.updated 事件),模型该做的是正常调工具,由 harness 决定要不要问人。
// 让模型主动调一个 request_permission 工具是把 harness 的职责推给模型 ——
// 它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。
//
// relay_key 用 opencode 的 permission.id 做幂等键permission.updated 会重复触发,
// 插件重连也会重放,没有它同一次询问会生成好几封邮件。
//
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 → 无人可问则 409
// 插件若把来信人当决策人Agent 之间转派任务时权限邮件会发给 Agent 自己 ——
// Agent 不可能在界面上点「同意」,服务端的用户推送也投进一个不存在的通道,
// 那条会话于是无声挂死pi 侧真实发生过)。
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 ───
//
// 共用客户端lib/sse-client.js跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
// 之前这里手写的解析把 evt/data 当 read() 的局部变量,一帧被切在两个 chunk
// 就整帧静默丢弃;也不带 Last-Event-ID断线期间的事件永远回放不出来。
let sseClientRef = null;
function startSSE(onEvent) {
sseClientRef?.stop?.();
sseClientRef = createSSEClient({
authHeaders,
baseURL: GATEWAY_URL.replace(/\/+$/, ""),
path: "/api/v1/events/stream",
onEvent,
log: (msg) => console.error("[mail-bridge]", msg),
});
return sseClientRef;
}
// ─── Plugin ───
// AgentMail 会话 ↔ opencode 会话的绑定。
// 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定,
// 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
// 上限淘汰(兜底)。没有它们,插件跑几周之后表里躺着几十万条再也不会被查到的
// 条目,而 GC 收不掉(还被强引用着)。
const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id
const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id供 event 钩子回写命名)
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
// 权限询问的双向定位。
//
// opencode 的 permission.id 与 AgentMail 的 mail_id 是两个 id 空间:
// 转出去时要记住 permission 属于哪个会话(回信地址从那里来),
// 人类决策回来时要用 permission.id 去回复 opencode。
// 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。
//
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。
// 静默淘汰一条会让 opencode 侧那次工具调用永远等不到回答 —— 而它有确定的
// 清理路径(决策到达 / 询问被取消),不需要靠猜。
const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
// 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> assistant message id
// 哪些会话参与邮件往来idle 时要把总结转回去。
//
// 两个来源:① 收到邮件后新建的会话 ② **被接管的平台会话**(人在 TUI 里开的,
// 但已经有邮件投进来了)。后者从接管那一刻起加入 —— 不加的话邮件投进去了
// 却永远没有回音,发件人只看到信发出去后再无音讯。
//
// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。
const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // opencode session id
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels = [];
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 };
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的
// 那种)—— 投进它而不是新建。
//
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界:人在界面上聊了
// 一半想转到邮件继续,或者想把一封邮件投进正在谈的那条会话。补全早就把平台
// 会话列为候选,这一跳补上投递侧。
//
// 新建会让人在 TUI 里看不到这封邮件带来的对话,而那正是接管的目的。
//
// opencode 上这件事最省力会话由服务端持有单一写者promptAsync 本来
// 就是「给这个 session id 发一轮」,不区分谁建的。**不需要**校验它是否活着。
const adoptedID = adoptedSessionID(data);
if (adoptedID) {
// 平台侧那条会话可能已经被人删了(镜像是快照,可以过期)。
// 校验一次:直接 prompt 一个不存在的 id 会得到一个语焉不详的 HTTP 错误,
// 而这里能给出「它没了,去 .new」这种可操作的话。
let ok = false;
try {
const got = await client.session.get({ path: { id: adoptedID } });
ok = Boolean((got?.data ?? got)?.id);
} catch {
ok = false;
}
if (!ok) throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除"));
if (mailSessionID) {
sessionMap.set(mailSessionID, adoptedID);
reverseMap.set(adoptedID, mailSessionID);
// 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来,
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
mailDrivenSessions.add(adoptedID);
}
console.error(`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID}`);
return { sessionID: adoptedID, reused: true, adopted: true };
}
// 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。
//
// 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的
// directory 会让所有邮件会话都挤在同一个目录里,与地址写的完全无关;
// 而 opencode 按 directory 归属项目,写错了会话就归到别的项目下。
//
// 校验逻辑与 DSH 侧共用lib/workspace.js目录不存在时不创建、
// 拒绝相对路径。opencode 的兜底是插件启动时的 directory。
const { cwd: wantDir, grouped } = resolveWorkspaceCwd(data.to_workspace, directory);
if (!grouped && data.to_workspace) {
console.error(
`[mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${wantDir || "(平台默认)"}`);
}
// 故意不传 titleopencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。
let created;
try {
created = await client.session.create({
query: wantDir ? { directory: wantDir } : undefined,
});
} catch (e) {
console.error(`[mail-bridge] session.create 失败: ${e?.message || e}`);
throw e;
}
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;
// 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。
const ctx = mailContexts.get(mailSessionID);
// **只给人类来信自动转发**(见 lib/relay-policy.js
//
// 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为「我只要
// 把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮直到
// 撞上连续 relay 跳数上限。放在取消息列表**之前**判:少一次网关往返。
const policy = autoRelayDecision({
fromHuman: ctx?.fromHuman === true,
replyTo: ctx?.replyTo,
});
if (!policy.relay) {
console.error(`[mail-bridge] 本轮不自动转发:${policy.reason}`);
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) {
// 模型 idle 但没有任何完成的 assistant 文本:空回复或被中断在文本前。
// 此前静默返回 —— 发件人等不到任何回复B-6 的缺口:跳过了
// 「模型全部失败」那条兜底,因为没有失败可报,却也没有产出)。
// 给发件人一封失败通知,让他知道这封已经处理过了但没结果。
try {
await apiPost("/mail/send", {
to: ctx?.replyTo || "",
subject: `处理失败: ${ctx?.subject || "(无主题)"}`,
body: [
`这封邮件(主题:${ctx?.subject || "(无主题)"})的处理轮次已结束,但没有产出任何回复文本。`,
"",
"可能原因:模型空响应、输出被中断、或工具调用异常耗尽轮次。",
"请检查后重试。",
].join("\n"),
reply_to: ctx?.mailID || "",
relay: "summary",
relay_key: clampRelayKey(`empty-reply:${mailSessionID || sessionID}`),
});
console.error(`[mail-bridge] 已发空回复失败通知给 ${ctx?.replyTo || ""}`);
} catch (e) {
// 失败通知也发不出去就静默:这次至少试过了
console.error("[mail-bridge] 空回复失败通知也发不出去:", e?.message || e);
}
return null;
}
// 本地去重(服务端另有 relay_key 幂等兜底,这里只是少打一次网关)
if (relayedSummaries.get(sessionID) === last.id) 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: clampRelayKey(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 BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> { replyTo, subject, mailID }
/**
* 会话归档 → 忘掉它的全部映射。
*
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有新
* 邮件投进来,`session.idle` 也不该再把总结转回去(会话已经收不了信)。
* 留着这些条目只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
*
* opencode 侧那条会话**不删**:人可能还在 TUI 里看它。这里只解除邮件绑定。
*
* @param {string} mailSessionID
*/
function forgetSession(mailSessionID) {
if (!mailSessionID) return;
// peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。
const opencodeID = sessionMap.peek(mailSessionID);
mailContexts.delete(mailSessionID);
sessionMap.delete(mailSessionID);
if (!opencodeID) return;
reverseMap.delete(opencodeID);
mailDrivenSessions.delete(opencodeID);
relayedSummaries.delete(opencodeID);
syncedTitles.delete(opencodeID);
}
// relaySummary 需要 client/directory而 event 钩子拿不到它们
// (只在插件初始化时给一次)。插件启动时把它们闭包进来。
let relaySummaryRef = async () => null;
// 人类决策回来 → 回复 opencode 的原生权限询问。
//
// 决策语义映射回 opencode 的三态:
// 同意 → once (仅这一次)
// 一直同意 → always (后续同类不再问)
// 拒绝 → reject
//
// relay_key= opencode 的 permission.id由服务端随决策事件回传
// 所以插件重启丢了 pendingPermissions 也能续上 —— 这个映射不能只存在内存里。
/**
* 权限结论的提示词 —— **必须带上人类的说明**。
*
* 2026-09-13 线上缺陷人类写「我说了让你拉取仓库到program下你听不懂吗」而桥只给
* 模型 `决策` 一个词(`note` 在 SSE 回包里没人读),模型把同一条命令又问了一遍。
*/
function permissionPrompt(data) {
const note = typeof data?.note === "string" ? data.note.trim() : "";
return (
`你之前发起的权限请求已有结论:${data?.decision}(决策人:${data?.decided_by || "用户"})。` +
(note ? `\n用户的说明:${note}` : "") +
`\n请据此继续后续工作。`
);
}
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 || "");
// 三态映射,且**认不出的一律 reject**fail closedN-9
//
// 这里原先是 `... : "once"` —— 兜底落在放行一侧。那意味着任何意外文本
// (空串、历史数据里的旧选项、将来服务端新增的选项)都会放行一次
// 没人批准的危险操作。判断顺序也重要always 必须先判,因为 isApproval
// 对「一直同意」同样为真。
const response = isAlwaysDecision(decision) ? "always"
: isApproval(decision) ? "once"
: "reject";
await client.postSessionIdPermissionsPermissionId({
path: { id: sessionID, permissionID: permID },
query: directory ? { directory } : undefined,
body: { response },
});
pendingPermissions.delete(permID);
// openCode 的权限回执只有 once/always/reject**放不下人类的说明**;而说明是模型
// 下一步该怎么改的唯一依据,所以有它时另外投一趟通知(多一轮 < 悄悄丢掉人的指令)。
if (typeof data.note === "string" && data.note.trim()) {
await deliverMail(client, directory, data, "permission").catch((e) =>
console.error("[mail-bridge] 决策说明投递失败:", e?.message || e),
);
}
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。
// 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。
//
// 多记两个服务端信号:
// fromHuman —— 只有人类来信才自动转发(见 lib/relay-policy.js
// inReplyTo —— 这封是不是对本方某封信的回复
// 不存进上下文的话relaySummary 那一刻拿不到它们(那个函数只有 sessionID
if (kind === "mail" && data.session_id) {
mailContexts.set(data.session_id, {
replyTo: data.from_name || "",
subject: data.subject || "",
mailID: data.mail_id || "",
fromHuman: data.from_human === true,
inReplyTo: data.in_reply_to || "",
});
}
const fromHuman = data.from_human === true;
// opencode 1.18+ 不支持 session.create permission 参数,
// 只能在提示词里告知模型档位约束advisory 路径)。
const permBriefing = modeBriefing({ mode: normalizeMode(data.permission_mode), enforcement: 'advisory', workspace: directory || '' });
const text = kind === "permission"
? permissionPrompt(data)
: [
inboundHeadline({
inReplyTo: data.in_reply_to,
fromHuman,
catchup: data.catchup,
reused,
}),
``,
`发件人:${data.from_name || "unknown"}`,
`主题:${data.subject || "(无主题)"}`,
`邮件 ID${data.mail_id || "unknown"}`,
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
`身份:你是 ${AGENT_NAME}`,
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
``,
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
``,
...replyInstruction({ fromHuman, replyAddress: data.reply_address }),
``,
permBriefing,
].join("\n");
// 按管理员划定的范围逐个尝试,全部失败才回一封说明失败原因的邮件。
//
// 必须发那封信:模型一次都没跑起来时会话里没有任何 assistant 消息,
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
//
// **promptAsync 返回不代表模型跑起来了**(名字里的 Async 就是这个意思):
// 无效 provider 的失败通过 `session.error` 事件到达,而不是它的 reject。
// 因此只包 try/catch 的话第二个模型永远不会被试到 —— 用 awaitFirstTurn 等结论。
const attempts = modelAttemptOrder(allowedModels, {
provider: REPLY_PROVIDER,
model: REPLY_MODEL,
});
const failures = [];
for (const route of attempts) {
const label = route ? `${route.provider}/${route.model}` : "(平台默认)";
const watching = awaitFirstTurn(sessionID);
try {
await client.session.promptAsync({
path: { id: sessionID },
query: directory ? { directory } : undefined,
body: {
// route 为 undefined 表示不指定模型,交给平台自己选
...(route ? { model: { providerID: route.provider, modelID: route.model } } : {}),
parts: [{ type: "text", text }],
},
});
} catch (e) {
// 同步就被拒(参数非法、会话不存在等)
watching.cancel();
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
console.error(`[mail-bridge] 模型 ${label} 提交失败:`, e?.message || e);
continue;
}
const outcome = await watching.result;
if (outcome.ok) {
if (failures.length > 0) {
console.error(`[mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID, reused };
}
failures.push({ ...(route ?? {}), error: outcome.error });
console.error(`[mail-bridge] 模型 ${label} 失败:`, outcome.error);
}
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
// 这是插件的故障报告,不是模型的自主发信。
if (kind === "mail" && data.from_name) {
try {
await apiPost("/mail/send", {
to: data.from_name,
subject: `处理失败: ${data.subject || "(无主题)"}`,
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || "",
relay: "summary",
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionID}`),
});
console.error(`[mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
} catch (e) {
console.error("[mail-bridge] 失败回报也发不出去:", e?.message || e);
}
}
throw new Error(
`划定范围内的 ${failures.length} 个模型全部失败:` +
failures.map(f => f.error).join(" | "));
}
// ─── 首轮结果观察 ───
//
// opencode 的事件是通过插件的 event 钩子进来的,而 deliverMail 在钩子之外,
// 因此这里用一个「等待者」表event 钩子看到 session.error / session.idle 时
// 唤醒对应会话的等待者。
const turnWatchers = new Map(); // sessionID -> { resolve, timer }
/**
* 等这个会话的首轮跑起来或失败。
*
* 超时按「成功」处理:模型可能只是很慢(首 token 前要装载上下文),
* 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。
*
* @param {string} sessionID opencode 会话 id
* @param {number} timeoutMs 判定窗口
*/
function awaitFirstTurn(sessionID, timeoutMs = 60000) {
let settle;
const result = new Promise(res => { settle = res; });
const finish = (r) => {
const w = turnWatchers.get(sessionID);
if (!w) return;
clearTimeout(w.timer);
turnWatchers.delete(sessionID);
w.resolve(r);
};
const timer = setTimeout(() => finish({ ok: true }), timeoutMs);
turnWatchers.set(sessionID, { resolve: settle, timer });
return {
result,
cancel: () => finish({ ok: true }),
};
}
/** event 钩子调用:这个会话的首轮有结论了。 */
function settleFirstTurn(sessionID, outcome) {
const w = turnWatchers.get(sessionID);
if (!w) return false;
clearTimeout(w.timer);
turnWatchers.delete(sessionID);
w.resolve(outcome);
return true;
}
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;
}
}
// 模型目录:随心跳上报,让配置页看到的清单跟着平台的实际状态走。
//
// 只在注册时报一次的话目录会静静变陈(换 provider 配置、上游上下线、
// 换 API key 都会让它失准),管理员会选中一个平台其实调不到的模型,
// 而失败要到真发邮件时才暴露。
async function reportModels() {
try {
const cfg = await client.config.providers();
return snapshotOpencodeModels(cfg?.data ?? cfg);
} catch (e) {
// 拉不到就**省略**该字段,而不是传空数组:
// 空数组的语义是「平台确实一个模型都拿不到」,会把配置页清成空白。
console.error("[mail-bridge] 模型目录读取失败:", e?.message || e);
return undefined;
}
}
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
//
// 有界:插件跟着 opencode serve 一起长期活着,这里会攒下每一封处理过的
// 邮件 id 而永远没有出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
/**
* 补投离线期间积压的未读邮件。
*
* SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。
* 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。
*/
async function catchUp(pending) {
if (!pending) return;
try {
const box = await apiGet("/mail/inbox?status=unread&limit=20");
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (tasks.length === 0) return;
console.error(`[mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
for (const ev of tasks) {
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
// selectCatchup 只在拉完那一刻去过重)
if (deliveredMails.has(ev.mail_id)) continue;
deliveredMails.add(ev.mail_id);
try {
await deliverMail(client, directory, ev, "mail");
} catch (e) {
console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e);
}
}
} catch (e) {
console.error("[mail-bridge] 补投失败:", e?.message || e);
}
}
let caughtUp = false;
const beat = async () => {
const [platform_sessions, models] = await Promise.all([
reportSessions(),
reportModels(),
]);
const body = {};
// opencode 1.18+ 的 session.create 不支持 permission 参数,
// 权限只能通过提示词告知模型advisory
body.mode_enforcement = 'advisory';
if (platform_sessions) body.platform_sessions = platform_sessions;
if (models) body.models = models;
try {
const res = await apiPost("/agent/heartbeat", body);
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
// 插件最多一个周期30 秒)就能看到新值,不需要重启。
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
// 只在首个成功的心跳后补投一次:之后的积压都由 SSE 覆盖,
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
if (!caughtUp) {
caughtUp = true;
await catchUp(res?.pending_mails);
}
} catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
}
};
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 === "session_archived") {
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
forgetSession(data?.session_id || "");
return;
}
if (type !== "new_mail") return;
if (data?.mail_id) deliveredMails.add(data.mail_id);
// 决策回执不是"新任务"(内容已随 permission_decision 交付)—— 同 pi 桥那条注释:
// 按新邮件再投一次会把人类真正的新邮件挤在这条会话的队列后面。
if (data?.mail_type === "permission_decision") {
deliverMail(client, directory, data, "permission").catch((e) =>
console.error("[mail-bridge] 决策回执投递失败:", e?.message || e),
);
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 (sseClientRef) sseClientRef.stop();
});
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
// ★ 幂等命中:网关回 200 `{status:"duplicate_relay"}` 并**提前返回** ——
// 没有产生新的询问,也永远不会有人来决策,而这里只等
// `permission_decision` 事件。不认它的代价是静默挂死。
// 详见 lib/relay-key.js 的 isDuplicateRelay。
const accepted = 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: clampRelayKey(input.id),
});
if (isDuplicateRelay(accepted)) {
console.error(`[mail-bridge] 权限询问被判为重复 ${input.id},本次没有产生新请求`);
pendingPermissions.delete(input.id);
output.status = "deny";
output.reason = [
`这个授权询问之前已经发过一次key=${clampRelayKey(input.id)}),本次没有产生新的询问。`,
"可能原因:插件重启后重放同一轮,或上一次询问已经有人决定过但这个决策没有回到这里。",
"请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
].join("\n");
return;
}
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}${input.type}`);
} catch (e) {
pendingPermissions.delete(input.id);
// 4xx = 请求本身被服务端拒绝,重试一万次也是同一个结果。
//
// 必须当场 deny保持 "ask" 等于把会话交给本地 TUI 弹窗,
// 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。
//
// 两个真实事故都是这个形状:
// 409pi 把任务派给自己另一条会话,那条要跑 bash
// 权限邮件无人可投,整条线索卡死
// 400relay_key 超长extended thinking 把思考签名拼进了 toolCallId
// 被当暂时失败让位 → 那条命令无人批准就执行了
//
// deny 的同时把原因带给模型opencode 把 reason 作为工具报错回去),
// 它才知道下一步该换什么做法。
/*
* 409 有**两种**含义正确反应相反2026-09-14与 dsh/pi/zcode 桥同一处缺陷):
* 「这条链上没有人类」→ 拒绝;「**本档根本不该问**」→ 服务端在回包里带
* `permission_mode`,是 full 档就应当**放行**full 档工具调用无需审批)。
*
* 走到第二种的路径补投漏传档位lib/catchup.js同日已修→ 本轮按 workspace
* 拦下来问人 → 服务端按真实档位回 409 → 旧代码当"无人可问"拒绝,
* 一条 full 档会话的工具调用全被自己人拦死。
*
* **只认服务端明说的 full**plan 与"无人可问"照旧拒绝(猜宽了就是提权)。
* 这个桥没有会话档位旋钮,所以只修这一层。
*/
if (Number(e?.status) === 409 && String(e?.body?.permission_mode || "") === "full") {
console.error(
`[mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${input.id}):无需审批`,
);
output.status = "allow";
return;
}
if (isPermanentFailure(e)) {
const b = e?.body || {};
console.error(
`[mail-bridge] 权限询问遇到永久失败HTTP ${e?.status}),当场拒绝 ${input.id}${b.error || e?.message || ""}`,
);
output.status = "deny";
output.reason = [
b.error || `无法把授权请求送达给人类HTTP ${e?.status}`,
b.detail || "",
b.suggestion || "这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
].filter(Boolean).join("\n");
return;
}
// 暂时失败5xx / 408 / 429 / 网络拖动)保持 ask那些真的可能下一次就好
// 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
console.error("[mail-bridge] 权限询问转发暂时失败(保持 ask:", e?.message || e);
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后者在流式生成中反复触发
// 转出去的会是半截话。
// 1.5) 模型调用出错 → 唤醒等待者,让 deliverMail 换下一个模型。
// promptAsync 已经返回过了,失败只能从这里得知。
if (event?.type === "session.error") {
const sid = event.properties?.sessionID;
const err = event.properties?.error;
const msg = err?.data?.message || err?.name || JSON.stringify(err ?? {});
if (sid && settleFirstTurn(sid, { ok: false, error: msg })) {
return; // 正在降级尝试中,不当作一次普通故障
}
if (sid && mailDrivenSessions.has(sid)) {
console.error(`[mail-bridge] 会话 ${sid} 出错:`, msg);
}
return;
}
if (event?.type === "session.idle") {
const sid = event.properties?.sessionID;
// 首轮跑到 idle = 这一路走通了(哪怕没产出文本)
if (sid) settleFirstTurn(sid, { ok: true });
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,
read_mail: readMailTool,
forward_mail: forwardMailTool,
upload_attachment: uploadAttachmentTool,
download_attachment: downloadAttachmentTool,
connect_to_server: connectToServerTool,
// 寻址发现:让模型选地址而不是拼地址
suggest_address: suggestAddressTool,
list_contacts: listContactsTool,
session_participants: sessionParticipantsTool,
read_thread: readThreadTool,
},
};
}