线上事故(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。
1455 lines
66 KiB
JavaScript
1455 lines
66 KiB
JavaScript
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("会话 ID(read_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 || "(平台默认)"}`);
|
||
}
|
||
|
||
// 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
|
||
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 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.idle:opencode 在一轮跑完(不再有工具调用与生成)时发这个事件,
|
||
// 此刻的最后一条 assistant 消息就是本轮结论。
|
||
// 不用 message.updated:那会在流式生成过程中反复触发,转出去的是半截话。
|
||
async function relaySummary(client, directory, sessionID) {
|
||
const mailSessionID = reverseMap.get(sessionID);
|
||
if (!mailSessionID) return null; // 不是邮件驱动的会话,不碰
|
||
if (!mailDrivenSessions.has(sessionID)) return null;
|
||
|
||
// 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。
|
||
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 closed,N-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,让它自己恢复执行。
|
||
// 这条路径不走 deliverMail:opencode 的权限机制会在收到回复后继续原来的工具调用,
|
||
// 再往会话里塞一段「你的请求已批准」的文字只会干扰它。
|
||
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 —— 模型会永久挂在那里。
|
||
//
|
||
// 两个真实事故都是这个形状:
|
||
// 409:pi 把任务派给自己另一条会话,那条要跑 bash,
|
||
// 权限邮件无人可投,整条线索卡死
|
||
// 400:relay_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,
|
||
},
|
||
};
|
||
}
|