Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

本仓库把 Codex 的任务派发给本机 ZCode 执行。任何 agent 动手前先读这份文件。
本仓库把 Codex(或 Claude Code,见 ADR-010)的任务派发给本机 ZCode 执行。任何 agent 动手前先读这份文件。

## 阅读顺序

Expand Down
6 changes: 4 additions & 2 deletions docs/INTERFACES.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 当前接口与兼容边界

> Status: AUTHORITATIVE
> Last updated: 2026-10-15
> Last verified: 2026-10-15,最新上游整合分支的验证结果见[审查说明](reports/2026-10-15-browser-and-transport-fixes.md);旧版本实测与当前分支回归分别记录。
> Last updated: 2026-10-22
> Last verified: 2026-10-15,最新上游整合分支的验证结果见[审查说明](reports/2026-10-15-browser-and-transport-fixes.md);旧版本实测与当前分支回归分别记录。2026-10-22 新增的等待器段落只经 `test/wait-task.test.ts` 与 `validate:plugin` 核对,其余段落未重新核对。
> Verified against: 上游fae74e45033a50fb560c0014d1a6d60a7dc771d1 + 本分支修复;受测源码指纹见审查说明。

准确类型与 schema 以 `src/interfaces.ts` 和 `src/mcp/schemas.ts` 为准。核心公共入口见 [SHARED_CORE.md](SHARED_CORE.md)。历史源码注释中的 V0.1/FROZEN 是沿革说明,不代表当前新增功能已经冻结。
Expand All @@ -21,6 +21,8 @@ zcode_events 使用单调 seq cursor、limit 1–200、wait_ms 0–25,000、raw/

`zcode_feedback` 是独立的 `TaskFeedbackSnapshotV01` v0.1 聚合层,含 `schema_version: "0.1"`,并返回结构化 snapshot 与简洁文本。它不替代 `zcode_events`,也不改变 `TaskResult`、状态接口或已持久化记录。字段来源分为 Bridge task state(task、attempt、status、起止时间和墙钟 duration)、runtime-observed(只接受当前 attempt 规范化 `model_selected`、`model_tool_call`、`tool_status` 事件中的 allowlisted 字段)和 Agent report(仅当前 attempt 出现 `report_ready`、结果 attempt/status 匹配且结果为 `completed` 或 `waiting_for_master` 时,标记 `source: "agent_report"`)。模型不从请求或 catalog 回退;runtime model 缺失时为 `null`。`phase` 与 `progress` 固定为 `null`。activity 使用 Bridge 持久化事件时生成的 observation timestamp,语义固定为 `last_observed`;不表达工具仍在运行。交互状态固定为 `not_observed`,表示 snapshot 未确立可靠的当前交互状态,不表示历史中没有交互或没有待处理请求;原始交互事件仍由 `zcode_events` 提供。该 snapshot 不包含 runtime turn outcome 或 Bridge failure reason 字段,因此文本只显示可由 Bridge status 支持的完成/失败状态,不推断 turn 成功、目标完成或主机验收。Agent 报告中的 summary、文件、tests 与 issues 均不是 Bridge 独立验证结果。

宿主侧等待器(ADR-010)不是 MCP 工具,也不在控制面内:`plugins/codex-zcode-bridge/scripts/wait-task.mjs --task <id> [--after-seq N] [--timeout-s 1..86400] [--poll-ms 250..60000] [--data-dir ABS]` 只读 `status.json`、`events.jsonl` 和 `events.gen`,不写任何文件。它在 `after_seq` 之后出现未答复交互、任务终态或期限到达时退出,输出 `zcode-bridge/wait-task-v1` JSON(`reason`、`status`、`attempt`、`next_after_seq`、`pending`、`recent_output`)。退出码 0 表示需要宿主处理,3 表示超时,2 表示参数或读取错误。`pending.preview` 为 true 时只给出预览和 `full_request` 引用。宿主仍须通过 `zcode_events` 与 `zcode_interaction_reply` 决定和执行,不能凭等待器输出批准或判定完成。数据根解析与插件模式相同,其他启动方式须传 `--data-dir`。

zcode_interaction_reply 必须引用当前 attempt 的 interaction_requested.request_id,不能复用旧 ID。permission 只接受 allow/deny;user input 接受 accept/decline,answers 以完整问题文本为键。工具 schema 与 TaskManager 校验回答长度、支持的问题和允许选项。任务约束与路径声明不是 OS 权限控制。

大审批输入事件与完整性边界(ADR-008):审批 params 完整私有保存至 1 MiB UTF-8 bytes(计量单位是 `JSON.stringify(params)` 的紧凑序列化,不是整个 JSON 记录文件)。单事件行仍守 64,000 bytes:小请求保持既有完整字段形状;超限请求发布为 `preview: true` 的有界预览(`input_preview`、`input_bytes`、可选 `questions_preview`/`options_preview`),并附 `full_request` 引用(同任务/attempt 私有记录的 `file` 路径、`bytes`、`sha256`)。`bytes`/`sha256` 对应上述序列化 params,宿主复核时用同一单位,且预览不是全文,需要全文必须读引用记录。allow/accept 在 interaction 锁内校验记录与字节/哈希一致后落盘:完全无新元数据且 <=旧 32,000-byte 上限的记录按 legacy 兼容;缺/错类型元数据、不匹配、或验证时超过 1 MiB 的一律返回 TASK_STATE 且不记为已授权。等待中记录消失、损坏或与最初请求不一致时 worker 明确 decline(`interaction_request_undeliverable` 事件,`interaction_record_missing`/`interaction_record_unreadable`/`interaction_record_mismatch`),不悬挂轮询。超过 1 MiB 的请求以 `interaction_request_oversized` 事件(actual/limit)明确拒绝并建议拆分输入。SHA256/bytes 是损坏与意外改动的核对手段,不是对同 OS 账户恶意篡改的安全隔离。
Expand Down
29 changes: 29 additions & 0 deletions docs/decisions/ADR-010-claude-code-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ADR-010:Claude Code 作为宿主的增量接入

> Status: DECISION / Implemented proposal for maintainer review
> Date: 2026-10-22

## Context

MCP 服务本身与宿主无关:工具描述已写作 "the calling host"。本机 Claude Code 通过用户级 `mcpServers` 直接运行 `plugins/codex-zcode-bridge/dist/bridge.mjs`,并与 Codex 共用 `runtime-config.json` 指定的数据根。缺口在宿主侧:

- Skill 只把 Codex 写成宿主,包括 `create_goal`、“Codex 项目根目录”和 token 行。Claude Code 没有 Goal 工具,照抄会误报“已启用持久续行”。
- Claude Code 的长等待机制是后台命令退出后通知宿主。用 `zcode_events` 的 `wait_ms`(上限 25 秒)等待,宿主每轮都要重复调用工具,auto mode 下每次调用还要经过权限分类。观察到的后果是:长任务的宿主等待被拆成大量短调用。

## Decision

1. 新增只读等待器 `plugins/codex-zcode-bridge/scripts/wait-task.mjs`,只依赖 Node 内置模块,从已安装插件直接运行。
- 读取:`status.json`、`events.jsonl`(按字节游标增量读取)、`events.gen`。
- 退出时机:`after_seq` 之后出现尚未答复的 `interaction_requested`(按去掉 `<n>:` 前缀的 request_id,与 `interaction_reply_submitted`、`interaction_replied`、`interaction_request_undeliverable`、`interaction_request_oversized` 配对);或任务进入终态;或到达期限。
- 输出 `zcode-bridge/wait-task-v1` JSON。退出码:0 表示需要宿主处理,3 表示超时,2 表示参数或读取错误。
- 代际标记变化或文件变短时从头重扫,不信任旧游标。
2. 等待器按 ADR-004 只是观察,不属于控制面。它不写任何文件,不改变任务状态,也不替代 `zcode_events`。宿主不得仅凭它的输出批准交互或判定完成;`preview: true` 时输出的只是预览,仍须读取 `full_request`。
3. 数据根复刻插件模式:`runtime-config.json` 中的 `ZCODE_BRIDGE_DATA_DIR` 优先于环境变量,空值或 null 视为清除;未设时取 `~/.codex/codex-zcode-bridge`;`--data-dir` 优先于以上全部。legacy 设置目录不在复刻范围内,这种情况用 `--data-dir`。
4. Skill 改为宿主中立,新增“Claude Code 宿主”一节,写明等待器用法、权限与 auto mode 的边界、token 行写法和项目指令加载方式。Codex 的 Goal 规则保持不变。
5. 不改 MCP 工具名、schema、任务状态语义和隐私边界,不新增运行时依赖。本次不新增 `.claude-plugin/` 清单:现有 `hooks/hooks.json` 与 `.mcp.json` 按 Codex 约定编写(`${PLUGIN_ROOT}`、相对 `cwd`),在 Claude Code 插件加载实测前,不声明 Bridge 可作为 Claude Code 插件安装。

## Consequences

- 等待器依赖任务存储的文件布局:原子替换的 `status.json`、追加写入的 `events.jsonl` 和一次性生成的 `events.gen`。布局变化时必须同步修改等待器及其测试。
- 等待器只报告 `after_seq` 之后的交互。宿主须传入最近一次读到的游标;早于游标仍未答复的请求,应通过 `zcode_events` 处理。
- 验证范围:`test/wait-task.test.ts` 覆盖终态、交互配对与游标过滤、预览引用、超时、追加与半行、代际重写、数据根优先级、参数拒绝,以及“不写文件”的快照核对;`validate:plugin` 检查文件存在,且只导入 `node:` 内置模块。Claude Code 后台通知的真实链路只在本机 RecShop 任务中观察过,未做跨版本验证。
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ADR-001 到 ADR-003 只搬运权威文档里已经写明的结论。ADR-004 原
- [ADR-007](ADR-007-browser-host-rpc.md):可选headless浏览器宿主;默认关闭,限定版本实测。
- [ADR-008](ADR-008-session-frames-and-large-interactions.md):会话帧与大审批输入;独立资源边界及可恢复错误。
- [ADR-009](ADR-009-windows-identity-bound-tree-termination.md):Windows 进程树身份绑定归属与终止;句柄事务、零容差区间归属、单调失败锁存与有界传输。
- [ADR-010](ADR-010-claude-code-host.md):Claude Code 作为宿主的增量接入;只读任务等待器与宿主中立 Skill,MCP 合同不变。

## 命名

Expand Down
236 changes: 236 additions & 0 deletions plugins/codex-zcode-bridge/scripts/wait-task.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/usr/bin/env node
// Read-only task waiter for hosts that are notified when a background command
// exits (for example Claude Code `run_in_background`). It reads the Bridge task
// store and returns when the task needs the host: a pending interaction, a
// terminal status, or the deadline. It never writes Bridge state and is not a
// control path; act through the MCP tools (ADR-004, ADR-010).
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const TERMINAL = new Set(["completed", "failed", "cancelled", "waiting_for_master"]);
const SETTLED = new Set(["interaction_reply_submitted", "interaction_replied", "interaction_request_undeliverable", "interaction_request_oversized"]);
const INPUT_LIMIT = 4_000;
const OUTPUT_LIMIT = 1_500;
const SCHEMA = "zcode-bridge/wait-task-v1";

class UsageError extends Error {}

function parseArgs(argv) {
const options = { task: null, afterSeq: 0, timeoutS: 1_500, pollMs: 2_000, dataDir: null };
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
const value = argv[index + 1];
if (flag === "--help" || flag === "-h") throw new UsageError("");
if (value === undefined) throw new UsageError(`missing value for ${flag}`);
index += 1;
if (flag === "--task") options.task = value;
else if (flag === "--after-seq") options.afterSeq = integer(flag, value, 0, Number.MAX_SAFE_INTEGER);
else if (flag === "--timeout-s") options.timeoutS = integer(flag, value, 1, 86_400);
else if (flag === "--poll-ms") options.pollMs = integer(flag, value, 250, 60_000);
else if (flag === "--data-dir") options.dataDir = value;
else throw new UsageError(`unknown option ${flag}`);
}
if (!options.task || !TASK_ID.test(options.task)) throw new UsageError("--task must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$");
return options;
}

function integer(flag, value, min, max) {
if (!/^\d+$/u.test(value)) throw new UsageError(`${flag} must be an integer`);
const parsed = Number(value);
if (parsed < min || parsed > max) throw new UsageError(`${flag} must be within ${min}..${max}`);
return parsed;
}

// Mirrors the plugin-mode data root: runtime-config.json overrides the
// environment (empty or null clears it), and an unset value falls back to
// the host settings directory. --data-dir overrides everything.
export function resolveDataDir(flag, env = process.env, home = homedir()) {
if (flag) return requireAbsolute(flag, "--data-dir");
const settingsDirectory = path.join(home, ".codex", "codex-zcode-bridge");
let value = env["ZCODE_BRIDGE_DATA_DIR"]?.trim() ?? "";
try {
const parsed = JSON.parse(readFileSync(path.join(settingsDirectory, "runtime-config.json"), "utf8"));
if (parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "ZCODE_BRIDGE_DATA_DIR")) {
const configured = parsed["ZCODE_BRIDGE_DATA_DIR"];
value = typeof configured === "string" ? configured.trim() : "";
}
} catch (error) {
if (error?.code !== "ENOENT") throw new Error(`cannot read Bridge runtime-config.json: ${error instanceof Error ? error.message : String(error)}`);
}
return requireAbsolute(value || settingsDirectory, "ZCODE_BRIDGE_DATA_DIR");
}

function requireAbsolute(value, label) {
if (!path.isAbsolute(value)) throw new UsageError(`${label} must be an absolute path: ${value}`);
return value;
}

function readStatus(taskDir) {
try {
const parsed = JSON.parse(readFileSync(path.join(taskDir, "status.json"), "utf8"));
return parsed && typeof parsed.status === "string" ? parsed : null;
} catch (error) {
if (error?.code === "ENOENT") throw new Error(`task not found: ${path.basename(taskDir)}`);
return null; // A concurrent atomic replace can be retried on the next poll.
}
}

function requestKey(requestId) {
return typeof requestId === "string" ? requestId.replace(/^\d+:/u, "") : null;
}

// Tails events.jsonl from a byte cursor. A shorter file or a new generation
// marker means the log was replaced; the scan restarts from the beginning.
class EventTail {
constructor(taskDir) {
this.file = path.join(taskDir, "events.jsonl");
this.genFile = path.join(taskDir, "events.gen");
this.offset = 0;
this.partial = "";
this.generation = null;
}

read() {
let generation = null;
try { generation = readFileSync(this.genFile, "utf8").trim(); } catch { /* not created yet */ }
let size = 0;
try { size = statSync(this.file).size; } catch (error) { if (error?.code === "ENOENT") return { events: [], reset: false }; throw error; }
let reset = false;
if ((this.generation !== null && generation !== this.generation) || size < this.offset) {
this.offset = 0;
this.partial = "";
reset = true;
}
this.generation = generation;
if (size === this.offset) return { events: [], reset };
const length = size - this.offset;
const buffer = Buffer.alloc(length);
const fd = openSync(this.file, "r");
try {
let done = 0;
while (done < length) {
const count = readSync(fd, buffer, done, length - done, this.offset + done);
if (count === 0) break;
done += count;
}
this.offset += done;
const text = this.partial + buffer.subarray(0, done).toString("utf8");
const lines = text.split("\n");
this.partial = lines.pop() ?? "";
const events = [];
for (const line of lines) {
if (!line.trim()) continue;
try { events.push(JSON.parse(line)); } catch { /* skip a damaged line */ }
}
return { events, reset };
} finally {
closeSync(fd);
}
}
}

function truncate(text, limit) {
return text.length <= limit ? { text, truncated: false } : { text: `${text.slice(0, limit)}…`, truncated: true };
}

function describePending(event) {
const details = event.details ?? {};
const rawInput = details.preview ? details.input_preview : details.input;
const input = truncate(typeof rawInput === "string" ? rawInput : JSON.stringify(rawInput ?? null), INPUT_LIMIT);
return {
seq: event.seq,
request_id: details.request_id ?? null,
method: details.method ?? null,
tool: details.toolName ?? details.tool_name ?? null,
reason: details.reason ?? null,
preview: details.preview === true,
input: input.text,
input_truncated: input.truncated,
full_request: details.full_request ?? null,
questions: Array.isArray(details.questions) ? details.questions : undefined,
};
}

export async function waitForTask(options, now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
const dataDir = resolveDataDir(options.dataDir);
const taskDir = path.join(dataDir, ".tasks", options.task);
const tail = new EventTail(taskDir);
const started = now();
const deadline = started + options.timeoutS * 1_000;
const pending = new Map();
let lastSeq = options.afterSeq;
let recent = "";
let seen = 0;
for (;;) {
const status = readStatus(taskDir);
const { events, reset } = tail.read();
if (reset) { pending.clear(); recent = ""; seen = 0; lastSeq = options.afterSeq; }
for (const event of events) {
if (typeof event.seq !== "number" || event.seq <= options.afterSeq) continue;
seen += 1;
lastSeq = Math.max(lastSeq, event.seq);
const key = requestKey(event.details?.request_id);
if (event.type === "interaction_requested" && key) pending.set(key, event);
else if (SETTLED.has(event.type) && key) pending.delete(key);
else if (event.type === "model_output" && typeof event.summary === "string") recent = (recent + event.summary).slice(-OUTPUT_LIMIT * 2);
}
const first = [...pending.values()].sort((a, b) => a.seq - b.seq)[0];
const terminal = status && TERMINAL.has(status.status);
const reason = first ? "interaction" : terminal ? "terminal" : now() >= deadline ? "timeout" : null;
if (reason) {
return {
schema: SCHEMA,
task_id: options.task,
reason,
status: status?.status ?? null,
attempt: status?.attempt ?? null,
elapsed_ms: now() - started,
next_after_seq: lastSeq,
events_seen: seen,
pending: first ? describePending(first) : null,
pending_count: pending.size,
recent_output: recent.slice(-OUTPUT_LIMIT),
note: "Observation only. Decide and act through zcode_events/zcode_interaction_reply; a preview is not the full request, and completed is not acceptance.",
};
}
await sleep(Math.min(options.pollMs, Math.max(0, deadline - now())));
}
}

async function main() {
let options;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
if (!(error instanceof UsageError)) throw error;
if (error.message) console.error(`wait-task: ${error.message}`);
console.error("usage: node wait-task.mjs --task <task_id> [--after-seq N] [--timeout-s S] [--poll-ms MS] [--data-dir ABS_PATH]");
process.exitCode = 2;
return;
}
try {
const result = await waitForTask(options);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
process.exitCode = result.reason === "timeout" ? 3 : 0;
} catch (error) {
console.error(`wait-task: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 2;
}
}

// Run only as the process entry point so tests can import the functions.
function isEntry() {
if (process.argv[1] === undefined) return false;
try {
const self = realpathSync(fileURLToPath(import.meta.url));
const entry = realpathSync(path.resolve(process.argv[1]));
return process.platform === "win32" ? self.toLowerCase() === entry.toLowerCase() : self === entry;
} catch {
return false;
}
}

if (isEntry()) await main();
Loading
Loading