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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user