diff --git a/AGENTS.md b/AGENTS.md index 78e59c3..02bed91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -本仓库把 Codex 的任务派发给本机 ZCode 执行。任何 agent 动手前先读这份文件。 +本仓库把 Codex(或 Claude Code,见 ADR-010)的任务派发给本机 ZCode 执行。任何 agent 动手前先读这份文件。 ## 阅读顺序 diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 70872d1..34a2624 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -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 是沿革说明,不代表当前新增功能已经冻结。 @@ -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 [--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 账户恶意篡改的安全隔离。 diff --git a/docs/decisions/ADR-010-claude-code-host.md b/docs/decisions/ADR-010-claude-code-host.md new file mode 100644 index 0000000..5d66ce0 --- /dev/null +++ b/docs/decisions/ADR-010-claude-code-host.md @@ -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`(按去掉 `:` 前缀的 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 任务中观察过,未做跨版本验证。 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d57b8c2..9b0cb95 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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 合同不变。 ## 命名 diff --git a/plugins/codex-zcode-bridge/scripts/wait-task.mjs b/plugins/codex-zcode-bridge/scripts/wait-task.mjs new file mode 100644 index 0000000..a035d43 --- /dev/null +++ b/plugins/codex-zcode-bridge/scripts/wait-task.mjs @@ -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 [--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(); diff --git a/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md b/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md index ecaedd4..f16a826 100644 --- a/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md +++ b/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md @@ -1,30 +1,40 @@ --- name: zcode-bridge -description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 ZCode,跟进实时进度、选择模型、按需要由 Codex 准备 worktree、审查改动并决定如何接收。 +description: 通过 ZCode Bridge 把已授权的开发任务交给本机 ZCode(宿主为 Codex 或 Claude Code),跟进实时进度、选择模型、按需要由宿主准备 worktree、审查改动并决定如何接收。 --- # Zcode Bridge -你是 Master,ZCode 是执行一个有明确边界任务的 worker。仅委派用户已授权的实现工作。不同执行目录可并发;不得让 Codex 与 ZCode 同时修改同一个实际执行目录。Bridge 会串行化共享执行目录的任务。 +你是 Master(宿主为 Codex 或 Claude Code,下文统称“宿主”),ZCode 是执行一个有明确边界任务的 worker。仅委派用户已授权的实现工作。不同执行目录可并发;不得让宿主与 ZCode 同时修改同一个实际执行目录。Bridge 会串行化共享执行目录的任务。 ## 持续目标 -- 用户明确要求跨轮持续推进,例如“持续做到项目完成或我让你停止”时,把它视为当前 Codex 线程的长期目标;在派发首个 ZCode 任务前调用 `create_goal`,不要只在普通回复里复述持续意图。目标写明用户授权的最终结果、独立审查/验证要求、接收改动的条件,以及用户叫停或必须等待用户决定时的停止条件。不得借此扩大用户授权;除非用户明确指定,不设置 Goal token budget。 +- 用户明确要求跨轮持续推进,例如“持续做到项目完成或我让你停止”时,把它视为当前宿主会话的长期目标。Codex 在派发首个 ZCode 任务前调用 `create_goal`(Claude Code 见下方“Claude Code 宿主”),不要只在普通回复里复述持续意图。目标写明用户授权的最终结果、独立审查/验证要求、接收改动的条件,以及用户叫停或必须等待用户决定时的停止条件。不得借此扩大用户授权;除非用户明确指定,不设置 Goal token budget。 - 单次、有明确边界的委派或一次性问答不创建 Goal。若当前环境没有 Goal 工具,明确告知用户无法启用持久续行,不要声称已经创建。 - Goal 属于当前线程。保持它活动,直到最终目标经证据核验完成;Bridge 的 `completed`、ZCode 的完成报告或“任务仍在运行”都不单独满足完成条件。只有所有必要任务终态、改动完成独立审查和适用验证、授权范围内的改动已接收到目标工作区,才可完成 Goal。 -- 若 Codex 当前轮结束时 Bridge 任务仍在运行,保留活动 Goal 并说明当前状态,让 Goal 在可继续时接手轮询;不要把中间状态作为最终结论或自行暂停 Goal。用户要求停止时遵从其指示。 +- 若宿主当前轮结束时 Bridge 任务仍在运行,保留活动 Goal 并说明当前状态,让 Goal 在可继续时接手轮询;不要把中间状态作为最终结论或自行暂停 Goal。用户要求停止时遵从其指示。 + +## Claude Code 宿主 + +以下只在宿主为 Claude Code 时适用;其他规则两种宿主相同。 + +- 续行:Claude Code 没有 Goal 工具,不要声称已创建 Goal。派发或回复交互后,用后台命令等待(Bash 的 `run_in_background`):`node <插件目录>/scripts/wait-task.mjs --task --after-seq <上次 next_seq> --timeout-s 1500`。它在出现未答复交互、任务终态或超时时退出,宿主会收到完成通知;不要用前台 sleep 或循环反复调用 `zcode_events` 等待。退出码 0 表示需要处理(`reason` 为 `interaction` 或 `terminal`),3 表示超时,以输出中的 `next_after_seq` 重新等待;2 是参数或读取错误,先报告原因。用户要求跨会话持续推进时,可用 `/loop` 或计划唤醒作长间隔兜底,但仍不是持久 Goal。 +- 等待器只做观察:它只读任务存储,不改状态、不代替 `zcode_events`。处理前仍用 `zcode_events` 读取对应事件;`pending.preview` 为 true 时输入只是预览,按跟进第 5 步读取 `full_request` 后再决定。不得仅凭等待器输出批准请求或判定完成。 +- 数据目录:等待器与 Bridge 插件模式取同一数据根(`runtime-config.json` 的 `ZCODE_BRIDGE_DATA_DIR` 优先于环境变量,未设时为 `~/.codex/codex-zcode-bridge`);Bridge 以其他方式启动时用 `--data-dir` 传绝对路径。 +- 权限:Claude Code 调用 Bridge MCP 工具受其自身权限与 auto mode 约束。放行规则写在用户或项目的 `settings.local.json`(如 `mcp__zcode_bridge`),不写进入库的项目设置。宿主侧被拒绝时停止并向用户说明原因与所需授权,不换工具绕过。 +- 项目指令:Claude Code 读取 `CLAUDE.md`(可用 `@AGENTS.md` 导入既有约束);任务包仍引用项目约束文档的路径,不假定 ZCode 已读过宿主对话。 ## 派发 -1. 确认 Codex 项目根目录,并始终把它作为 zcode_task 的 `workspace`。根据 Codex 对任务的执行指示决定是否需要 worktree;需要时由 Codex 创建并准备,再把其现存绝对路径作为 `worktree_path`。Bridge 不创建、选择或删除 worktree;未提供该字段时任务直接在项目根目录执行。 -2. 将 objective、requirements、allowed_paths、forbidden_paths、acceptance_criteria 和 test_commands 写具体,路径约束只是任务指令,不是强制沙箱。只在任务需要时传入精简的 `context`,按 `PROJECT DECISIONS`、`CONSTRAINTS`、`RELEVANT FILES`、`OPEN DECISIONS — DO NOT CHOOSE` 组织;不要复制整段 Codex 对话。没有未决事项时省略该小节。不要把验收责任交给 ZCode。 +1. 确认宿主项目根目录,并始终把它作为 zcode_task 的 `workspace`。根据宿主对任务的执行指示决定是否需要 worktree;需要时由宿主创建并准备,再把其现存绝对路径作为 `worktree_path`。Bridge 不创建、选择或删除 worktree;未提供该字段时任务直接在项目根目录执行。 +2. 将 objective、requirements、allowed_paths、forbidden_paths、acceptance_criteria 和 test_commands 写具体,路径约束只是任务指令,不是强制沙箱。只在任务需要时传入精简的 `context`,按 `PROJECT DECISIONS`、`CONSTRAINTS`、`RELEVANT FILES`、`OPEN DECISIONS — DO NOT CHOOSE` 组织;不要复制整段宿主对话。没有未决事项时省略该小节。不要把验收责任交给 ZCode。 3. 用户指定 ZCode 模型时,传入 model: { provider_id, model_id }。优先使用 runtime 报告的精确 provider/model 标识;Bridge 会识别配置中声明的旧版 builtin ID 和账户 provider ID。若模型预检失败,从 `model_catalog` 事件复制 runtime 实际报告的完整标识再重试;不要猜思考档位。用户未指定时省略 model,保留 ZCode session 默认模型。 4. 调用 zcode_task。MCP 报错时先解释和解决具体配置问题,不要重复提交相同 task_id。 ## 跟进 -1. `zcode_task` 返回后立即调用 `zcode_events`(`after_seq: 0, view: "summary"`),不要先做别的工作或只复述 queued receipt。先报告 `workspace_ready` 中的 `project_path`、`execution_path`(如有则说明 Codex 准备的 worktree)和 queued/running 状态。按 `next_seq` 和 `wait_ms` 继续读取,直到出现 `turn_started`、明确启动失败或终态;不要忙轮询。 -2. 在 `turn_started` 后、等待模型输出前,先向用户报告:Codex 项目根目录、实际执行目录、ZCode session ID、runtime 实际报告的 provider/model、runtime 实际报告的思考档位和执行模式。思考档位从 `session_ready.details.reasoning_level` 或 `model_selected.details.reasoning_level` 读取;若只有 `requested_reasoning_level`,说明这是请求值而不是 runtime 确认值;两者都没有时明确说 runtime 未报告档位,不要用模型目录的默认档位冒充当前档位。模式来自 Bridge 配置,默认 `yolo`;若为 `yolo`,明确提醒普通工具操作可能不经审批且使用当前 OS 账户权限,并说明可将 `ZCODE_BRIDGE_MODE` 设为 `build`。若使用 worktree,说明它不是 OS 沙箱。模型字段缺失时明确说 runtime 没有报告;不要把用户请求的模型或项目默认值猜成实际已选模型。 +1. `zcode_task` 返回后立即调用 `zcode_events`(`after_seq: 0, view: "summary"`),不要先做别的工作或只复述 queued receipt。先报告 `workspace_ready` 中的 `project_path`、`execution_path`(如有则说明宿主准备的 worktree)和 queued/running 状态。按 `next_seq` 和 `wait_ms` 继续读取,直到出现 `turn_started`、明确启动失败或终态;不要忙轮询。 +2. 在 `turn_started` 后、等待模型输出前,先向用户报告:宿主项目根目录、实际执行目录、ZCode session ID、runtime 实际报告的 provider/model、runtime 实际报告的思考档位和执行模式。思考档位从 `session_ready.details.reasoning_level` 或 `model_selected.details.reasoning_level` 读取;若只有 `requested_reasoning_level`,说明这是请求值而不是 runtime 确认值;两者都没有时明确说 runtime 未报告档位,不要用模型目录的默认档位冒充当前档位。模式来自 Bridge 配置,默认 `yolo`;若为 `yolo`,明确提醒普通工具操作可能不经审批且使用当前 OS 账户权限,并说明可将 `ZCODE_BRIDGE_MODE` 设为 `build`。若使用 worktree,说明它不是 OS 沙箱。模型字段缺失时明确说 runtime 没有报告;不要把用户请求的模型或项目默认值猜成实际已选模型。 3. 如果在 `turn_started` 前失败,立即报告 Bridge 的启动错误;只有在 `session_ready` 已出现时才能声称 ZCode session 已创建。不存在 `session_ready` 时说明没有 ZCode session/model 元数据。 4. 运行期间用 `zcode_events` 的 `after_seq` 读取增量事件,默认使用 `view: "summary"`,需要逐条文本时改用 `view: "raw"`;`next_seq` 会跨过已合并事件。`wait_ms` 可设为 10000–25000;如需快速刷新状态,可调用 `zcode_status`。需要简洁、可直接呈现的当前 attempt 摘要时调用 `zcode_feedback`;其 `last_observed` activity 不是当前运行中的工具状态,`interaction: not_observed` 也不代表没有交互。心跳只证明 worker 响应,工具事件只证明已观测活动;没有命令输出或结果时,不把它们描述成测试持续推进、已通过或源码已还原。`stalled` 是提示,不自动取消或重派。向用户简短汇报模型可见输出和工具活动摘要。事件不包含隐藏推理;`interaction_requested` 会包含完成决定所需的有限请求细节。 5. 收到 `interaction_requested` 时,先向用户概述请求的方法、工具、理由、输入、选项或问题。事件带 `preview: true` 时,`input_preview` 只是有界预览,不是全文:需要完整输入时读 `details.full_request`(同一任务/attempt 的私有记录路径,`bytes`/`sha256` 按 `JSON.stringify(params)` 紧凑序列化计量,不是整个 JSON 记录文件),不要把预览当全文或据此放行。权限请求不得从任务描述、worktree 或 `yolo` 模式推定为获准:只有用户明确授权该项操作才可用 `zcode_interaction_reply` 的 `allow`,否则向用户询问或明确拒绝。批准由 Bridge 在落盘前校验完整记录一致;记录缺失、损坏或被改动时请求会被明确拒绝且不执行,此时如实向用户报告原因。用户输入问题只可依据已知信息或用户明确指示回答;计划审批须让用户决定。对于 `interaction/requestUserInput` 的 AskUserQuestion,`answers` 必须以请求中每个 `questions[].question` 的完整原文作为键,以用户选择或明确回答作为值;不得用 `header`、选项标签或选项值作键。先从 `interaction_requested.details.questions`(必要时从 `details.input.questions`)复制准确问题文本,再调用 `zcode_interaction_reply`。若 Bridge 报 `answers must be keyed by the exact ZCode question text`,用原问题文本修正键并重试同一个待处理请求。回复后继续读取事件并确认 ZCode 已继续或结束。 @@ -36,8 +46,8 @@ description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 Z 1. 在事件报告的实际执行目录检查 Git 状态、完整 diff、实际文件和测试。对照用户目标、允许/禁止路径和验收标准逐项核实。 2. 不能仅相信 AgentReport.files_changed、ZCode 报告的测试状态或模型输出;独立运行适用的验收命令。 -3. 若执行目录是 worktree,在审查通过后由 Codex 按用户授权把所需改动接收到项目工作区;先保留项目中已有的用户改动、检查冲突,再复查并运行验收。若直接在项目根目录执行,则在同一目录审查。 -4. Bridge 不会自动应用、合并或删除 worktree。不要在审查及接收改动前删除 Codex 准备的 worktree,也不要自动提交、推送或创建 PR。 +3. 若执行目录是 worktree,在审查通过后由宿主按用户授权把所需改动接收到项目工作区;先保留项目中已有的用户改动、检查冲突,再复查并运行验收。若直接在项目根目录执行,则在同一目录审查。 +4. Bridge 不会自动应用、合并或删除 worktree。不要在审查及接收改动前删除宿主准备的 worktree,也不要自动提交、推送或创建 PR。 5. 如果结果未达标,调用 zcode_continue,反馈具体失败证据;续作会复用同一 ZCode session 和原执行目录。任务运行期间若用户取消,调用 zcode_cancel 并确认终态。 6. 如果结果为 `invalid_agent_report` 且执行工作已完成,先检查 `report_candidate`、原始 `zcode_output` 和实际 diff;续作提示只要求修复 JSON 报告,不要再次编辑或重跑测试。缺少的决定字段必须基于证据补齐,无法确认时设 `needs_master_decision=true`。 7. 任务为 failed 时用 `zcode_result.error_code` 确认原因,不仅凭 `zcode_feedback` 的失败标签判断代码失败。`cleanup_failed` 是当时清理未能确认;后续 `zcode_status.observation.cleanup=verified` 表示已取得退出证据,原失败仍保留。分别报告两个事实,再独立验收代码。新鲜查询为 unknown 或 unverified 时不自行删除锁、改历史状态或另起同目录任务;旧不可读取锁需要独立诊断,不能按时间删锁。 @@ -48,7 +58,7 @@ description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 Z - Bridge 单进程默认最多运行 8 个 worker,可用 `ZCODE_BRIDGE_MAX_CONCURRENT_WORKERS` 设为 1–8;执行目录重叠的任务会串行。多个 Bridge 进程之间没有全局调度锁,共享数据目录时不要并行提交冲突任务。 - 单次执行默认上限为 60 分钟;可在任务中设置 `timeout_ms`(60 秒至 4 小时),或用 `ZCODE_BRIDGE_TIMEOUT_MS` 设置用户默认值。超时后 Bridge 会终止该次进程树并保留 attempt 证据。 - 每个任务通过本机 ZCode app-server 运行;默认模式为 `yolo`,也可通过 `ZCODE_BRIDGE_MODE` 配置。`yolo` 会放行普通工具操作。记录版本上已完成单次 43,014-byte Write 的真实权限审批往返实测;其他审批组合未据此认证。告知用户实际执行模式和当前账户权限边界,不要把 worktree 描述成沙箱。 -- `workspace` 是项目身份路径;`worktree_path`(如提供)是实际执行路径。Codex 决定是否准备 worktree,Bridge 只校验并使用所给路径。Git worktree 是工作区隔离,不是 OS 沙箱。allowed_paths / forbidden_paths 是 worker 指令;不得声称它们能强制阻止所有越界命令。 +- `workspace` 是项目身份路径;`worktree_path`(如提供)是实际执行路径。宿主决定是否准备 worktree,Bridge 只校验并使用所给路径。Git worktree 是工作区隔离,不是 OS 沙箱。allowed_paths / forbidden_paths 是 worker 指令;不得声称它们能强制阻止所有越界命令。 - ZCode 不得选择仍未解决的 `OPEN DECISIONS`;后续 Master Feedback 明确给出决定后,按新决定继续。即使未列出,遇到需求冲突或会实质改变外部行为的缺失决定,也要提出具体问题、设置 `needs_master_decision=true`,并继续不依赖该决定的工作。低影响实现选择可采用最简单一致的方案,同时报告假设。 - 不要把 ZCode Hooks、Desktop 历史索引、自动化或并行 worker 当作已启用能力。 - 不要为了方便而修改 ZCode provider 配置或将凭据写入任务 prompt、日志或仓库。 @@ -57,9 +67,9 @@ description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 Z 向用户汇报任务结果时使用固定结构:**一句话结论 → 任务总览 → 验收证据 → 待处理事项**。让用户能从固定位置回答四个问题:完成了什么、验证到哪一步、花了多少时间/token、还需要决定什么。 -1. 任务总览是纵向两列表(项目 / 内容),固定包含:目标与交付、执行标识(task_id、attempt)、执行状态(Bridge 原始状态,注明"不等同验收通过")、独立验收(通过/未通过/未验证/部分验证,注明责任方与范围)、接收与发布(已接收/未接收、已提交/未提交、已推送/未推送、已发布/未发布,各附证据;没有执行过的操作写"未提交/未推送/未发布",不适用写"不适用")、本次耗时(排队/执行/独立验证分开列,未知项写"未知",不拼成虚假总数)、Codex 主会话 token、ZCode 执行 token、模型与推理档位、后续动作。 +1. 任务总览是纵向两列表(项目 / 内容),固定包含:目标与交付、执行标识(task_id、attempt)、执行状态(Bridge 原始状态,注明"不等同验收通过")、独立验收(通过/未通过/未验证/部分验证,注明责任方与范围)、接收与发布(已接收/未接收、已提交/未提交、已推送/未推送、已发布/未发布,各附证据;没有执行过的操作写"未提交/未推送/未发布",不适用写"不适用")、本次耗时(排队/执行/独立验证分开列,未知项写"未知",不拼成虚假总数)、宿主主会话 token(写明 Codex 或 Claude Code)、ZCode 执行 token、模型与推理档位、后续动作。 2. 验收证据表固定三列"检查项 / 结果 / 证据"。结果必须区分:独立实测(你真实运行过的)、worker自报(tests 等报告值)、历史记录、NOT RUN。没有独立验证时,绝不能因为 worker 报告 tests=passed 就把验收标成通过。 -3. token 与模型判读规则:Codex 主会话 token 目前无来源,固定写"未取得:当前宿主未提供本次调用统计",不得用账户额度、会话总量或文本长度估算。ZCode token 显示 `zcode_result.usage` 的输入/输出/总量及完整性,缺失写"未报告",不填 0;运行中写"截至当前未取得最终 token",绝不能引用上一次任务的用量。模型只显示 runtime 确认值(`session_ready`/`model_selected` 的 provider_id/model_id 与 reasoning_level);请求值标"请求",runtime 未报告写"未报告";请求与实际不一致时明确标出差异。 +3. token 与模型判读规则:宿主主会话 token(Codex 与 Claude Code 均同)目前无来源,固定写"未取得:当前宿主未提供本次调用统计",不得用账户额度、会话总量或文本长度估算。ZCode token 显示 `zcode_result.usage` 的输入/输出/总量及完整性,缺失写"未报告",不填 0;运行中写"截至当前未取得最终 token",绝不能引用上一次任务的用量。模型只显示 runtime 确认值(`session_ready`/`model_selected` 的 provider_id/model_id 与 reasoning_level);请求值标"请求",runtime 未报告写"未报告";请求与实际不一致时明确标出差异。 4. 运行中的反馈只报有意义的变化:当前阶段(`zcode_status.observation.activity`)、已执行时长、最后业务进展时间、阻塞项;不反复输出整份完成报告。 5. 格式:时间用分/秒,超过 1 小时用时/分/秒;token 用千位分隔整数;表格单元格内的竖线、换行、链接要转义;大批同类测试可分组,详细日志放链接不进表格;session ID、PID、OS 指纹、内部 seq 放诊断详情,不占完成反馈。任务成功但清理未确认时,两个事实分开写,不只给一个笼统 PASS/FAIL。 6. `zcode_status.observation` 是统一观测(worker/runtime 存活、activity、result、cleanup、stalled 提示)。`stalled` 只是观测提示,不代表失败,也不要因此自动重试或取消;`unknown` 不判死。 diff --git a/scripts/validate-plugin.mjs b/scripts/validate-plugin.mjs index d8a2ed0..0513445 100644 --- a/scripts/validate-plugin.mjs +++ b/scripts/validate-plugin.mjs @@ -50,10 +50,18 @@ for (const relativePath of [ "hooks/session-start.mjs", "hooks/configure-runtime.ps1", "skills/zcode-bridge/SKILL.md", + "scripts/wait-task.mjs", ]) { await access(path.join(pluginRoot, relativePath)); } +// The waiter runs straight from the installed plugin (ADR-010), so it may only +// import Node built-ins. +const waiter = await readFile(path.join(pluginRoot, "scripts", "wait-task.mjs"), "utf8"); +for (const match of waiter.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/gu)) { + if (!match[1].startsWith("node:")) throw new Error(`scripts/wait-task.mjs must import only node: built-ins (found ${match[1]})`); +} + // The installed plugin must load without the development checkout: bundles // must not embed the repository's absolute path, and the ESM bundles must not // statically import playwright-core — the pinned runtime ships only inside diff --git a/test/validate-plugin-root-pattern.test.ts b/test/validate-plugin-root-pattern.test.ts index 162ecc2..9503683 100644 --- a/test/validate-plugin-root-pattern.test.ts +++ b/test/validate-plugin-root-pattern.test.ts @@ -274,6 +274,7 @@ test("the real validator rejects injected development-path references and keeps path.join(pluginRoot, "worker", "vendor", "pkg"), path.join(pluginRoot, "hooks"), path.join(pluginRoot, "skills", "zcode-bridge"), + path.join(pluginRoot, "scripts"), ]) { mkdirSync(directory, { recursive: true }); } @@ -289,7 +290,7 @@ test("the real validator rejects injected development-path references and keeps writeFileSync(path.join(pluginRoot, ".mcp.json"), JSON.stringify(mcp)); writeFileSync(path.join(pluginRoot, "worker", "vendor", "package.json"), JSON.stringify({ name: "playwright-core" })); writeFileSync(path.join(pluginRoot, "worker", "vendor", "browsers.json"), JSON.stringify({ browsers: [{ name: "chromium" }] })); - for (const file of ["hooks/hooks.json", "hooks/session-start.mjs", "hooks/configure-runtime.ps1", "skills/zcode-bridge/SKILL.md"]) { + for (const file of ["hooks/hooks.json", "hooks/session-start.mjs", "hooks/configure-runtime.ps1", "skills/zcode-bridge/SKILL.md", "scripts/wait-task.mjs"]) { writeFileSync(path.join(pluginRoot, file), ""); } diff --git a/test/wait-task.test.ts b/test/wait-task.test.ts new file mode 100644 index 0000000..690fcd1 --- /dev/null +++ b/test/wait-task.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { appendFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const WAITER = fileURLToPath(new URL("../../plugins/codex-zcode-bridge/scripts/wait-task.mjs", import.meta.url)); +const TASK = "wait-fixture"; + +interface Fixture { root: string; dataDir: string; taskDir: string; home: string } + +function fixture(status: string, events: object[]): Fixture { + const root = mkdtempSync(path.join(tmpdir(), "bridge-wait-")); + const dataDir = path.join(root, "data"); + const taskDir = path.join(dataDir, ".tasks", TASK); + const home = path.join(root, "home"); + mkdirSync(taskDir, { recursive: true }); + mkdirSync(home); + writeStatus(taskDir, status); + writeFileSync(path.join(taskDir, "events.jsonl"), events.map((event) => `${JSON.stringify(event)}\n`).join("")); + writeFileSync(path.join(taskDir, "events.gen"), "gen-1"); + return { root, dataDir, taskDir, home }; +} + +function writeStatus(taskDir: string, status: string): void { + writeFileSync(path.join(taskDir, "status.json"), JSON.stringify({ task_id: TASK, status, attempt: 2 })); +} + +function childEnv(home: string, extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, ...extra }; + for (const key of Object.keys(env)) if (key.startsWith("ZCODE_") && !(key in extra)) delete env[key]; + return env; +} + +function run(args: string[], home: string, extra: Record = {}) { + const result = spawnSync(process.execPath, [WAITER, ...args], { env: childEnv(home, extra), encoding: "utf8", timeout: 20_000, windowsHide: true }); + return { status: result.status, stderr: result.stderr, json: result.stdout.trim() ? JSON.parse(result.stdout) : null }; +} + +function snapshot(dir: string): string[] { + return readdirSync(dir, { recursive: true }).map(String).sort().map((name) => { + const stat = statSync(path.join(dir, name)); + return `${name}:${stat.size}:${stat.mtimeMs}`; + }); +} + +const requested = (seq: number, id: string, extra: object = {}) => ({ seq, at: "t", type: "interaction_requested", summary: "s", details: { request_id: `1:${id}`, method: "interaction/requestPermission", toolName: "Bash", reason: "High risk", input: { command: `cmd-${seq}` }, ...extra } }); +const replied = (seq: number, id: string) => ({ seq, at: "t", type: "interaction_replied", summary: "s", details: { request_id: id } }); +const output = (seq: number, text: string) => ({ seq, at: "t", type: "model_output", summary: text }); + +test("terminal status returns at once with the cursor and without writing", () => { + const f = fixture("waiting_for_master", [output(1, "hello "), output(2, "world")]); + try { + const before = snapshot(f.root); + const result = run(["--task", TASK, "--data-dir", f.dataDir, "--timeout-s", "5"], f.home); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.reason, "terminal"); + assert.equal(result.json.status, "waiting_for_master"); + assert.equal(result.json.attempt, 2); + assert.equal(result.json.next_after_seq, 2); + assert.equal(result.json.recent_output, "hello world"); + assert.deepEqual(snapshot(f.root), before, "the waiter must not create or modify files"); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("reports the earliest unanswered request after the cursor; answered and older ones are ignored", () => { + const f = fixture("running", [requested(3, "perm_old"), requested(5, "perm_answered"), replied(6, "perm_answered"), requested(7, "perm_a"), requested(9, "perm_b")]); + try { + const result = run(["--task", TASK, "--data-dir", f.dataDir, "--after-seq", "4", "--timeout-s", "5"], f.home); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.reason, "interaction"); + assert.equal(result.json.pending.seq, 7); + assert.equal(result.json.pending.request_id, "1:perm_a"); + assert.equal(result.json.pending.tool, "Bash"); + assert.equal(result.json.pending.input, JSON.stringify({ command: "cmd-7" })); + assert.equal(result.json.pending_count, 2); + assert.equal(result.json.next_after_seq, 9); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("a preview request carries the preview text and the full_request reference, never a synthesized full input", () => { + const reference = { file: "interactions/x.json", bytes: 90_000, sha256: "ab" }; + const f = fixture("running", [requested(2, "perm_big", { preview: true, input: undefined, input_preview: "{\"content\":\"abc", full_request: reference })]); + try { + const result = run(["--task", TASK, "--data-dir", f.dataDir, "--timeout-s", "5"], f.home); + assert.equal(result.json.pending.preview, true); + assert.equal(result.json.pending.input, "{\"content\":\"abc"); + assert.deepEqual(result.json.pending.full_request, reference); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("times out with exit 3 and keeps the given cursor when nothing new arrives", () => { + const f = fixture("running", [output(1, "x")]); + try { + const result = run(["--task", TASK, "--data-dir", f.dataDir, "--after-seq", "1", "--timeout-s", "1", "--poll-ms", "250"], f.home); + assert.equal(result.status, 3, result.stderr); + assert.equal(result.json.reason, "timeout"); + assert.equal(result.json.next_after_seq, 1); + assert.equal(result.json.pending, null); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("notices events appended while waiting, including a split final line", async () => { + const f = fixture("running", [output(1, "start")]); + try { + const child = spawn(process.execPath, [WAITER, "--task", TASK, "--data-dir", f.dataDir, "--after-seq", "1", "--timeout-s", "15", "--poll-ms", "250"], { env: childEnv(f.home), windowsHide: true }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + const line = `${JSON.stringify(requested(2, "perm_late"))}\n`; + await new Promise((resolve) => setTimeout(resolve, 600)); + appendFileSync(path.join(f.taskDir, "events.jsonl"), line.slice(0, 40)); + await new Promise((resolve) => setTimeout(resolve, 600)); + appendFileSync(path.join(f.taskDir, "events.jsonl"), line.slice(40)); + assert.equal(await exited, 0); + const result = JSON.parse(stdout); + assert.equal(result.reason, "interaction"); + assert.equal(result.pending.request_id, "1:perm_late"); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("a replaced log generation restarts the scan instead of trusting the old cursor", async () => { + const f = fixture("running", [output(1, "a"), requested(2, "perm_x"), replied(3, "perm_x")]); + try { + const child = spawn(process.execPath, [WAITER, "--task", TASK, "--data-dir", f.dataDir, "--timeout-s", "15", "--poll-ms", "250"], { env: childEnv(f.home), windowsHide: true }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + await new Promise((resolve) => setTimeout(resolve, 600)); + // Longer than the old log, so only the generation marker reveals the rewrite. + const filler = Array.from({ length: 20 }, (_, index) => `${JSON.stringify(output(index + 1, "replacement filler"))}\n`).join(""); + writeFileSync(path.join(f.taskDir, "events.gen"), "gen-2"); + writeFileSync(path.join(f.taskDir, "events.jsonl"), `${filler}${JSON.stringify(requested(30, "perm_y"))}\n`); + assert.equal(await exited, 0); + assert.equal(JSON.parse(stdout).pending.request_id, "1:perm_y"); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("data root follows runtime-config over the environment, and --data-dir over both", () => { + const f = fixture("completed", []); + const settings = path.join(f.home, ".codex", "codex-zcode-bridge"); + try { + mkdirSync(settings, { recursive: true }); + const wrong = path.join(f.root, "elsewhere"); + writeFileSync(path.join(settings, "runtime-config.json"), JSON.stringify({ ZCODE_BRIDGE_DATA_DIR: f.dataDir })); + assert.equal(run(["--task", TASK, "--timeout-s", "2"], f.home, { ZCODE_BRIDGE_DATA_DIR: wrong }).json.reason, "terminal"); + + writeFileSync(path.join(settings, "runtime-config.json"), JSON.stringify({ ZCODE_BRIDGE_DATA_DIR: null })); + const cleared = run(["--task", TASK, "--timeout-s", "2"], f.home, { ZCODE_BRIDGE_DATA_DIR: f.dataDir }); + assert.equal(cleared.status, 2, "a null config value clears the environment and falls back to the settings directory"); + assert.match(cleared.stderr, /task not found/); + + assert.equal(run(["--task", TASK, "--data-dir", f.dataDir, "--timeout-s", "2"], f.home, { ZCODE_BRIDGE_DATA_DIR: wrong }).json.reason, "terminal"); + const relative = run(["--task", TASK, "--data-dir", "relative/dir"], f.home); + assert.equal(relative.status, 2); + assert.match(relative.stderr, /absolute path/); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("rejects invalid task ids and unknown options before reading anything", () => { + const home = mkdtempSync(path.join(tmpdir(), "bridge-wait-home-")); + try { + for (const args of [["--task", "../escape"], ["--task", TASK, "--bogus", "1"], ["--task", TASK, "--timeout-s", "0"]]) { + const result = run(args, home); + assert.equal(result.status, 2, args.join(" ")); + assert.match(result.stderr, /usage: node wait-task\.mjs/); + } + } finally { rmSync(home, { recursive: true, force: true }); } +});