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:
2026-09-12 14:58:36 +08:00
parent 4b129b5f49
commit c5e1d562eb
23 changed files with 2912 additions and 52 deletions

View 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);
});
}

View 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');
}

View 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` 属 destructivehigh→ ask
* `Write`/`Edit` 有 workspace 副作用 → ask。
* - `checkEditMode``permissionName === "edit"` 的工作区文件编辑放行,其余退回 build。
* - plan`mode.plan.nonReadOnly` → 非只读一律**拒**。
* - yolo一律放行。
*
* 于是映射为plan → planworkspace → buildfull → 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 授权钩子)`;
}

View 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);
});
});
}