feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。
## --mode 是必传的(不传等于关掉授权系统)
ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。
档位映射(依据是 CLI 产物里的规则表,不是猜):
plan → --mode plan (mode.plan.nonReadOnly:非只读一律拒)
workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
full → --mode yolo (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。
## 一轮怎么跑
node <zcode.cjs> --prompt <提示词> --output-format stream-json \
--cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N
用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。
## 回信策略(与另三桥同源)
- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
收件箱里两封说同一件事的邮件,311 与 342 字节)。
## 两处健壮性(都是实现时自己发现的真问题)
- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
(unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
而既没有驱动看着它、也没有本地界面看着它。
## 自报强制力只声明得出来的事
驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。
## 验证
- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
反向验证:让 workspace.js 分叉会被抓住)
## 我自己写错并被测试抓出来的三处(值得记)
1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
→ 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
This commit is contained in:
400
plugins/zcode-mail-bridge/src/index.mjs
Normal file
400
plugins/zcode-mail-bridge/src/index.mjs
Normal file
@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ZCode 的 AgentMail 驱动:**收到来信 → 起一轮 ZCode → 把结论回信**。
|
||||
*
|
||||
* 这是让 ZCode 成为一等 Agent 的那一半(另一半是插件:MCP 工具面 + 授权钩子)。
|
||||
*
|
||||
* # 与另三个桥的关系
|
||||
*
|
||||
* 结构对齐 pi / dsh / opencode 三桥:SSE 订阅 → 去重 → 解析工作目录与档位 →
|
||||
* 跑一轮 → 按策略回信 → 心跳。可复用的部分一律走 `lib/`(逐字节同源):
|
||||
* 事件补投、工作目录解析、回信策略、去重判据、SSE 帧解析。
|
||||
*
|
||||
* 差别只在「怎么跑一轮」:ZCode 用 **headless CLI**
|
||||
* (`--prompt … --output-format stream-json`),不是 SDK。
|
||||
*
|
||||
* # 三个必须记住的约束
|
||||
*
|
||||
* 1. **`--mode` 必传**。`--prompt` 的默认 mode 是 `yolo`,而 yolo 会绕过全部
|
||||
* 权限询问 —— 授权钩子根本不会触发,授权系统会**静默消失**(不报错,
|
||||
* 只是没有任何询问)。档位映射见 `src/turn-mode.mjs`。
|
||||
* 2. **失败必须回信**。邮件驱动的会话没有本地界面,一轮跑不起来而什么都不发,
|
||||
* 发件人只会觉得「信发出去了,然后再无音讯」。
|
||||
* 3. **模型自己发过信就不再自动转发**。工具跑在 ZCode 派生的 MCP 服务器进程里,
|
||||
* 与驱动不是同一个进程,所以经 `lib/explicit-sends.mjs` 落盘对齐。
|
||||
*
|
||||
* # 串行
|
||||
*
|
||||
* 一轮一次。ZCode 的会话与工作目录是重资源,同一目录并发跑两轮会互相踩;
|
||||
* 代价是一封长信会挡住后面的信 —— 这是显式取舍,不是遗漏(见 README 的已知缺口)。
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { GatewayClient, GatewayError } from '../lib/gateway.mjs';
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
import { BoundedSet, BoundedMap, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { autoRelayDecision } from '../lib/relay-policy.js';
|
||||
import { shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
import { normalizeMode } from '../lib/permission-mode.js';
|
||||
import { clampRelayKey } from '../lib/relay-key.js';
|
||||
import { explicitSendsFile, readExplicitSends } from '../lib/explicit-sends.mjs';
|
||||
import { zcodeModeForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs';
|
||||
import { buildMailPrompt, replySubject, renderTurnFailure } from './prompt.mjs';
|
||||
import { runTurn, DEFAULT_CLI } from './zcode-run.mjs';
|
||||
|
||||
const log = (...parts) => console.error('[zcode-mail-bridge]', ...parts);
|
||||
|
||||
const CONFIG = {
|
||||
gatewayURL: process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180',
|
||||
agentName: process.env.AGENTMAIL_AGENT_NAME || 'zcode',
|
||||
turnTimeoutMs: Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 20 * 60 * 1000),
|
||||
maxTurns: Number(process.env.AGENTMAIL_MAX_TURNS || 0) || undefined,
|
||||
workspaceRoot: process.env.AGENTMAIL_WORKSPACE_ROOT || '',
|
||||
cliPath: process.env.AGENTMAIL_ZCODE_CLI || DEFAULT_CLI
|
||||
};
|
||||
|
||||
/**
|
||||
* 没有 `to_workspace` 时的兜底目录。
|
||||
*
|
||||
* **不能用共用的 `mailSessionFallback`** —— 那个函数的目录名写的是 `~/.dsh`
|
||||
* (它注释里也写明是「没有天然兜底的平台(DSH)用这个」)。各平台的会话存储
|
||||
* 各不相同,把 ZCode 的会话塞进 `~/.dsh` 下会造成两个平台的会话目录互相污染。
|
||||
*
|
||||
* @param {string} sessionKey
|
||||
*/
|
||||
export function zcodeSessionFallback(sessionKey, rootOverride) {
|
||||
const root = rootOverride || CONFIG.workspaceRoot;
|
||||
if (root) return join(root, String(sessionKey || 'default'));
|
||||
return join(homedir(), '.zcode', 'mail-sessions', String(sessionKey || 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自报给网关的强制力。
|
||||
*
|
||||
* **只声明得出来的事**:ZCode 上的档位强制全部靠插件里的 PermissionRequest 钩子,
|
||||
* 钩子没被注册(插件没启用 / 被禁 / 清单被改坏)时我们什么也拦不住,
|
||||
* 那时还报 native 就是在替一个不存在的能力背书 —— 而这个声明的用途正是
|
||||
* 让人相信「这一档在这里是被强制的」。
|
||||
*
|
||||
* 查的是插件自己的 `hooks/hooks.json`(驱动就住在这个插件里),
|
||||
* 不需要额外的配置项。
|
||||
*/
|
||||
export function detectModeEnforcement({ hooksFile } = {}) {
|
||||
const file = hooksFile || fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const entries = cfg?.hooks?.PermissionRequest;
|
||||
if (Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0)) {
|
||||
return { enforcement: 'native', reason: `钩子已注册(${file})` };
|
||||
}
|
||||
return { enforcement: 'advisory', reason: `钩子清单里没有 PermissionRequest(${file})` };
|
||||
} catch (e) {
|
||||
return { enforcement: 'advisory', reason: `读不到钩子清单(${file}):${e?.message || e}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** 描述错误:把「网关可达但返回 4xx」与「连不上」分开 —— 两者的应对完全不同。 */
|
||||
export function describeError(e) {
|
||||
if (e instanceof GatewayError) {
|
||||
return `网关返回 HTTP ${e.status}(${e.path}):${typeof e.body === 'string' ? e.body : e.message}`;
|
||||
}
|
||||
return e?.message || String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个驱动实例。
|
||||
*
|
||||
* 依赖全部注入,所以整条流水线(事件 → 提示词 → 一轮 → 回信判定 → 发信载荷)
|
||||
* 可以在没有模型、没有 ZCode 的情况下被端到端断言。
|
||||
*/
|
||||
export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = process.env, config } = {}) {
|
||||
// 配置可覆盖:测试需要把工作目录指到临时目录,不能碰真实的家目录。
|
||||
const CFG = { ...CONFIG, ...(config || {}) };
|
||||
const delivered = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
/** AgentMail 会话 id → { zcodeSessionId, cwd, tier, turns } */
|
||||
const sessions = new BoundedMap(MAX_TRACKED_SESSIONS);
|
||||
const queue = [];
|
||||
let running = false;
|
||||
/** 当前在途回合的杀进程函数(关停时要终止它,否则会留下跑工具的孤儿)。 */
|
||||
let currentKill = null;
|
||||
|
||||
/** 注册表只用于日志与自检:它让「为什么一轮授权询问都没发生」有据可查。 */
|
||||
const stats = { turns: 0, relays: 0, skippedRelay: 0, failures: 0 };
|
||||
|
||||
function resolveCwd(data) {
|
||||
const sessionId = data?.session_id || data?.mail_id || 'unknown';
|
||||
const fallback = zcodeSessionFallback(sessionId, CFG.workspaceRoot);
|
||||
const { cwd, grouped } = resolveWorkspaceCwd(data?.to_workspace, fallback);
|
||||
if (!grouped) logFn(`会话 ${sessionId} 没有可用的 to_workspace,用兜底目录 ${cwd}`);
|
||||
ensureCwd(cwd, grouped);
|
||||
return cwd;
|
||||
}
|
||||
|
||||
async function relay({ data, text, kind }) {
|
||||
const fromHuman = data?.from_human === true;
|
||||
const decision = autoRelayDecision({ fromHuman, replyTo: data?.from_name });
|
||||
if (!decision.relay) {
|
||||
logFn(`不自动转发(${decision.reason})`);
|
||||
stats.skippedRelay++;
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionId = data?.session_id || '';
|
||||
const sent = readExplicitSends(explicitSendsFile(env), {
|
||||
sessionId,
|
||||
// 只认本轮之后的记录:早于本轮的发信属于上一次往返,不该让这一轮沉默。
|
||||
since: Date.now() - CFG.turnTimeoutMs
|
||||
});
|
||||
if (shouldSkipAutoRelay(sent, data.from_name, data.mail_id)) {
|
||||
logFn(`本轮模型已主动回信 ${data.from_name},跳过自动转发`);
|
||||
stats.skippedRelay++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// relay + relay_key 走免配额通道:模型已经把话说完了,驱动只是把它搬进邮件。
|
||||
// 对搬运收配额会让「配额用尽」变成「连交代都做不到」。
|
||||
const relayKey = clampRelayKey(`zcode:${data.mail_id || kind}`);
|
||||
await client.post('/mail/send', {
|
||||
to: data.from_name,
|
||||
subject: replySubject(data.subject),
|
||||
body: text,
|
||||
reply_to: data.mail_id || '',
|
||||
relay: 'summary',
|
||||
relay_key: relayKey
|
||||
});
|
||||
stats.relays++;
|
||||
logFn(`已回信给 ${data.from_name}(${text.length} 字)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processMail(data) {
|
||||
const sessionId = data?.session_id || '';
|
||||
const tier = normalizeMode(data?.permission_mode);
|
||||
const mode = zcodeModeForTier(tier);
|
||||
const cwd = resolveCwd(data);
|
||||
const prev = sessions.get(sessionId);
|
||||
const resume = prev?.zcodeSessionId || '';
|
||||
|
||||
logFn(`处理 ${data.mail_id}|${describeTier(tier, mode)}|cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`);
|
||||
if (!modeReachesPermissionHook(mode) && tier !== 'plan') {
|
||||
// 只有 full 档会走到这里,且是刻意的。写日志是因为「没有权限询问」
|
||||
// 在 yolo 下是预期行为,在 build 下则是缺陷 —— 两者必须能区分。
|
||||
logFn(`注意:--mode ${mode} 不会产生权限询问(本档如此设计)`);
|
||||
}
|
||||
|
||||
const prompt = buildMailPrompt({ agentName: CONFIG.agentName, data });
|
||||
|
||||
const outcome = await runTurnFn(
|
||||
{
|
||||
prompt,
|
||||
cwd,
|
||||
mode,
|
||||
maxTurns: CFG.maxTurns,
|
||||
resumeSessionId: resume || undefined,
|
||||
turnTimeoutMs: CFG.turnTimeoutMs,
|
||||
cliPath: CFG.cliPath,
|
||||
// 注入给 ZCode 进程(→ 继承给插件、钩子、MCP 服务器):
|
||||
// 授权钩子靠 AGENTMAIL_SESSION_ID 判断「有没有本地界面」,
|
||||
// 靠 AGENTMAIL_PERMISSION_MODE 决定档位。
|
||||
env: {
|
||||
AGENTMAIL_SESSION_ID: sessionId,
|
||||
AGENTMAIL_PERMISSION_MODE: tier,
|
||||
AGENTMAIL_MAIL_SUBJECT: data?.subject || '',
|
||||
AGENTMAIL_REPLY_TO: data?.mail_id || ''
|
||||
}
|
||||
},
|
||||
{ log: logFn, onChild: kill => {
|
||||
currentKill = kill;
|
||||
} }
|
||||
);
|
||||
currentKill = null;
|
||||
|
||||
stats.turns++;
|
||||
|
||||
if (outcome.sessionId) {
|
||||
sessions.set(sessionId, {
|
||||
zcodeSessionId: outcome.sessionId,
|
||||
cwd,
|
||||
tier,
|
||||
turns: (prev?.turns || 0) + 1
|
||||
});
|
||||
}
|
||||
|
||||
const failed = outcome.timedOut || (outcome.exitCode !== 0 && !outcome.response);
|
||||
if (failed) {
|
||||
stats.failures++;
|
||||
const reason = outcome.timedOut
|
||||
? `回合超时(${Math.round(CFG.turnTimeoutMs / 1000)} 秒),已终止进程树`
|
||||
: `ZCode 退出码 ${outcome.exitCode}${outcome.stderrTail ? `:\n${outcome.stderrTail}` : ''}`;
|
||||
logFn(`一轮失败:${reason}`);
|
||||
// 失败必须回信:否则发件人只看到「信发出去了,然后再无音讯」。
|
||||
try {
|
||||
await client.post('/mail/send', {
|
||||
to: data?.from_name,
|
||||
subject: `处理失败: ${data?.subject || '(无主题)'}`,
|
||||
body: renderTurnFailure([{ kind: outcome.timedOut ? '超时' : 'CLI 失败', error: reason }], data?.subject),
|
||||
reply_to: data?.mail_id || '',
|
||||
relay: 'summary',
|
||||
relay_key: clampRelayKey(`zcode-failure:${data?.mail_id || sessionId}`)
|
||||
});
|
||||
} catch (e) {
|
||||
logFn(`失败回报也发不出去:${describeError(e)}`);
|
||||
}
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
const text = String(outcome.response || '').trim();
|
||||
if (!text) {
|
||||
// 退出码 0 但没有最终文本:常见于模型只调了工具就结束。
|
||||
// 这时**不冒充**回信(会让收件人以为模型什么都没做),但要留下日志。
|
||||
logFn('这一轮没有产出最终文本,不自动回信(若模型自己发过信,那封就是答复)');
|
||||
return { ok: true, relayed: false };
|
||||
}
|
||||
|
||||
return { ok: true, relayed: await relay({ data, text, kind: 'mail' }) };
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
while (queue.length) {
|
||||
const data = queue.shift();
|
||||
try {
|
||||
await processMail(data);
|
||||
} catch (e) {
|
||||
// 一封邮件处理崩了不能把驱动带走:后面还有很多信。
|
||||
logFn(`处理 ${data?.mail_id} 时异常:${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE 事件入口。必须廉价 —— 它跑在读循环上。 */
|
||||
function handleEvent(type, data) {
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || delivered.has(id)) return;
|
||||
delivered.add(id);
|
||||
queue.push(data);
|
||||
void drain();
|
||||
}
|
||||
|
||||
async function catchUp(pendingMails) {
|
||||
const mails = selectCatchup(pendingMails, delivered);
|
||||
if (!mails.length) return 0;
|
||||
logFn(`补投 ${mails.length} 封停机期间到达的邮件`);
|
||||
for (const m of mails) handleEvent('new_mail', m.data ?? m);
|
||||
return mails.length;
|
||||
}
|
||||
|
||||
return {
|
||||
handleEvent,
|
||||
catchUp,
|
||||
processMail,
|
||||
stats,
|
||||
sessions,
|
||||
delivered,
|
||||
/**
|
||||
* 关停:终止在途回合。
|
||||
*
|
||||
* 不做这件事的后果是——systemd 杀掉驱动之后,那个 ZCode 进程还在跑工具,
|
||||
* 而既没有驱动看着它,也没有本地界面看着它。宁可丢掉这一轮的工作。
|
||||
*/
|
||||
abort() {
|
||||
// 先取后清:杀过就算完,重复关停(SIGTERM 后再来一个)不该重复杀。
|
||||
const kill = currentKill;
|
||||
currentKill = null;
|
||||
if (kill) {
|
||||
logFn('关停:终止在途的 ZCode 回合');
|
||||
try {
|
||||
kill('SIGTERM');
|
||||
} catch {
|
||||
/* 已经结束了 */
|
||||
}
|
||||
}
|
||||
queue.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 真实入口 ───────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const client = new GatewayClient(process.env);
|
||||
const missing = client.checkConfig();
|
||||
if (missing.length) {
|
||||
log(`配置不完整,缺少 ${missing.join('、')};驱动不会启动(静默启动会让信永远没人处理)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const driver = createDriver({ client, logFn: log });
|
||||
let caughtUp = false;
|
||||
let timer;
|
||||
|
||||
try {
|
||||
await client.register();
|
||||
log(`已接入 ${client.baseURL},身份 ${client.agentName}`);
|
||||
} catch (e) {
|
||||
// 密钥未登记时说清该做什么,别只留一句 401。
|
||||
log(`注册失败:${describeError(e)}`);
|
||||
log('若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。');
|
||||
}
|
||||
|
||||
const beat = async () => {
|
||||
try {
|
||||
// mode_enforcement 只声明得出来的事:挡得住工具的是插件里的授权钩子,
|
||||
// 钩子没注册时我们什么也拦不住(见 detectModeEnforcement)。
|
||||
const res = await client.post('/agent/heartbeat', {
|
||||
mode_enforcement: detectModeEnforcement().enforcement
|
||||
});
|
||||
if (!caughtUp) {
|
||||
caughtUp = true;
|
||||
await driver.catchUp(res?.pending_mails);
|
||||
}
|
||||
} catch {
|
||||
// 心跳失败不刷错误日志:真连不上时网关会把它判成离线,那才是可见信号。
|
||||
}
|
||||
};
|
||||
await beat();
|
||||
timer = setInterval(beat, 30_000);
|
||||
|
||||
createSSEClient({
|
||||
authHeaders: () => client.authHeaders(),
|
||||
baseURL: client.baseURL,
|
||||
path: '/api/v1/events/stream',
|
||||
log,
|
||||
onEvent: (type, data) => driver.handleEvent(type, data)
|
||||
});
|
||||
|
||||
const shutdown = reason => {
|
||||
log(`收到 ${reason},关停中…(已处理 ${driver.stats.turns} 轮,回信 ${driver.stats.relays} 封)`);
|
||||
if (timer) clearInterval(timer);
|
||||
driver.abort();
|
||||
client.stopSSE?.();
|
||||
// 给杀进程留一点时间再退:自己先死会把 ZCode 变成孤儿。
|
||||
setTimeout(() => process.exit(0), 1200);
|
||||
};
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
|
||||
|
||||
const enforcement = detectModeEnforcement();
|
||||
log(`档位强制力自报:${enforcement.enforcement}(${enforcement.reason})`);
|
||||
log(`驱动就绪:CLI ${basename(CONFIG.cliPath)},回合上限 ${Math.round(CONFIG.turnTimeoutMs / 1000)} 秒`);
|
||||
}
|
||||
|
||||
// 直接执行时启动;被 import 时只导出(测试要用 createDriver)。
|
||||
const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isDirect) {
|
||||
main().catch(e => {
|
||||
log(`启动失败:${describeError(e)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
109
plugins/zcode-mail-bridge/src/prompt.mjs
Normal file
109
plugins/zcode-mail-bridge/src/prompt.mjs
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 由一封邮件构造驱动 ZCode 的提示词。
|
||||
*
|
||||
* 结构与 pi / dsh / opencode 三桥**同源**(复用 `lib/relay-policy.js` 的
|
||||
* `inboundHeadline` 与 `replyInstruction`):同一封邮件派到不同 Agent 上,
|
||||
* 模型该看到同样的交代。措辞一旦在某个平台走样,就会出现「同一封邮件
|
||||
* 在这个 Agent 上会回信、在那个 Agent 上装死」这种只在单平台复现的问题。
|
||||
*
|
||||
* 两个关键信号都来自服务端,不由插件猜:
|
||||
*
|
||||
* - `from_human`:决定「插件会不会替你回信」。猜错的代价不对称 ——
|
||||
* 把 Agent 的来信说成人的来信,会让模型以为有人会替它开口而什么都不做;
|
||||
* 反之只是多调一次 send_mail。
|
||||
* - `in_reply_to`:这封是回信还是新任务。模型分不清这两者时,会把对方一句
|
||||
* 「已收到」当成新待办再做一遍(Agent↔Agent 客套循环的真正成因)。
|
||||
*/
|
||||
|
||||
import { inboundHeadline, replyInstruction } from '../lib/relay-policy.js';
|
||||
|
||||
/** 去掉已有的 Re:/答复前缀,避免 `Re: Re: Re: …` 越滚越长。 */
|
||||
export function replySubject(subject) {
|
||||
const base = String(subject ?? '')
|
||||
.replace(/^\s*(re|答复|回复)\s*[::]\s*/gi, '')
|
||||
.trim();
|
||||
return base ? `Re: ${base}` : '本轮工作总结';
|
||||
}
|
||||
|
||||
/**
|
||||
* 一轮完全跑不起来时的回信正文。
|
||||
*
|
||||
* **必须发这封信**:模型一次都没跑起来 → 会话里没有任何产出 → 自动转发什么也
|
||||
* 不会发 → 发件人只会觉得「信发出去了,然后再无音讯」。邮件驱动的会话没有
|
||||
* 本地界面可以让人看见错误,回信是唯一的出口。
|
||||
*
|
||||
* 为什么不复用共用的 `renderFailureReport`:它的**建议**是平台特有的
|
||||
* (「调整可用模型范围」),而 ZCode 跑不起来的常见成因是没登录、缺模型配置、
|
||||
* CLI 本身报错 —— 照着那句建议去后台改模型范围,什么也解决不了。
|
||||
*
|
||||
* @param {{kind: string, error: string}[]} failures
|
||||
* @param {string} subject
|
||||
*/
|
||||
export function renderTurnFailure(failures, subject) {
|
||||
const list = Array.isArray(failures) ? failures : [];
|
||||
const lines = [
|
||||
`本次未能处理「${subject || '(无主题)'}」:ZCode 一轮都没能跑完。`,
|
||||
'',
|
||||
`已尝试 ${list.length} 次:`,
|
||||
''
|
||||
];
|
||||
list.forEach((f, i) => {
|
||||
lines.push(`${i + 1}. **${f?.kind || '失败'}**`);
|
||||
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push('常见成因(按可能性):');
|
||||
lines.push('- **没有登录**:ZCode 的模型访问要 OAuth,未登录时 headless 会直接报缺模型配置;');
|
||||
lines.push(' 在服务器上跑 `node <zcode.cjs> login`,或直接开一次客户端扫码。');
|
||||
lines.push('- **模型配置缺失**:`~/.zcode/cli/config.json` 里没有显式 provider。');
|
||||
lines.push('- **CLI 路径不对**:`AGENTMAIL_ZCODE_CLI` 指向的 `zcode.cjs` 不存在或不可执行。');
|
||||
lines.push('- **工作目录不可写**:ZCode 要在里面建会话状态。');
|
||||
lines.push('');
|
||||
lines.push('修好后可以重新把原邮件发一次,或直接回复这封信。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{agentName: string, data: any, kind?: string, reused?: boolean}} input
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false }) {
|
||||
// 权限结论不是「一封新邮件」,而是我们自己在等的那件事有了答复。
|
||||
// 当成普通邮件处理,模型会去 read_inbox 找一封其实已经不需要读的信。
|
||||
if (kind === 'permission') {
|
||||
return [
|
||||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||||
`(决策人:${data?.decided_by || '用户'})。`,
|
||||
'请据此继续后续工作。'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const fromHuman = data?.from_human === true;
|
||||
const lines = [
|
||||
inboundHeadline({
|
||||
inReplyTo: data?.in_reply_to,
|
||||
fromHuman,
|
||||
catchup: data?.catchup,
|
||||
reused
|
||||
}),
|
||||
'',
|
||||
`发件人:${data?.from_name || 'unknown'}`,
|
||||
`主题:${data?.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data?.mail_id || 'unknown'}`
|
||||
];
|
||||
|
||||
if (data?.in_reply_to) lines.push(`回的是你那封:${data.in_reply_to}`);
|
||||
if (!reused) lines.push(`身份:你是 ${agentName}`);
|
||||
// 服务端算好的回信地址。带上它是因为模型**确实会**自己发信(抄送第三方、
|
||||
// 分多封交代不同的事)。让它自己拼三维地址的话,`.new` 会被拼进去,
|
||||
// 于是回信静默开出一条新会话,原线索里再无下文。
|
||||
if (data?.reply_address) lines.push(`回信地址:${data.reply_address}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||||
'然后处理其中的请求。',
|
||||
...replyInstruction({ fromHuman, replyAddress: data?.reply_address })
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
67
plugins/zcode-mail-bridge/src/turn-mode.mjs
Normal file
67
plugins/zcode-mail-bridge/src/turn-mode.mjs
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* AgentMail 的档位 → ZCode `--mode` 的映射。
|
||||
*
|
||||
* # 为什么这个映射必须存在,而且不能想当然
|
||||
*
|
||||
* ZCode 的权限判定里有一条:
|
||||
*
|
||||
* t.mode === "yolo" ? this.allow(t, i, "mode.yolo", "Yolo mode bypasses permission prompts")
|
||||
*
|
||||
* 也就是 **`yolo` 会绕过全部权限询问**,PermissionRequest 钩子根本不会触发 ——
|
||||
* 我们的授权桥会**静默消失**(不是报错,是没有询问,看起来一切正常)。
|
||||
*
|
||||
* 而 `--prompt` 的**默认 mode 就是 `yolo`**(`--mode` 的 help 写着
|
||||
* "default: yolo for --prompt")。所以驱动若图省事不传 `--mode`,
|
||||
* 人就会以为「授权系统在管事」,实际每一条命令都已经自动放行了。
|
||||
*
|
||||
* 反过来也不能一律传 `build`:档位的意义就是三种不同的行为。
|
||||
*
|
||||
* # 依据(从 CLI 产物里读出的规则表,不是猜)
|
||||
*
|
||||
* - `checkBuildMode`:只读放行;critical/high 风险 → **ask**;
|
||||
* 有副作用 / 需要审批 → **ask**。`Bash` 属 destructive(high)→ ask;
|
||||
* `Write`/`Edit` 有 workspace 副作用 → ask。
|
||||
* - `checkEditMode`:`permissionName === "edit"` 的工作区文件编辑放行,其余退回 build。
|
||||
* - plan:`mode.plan.nonReadOnly` → 非只读一律**拒**。
|
||||
* - yolo:一律放行。
|
||||
*
|
||||
* 于是映射为:plan → plan,workspace → build,full → yolo。
|
||||
*/
|
||||
|
||||
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
|
||||
|
||||
/** ZCode 认识的 headless mode(`normalizePromptMode` 只接受这四个)。 */
|
||||
export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo'];
|
||||
|
||||
const MODE_FOR_TIER = {
|
||||
[MODE_PLAN]: 'plan',
|
||||
workspace: 'build',
|
||||
[MODE_FULL]: 'yolo'
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} tier AgentMail 的档位(plan / workspace / full)
|
||||
* @returns {'build'|'edit'|'plan'|'yolo'}
|
||||
*/
|
||||
export function zcodeModeForTier(tier) {
|
||||
const t = normalizeMode(tier) || DEFAULT_MODE;
|
||||
return MODE_FOR_TIER[t] || 'build';
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个 mode 是否会让危险操作走到我们的授权钩子。
|
||||
*
|
||||
* 用于启动自检与日志 —— 「驱动跑起来了但一次授权询问都没发生」有两种成因
|
||||
* (真没人碰危险工具 / mode 把询问绕过了),它们必须以不同的方式被看见。
|
||||
*/
|
||||
export function modeReachesPermissionHook(mode) {
|
||||
return mode === 'build' || mode === 'edit';
|
||||
}
|
||||
|
||||
/** 权限档位的人话解释,写进日志与回信里,方便复盘「当时是什么档」。 */
|
||||
export function describeTier(tier, mode = zcodeModeForTier(tier)) {
|
||||
const t = normalizeMode(tier) || DEFAULT_MODE;
|
||||
if (t === MODE_PLAN) return `${t} 档 → --mode ${mode}(只读,ZCode 自己就会拒非只读工具)`;
|
||||
if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过权限询问)`;
|
||||
return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`;
|
||||
}
|
||||
276
plugins/zcode-mail-bridge/src/zcode-run.mjs
Normal file
276
plugins/zcode-mail-bridge/src/zcode-run.mjs
Normal file
@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 跑一轮 ZCode:起一个 headless 进程,把事件流读回来,取出最终回复。
|
||||
*
|
||||
* # 为什么用 `--output-format stream-json` 而不是 `--json`
|
||||
*
|
||||
* 两者都能在最后给出 `{sessionId, response}`(这是从 CLI 产物里读出的契约:
|
||||
* 逐条事件写 `mapSessionEvent(event)`,最后补一行 `{type:"result", …}`)。
|
||||
* 差别在**过程可见**:`--json` 全程没有输出,一个卡住的回合与一个正在干活的
|
||||
* 回合在外部看起来完全一样 —— 而邮件驱动的会话没有界面,除了日志没人能看见它。
|
||||
* stream-json 让「正在做什么」进得了日志,也让超时能被归因。
|
||||
*
|
||||
* # 会话延续
|
||||
*
|
||||
* 首轮没有 `--resume`,从 `result` 行里取回 `sess_...` 存下来;后续同一
|
||||
* AgentMail 会话的信都带上 `--resume`,于是模型记得前几轮。丢了它就等于
|
||||
* 「每封信都从零开始」,而模型会表现得像没见过之前的要求。
|
||||
*
|
||||
* # 超时
|
||||
*
|
||||
* 到期杀**进程树**:ZCode 会派生工具子进程(bash 等),只杀父进程会留下一堆
|
||||
* 孤儿继续跑,而且它们还占着工作目录。
|
||||
*/
|
||||
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
|
||||
/** 默认的 CLI 入口:ZCode 自带的纯 CLI(不是 Electron 那个 GUI 入口)。 */
|
||||
export const DEFAULT_CLI = '/opt/ZCode/resources/glm/zcode.cjs';
|
||||
|
||||
const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 拼出一次 headless 调用的参数表。
|
||||
*
|
||||
* 单独抽出来是为了可测:`--mode` 漏掉会**静默绕过全部授权询问**
|
||||
* (`--prompt` 的默认 mode 是 yolo),这种缺陷不会报错,只会让授权系统消失。
|
||||
*/
|
||||
export function buildRunArgs({
|
||||
prompt,
|
||||
cwd,
|
||||
mode,
|
||||
maxTurns,
|
||||
resumeSessionId,
|
||||
allowedTools,
|
||||
disallowedTools,
|
||||
cliPath = DEFAULT_CLI
|
||||
}) {
|
||||
const args = [
|
||||
cliPath,
|
||||
'--prompt',
|
||||
prompt,
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--cwd',
|
||||
cwd
|
||||
];
|
||||
// mode 必传:不传就是 yolo,等于关掉授权。
|
||||
if (!mode) throw new Error('buildRunArgs 需要 mode(不传等于 yolo,会绕过授权询问)');
|
||||
args.push('--mode', mode);
|
||||
if (maxTurns) args.push('--max-turns', String(maxTurns));
|
||||
if (resumeSessionId) args.push('--resume', resumeSessionId);
|
||||
if (Array.isArray(allowedTools) && allowedTools.length) {
|
||||
args.push('--allowed-tools', allowedTools.join(','));
|
||||
}
|
||||
if (Array.isArray(disallowedTools) && disallowedTools.length) {
|
||||
args.push('--disallowed-tools', disallowedTools.join(','));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一行 stream-json 输出的**纯函数**部分。
|
||||
*
|
||||
* 返回 `null` 表示这行不是我们要的(非 JSON、或没有意义的行)——
|
||||
* 调用方据此计数,好让「输出格式变了」这件事能被发现,而不是静默当成没输出。
|
||||
*
|
||||
* @returns {{kind:'event'|'result', event?:any, sessionId?:string, response?:string}|null}
|
||||
*/
|
||||
export function parseStreamLine(line) {
|
||||
const text = String(line ?? '').trim();
|
||||
if (!text || text[0] !== '{') return null;
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
if (obj.type === 'result') {
|
||||
return {
|
||||
kind: 'result',
|
||||
sessionId: typeof obj.sessionId === 'string' ? obj.sessionId : '',
|
||||
response: typeof obj.response === 'string' ? obj.response : '',
|
||||
eventCount: typeof obj.eventCount === 'number' ? obj.eventCount : undefined,
|
||||
projection: obj.projection
|
||||
};
|
||||
}
|
||||
return { kind: 'event', event: obj };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一轮。
|
||||
*
|
||||
* @param {{prompt:string, cwd:string, mode:string, maxTurns?:number,
|
||||
* resumeSessionId?:string, turnTimeoutMs?:number,
|
||||
* env?:Record<string,string>, cliPath?:string, nodePath?:string,
|
||||
* onChild?:(kill:(signal?:string)=>void)=>void}} opts
|
||||
* @param {{spawn?:Function, log?:Function}} [deps] spawn 可注入以便测试
|
||||
* @returns {Promise<{sessionId:string, response:string, events:any[], exitCode:number,
|
||||
* unparsable:number, timedOut:boolean, killed:boolean,
|
||||
* stderrTail:string, argv:string[]}>}
|
||||
*/
|
||||
export function runTurn(opts, deps = {}) {
|
||||
const spawn = deps.spawn || nodeSpawn;
|
||||
const log = deps.log || (() => {});
|
||||
const nodePath = opts.nodePath || process.execPath;
|
||||
const args = buildRunArgs(opts);
|
||||
const timeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
||||
// 宽限期:SIGTERM 之后等多久 SIGKILL;再等多久就无论如何收尾。
|
||||
// 可配是为了测试 —— 生产用默认值。
|
||||
const killGraceMs = opts.killGraceMs ?? 5000;
|
||||
const settleGraceMs = opts.settleGraceMs ?? 2000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
return new Promise(resolve => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(nodePath, args, {
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// 自己建进程组,超时时能整组杀 —— 否则 ZCode 派生的工具子进程会活下来。
|
||||
detached: process.platform !== 'win32'
|
||||
});
|
||||
} catch (e) {
|
||||
resolve({
|
||||
sessionId: '',
|
||||
response: '',
|
||||
events: [],
|
||||
exitCode: -1,
|
||||
unparsable: 0,
|
||||
timedOut: false,
|
||||
killed: false,
|
||||
stderrTail: `spawn 失败:${e?.message || e}`,
|
||||
argv: args
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const events = [];
|
||||
let result = null;
|
||||
let unparsable = 0;
|
||||
let stdoutBuf = '';
|
||||
const stderrLines = [];
|
||||
let timedOut = false;
|
||||
let killed = false;
|
||||
let settled = false;
|
||||
|
||||
const killTree = signal => {
|
||||
killed = true;
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
// 负号 = 整个进程组
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* 已经没了 */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
log(`回合超时(${Math.round(timeoutMs / 1000)} 秒),终止进程树`);
|
||||
killTree('SIGTERM');
|
||||
// 给它一点时间优雅退出;不走就强杀(工具子进程不会自己收手)。
|
||||
//
|
||||
// 这几个定时器**刻意不 unref**:在途的回合应该让进程活着。
|
||||
// unref 过的定时器会让「没有其它活跃句柄」的进程直接退出,
|
||||
// 于是收尾这段代码根本没机会跑(测试里就是这样暴露的)。
|
||||
setTimeout(() => killTree('SIGKILL'), killGraceMs);
|
||||
// ★ 最后兵底:**必须**让这一轮结束。
|
||||
//
|
||||
// 没有这一步时,一个既不退也不报错的孩子会让 Promise 永不 settle ——
|
||||
// 驱动会对那封信永远挂住,而且队列是串行的,后面的信全都不再被处理。
|
||||
// 杀掉进程本来就应该算「这一轮结束了」,而不是「再等等看」。
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
log('强杀后仍未退出,按超时收尾(不再等)');
|
||||
finish(-1);
|
||||
}, killGraceMs + settleGraceMs);
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = exitCode => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
sessionId: result?.sessionId || '',
|
||||
response: result?.response || '',
|
||||
events,
|
||||
exitCode,
|
||||
unparsable,
|
||||
timedOut,
|
||||
killed,
|
||||
stderrTail: stderrLines.slice(-8).join('\n'),
|
||||
argv: args
|
||||
});
|
||||
};
|
||||
|
||||
// 把「怎么杀」交给调用方:驱动在收到 SIGTERM 时要能终止在途的回合。
|
||||
// 不交出去的话,systemd 杀掉驱动之后那个 ZCode 进程还在跑工具,
|
||||
// 而没有任何人(也没有任何界面)看着它。
|
||||
if (typeof opts.onChild === 'function') {
|
||||
try {
|
||||
opts.onChild(killTree);
|
||||
} catch {
|
||||
/* 调用方自己的问题,不影响这一轮 */
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.on('data', chunk => {
|
||||
stdoutBuf += chunk.toString('utf8');
|
||||
let nl;
|
||||
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
||||
const line = stdoutBuf.slice(0, nl);
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1);
|
||||
const parsed = parseStreamLine(line);
|
||||
if (!parsed) {
|
||||
// 空行是正常的(CLI 用空格分隔事件),只统计真的不像 JSON 的行。
|
||||
if (line.trim()) unparsable++;
|
||||
continue;
|
||||
}
|
||||
if (parsed.kind === 'result') {
|
||||
result = parsed;
|
||||
} else {
|
||||
events.push(parsed.event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', chunk => {
|
||||
for (const line of chunk.toString('utf8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
stderrLines.push(t);
|
||||
// 实时转出去 —— 邮件驱动没有界面,日志是唯一能看见它在干什么的地方。
|
||||
log(t);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', e => {
|
||||
stderrLines.push(`进程错误:${e?.message || e}`);
|
||||
finish(-1);
|
||||
});
|
||||
|
||||
child.on('close', code => {
|
||||
// 收尾:最后一行可能没有换行
|
||||
if (stdoutBuf.trim()) {
|
||||
const parsed = parseStreamLine(stdoutBuf);
|
||||
if (parsed?.kind === 'result') result = parsed;
|
||||
else if (parsed?.kind === 'event') events.push(parsed.event);
|
||||
else unparsable++;
|
||||
}
|
||||
log(
|
||||
`回合结束:退出码 ${code},事件 ${events.length} 条,` +
|
||||
`会话 ${result?.sessionId || '(无)'},耗时 ${Math.round((Date.now() - startedAt) / 1000)} 秒`
|
||||
);
|
||||
finish(code ?? -1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user