From 4c30de2cf73f1d75867a0bfa87a5892716b882fc Mon Sep 17 00:00:00 2001 From: bikeread Date: Fri, 10 Jul 2026 16:48:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=AF=E6=8F=92=E6=8B=94=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E6=B3=A8=E5=85=A5=20=E2=80=94=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E8=BD=BD=E4=BD=93=E5=8F=96=E4=BB=A3=20CLAUDE.md/AGENT?= =?UTF-8?q?S.md=20=E9=9D=99=E6=80=81=E5=86=99=E5=85=A5=20/=20pluggable=20c?= =?UTF-8?q?ollaboration=20injection=20via=20runtime=20carriers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit abg init 不再默认写入项目文档;协作契约改为桥接期运行时下发: - Codex 侧:proxy 在 thread/start 合并 developerInstructions;既有线程 resume 后 经 thread/inject_items 注入 developer contract item;(threadId, contractHash) 幂等落 pair state;hash 变化重发带取代头的完整合同;旧协议显式报错不静默回退。 - Claude 侧:插件 SessionStart hook 经 bridge-server.js --print-session-context 在 daemon 健康 + TUI 已连时注入会话上下文(绕过通知冷却)。 - CLI:init 默认零写入(--inject-docs 保留旧行为);新增 deinit 摘除存量注入块。 - 配置:injection.runtime 双侧同源开关(false 时 Codex 侧旁路、Claude hook 全静默)。 EN: abg init no longer writes CLAUDE.md/AGENTS.md by default. The collaboration contract is delivered at runtime while a bridge is attached: the codex proxy merges thread/start.developerInstructions (thread/inject_items for resumed threads, idempotent per (threadId, contractHash), full-supersede on hash change, explicit compatibility error on old protocols — never a silent AGENTS.md fallback); the plugin SessionStart hook emits the Claude-side context only while the daemon is healthy and the TUI is attached. `init --inject-docs` keeps the legacy static sections; new `abg deinit` removes previously injected blocks; `injection.runtime` is the shared opt-out for both carriers. Verified on real codex-cli 0.144.1: resume hold → inject → persist → release (200ms), non-destructive merge alongside client instructions, idempotent re-resume (contract count stays 1). Gate: typecheck + 1685 tests + plugin sync. 分工 / Split: Claude(contract module, hook carrier, CLI init/deinit, docs) × Codex(proxy carriers, ledger, config wiring, protocol E2E) — cross-reviewed in-session. --- CLAUDE.md | 6 +- README.md | 11 +- README.zh-CN.md | 11 +- docs/test-plans/pluggable-injection.md | 57 ++ plugins/agentbridge/README.md | 4 +- plugins/agentbridge/commands/init.md | 3 + plugins/agentbridge/hooks/hooks.json | 2 +- plugins/agentbridge/scripts/health-check.sh | 61 +- plugins/agentbridge/server/bridge-server.js | 227 ++++- plugins/agentbridge/server/daemon.js | 833 ++++++++++++++----- src/app-server-protocol.ts | 7 + src/bridge.ts | 9 + src/cli.ts | 14 +- src/cli/deinit.ts | 71 ++ src/cli/doctor.ts | 11 +- src/cli/init.ts | 27 +- src/codex-adapter.ts | 422 +++++++++- src/collaboration-contract.ts | 107 +++ src/config-service.ts | 16 +- src/daemon.ts | 7 +- src/integration-test/daemon-wiring.test.ts | 57 ++ src/integration-test/fixtures/fake-codex.ts | 33 +- src/marker-section.ts | 56 ++ src/session-context-hook.ts | 125 +++ src/state-dir.ts | 9 + src/thread-state.ts | 89 ++ src/unit-test/codex-adapter.test.ts | 353 +++++++- src/unit-test/collaboration-contract.test.ts | 66 ++ src/unit-test/config-service.test.ts | 17 + src/unit-test/deinit.test.ts | 67 ++ src/unit-test/marker-section.test.ts | 58 +- src/unit-test/session-context-hook.test.ts | 144 ++++ src/unit-test/thread-state.test.ts | 52 ++ 33 files changed, 2783 insertions(+), 249 deletions(-) create mode 100644 docs/test-plans/pluggable-injection.md create mode 100644 src/cli/deinit.ts create mode 100644 src/collaboration-contract.ts create mode 100644 src/session-context-hook.ts create mode 100644 src/unit-test/collaboration-contract.test.ts create mode 100644 src/unit-test/deinit.test.ts create mode 100644 src/unit-test/session-context-hook.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1299dbe1..df228a74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Runtime is **Bun** — do not change the local Bun version. | Validate plugin manifest | `bun run validate:plugin` (requires `claude` CLI) | | Local dev link | `bun link` then `agentbridge dev` (registers local marketplace + installs plugin; repo checkout only) | | Update global CLI + plugin | `bun run install:global` (one command: rebuild, replace global install, sync plugin; `--skip-plugin` to opt out) | +| Remove legacy doc sections | `agentbridge deinit` (strips the `` blocks from CLAUDE.md / AGENTS.md; runtime delivery unaffected) | | Start session | `agentbridge claude` (one terminal) + `agentbridge codex` (another) | | Show quota snapshot | `agentbridge budget [--json]` (both agents' 5h/weekly usage, drift, pause state) | | Stop everything | `agentbridge kill` | @@ -59,8 +60,9 @@ Claude Code ── MCP stdio ──▶ bridge.ts (foreground) - **`src/daemon-lifecycle.ts`** — shared `ensureRunning` / `kill` / startup-lock logic; both the CLI and `bridge.ts` call into this. - **`src/daemon-client.ts`** — typed WS client used by `bridge.ts` to talk to the daemon control port. - **`src/config-service.ts`** + **`src/state-dir.ts`** — read/write `.agentbridge/config.json` and resolve the platform state dir (`daemon.pid`, `status.json`, `agentbridge.log`, `killed` sentinel, `startup.lock`). -- **`src/cli.ts` + `src/cli/*.ts`** — `abg` / `agentbridge` command router (`init`, `claude`, `codex`, `pairs`, `doctor`, `budget`, `kill`, `dev`). -- **`src/marker-section.ts` + `src/collaboration-content.ts`** — idempotent marker-based injection of the `` block into `CLAUDE.md` and `AGENTS.md` during `abg init`. (Other agent config files — GEMINI.md / .cursorrules / .kiro etc. — are NOT injected today; backlog, not shipped behavior.) +- **`src/cli.ts` + `src/cli/*.ts`** — `abg` / `agentbridge` command router (`init`, `deinit`, `claude`, `codex`, `pairs`, `doctor`, `budget`, `kill`, `dev`). +- **`src/collaboration-contract.ts` + `src/session-context-hook.ts`** — the runtime collaboration carriers (the default since the pluggable-injection change): the codex proxy injects `CODEX_DEVELOPER_CONTRACT` as native developer context (idempotent per `(threadId, contractHash())`), and the plugin SessionStart hook emits `CLAUDE_SESSION_CONTEXT` via `bridge-server.js --print-session-context` only while the daemon is healthy + TUI attached. Projects keep zero static footprint. +- **`src/marker-section.ts` + `src/collaboration-content.ts`** — legacy marker-based injection of the `` block into `CLAUDE.md` and `AGENTS.md`; opt-in via `abg init --inject-docs`, removable with `abg deinit`. (Other agent config files — GEMINI.md / .cursorrules / .kiro etc. — are NOT injected; backlog, not shipped behavior.) - **`src/bridge-disabled-state.ts` + `src/tui-connection-state.ts`** — disabled-reason and TUI-connect state machines used by the kickoff + reconnect UX. ### Data flow invariants diff --git a/README.md b/README.md index 58305ee8..229c48aa 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ What that buys you, concretely: A common worry about real-time bidirectional messaging is that the two agents' contexts merge and grow without bound. They don't. **The bridge passes messages, not context** — each agent keeps its own context window, and the bridge never copies one agent's full transcript into the other. (And which agent plans vs executes is your call — the roles aren't fixed; Codex can drive Claude just as easily.) Three filters keep what actually crosses small: 1. **Only `agentMessage` crosses.** The bridge forwards an agent's actual conclusions, not its tool-call noise — `commandExecution`, `fileChange`, and reasoning deltas never reach the other side, nor does its full scrollback. -2. **Three-tier marker routing** (default `filtered` mode). Each message is tagged and the daemon routes by tag: `[IMPORTANT]` forwards immediately, `[STATUS]` is buffered and batched into one periodic summary (default: 3 updates or 15s), `[FYI]` is dropped. The marker rules live once in the project's `AGENTS.md` (written by `abg init`), loaded at agent startup. -3. **The collaboration contract lives once** in `AGENTS.md`, not appended to every message (which would pollute every thread and its resume title). +2. **Three-tier marker routing** (default `filtered` mode). Each message is tagged and the daemon routes by tag: `[IMPORTANT]` forwards immediately, `[STATUS]` is buffered and batched into one periodic summary (default: 3 updates or 15s), `[FYI]` is dropped. The marker rules ride the runtime collaboration contract, delivered automatically while the bridge is attached. +3. **The collaboration contract lives once per thread** — injected as native developer context when the bridge attaches (and for Claude, per session via the plugin hook), not written into your project files and not appended to every message (which would pollute every thread and its resume title). Your `CLAUDE.md` / `AGENTS.md` stay untouched, and new sessions started without a bridge receive no collaboration contract (at most a short operational notice on how to start the bridge). One honest caveat: a Codex thread that *was* bridged keeps the injected contract in its own session history (Codex's storage, not your repo) — the contract's opening scope clause neutralizes it there ("no live bridge messages → ignore this, work solo"). Net effect: each side receives a curated stream of meaningful messages, so context grows with the number of real exchanges — not the other agent's raw activity. Set `AGENTBRIDGE_FILTER_MODE=full` (or the config equivalent) when you *do* want the unfiltered stream. @@ -156,7 +156,8 @@ agentbridge codex # (another terminal) Start Codex TUI connected to the bridge | Command | Description | |---------|-------------| -| `abg init` | Install plugin, check dependencies (bun/claude/codex), generate `.agentbridge/config.json` | +| `abg init [--inject-docs]` | Install plugin, check dependencies (bun/claude/codex), generate `.agentbridge/config.json`. Collaboration guidance is delivered at runtime while the bridge runs; `--inject-docs` additionally writes the legacy static sections into `CLAUDE.md` / `AGENTS.md` | +| `abg deinit` | Remove AgentBridge sections previously injected into this project's `CLAUDE.md` / `AGENTS.md` (runtime delivery is unaffected) | | `abg claude [args...]` | Start Claude Code with push channel enabled. **Runs with `--dangerously-skip-permissions` by default** (opt out: `--safe` or `AGENTBRIDGE_SAFE=1`). Clears any killed sentinel from a previous `kill`. Pass-through args are forwarded to `claude` | | `abg codex [args...]` | Start Codex TUI connected to AgentBridge daemon. **Bare `abg codex` auto-resumes the pair's last thread; use `abg codex --new` for a fresh thread. TUI launches run with `--yolo` by default** (opt out: `--safe` or `AGENTBRIDGE_SAFE=1`; non-TUI subcommands like `exec` are never touched). Pass-through args forwarded to `codex` | | `abg resume [claude\|codex]` | No target: print the resume commands for this directory's last Claude session and this pair's current Codex thread. With a target: resume that side directly | @@ -231,7 +232,9 @@ Running `agentbridge init` creates a `.agentbridge/` directory in your project r | File | Purpose | |------|---------| -| `config.json` | Machine-readable project config (Codex ports, turn coordination, idle shutdown) | +| `config.json` | Machine-readable project config (Codex ports, turn coordination, idle shutdown, runtime injection) | + +Notable switch: `"injection": {"runtime": true}` (default) controls the runtime delivery of the collaboration contract on **both** sides — set it to `false` to opt a project out entirely (Codex developer-context injection off, and the plugin's SessionStart hook goes fully silent for that workspace). The config is loaded by the CLI and daemon at startup. Re-running `init` is idempotent and will not overwrite existing files. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a92bd61..a4ee03a7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,8 +65,8 @@ English version: [README.md](README.md) 很多人对"实时双向通信"最大的担心是:两个 agent 的上下文会不会合并、越滚越大。不会。**桥传的是消息,不是上下文** —— 每个 agent 各自维护自己的上下文窗口,桥从不会把一方的完整对话历史拷进另一方。(而且谁规划、谁执行完全由你定,角色不写死,让 Codex 指挥 Claude 也一样。)在这个前提上,三层过滤让真正跨过桥的东西尽量少: 1. **只转发 `agentMessage`。** 桥只转发 agent 真正说出来的结论,它执行命令的输出、`commandExecution`、`fileChange`、推理过程这些中间噪声和完整 scrollback 都不过桥。每一方看到的是对方的结论,不是干活的流水账。 -2. **三级标签路由**(默认 `filtered` 模式)。每条消息带标签,daemon 按标签决定去留:`[IMPORTANT]` 立刻转发,`[STATUS]` 先缓冲、攒几条(默认 3 条或 15 秒)合并成一条摘要,`[FYI]` 直接丢。标签规则一次性写在项目的 `AGENTS.md` 里(`abg init` 注入),agent 启动读一次。 -3. **协作契约只存一份**在 `AGENTS.md`,不附带在每条消息上(否则每个 thread 和它的 resume 标题都会被污染)。 +2. **三级标签路由**(默认 `filtered` 模式)。每条消息带标签,daemon 按标签决定去留:`[IMPORTANT]` 立刻转发,`[STATUS]` 先缓冲、攒几条(默认 3 条或 15 秒)合并成一条摘要,`[FYI]` 直接丢。标签规则随运行时协作契约自动下发,桥接着才生效。 +3. **协作契约每个 thread 只注入一份**——桥接上时以原生 developer 上下文注入(Claude 侧则由插件 hook 按会话注入),既不写进你的项目文件,也不附带在每条消息上(否则每个 thread 和它的 resume 标题都会被污染)。你的 `CLAUDE.md` / `AGENTS.md` 保持原样,没有桥的新会话不会收到协作契约(至多一条"如何启动桥"的运营短提示)。一个诚实的边界:**桥接过的** Codex thread 会在它自己的会话历史里保留已注入的契约(存在 Codex 的存储里,不在你的仓库)——契约开头的失效条款会在脱桥后使其自动中和("没有真实桥消息就忽略本契约、独立工作")。 最终效果:每一方收到的是对方精选过的有意义消息,上下文的增长跟的是"有效交流的条数",不是"对方活动的原始量"。需要看完整原文时,设 `AGENTBRIDGE_FILTER_MODE=full` 即可关掉过滤。 @@ -156,7 +156,8 @@ agentbridge codex # (另一个终端)启动 Codex TUI 连接 Bridge | 命令 | 说明 | |------|------| -| `abg init` | 安装插件、检查依赖(bun/claude/codex)、生成 `.agentbridge/config.json` | +| `abg init [--inject-docs]` | 安装插件、检查依赖(bun/claude/codex)、生成 `.agentbridge/config.json`。协作指引在桥运行时自动下发;`--inject-docs` 才会额外把旧版静态段落写进 `CLAUDE.md` / `AGENTS.md` | +| `abg deinit` | 移除以前注入到本项目 `CLAUDE.md` / `AGENTS.md` 的 AgentBridge 段落(不影响运行时下发) | | `abg claude [args...]` | 启动 Claude Code 并启用 push channel。**默认带 `--dangerously-skip-permissions`**(关闭:`--safe` 或 `AGENTBRIDGE_SAFE=1`)。自动清除上次 `kill` 留下的 sentinel。额外参数透传给 `claude` | | `abg codex [args...]` | 启动连接 AgentBridge daemon 的 Codex TUI。**裸 `abg codex` 自动续接该对上次的 thread;`abg codex --new` 开新 thread。TUI 默认带 `--yolo`**(关闭:`--safe` 或 `AGENTBRIDGE_SAFE=1`;`exec` 等非 TUI 子命令不受影响)。额外参数透传给 `codex` | | `abg resume [claude\|codex]` | 不带目标:打印本目录上次 Claude 会话 + 本对当前 Codex thread 的续接命令。带目标:直接续接该侧 | @@ -231,7 +232,9 @@ AgentBridge 是一个**两进程**本地 Bridge: | 文件 | 用途 | |------|------| -| `config.json` | 机器可读的项目配置(Codex 端口、回合协调、空闲关闭) | +| `config.json` | 机器可读的项目配置(Codex 端口、回合协调、空闲关闭、运行时注入) | + +值得一提的开关:`"injection": {"runtime": true}`(默认)控制协作契约在**双侧**的运行时下发——设为 `false` 可让项目整体退出(Codex 侧 developer 上下文注入关闭,Claude 侧 SessionStart hook 对该工作区完全静默)。 CLI 和 daemon 启动时会加载该配置。重复运行 `init` 是幂等的,不会覆盖已有文件。 diff --git a/docs/test-plans/pluggable-injection.md b/docs/test-plans/pluggable-injection.md new file mode 100644 index 00000000..8dadae07 --- /dev/null +++ b/docs/test-plans/pluggable-injection.md @@ -0,0 +1,57 @@ +# E2E 测试计划:可插拔协作注入 / Pluggable collaboration injection + +- **分支 / Branch**: `feat/pluggable-injection` +- **日期 / Date**: 2026-07-10 +- **前置 / Precondition**: `bun run typecheck && bun test src` 全绿;改过 `src/` 后已执行 `bun run build:plugin`(安装的插件加载的是 bundle,不是 TS 源码)。 + +## 变更摘要 / What changed + +静态注入退役为可选项:`abg init` 不再默认写 `CLAUDE.md` / `AGENTS.md`。协作指引改为运行时下发: + +- **Claude 侧**:SessionStart hook(`health-check.sh` → `bridge-server.js --print-session-context`)在 daemon 健康 + Codex TUI 已连接时输出完整协作上下文(绕过冷却戳,每个新会话必达);桥不在时只有原有短提示。 +- **Codex 侧**:daemon 的 codex proxy 在 `thread/start` 合并注入 `developerInstructions`,对既有线程在 `thread/resume` 后、放行首个 turn 前用 `thread/inject_items` 注入 developer contract item;按 `(threadId, contractHash())` 幂等,hash 变化走 `codexContractSupersedePayload`(重发完整合同)。 +- **CLI**:`abg init --inject-docs`(旧行为,选择性开启)、`abg deinit`(摘除存量注入段落)。 +- **配置**:`.agentbridge/config.json` 的 `injection.runtime: false` 关闭双侧运行时注入——Codex 侧 proxy 完全旁路,Claude 侧 SessionStart hook 对该工作区**完全静默**(唯一例外:resume-ack 恢复哨兵,一次性消费)。接受的拼写与 `normalizeBoolean` 一致(`false`/`"false"`/`"0"` 关,`true`/`"true"`/`"1"` 开,其余走默认开)。 +- **裁定备注**:桥未启动时的"如何启动"短提示是运营提示、先于本 PR 存在,默认配置下保留(A3/A4);用户显式 `runtime=false` 表达的是"这个项目别打扰我",因此该配置下短提示也一并静默(A5)。 + +## A. Claude 侧 hook(人工 / manual) + +| # | 步骤 | 预期 | +|---|------|------| +| A1 | 桥全启(`abg claude` + `abg codex`),另开一个新 Claude Code 会话 | SessionStart 附加上下文包含 "AgentBridge is running" 状态行 + `[AgentBridge runtime context]` 完整协作块 | +| A2 | A1 后 2 分钟内(冷却窗口内)再开一个新会话 | 仍然收到完整协作块(上下文路径绕过冷却) | +| A3 | `abg kill` 后开新会话 | 只有 "daemon is not reachable" 短提示,无协作块 | +| A4 | daemon 在跑但 Codex TUI 未连接时开新会话 | 只有 "Codex TUI is not connected" 短提示,无协作块 | +| A5 | 项目 config 写入 `{"injection": {"runtime": false}}`,桥全启,开新会话 | hook **零输出**(无协作块、无短提示;`"false"`/`"0"` 拼写等效) | +| A5b | 同 A5 配置但桥未启动,开新会话 | 同样零输出(短提示也被显式退出静默) | +| A6 | 直接执行 `bun plugins/agentbridge/server/bridge-server.js --print-session-context --workspace <项目路径> --notice ""` | stdout 输出单行合法 hook JSON;不启动任何桥进程、不创建 state 目录 | + +## B. init / deinit(人工 / manual) + +| # | 步骤 | 预期 | +|---|------|------| +| B1 | 空目录 `abg init` | 生成 `.agentbridge/config.json`;**不创建/不修改** `CLAUDE.md` / `AGENTS.md`;输出说明运行时下发 + 两个相关命令 | +| B2 | `abg init --inject-docs` | 与旧版一致:两文件写入 `` 段落,幂等可重跑 | +| B3 | B2 之后 `abg deinit` | 两文件恢复原状(仅注入块的文件变空并提示可删除);重复执行提示 "no AgentBridge section found" | +| B4 | 对本仓库执行 `abg deinit`(真实存量项目) | 仅摘除 marker 块,块外内容零丢失(`git diff` 验证) | +| B5 | 手工破坏 marker(删掉 end 标记)后 `abg deinit` | 报 "skipped — Malformed",文件未被改动 | + +## C. Codex 侧 developer contract(Codex 负责验证 / verified by Codex) + +| # | 场景 | 预期 | +|---|------|------| +| C1 | 桥接下新建线程 | 真实 `thread/start` 请求携带合并后的 `developerInstructions`(不覆盖客户端已有值);pair state 记录 `(threadId, contractHash())` | +| C2 | 首次桥接一个既有线程(resume) | resume 成功响应被扣住,`thread/inject_items`(role=developer)成功且 pair state 原子落盘后才放行;注入失败/超时以明确错误结束原请求,resume 不悬挂 | +| C3 | 同一线程再次 resume(hash 未变) | 不重复注入 | +| C4 | 修改合同内容后(hash 变化)resume 老线程 | 注入 `codexContractSupersedePayload(旧hash)`(取代头 + 完整新合同),落账新 hash | +| C5 | `injection.runtime=false` | 双向完全旁路,行为与改动前一致 | +| C6 | 老版本 Codex(协议无该字段) | 显式兼容性错误,不静默回退写 AGENTS.md | +| C7 | 残留边界确认:桥接过的线程脱离桥后 `codex resume` | 旧合同仍在 rollout(已知硬边界);观察模型遵循开头失效条款(无桥消息即独立工作,不等待 Claude) | + +## D. 回归 / Regression + +| # | 步骤 | 预期 | +|---|------|------| +| D1 | `bun run check` | typecheck + 全量测试 + plugin sync + plugin versions 全绿 | +| D2 | 正常双端会话互发消息([IMPORTANT]/[STATUS]) | 路由行为与改动前一致(marker 规则现在随 contract 下发) | +| D3 | `abg doctor` | 无新增 FAIL | diff --git a/plugins/agentbridge/README.md b/plugins/agentbridge/README.md index 2091bbd6..b02bbcc2 100644 --- a/plugins/agentbridge/README.md +++ b/plugins/agentbridge/README.md @@ -1,6 +1,6 @@ # AgentBridge Plugin -Claude Code plugin for AgentBridge. This plugin packages the AgentBridge MCP frontend with push channel delivery (a failed push falls back to an in-memory queue drained by `get_messages`), the `/agentbridge:init` command, and a non-blocking SessionStart health check. +Claude Code plugin for AgentBridge. This plugin packages the AgentBridge MCP frontend with push channel delivery (a failed push falls back to an in-memory queue drained by `get_messages`), the `/agentbridge:init` command, and a non-blocking SessionStart hook that delivers the runtime collaboration context while the bridge is up (and short health notices otherwise). ## Structure @@ -39,5 +39,5 @@ This creates self-contained bundles at: - The plugin frontend launches the sibling daemon bundle via `AGENTBRIDGE_DAEMON_ENTRY=./daemon.js`. - Claude delivery is always push notifications. If a push fails, the message is queued and can be drained via `get_messages` (per-message fallback — the legacy `AGENTBRIDGE_MODE=pull` mode was removed and the env var is ignored with a one-time warning). -- The SessionStart hook is informational only. It never starts or stops the daemon. +- The SessionStart hook never starts or stops the daemon. While the bridge is up (daemon healthy + Codex TUI attached) it delegates to `bridge-server.js --print-session-context` and injects the load-bearing collaboration context (bypassing the notice cooldown); otherwise it emits cooldown-limited informational notices. An explicit `injection.runtime=false` in `.agentbridge/config.json` silences it entirely (except the one-shot resume-ack recovery notice). - The command at `/agentbridge:init` edits project-local `.agentbridge/` files only; plugin installation and marketplace registration remain terminal-side tasks (`agentbridge init` / `agentbridge dev`). diff --git a/plugins/agentbridge/commands/init.md b/plugins/agentbridge/commands/init.md index e6ef8128..503f9e79 100644 --- a/plugins/agentbridge/commands/init.md +++ b/plugins/agentbridge/commands/init.md @@ -36,6 +36,9 @@ If `.agentbridge/config.json` is missing, create it with this default template: "attentionWindowSeconds": 15, "busyGuard": true }, + "injection": { + "runtime": true + }, "idleShutdownSeconds": 30 } ``` diff --git a/plugins/agentbridge/hooks/hooks.json b/plugins/agentbridge/hooks/hooks.json index 04b0f9e2..1c3d6b70 100644 --- a/plugins/agentbridge/hooks/hooks.json +++ b/plugins/agentbridge/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Hint-only health checks for AgentBridge startup. These hooks never orchestrate the daemon.", + "description": "SessionStart delivery for AgentBridge: injects the runtime collaboration context while the bridge is up (daemon healthy + Codex TUI attached), falls back to informational health notices otherwise. Never orchestrates the daemon.", "hooks": { "SessionStart": [ { diff --git a/plugins/agentbridge/scripts/health-check.sh b/plugins/agentbridge/scripts/health-check.sh index fe1a9355..9fd1b474 100755 --- a/plugins/agentbridge/scripts/health-check.sh +++ b/plugins/agentbridge/scripts/health-check.sh @@ -102,6 +102,23 @@ EOF # stale → sentinel consumed above; fall through to the normal health check below. fi +# ── injection.runtime opt-out ──────────────────────────────────────────────── +# An explicit injection.runtime=false silences this hook for the workspace +# ENTIRELY: the user opted the project out of runtime delivery, so neither +# collaboration context nor start-the-bridge notices should reach the session. +# The verdict comes from the SAME code path the context injection uses +# (isRuntimeInjectionEnabled via the bundled helper's --check mode) — shell +# never re-implements JSON parsing. Only a literal "disabled" exits; any +# helper failure (missing bun/bundle, crash) fails OPEN so a broken helper +# cannot mute the notices. The resume-ack sentinel above stays exempt on +# purpose: it is a one-shot, consume-once recovery escape hatch. +if command -v bun >/dev/null 2>&1 && [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then + injection_state="$(bun "${CLAUDE_PLUGIN_ROOT}/server/bridge-server.js" --print-session-context --check --workspace "$workspace" 2>/dev/null || true)" + if [ "$injection_state" = "disabled" ]; then + exit 0 + fi +fi + if ! command -v curl >/dev/null 2>&1; then exit 0 fi @@ -111,19 +128,12 @@ workspace_key="$(printf '%s' "$workspace" | cksum | awk '{print $1}')" stamp_file="${state_root}/sessionstart-${workspace_key}.stamp" now="$(date +%s)" -if [ -f "$stamp_file" ]; then - last_notice="$(cat "$stamp_file" 2>/dev/null || echo 0)" - if [ $((now - last_notice)) -lt "$cooldown_seconds" ]; then - exit 0 - fi -fi - -printf '%s' "$now" >"$stamp_file" 2>/dev/null || true - # In-session plugin-update reminder: compare the INSTALLED plugin version against # the latest npm version cached by the CLI notifier (src/update-notifier.ts). This # is how a user who updated the npm CLI but not the plugin learns of the mismatch # from inside Claude Code. Best-effort + silent: never blocks the hook. +# Computed BEFORE the cooldown gate because the runtime-context path below +# bypasses that gate and also carries the notice. plugin_notice="" if command -v bun >/dev/null 2>&1 && [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then plugin_notice="$(bun "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-update-notice.mjs" "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" 2>/dev/null || true)" @@ -131,12 +141,37 @@ fi health_json="$(curl -fsS --max-time 1 "http://127.0.0.1:${port}/healthz" 2>/dev/null || true)" -if [ -n "$health_json" ]; then - tui_connected="false" - if printf '%s' "$health_json" | grep -q '"tuiConnected":true'; then - tui_connected="true" +tui_connected="false" +if [ -n "$health_json" ] && printf '%s' "$health_json" | grep -q '"tuiConnected":true'; then + tui_connected="true" +fi + +# ── Runtime collaboration context (the pluggable CLAUDE.md replacement) ────── +# When the bridge is fully up (daemon healthy + Codex TUI attached), delegate to +# the bundled server entry: it decides whether `injection.runtime` allows it and +# prints the COMPLETE hook JSON (status line + collaboration context). This path +# deliberately BYPASSES the cooldown stamp — the context is load-bearing and +# every new session (startup/resume/clear/compact) must receive it, unlike the +# informational notices below. Empty output (injection disabled by config, or +# the entry failed) falls through to the cooldown-gated short notices. +if [ "$tui_connected" = "true" ] && command -v bun >/dev/null 2>&1 && [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then + context_json="$(bun "${CLAUDE_PLUGIN_ROOT}/server/bridge-server.js" --print-session-context --workspace "$workspace" --notice "${plugin_notice}" 2>/dev/null || true)" + if [ -n "$context_json" ]; then + printf '%s\n' "$context_json" + exit 0 + fi +fi + +if [ -f "$stamp_file" ]; then + last_notice="$(cat "$stamp_file" 2>/dev/null || echo 0)" + if [ $((now - last_notice)) -lt "$cooldown_seconds" ]; then + exit 0 fi +fi +printf '%s' "$now" >"$stamp_file" 2>/dev/null || true + +if [ -n "$health_json" ]; then if [ "$tui_connected" = "true" ]; then cat < { set: __exportSetter.bind(all, name) }); }; +var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res); // node_modules/ajv/dist/compile/codegen/code.js var require_code = __commonJS((exports) => { @@ -6517,6 +6518,208 @@ var require_dist = __commonJS((exports, module) => { exports.default = formatsPlugin; }); +// src/collaboration-content.ts +var BUDGET_PACING = `### Budget pacing \u2014 drive the WEEKLY quota to ~100% over the week, evenly, without reaching a 5h cap (active when agent-quota-guard is installed) +- **Core principle: token is the means, value is the end.** Raising intensity means producing more real parallel value (deeper reviews, more independent exploration / verification / genuine subtasks) \u2014 never manufacturing low-value work to consume quota. The budget to MAXIMIZE is the **weekly** quota (refreshed once a week): drive each side's weekly toward ~100% by its weekly reset, and consume it **evenly** across the week \u2014 front-loading then starving, or under-consuming throughout, both leave weekly quota unredeemed (forfeited). The **5h window is NOT a quota bucket to fill \u2014 it is a RATE CAP**: stay under it within any 5h period; reaching it = a forced pause until the 5h resets = wasted time, not progress. +- **Re-query your budget before EVERY allocation decision** \u2014 Claude: \`get_budget\` \u2192 **rendered text** covering both sides; Codex: \`check_budget\` with \`agent:"claude"|"codex"\` \u2192 **normalized JSON**, per side. (Two different shapes \u2014 read the right one below.) Never reuse remembered numbers: a weekly window can refresh EARLY (resetting both 5h and weekly), fully restoring a side you believed was exhausted. +- **Even-pacing test (per side \u2014 Claude runs it)** \u2014 compare two quantities: *budget-windows* = how many 5h windows the weekly quota still covers at the current burn rate; *clock-windows* = how many 5h windows physically fit before the weekly reset = (weekly reset \u2212 now) \xF7 5h. **Claude** (\`get_budget\` text) carries BOTH, pre-computed for BOTH sides: the lines "\u6309\u5F53\u524D\u8282\u594F\uFF0C\u5468\u989D\u5EA6\u8FD8\u591F \u2026 \u4E2A 5h \u7A97\u53E3" (budget-windows) and "\u8DDD\u5468\u5237\u65B0\u8FD8\u80FD\u5BB9\u7EB3 \u2026 \u4E2A 5h \u7A97\u53E3\uFF08\u65F6\u949F\uFF09" (clock-windows). **Codex** (\`check_budget\` JSON) today carries only per-bucket \`util\` / \`reset_epoch\` / \`reset_after_seconds\` \u2014 no burn rate, no \`five_hour_windows_left\` \u2014 so Codex CANNOT compute budget-windows itself; it reads its weekly \`util\` and clock-windows only. To locate Codex's weekly bucket: of the \`buckets[]\` entries whose \`id\` contains \`seven_day\` or \`secondary_window\` (there can be several \u2014 e.g. a model-specific \`additional_rate_limits[\u2026]\` one at 0%), take the HIGHEST-util one (the binding account-level window, matching how the bridge parses it); its clock-windows = \`reset_after_seconds\` \xF7 5h (never the top-level \`reset_epoch\`, which tracks the current limiter, not necessarily the weekly window). For the budget-windows half and the raise/hold/reduce verdict, Codex relies on Claude's \`get_budget\` (the burn projection lives there, for both sides) and reports its own weekly \`util\` + reset timing so Claude can run the test. (If a future \`check_budget\` exposes \`five_hour_windows_left\` on the weekly bucket, Codex reads it directly.) **The verdict (Claude computes it, per side):** budget > clock \u2192 **under-consuming** (weekly will be left unused) \u2192 **raise intensity**; budget < clock \u2192 **over-consuming** (won't last to the weekly reset) \u2192 **reduce intensity**; within ~1 window, or no confident rate \u2192 **hold**. **Codex, absent a fresh Claude verdict, holds at its current intensity (it never escalates unilaterally) and stays clear of the 5h cap \u2014 surfacing its weekly \`util\` + reset timing so Claude can issue the verdict.** +- **Raise intensity \u2014 use the levers your role has.** Orchestrator (Claude): pick larger, more-decomposable tasks; run more parallel subagents at once (3\u20135+ vs 1); raise delegation density; open more concurrent streams (review + explore + verify in parallel). Executor (Codex): go deeper in-turn, take larger chunks, run more verification/repro. Both: deepen quality (multi-angle review, broader test/repro) \u2014 never manufacture make-work. **Reduce intensity:** fewer/serial subagents (Claude), short bounded chunks, defer optional deep work. Stay below the **\u52A8\u6001\u6682\u505C\u7EBF** (shown in \`get_budget\`; its \`\u4F59\u91CF\` = headroom from your current util to that soft line, measured on the resettable hard-winner window \u2014 the 5h OR the weekly window, whichever currently limits you) \u2014 that soft ceiling, not the raw 5h cap, is the "do not cross, avoid a forced pause" line. **If that line is absent, or you only have JSON (Codex),** fall back to the 5h bucket's raw util vs 100% (Codex: of the \`buckets[]\` entries whose \`id\` contains \`five_hour\` or \`primary_window\`, take the HIGHEST-util one) and keep clear of the 5h cap. +- **Distinguish 5h from weekly:** a 5h window resetting does NOT consume or waste weekly budget \u2014 it only refreshes your rate headroom, so you can keep going when weekly is under-consumed. A near 5h reset is therefore not urgency but the release of a rate limit. The real "unused = forfeited" is the **weekly budget as its WEEKLY reset nears**: if weekly is still under-consumed then, raise intensity (within the 5h cap) to use it. If even pacing needs a rate beyond one 5h window's capacity, you are rate-limited \u2192 keep each 5h window as full as possible (under the cap). +- **Two-subscription imbalance \u2014 the quotas are INDEPENDENT and differ in BOTH amount AND reset timing** (each side's weekly and 5h windows reset on different clocks). **The cross-side split is the orchestrator's (Claude) decision:** route more work to the side that is MORE under-consuming on the even-pacing test (the larger budget-windows \u2212 clock-windows gap); when EITHER side lacks a confident rate (so the gap can't be compared), fall back to the more budget-rich side (larger absolute weekly headroom). On any tie (equal gap, or equal headroom), prefer the side whose **weekly resets SOONER** (its leftover is forfeited earlier). **As the executor (Codex) you do NOT decide the global split** \u2014 execute what you're assigned, and when your own budget is rich report it (with evidence) so Claude routes more to you. The tighter / over-consuming side carries less. +- **Side-aware pause (the hard floor the code enforces \u2014 obey, do not reinvent), with each side's own action:** **Codex exhausted** (\`system_budget_pause\`) \u2192 Codex's turns stop (gate closed); **Claude** must not retry replies and continues solo on independent work, checkpointing the split point \u2014 but the SAME \`system_budget_pause\` is ALSO emitted when both sides are exhausted, so do not infer "solo" from the directive name alone: read its content (it names the paused side[s]) or re-check \`get_budget\`, and continue solo ONLY while Claude's own side is healthy; if Claude is also at its line, handle it as **Both** below. **Claude exhausted** (\`system_budget_handoff\`) \u2192 **Claude** sends ONE handoff (remaining tasks / context / artifact locations / acceptance criteria) then stops; **Codex** receives the baton and carries the work forward as far as its remaining quota allows that turn. **Both** \u2192 joint pause; checkpoint and wait for \`resume\` (Claude's own quota-guard also hard-stops Claude independently). A transient probe **429 is NOT exhaustion** \u2192 fall back to cached util and keep working.`, CLAUDE_MD_SECTION, AGENTS_MD_SECTION; +var init_collaboration_content = __esm(() => { + CLAUDE_MD_SECTION = `## AgentBridge \u2014 Multi-Agent Collaboration + +You are working in a **multi-agent environment** powered by AgentBridge. +Another AI agent (Codex, by OpenAI) is available in a parallel session on this machine. + +### Communication mechanism +- **Claude \u2192 Codex**: Use the AgentBridge MCP tools (\`reply\` / \`get_messages\`) \u2014 these are yours only. +- **Codex \u2192 Claude**: Codex has no symmetric tool. The bridge transparently intercepts Codex's normal output and forwards it to you as push notifications (if a push fails, drain the fallback queue with \`get_messages\`). +- If Codex ever complains it can't find a "send-to-Claude" API, remind it that its side is transparent \u2014 it just writes a reply and you'll see it. + +### When to collaborate vs. work solo +- **Collaborate** when the task benefits from a second perspective, parallel execution, or capabilities you lack (e.g., sandboxed code execution, independent verification). +- **Work solo** for simple, self-contained tasks where the coordination overhead isn't worth it. +- When in doubt, **propose a task split** to Codex rather than doing everything yourself. + +### Capability comparison +| Capability | Claude (you) | Codex | +|---|---|---| +| Architecture & planning | Strong | Moderate | +| Code review & analysis | Strong | Strong | +| Sandboxed code execution | No | Yes | +| File editing & refactoring | Yes (via tools) | Yes (via sandbox) | +| Web search & docs | Yes | Limited | +| Independent verification | Cross-review | Reproduce & test | + +### How to start collaborating +1. When you receive a complex task, **proactively propose a division of labor** to Codex via the reply tool. +2. State what you'll handle and what you'd like Codex to take on. +3. Ask for Codex's agreement or counter-proposal before proceeding. +4. After task completion, **cross-review** each other's work. + +${BUDGET_PACING}`; + AGENTS_MD_SECTION = `## AgentBridge \u2014 Multi-Agent Collaboration + +You are working in a **multi-agent environment** powered by AgentBridge. +Another AI agent (Claude, by Anthropic) is available in a parallel session on this machine. + +### Communication mechanism (read this first) +AgentBridge is a **transparent proxy** on your side. You do **not** have a tool to "send a message to Claude". + +- **Codex \u2192 Claude**: Just write your normal response. The bridge intercepts your \`agentMessage\` output and forwards it to Claude automatically. No tool call needed. +- **Claude \u2192 Codex**: Claude uses its own MCP tools (\`reply\` / \`get_messages\`). Those messages arrive in your session as new user turns \u2014 you'll see them like any other user input. + +**Do not** search the AgentBridge source for a Codex-side "send" / "reply" / "sendToClaude" API \u2014 it does not exist, and looking for it wastes turns. If you catch yourself thinking "I need to find how to message Claude", stop and just write your reply as normal text. + +### When to collaborate vs. work solo +- **Collaborate** when the task benefits from a second perspective, parallel execution, or capabilities the other agent has. +- **Work solo** for simple, self-contained tasks where the coordination overhead isn't worth it. +- When in doubt, **propose a task split** to Claude rather than doing everything yourself. + +### Capability comparison +| Capability | Codex (you) | Claude | +|---|---|---| +| Sandboxed code execution | Yes | No | +| Reproduce & verify bugs | Strong | Limited | +| Architecture & planning | Moderate | Strong | +| Code review & analysis | Strong | Strong | +| Web search & docs | Limited | Yes | +| File editing & refactoring | Yes (via sandbox) | Yes (via tools) | + +### How to start collaborating +1. When you receive a complex task, **proactively propose a division of labor** in your response (Claude will receive it). +2. State what you'll handle and what you'd like Claude to take on. +3. Ask for Claude's agreement or counter-proposal before proceeding. +4. After task completion, **cross-review** each other's work. + +### Message markers +Put a marker at the **very start** of each \`agentMessage\` (it must be the first text \u2014 e.g. \`[IMPORTANT] Task done\`, not \`Task done [IMPORTANT]\`): +- \`[IMPORTANT]\` \u2014 decisions, reviews, completions, blockers +- \`[STATUS]\` \u2014 progress updates +- \`[FYI]\` \u2014 background context + +Keep \`agentMessage\` for high-value communication only. + +### Git operations \u2014 FORBIDDEN for you +You MUST NOT run git **write** commands: \`commit\`, \`push\`, \`pull\`, \`fetch\`, \`checkout -b\`, \`branch\`, \`merge\`, \`rebase\`, \`cherry-pick\`, \`tag\`, \`stash\`. They write the \`.git\` directory (blocked by your sandbox) and will hang your session. Read-only git (\`status\`, \`log\`, \`diff\`, \`show\`, \`rev-parse\`) is fine. Delegate **all** git writes to Claude: report what you changed and let Claude handle branching, committing, and pushing. + +### Role guidance +- Your default role: **Implementer, Executor, Verifier**. +- Analytical / review tasks: **Independent Analysis & Convergence**. +- Implementation tasks: **Architect \u2192 Builder \u2192 Critic**. +- Debugging tasks: **Hypothesis \u2192 Experiment \u2192 Interpretation**. +- Do not blindly follow Claude \u2014 challenge with evidence when you disagree. +- Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:". + +${BUDGET_PACING}`; +}); + +// src/collaboration-contract.ts +var CODEX_CONTRACT_SCOPE_CLAUSE = `[AgentBridge runtime contract \u2014 read this scope rule first] +This developer context is injected by AgentBridge (a local Claude<->Codex +bridge) and is BINDING ONLY while the bridge is actively attached to this +thread. The ONLY proof of attachment is bridged messages from Claude (or +AgentBridge system notices) arriving in the CURRENT session. Collaborate when +such messages arrive. NEVER wait for, block on, or address Claude merely +because this contract exists. If this thread was resumed outside the bridge +(no bridged messages in the current session), ignore everything below and +operate as a normal solo Codex session.`, CLAUDE_CONTEXT_SCOPE_CLAUSE = `[AgentBridge runtime context] +Injected by the AgentBridge plugin because the bridge daemon was healthy when +this session started. If the bridge later becomes unreachable (reply tool +errors, no channel messages arriving), treat collaboration as unavailable and +continue solo \u2014 do not wait for Codex.`, CODEX_DEVELOPER_CONTRACT, CLAUDE_SESSION_CONTEXT; +var init_collaboration_contract = __esm(() => { + init_collaboration_content(); + CODEX_DEVELOPER_CONTRACT = `${CODEX_CONTRACT_SCOPE_CLAUSE} + +${AGENTS_MD_SECTION}`; + CLAUDE_SESSION_CONTEXT = `${CLAUDE_CONTEXT_SCOPE_CLAUSE} + +${CLAUDE_MD_SECTION}`; +}); + +// src/session-context-hook.ts +var exports_session_context_hook = {}; +__export(exports_session_context_hook, { + runPrintSessionContext: () => runPrintSessionContext, + parseSessionContextArgs: () => parseSessionContextArgs, + isRuntimeInjectionEnabled: () => isRuntimeInjectionEnabled, + buildSessionContextHookJson: () => buildSessionContextHookJson, + buildSessionContextAdditionalContext: () => buildSessionContextAdditionalContext +}); +import { readFileSync as readFileSync6 } from "fs"; +import { join as join6 } from "path"; +function isRuntimeInjectionEnabled(configRaw) { + if (configRaw === null) + return true; + try { + const parsed = JSON.parse(configRaw); + const value = parsed?.injection?.runtime; + if (typeof value === "boolean") + return value; + if (value === "true" || value === "1") + return true; + if (value === "false" || value === "0") + return false; + return true; + } catch { + return true; + } +} +function buildSessionContextAdditionalContext(notice) { + const statusLine = "AgentBridge is running. Daemon healthy, Codex TUI connected. Bridge is ready for communication." + (notice ? ` ${notice}` : ""); + return `${statusLine} + +${CLAUDE_SESSION_CONTEXT}`; +} +function buildSessionContextHookJson(notice) { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: buildSessionContextAdditionalContext(notice) + } + }); +} +function parseSessionContextArgs(argv) { + let workspace = process.cwd(); + let notice; + let checkOnly = false; + for (let i = 0;i < argv.length; i++) { + if (argv[i] === "--workspace" && argv[i + 1] !== undefined) { + workspace = argv[++i]; + } else if (argv[i] === "--notice" && argv[i + 1] !== undefined) { + const value = argv[++i]; + if (value.trim() !== "") + notice = value; + } else if (argv[i] === "--check") { + checkOnly = true; + } + } + return { workspace, notice, checkOnly }; +} +function runPrintSessionContext(argv) { + const { workspace, notice, checkOnly } = parseSessionContextArgs(argv); + let configRaw = null; + try { + configRaw = readFileSync6(join6(workspace, ".agentbridge", "config.json"), "utf-8"); + } catch {} + const enabled = isRuntimeInjectionEnabled(configRaw); + if (checkOnly) { + console.log(enabled ? "enabled" : "disabled"); + return 0; + } + if (!enabled) { + return 0; + } + console.log(buildSessionContextHookJson(notice)); + return 0; +} +var init_session_context_hook = __esm(() => { + init_collaboration_contract(); +}); + // src/bridge.ts import { existsSync as existsSync7 } from "fs"; @@ -13849,6 +14052,9 @@ class StateDirResolver { get currentThreadFile() { return join(this.stateDir, "current-thread.json"); } + get codexContractStateFile() { + return join(this.stateDir, "codex-contracts.json"); + } get logFile() { return join(this.stateDir, "agentbridge.log"); } @@ -14707,10 +14913,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.30", "0.0.0-source"), - commit: defineString("99d0f4a", "source"), + commit: defineString("a3e927f", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("0cb79932198b", "source") + codeHash: defineString("c90d680de869", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) @@ -15791,6 +15997,9 @@ var DEFAULT_CONFIG = { turnCoordination: { attentionWindowSeconds: 15 }, + injection: { + runtime: true + }, idleShutdownSeconds: 30, budget: DEFAULT_BUDGET_CONFIG }; @@ -15870,7 +16079,7 @@ function hasCustomDecisionValues(config2) { const d = DEFAULT_CONFIG; const b = config2.budget; const db = d.budget; - return config2.idleShutdownSeconds !== d.idleShutdownSeconds || config2.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config2.codex.appPort !== d.codex.appPort || config2.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours; + return config2.idleShutdownSeconds !== d.idleShutdownSeconds || config2.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config2.injection.runtime !== d.injection.runtime || config2.codex.appPort !== d.codex.appPort || config2.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours; } function normalizeInteger(value, fallback) { if (typeof value === "number" && Number.isFinite(value)) @@ -16019,6 +16228,7 @@ function normalizeConfig(raw) { const codex = isRecord(config2.codex) ? config2.codex : {}; const daemon = isRecord(config2.daemon) ? config2.daemon : {}; const turnCoordination = isRecord(config2.turnCoordination) ? config2.turnCoordination : {}; + const injection = isRecord(config2.injection) ? config2.injection : {}; return { version: typeof config2.version === "string" ? config2.version : DEFAULT_CONFIG.version, codex: { @@ -16028,6 +16238,9 @@ function normalizeConfig(raw) { turnCoordination: { attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER) }, + injection: { + runtime: normalizeBoolean(injection.runtime, DEFAULT_CONFIG.injection.runtime) + }, idleShutdownSeconds: normalizeBoundedInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER), budget: normalizeBudgetConfig(config2.budget) }; @@ -16081,7 +16294,7 @@ class ConfigService { if (result.state === "parsed") return result.config; if (result.state === "corrupt") { - log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget thresholds / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them."); + log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget / runtime-injection / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them."); } return structuredClone(DEFAULT_CONFIG); } @@ -16474,6 +16687,10 @@ function redactData(value, key = "") { } // src/bridge.ts +if (process.argv.includes("--print-session-context")) { + const { runPrintSessionContext: runPrintSessionContext2 } = await Promise.resolve().then(() => (init_session_context_hook(), exports_session_context_hook)); + process.exit(runPrintSessionContext2(process.argv.slice(2))); +} var originalEnv = { ...process.env }; var bootstrapLogger = createProcessLogger({ component: "AgentBridgeFrontend" }); var envGuardResult = guardAgentBridgeEnv({ @@ -16557,7 +16774,7 @@ claude.setResumeAckHandler((resumeId, status) => { } }); daemonClient.on("turnStarted", ({ requestId, idempotencyKey, threadId, turnId }) => { - log(`Codex turn started for reply ${requestId} (turn=${turnId}, thread=${threadId}` + `${idempotencyKey ? `, idempotencyKey=${idempotencyKey}` : ""})`); + log(`Codex turn started for reply ${requestId} (turn=${turnId}, thread=${threadId}${idempotencyKey ? `, idempotencyKey=${idempotencyKey}` : ""})`); }); daemonClient.on("codexMessage", (message) => { log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`); diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index 5388db2c..bc654417 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.30", "0.0.0-source"), - commit: defineString("99d0f4a", "source"), + commit: defineString("a3e927f", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("0cb79932198b", "source") + codeHash: defineString("c90d680de869", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; @@ -281,6 +281,9 @@ class StateDirResolver { get currentThreadFile() { return join(this.stateDir, "current-thread.json"); } + get codexContractStateFile() { + return join(this.stateDir, "codex-contracts.json"); + } get logFile() { return join(this.stateDir, "agentbridge.log"); } @@ -298,6 +301,310 @@ class StateDirResolver { } } +// src/thread-state.ts +import { + existsSync as existsSync2, + readdirSync, + readFileSync as readFileSync2 +} from "fs"; +import { homedir as homedir2 } from "os"; +import { basename, join as join2 } from "path"; +var MAX_CODEX_CONTRACT_INJECTIONS = 256; +var CODEX_CONTRACT_HASH_RE = /^[0-9a-f]{12}$/; +function nowIso() { + return new Date().toISOString(); +} +function threadTag(identity) { + const name = identity.pairName ?? identity.pairId ?? "manual"; + return `abg:${name}:${identity.cwd}`; +} +function codexHome(env = process.env) { + return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join2(homedir2(), ".codex"); +} +function readRawCurrentThread(stateDir) { + try { + const parsed = JSON.parse(readFileSync2(stateDir.currentThreadFile, "utf-8")); + if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") { + return parsed; + } + } catch {} + return null; +} +function parseCodexContractInjection(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return null; + const record = value; + if (typeof record.threadId !== "string" || record.threadId.length === 0 || typeof record.contractHash !== "string" || !CODEX_CONTRACT_HASH_RE.test(record.contractHash) || typeof record.injectedAt !== "string" || record.injectedAt.length === 0) { + return null; + } + return { + threadId: record.threadId, + contractHash: record.contractHash, + injectedAt: record.injectedAt + }; +} +function readCodexContractState(stateDir) { + try { + const parsed = JSON.parse(readFileSync2(stateDir.codexContractStateFile, "utf-8")); + if (parsed?.version !== 1 || !Array.isArray(parsed.injections)) { + return { version: 1, injections: [] }; + } + return { + version: 1, + injections: parsed.injections.map(parseCodexContractInjection).filter((entry) => entry !== null).slice(-MAX_CODEX_CONTRACT_INJECTIONS) + }; + } catch { + return { version: 1, injections: [] }; + } +} +function readCodexContractHash(stateDir, threadId) { + const injections = readCodexContractState(stateDir).injections; + for (let index = injections.length - 1;index >= 0; index--) { + if (injections[index]?.threadId === threadId) { + return injections[index].contractHash; + } + } + return null; +} +function persistCodexContractInjection(stateDir, threadId, contractHash) { + if (!threadId || !CODEX_CONTRACT_HASH_RE.test(contractHash)) { + throw new Error("Codex contract state requires a non-empty threadId and a 12-character lowercase hex hash"); + } + const prior = readCodexContractState(stateDir).injections.filter((entry) => entry.threadId !== threadId); + const state = { + version: 1, + injections: [ + ...prior, + { threadId, contractHash, injectedAt: nowIso() } + ].slice(-MAX_CODEX_CONTRACT_INJECTIONS) + }; + atomicWriteJson(stateDir.codexContractStateFile, state); + return state; +} +function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) { + const sessionsDir = join2(codexHome(env), "sessions"); + if (!threadId || !existsSync2(sessionsDir)) + return null; + const exactName = `rollout-${threadId}.jsonl`; + const stack = [sessionsDir]; + let visited = 0; + while (stack.length > 0 && visited < maxEntries) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + visited++; + const path = join2(dir, entry.name); + if (entry.isDirectory()) { + stack.push(path); + continue; + } + if (!entry.isFile()) + continue; + const name = basename(entry.name); + if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) { + return path; + } + } + } + return null; +} +function writePendingCurrentThread(identity, threadId, reason) { + const state = { + version: 1, + status: "pending", + pairId: identity.pairId, + pairName: identity.pairName, + cwd: identity.cwd, + threadId, + updatedAt: nowIso(), + reason, + tag: threadTag(identity) + }; + atomicWriteJson(identity.stateDir.currentThreadFile, state); + return state; +} +function promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env = process.env) { + const rolloutPath = findCodexRolloutFile(threadId, env); + const state = { + version: 1, + status: rolloutPath ? "current" : "pending", + pairId: identity.pairId, + pairName: identity.pairName, + cwd: identity.cwd, + threadId, + updatedAt: nowIso(), + reason, + tag: threadTag(identity), + ...rolloutPath ? { rolloutPath, rolloutVerifiedAt: nowIso() } : {} + }; + atomicWriteJson(identity.stateDir.currentThreadFile, state); + return state; +} +async function persistCurrentThreadWithRolloutRetry(identity, threadId, reason, options = {}) { + const env = options.env ?? process.env; + const attempts = options.attempts ?? 20; + const delayMs = options.delayMs ?? 250; + const shouldContinue = options.shouldContinue ?? (() => true); + if (!shouldContinue()) + return null; + writePendingCurrentThread(identity, threadId, reason); + for (let attempt = 1;attempt <= attempts; attempt++) { + if (!shouldContinue()) { + options.log?.(`Abandoned current-thread persistence for ${threadId}: a newer thread became active`); + return null; + } + const state = promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env); + if (state.status === "current") { + options.log?.(`Current Codex thread persisted: ${threadId} (${state.rolloutPath})`); + return state; + } + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + if (!shouldContinue()) + return null; + options.log?.(`Current Codex thread left pending because no rollout file was found: ${threadId}`); + return readRawCurrentThread(identity.stateDir) ?? writePendingCurrentThread(identity, threadId, reason); +} + +// src/collaboration-contract.ts +import { createHash } from "crypto"; + +// src/collaboration-content.ts +var BUDGET_PACING = `### Budget pacing \u2014 drive the WEEKLY quota to ~100% over the week, evenly, without reaching a 5h cap (active when agent-quota-guard is installed) +- **Core principle: token is the means, value is the end.** Raising intensity means producing more real parallel value (deeper reviews, more independent exploration / verification / genuine subtasks) \u2014 never manufacturing low-value work to consume quota. The budget to MAXIMIZE is the **weekly** quota (refreshed once a week): drive each side's weekly toward ~100% by its weekly reset, and consume it **evenly** across the week \u2014 front-loading then starving, or under-consuming throughout, both leave weekly quota unredeemed (forfeited). The **5h window is NOT a quota bucket to fill \u2014 it is a RATE CAP**: stay under it within any 5h period; reaching it = a forced pause until the 5h resets = wasted time, not progress. +- **Re-query your budget before EVERY allocation decision** \u2014 Claude: \`get_budget\` \u2192 **rendered text** covering both sides; Codex: \`check_budget\` with \`agent:"claude"|"codex"\` \u2192 **normalized JSON**, per side. (Two different shapes \u2014 read the right one below.) Never reuse remembered numbers: a weekly window can refresh EARLY (resetting both 5h and weekly), fully restoring a side you believed was exhausted. +- **Even-pacing test (per side \u2014 Claude runs it)** \u2014 compare two quantities: *budget-windows* = how many 5h windows the weekly quota still covers at the current burn rate; *clock-windows* = how many 5h windows physically fit before the weekly reset = (weekly reset \u2212 now) \xF7 5h. **Claude** (\`get_budget\` text) carries BOTH, pre-computed for BOTH sides: the lines "\u6309\u5F53\u524D\u8282\u594F\uFF0C\u5468\u989D\u5EA6\u8FD8\u591F \u2026 \u4E2A 5h \u7A97\u53E3" (budget-windows) and "\u8DDD\u5468\u5237\u65B0\u8FD8\u80FD\u5BB9\u7EB3 \u2026 \u4E2A 5h \u7A97\u53E3\uFF08\u65F6\u949F\uFF09" (clock-windows). **Codex** (\`check_budget\` JSON) today carries only per-bucket \`util\` / \`reset_epoch\` / \`reset_after_seconds\` \u2014 no burn rate, no \`five_hour_windows_left\` \u2014 so Codex CANNOT compute budget-windows itself; it reads its weekly \`util\` and clock-windows only. To locate Codex's weekly bucket: of the \`buckets[]\` entries whose \`id\` contains \`seven_day\` or \`secondary_window\` (there can be several \u2014 e.g. a model-specific \`additional_rate_limits[\u2026]\` one at 0%), take the HIGHEST-util one (the binding account-level window, matching how the bridge parses it); its clock-windows = \`reset_after_seconds\` \xF7 5h (never the top-level \`reset_epoch\`, which tracks the current limiter, not necessarily the weekly window). For the budget-windows half and the raise/hold/reduce verdict, Codex relies on Claude's \`get_budget\` (the burn projection lives there, for both sides) and reports its own weekly \`util\` + reset timing so Claude can run the test. (If a future \`check_budget\` exposes \`five_hour_windows_left\` on the weekly bucket, Codex reads it directly.) **The verdict (Claude computes it, per side):** budget > clock \u2192 **under-consuming** (weekly will be left unused) \u2192 **raise intensity**; budget < clock \u2192 **over-consuming** (won't last to the weekly reset) \u2192 **reduce intensity**; within ~1 window, or no confident rate \u2192 **hold**. **Codex, absent a fresh Claude verdict, holds at its current intensity (it never escalates unilaterally) and stays clear of the 5h cap \u2014 surfacing its weekly \`util\` + reset timing so Claude can issue the verdict.** +- **Raise intensity \u2014 use the levers your role has.** Orchestrator (Claude): pick larger, more-decomposable tasks; run more parallel subagents at once (3\u20135+ vs 1); raise delegation density; open more concurrent streams (review + explore + verify in parallel). Executor (Codex): go deeper in-turn, take larger chunks, run more verification/repro. Both: deepen quality (multi-angle review, broader test/repro) \u2014 never manufacture make-work. **Reduce intensity:** fewer/serial subagents (Claude), short bounded chunks, defer optional deep work. Stay below the **\u52A8\u6001\u6682\u505C\u7EBF** (shown in \`get_budget\`; its \`\u4F59\u91CF\` = headroom from your current util to that soft line, measured on the resettable hard-winner window \u2014 the 5h OR the weekly window, whichever currently limits you) \u2014 that soft ceiling, not the raw 5h cap, is the "do not cross, avoid a forced pause" line. **If that line is absent, or you only have JSON (Codex),** fall back to the 5h bucket's raw util vs 100% (Codex: of the \`buckets[]\` entries whose \`id\` contains \`five_hour\` or \`primary_window\`, take the HIGHEST-util one) and keep clear of the 5h cap. +- **Distinguish 5h from weekly:** a 5h window resetting does NOT consume or waste weekly budget \u2014 it only refreshes your rate headroom, so you can keep going when weekly is under-consumed. A near 5h reset is therefore not urgency but the release of a rate limit. The real "unused = forfeited" is the **weekly budget as its WEEKLY reset nears**: if weekly is still under-consumed then, raise intensity (within the 5h cap) to use it. If even pacing needs a rate beyond one 5h window's capacity, you are rate-limited \u2192 keep each 5h window as full as possible (under the cap). +- **Two-subscription imbalance \u2014 the quotas are INDEPENDENT and differ in BOTH amount AND reset timing** (each side's weekly and 5h windows reset on different clocks). **The cross-side split is the orchestrator's (Claude) decision:** route more work to the side that is MORE under-consuming on the even-pacing test (the larger budget-windows \u2212 clock-windows gap); when EITHER side lacks a confident rate (so the gap can't be compared), fall back to the more budget-rich side (larger absolute weekly headroom). On any tie (equal gap, or equal headroom), prefer the side whose **weekly resets SOONER** (its leftover is forfeited earlier). **As the executor (Codex) you do NOT decide the global split** \u2014 execute what you're assigned, and when your own budget is rich report it (with evidence) so Claude routes more to you. The tighter / over-consuming side carries less. +- **Side-aware pause (the hard floor the code enforces \u2014 obey, do not reinvent), with each side's own action:** **Codex exhausted** (\`system_budget_pause\`) \u2192 Codex's turns stop (gate closed); **Claude** must not retry replies and continues solo on independent work, checkpointing the split point \u2014 but the SAME \`system_budget_pause\` is ALSO emitted when both sides are exhausted, so do not infer "solo" from the directive name alone: read its content (it names the paused side[s]) or re-check \`get_budget\`, and continue solo ONLY while Claude's own side is healthy; if Claude is also at its line, handle it as **Both** below. **Claude exhausted** (\`system_budget_handoff\`) \u2192 **Claude** sends ONE handoff (remaining tasks / context / artifact locations / acceptance criteria) then stops; **Codex** receives the baton and carries the work forward as far as its remaining quota allows that turn. **Both** \u2192 joint pause; checkpoint and wait for \`resume\` (Claude's own quota-guard also hard-stops Claude independently). A transient probe **429 is NOT exhaustion** \u2192 fall back to cached util and keep working.`; +var CLAUDE_MD_SECTION = `## AgentBridge \u2014 Multi-Agent Collaboration + +You are working in a **multi-agent environment** powered by AgentBridge. +Another AI agent (Codex, by OpenAI) is available in a parallel session on this machine. + +### Communication mechanism +- **Claude \u2192 Codex**: Use the AgentBridge MCP tools (\`reply\` / \`get_messages\`) \u2014 these are yours only. +- **Codex \u2192 Claude**: Codex has no symmetric tool. The bridge transparently intercepts Codex's normal output and forwards it to you as push notifications (if a push fails, drain the fallback queue with \`get_messages\`). +- If Codex ever complains it can't find a "send-to-Claude" API, remind it that its side is transparent \u2014 it just writes a reply and you'll see it. + +### When to collaborate vs. work solo +- **Collaborate** when the task benefits from a second perspective, parallel execution, or capabilities you lack (e.g., sandboxed code execution, independent verification). +- **Work solo** for simple, self-contained tasks where the coordination overhead isn't worth it. +- When in doubt, **propose a task split** to Codex rather than doing everything yourself. + +### Capability comparison +| Capability | Claude (you) | Codex | +|---|---|---| +| Architecture & planning | Strong | Moderate | +| Code review & analysis | Strong | Strong | +| Sandboxed code execution | No | Yes | +| File editing & refactoring | Yes (via tools) | Yes (via sandbox) | +| Web search & docs | Yes | Limited | +| Independent verification | Cross-review | Reproduce & test | + +### How to start collaborating +1. When you receive a complex task, **proactively propose a division of labor** to Codex via the reply tool. +2. State what you'll handle and what you'd like Codex to take on. +3. Ask for Codex's agreement or counter-proposal before proceeding. +4. After task completion, **cross-review** each other's work. + +${BUDGET_PACING}`; +var AGENTS_MD_SECTION = `## AgentBridge \u2014 Multi-Agent Collaboration + +You are working in a **multi-agent environment** powered by AgentBridge. +Another AI agent (Claude, by Anthropic) is available in a parallel session on this machine. + +### Communication mechanism (read this first) +AgentBridge is a **transparent proxy** on your side. You do **not** have a tool to "send a message to Claude". + +- **Codex \u2192 Claude**: Just write your normal response. The bridge intercepts your \`agentMessage\` output and forwards it to Claude automatically. No tool call needed. +- **Claude \u2192 Codex**: Claude uses its own MCP tools (\`reply\` / \`get_messages\`). Those messages arrive in your session as new user turns \u2014 you'll see them like any other user input. + +**Do not** search the AgentBridge source for a Codex-side "send" / "reply" / "sendToClaude" API \u2014 it does not exist, and looking for it wastes turns. If you catch yourself thinking "I need to find how to message Claude", stop and just write your reply as normal text. + +### When to collaborate vs. work solo +- **Collaborate** when the task benefits from a second perspective, parallel execution, or capabilities the other agent has. +- **Work solo** for simple, self-contained tasks where the coordination overhead isn't worth it. +- When in doubt, **propose a task split** to Claude rather than doing everything yourself. + +### Capability comparison +| Capability | Codex (you) | Claude | +|---|---|---| +| Sandboxed code execution | Yes | No | +| Reproduce & verify bugs | Strong | Limited | +| Architecture & planning | Moderate | Strong | +| Code review & analysis | Strong | Strong | +| Web search & docs | Limited | Yes | +| File editing & refactoring | Yes (via sandbox) | Yes (via tools) | + +### How to start collaborating +1. When you receive a complex task, **proactively propose a division of labor** in your response (Claude will receive it). +2. State what you'll handle and what you'd like Claude to take on. +3. Ask for Claude's agreement or counter-proposal before proceeding. +4. After task completion, **cross-review** each other's work. + +### Message markers +Put a marker at the **very start** of each \`agentMessage\` (it must be the first text \u2014 e.g. \`[IMPORTANT] Task done\`, not \`Task done [IMPORTANT]\`): +- \`[IMPORTANT]\` \u2014 decisions, reviews, completions, blockers +- \`[STATUS]\` \u2014 progress updates +- \`[FYI]\` \u2014 background context + +Keep \`agentMessage\` for high-value communication only. + +### Git operations \u2014 FORBIDDEN for you +You MUST NOT run git **write** commands: \`commit\`, \`push\`, \`pull\`, \`fetch\`, \`checkout -b\`, \`branch\`, \`merge\`, \`rebase\`, \`cherry-pick\`, \`tag\`, \`stash\`. They write the \`.git\` directory (blocked by your sandbox) and will hang your session. Read-only git (\`status\`, \`log\`, \`diff\`, \`show\`, \`rev-parse\`) is fine. Delegate **all** git writes to Claude: report what you changed and let Claude handle branching, committing, and pushing. + +### Role guidance +- Your default role: **Implementer, Executor, Verifier**. +- Analytical / review tasks: **Independent Analysis & Convergence**. +- Implementation tasks: **Architect \u2192 Builder \u2192 Critic**. +- Debugging tasks: **Hypothesis \u2192 Experiment \u2192 Interpretation**. +- Do not blindly follow Claude \u2014 challenge with evidence when you disagree. +- Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:". + +${BUDGET_PACING}`; + +// src/collaboration-contract.ts +var CODEX_CONTRACT_SCOPE_CLAUSE = `[AgentBridge runtime contract \u2014 read this scope rule first] +This developer context is injected by AgentBridge (a local Claude<->Codex +bridge) and is BINDING ONLY while the bridge is actively attached to this +thread. The ONLY proof of attachment is bridged messages from Claude (or +AgentBridge system notices) arriving in the CURRENT session. Collaborate when +such messages arrive. NEVER wait for, block on, or address Claude merely +because this contract exists. If this thread was resumed outside the bridge +(no bridged messages in the current session), ignore everything below and +operate as a normal solo Codex session.`; +var CLAUDE_CONTEXT_SCOPE_CLAUSE = `[AgentBridge runtime context] +Injected by the AgentBridge plugin because the bridge daemon was healthy when +this session started. If the bridge later becomes unreachable (reply tool +errors, no channel messages arriving), treat collaboration as unavailable and +continue solo \u2014 do not wait for Codex.`; +var CODEX_DEVELOPER_CONTRACT = `${CODEX_CONTRACT_SCOPE_CLAUSE} + +${AGENTS_MD_SECTION}`; +var CLAUDE_SESSION_CONTEXT = `${CLAUDE_CONTEXT_SCOPE_CLAUSE} + +${CLAUDE_MD_SECTION}`; +function contractHash(content = CODEX_DEVELOPER_CONTRACT) { + return createHash("sha256").update(content, "utf8").digest("hex").slice(0, 12); +} +function codexContractSupersedePayload(previousHash) { + return `[AgentBridge contract update] +This REPLACES the AgentBridge runtime contract injected earlier in this thread +(hash ${previousHash}). Disregard that earlier version entirely and follow only +the contract below. + +${CODEX_DEVELOPER_CONTRACT}`; +} + // src/port-cleanup.ts function portPidsCommand(port, platform2 = process.platform) { if (platform2 === "win32") { @@ -399,11 +706,11 @@ async function cleanupPorts(options) { } // src/rotating-log.ts -import { appendFileSync, existsSync as existsSync2, renameSync as renameSync2, statSync, unlinkSync as unlinkSync2 } from "fs"; +import { appendFileSync, existsSync as existsSync3, renameSync as renameSync2, statSync, unlinkSync as unlinkSync2 } from "fs"; import { dirname as dirname2 } from "path"; var DEFAULT_MAX_BYTES = 5 * 1024 * 1024; var DEFAULT_KEEP = 3; -var REAL_FS_OPS = { statSync, renameSync: renameSync2, unlinkSync: unlinkSync2, appendFileSync, existsSync: existsSync2 }; +var REAL_FS_OPS = { statSync, renameSync: renameSync2, unlinkSync: unlinkSync2, appendFileSync, existsSync: existsSync3 }; function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) { const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES); const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP); @@ -615,7 +922,7 @@ function clampInterruptTimeoutMs(requested) { import { createServer, connect } from "net"; import { spawnSync } from "child_process"; import { mkdirSync as mkdirSync3, rmSync, chmodSync } from "fs"; -import { join as join2 } from "path"; +import { join as join3 } from "path"; import { tmpdir } from "os"; var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT"; var HEADER_SEP = `\r @@ -664,8 +971,8 @@ function resolveCodexTransport(mode, runHelp = defaultRunCodexAppServerHelp) { } function codexSocketPath(appPort, baseTmpDir = tmpdir()) { const uid = typeof process.getuid === "function" ? process.getuid() : 0; - const dir = join2(baseTmpDir, `agentbridge-${uid}`); - const path = join2(dir, `codex-${appPort}.sock`); + const dir = join3(baseTmpDir, `agentbridge-${uid}`); + const path = join3(dir, `codex-${appPort}.sock`); if (path.length >= 104) { throw new Error(`Codex unix socket path is too long for the platform (${path.length} >= 104): ${path}. ` + `Set a shorter TMPDIR or use ${CODEX_TRANSPORT_ENV}=ws.`); } @@ -944,9 +1251,31 @@ class PendingRequestRegistry { } } +// src/version-utils.ts +var STABLE_SEMVER_RE = /^\d+\.\d+\.\d+$/; +function isStableVersion(v) { + return STABLE_SEMVER_RE.test(v.trim()); +} +function compareVersions(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0;i < 3; i++) { + const va = pa[i] ?? 0; + const vb = pb[i] ?? 0; + if (va < vb) + return -1; + if (va > vb) + return 1; + } + return 0; +} + // src/codex-adapter.ts class CodexAdapter extends EventEmitter { static RESPONSE_TRACKING_TTL_MS = 30000; + static MIN_RUNTIME_INJECTION_VERSION = "0.144.1"; + static RUNTIME_INJECTION_TIMEOUT_MS = 5000; + static RUNTIME_INJECTION_ERROR_CODE = -32041; proc = null; appServerPid = null; appServerWs = null; @@ -961,6 +1290,11 @@ class CodexAdapter extends EventEmitter { proxyPort; logFile; logger; + runtimeInjectionEnabled; + runtimeStateDir; + runtimeContractHash; + runtimeInjectionTimeoutMs; + pendingRuntimeContractInjections = new Map; tuiConnId = 0; connIdCounter = 0; secondaryConnections = new Map; @@ -1001,12 +1335,16 @@ class CodexAdapter extends EventEmitter { replayPending = new PendingRequestRegistry; replayMethods = new Map; static SESSION_REPLAY_TIMEOUT_MS = 5000; - constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) { + constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile, options = {}) { super(); this.appPort = appPort; this.proxyPort = proxyPort; this.logFile = logFile; this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile }); + this.runtimeInjectionEnabled = options.runtimeInjection?.enabled ?? true; + this.runtimeStateDir = options.runtimeInjection?.stateDir ?? new StateDirResolver; + this.runtimeContractHash = contractHash(); + this.runtimeInjectionTimeoutMs = options.runtimeInjection?.timeoutMs ?? CodexAdapter.RUNTIME_INJECTION_TIMEOUT_MS; } get appServerUrl() { return `ws://127.0.0.1:${this.appPort}`; @@ -1101,6 +1439,7 @@ class CodexAdapter extends EventEmitter { } this.outageQueue = []; this.clearOutageTimer(); + this.cancelRuntimeContractInjections("adapter disconnected", false); this.appServerWs?.close(); this.appServerWs = null; for (const [id, sec] of this.secondaryConnections) { @@ -1430,6 +1769,7 @@ class CodexAdapter extends EventEmitter { const tuiConnected = this.tuiWs !== null; this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`); this.appServerWs = null; + this.cancelRuntimeContractInjections("app-server connection closed", !intentional); this.clearResponseTrackingState(); this.resetTurnState("app-server connection closed"); if (!intentional) { @@ -1859,6 +2199,8 @@ class CodexAdapter extends EventEmitter { const method = parsed.method ?? `response:${parsed.id}`; this.log(`TUI \u2192 app-server: ${method}`); if (parsed.id !== undefined && parsed.method) { + if (!this.prepareRuntimeThreadRequest(ws, parsed)) + return; const proxyId = this.nextProxyId++; this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id }); this.trackPendingRequest(parsed, connId, proxyId); @@ -1879,6 +2221,65 @@ class CodexAdapter extends EventEmitter { this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`); } } + prepareRuntimeThreadRequest(ws, message) { + if (!this.runtimeInjectionEnabled) + return true; + const method = typeof message.method === "string" ? message.method : null; + if (method !== "thread/start" && method !== "thread/resume") + return true; + const params = typeof message.params === "object" && message.params !== null && !Array.isArray(message.params) ? message.params : {}; + if (method === "thread/resume") { + const threadId = params.threadId; + if (typeof threadId === "string" && threadId.length > 0 && readCodexContractHash(this.runtimeStateDir, threadId) === this.runtimeContractHash) { + return true; + } + } + const compatibilityError = this.runtimeInjectionCompatibilityError(); + if (compatibilityError) { + this.sendRuntimeRequestError(ws, message.id, compatibilityError); + return false; + } + if (method === "thread/start") { + const existing = params.developerInstructions; + if (existing !== undefined && existing !== null && typeof existing !== "string") { + this.sendRuntimeRequestError(ws, message.id, "AgentBridge cannot merge the runtime contract because " + "thread/start.params.developerInstructions is not a string or null."); + return false; + } + if (typeof existing !== "string" || existing.length === 0) { + params.developerInstructions = CODEX_DEVELOPER_CONTRACT; + } else if (!existing.includes(CODEX_DEVELOPER_CONTRACT)) { + params.developerInstructions = `${existing} + +${CODEX_DEVELOPER_CONTRACT}`; + } + message.params = params; + } + return true; + } + runtimeInjectionCompatibilityError() { + const version = this.appServerInfo?.version ?? null; + if (version !== null && isStableVersion(version) && compareVersions(version, CodexAdapter.MIN_RUNTIME_INJECTION_VERSION) >= 0) { + return null; + } + return `AgentBridge runtime collaboration injection requires Codex app-server >= ` + `${CodexAdapter.MIN_RUNTIME_INJECTION_VERSION}; detected ${version ?? "unknown"}. ` + `Upgrade Codex, or set {"injection":{"runtime":false}} in .agentbridge/config.json ` + `and restart AgentBridge. ` + `AgentBridge will not silently fall back to modifying AGENTS.md.`; + } + runtimeErrorResponse(id, message) { + return JSON.stringify({ + id, + error: { + code: CodexAdapter.RUNTIME_INJECTION_ERROR_CODE, + message + } + }); + } + sendRuntimeRequestError(ws, id, message) { + this.log(`Runtime contract request rejected: ${message}`); + try { + ws.send(this.runtimeErrorResponse(id, message)); + } catch (error) { + this.log(`Failed to send runtime contract error to TUI: ${error.message}`); + } + } detectJsonMethod(raw) { try { const parsed = JSON.parse(raw); @@ -2014,6 +2415,12 @@ class CodexAdapter extends EventEmitter { handleAppServerResponse(parsed, raw) { const responseId = parsed.id; const numericId = this.normalizeNumericId(responseId); + if (!isNaN(numericId)) { + const runtimeInjection = this.pendingRuntimeContractInjections.get(numericId); + if (runtimeInjection) { + return this.completeRuntimeContractInjection(parsed, runtimeInjection); + } + } const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined; if (mapping) { this.upstreamToClient.delete(numericId); @@ -2026,6 +2433,9 @@ class CodexAdapter extends EventEmitter { } parsed.id = mapping.clientId; this.log(`app-server \u2192 TUI: response (proxy id=${numericId} \u2192 client id=${String(mapping.clientId)}, conn #${mapping.connId})`); + const runtimeResponse = this.handleMappedRuntimeContractResponse(parsed, mapping.connId); + if (runtimeResponse !== false) + return runtimeResponse; const forwarded = this.patchResponse(parsed, JSON.stringify(parsed)); this.interceptServerMessage(parsed, mapping.connId); return forwarded; @@ -2070,6 +2480,153 @@ class CodexAdapter extends EventEmitter { this.log(`Dropping unmatched app-server response id ${String(responseId)}`); return null; } + handleMappedRuntimeContractResponse(response, connId) { + if (!this.runtimeInjectionEnabled || response.error) + return false; + const key = this.pendingKey(response.id, connId); + const pending = key ? this.pendingRequests.get(key) : undefined; + if (!pending) + return false; + if (pending.method === "thread/start" && pending.runtimeContractHash) { + const threadId2 = response.result?.thread?.id; + if (typeof threadId2 !== "string" || threadId2.length === 0) { + return this.failTrackedRuntimeRequest(response, connId, "Codex returned a successful thread/start response without thread.id; " + "AgentBridge could not persist runtime-contract idempotency state."); + } + try { + persistCodexContractInjection(this.runtimeStateDir, threadId2, pending.runtimeContractHash); + } catch (error) { + return this.failTrackedRuntimeRequest(response, connId, `AgentBridge injected the runtime contract but could not persist pair state: ${error.message}`); + } + this.log(`Runtime contract ${pending.runtimeContractHash} persisted for new thread ${threadId2}`); + return false; + } + if (pending.method !== "thread/resume" || !this.isLatestThreadSwitch(pending)) { + return false; + } + const threadId = response.result?.thread?.id; + if (typeof threadId !== "string" || threadId.length === 0) { + return this.failTrackedRuntimeRequest(response, connId, "Codex returned a successful thread/resume response without thread.id; " + "AgentBridge could not inject the runtime contract."); + } + const previousHash = readCodexContractHash(this.runtimeStateDir, threadId); + if (previousHash === this.runtimeContractHash) + return false; + const compatibilityError = this.runtimeInjectionCompatibilityError(); + if (compatibilityError) { + return this.failTrackedRuntimeRequest(response, connId, compatibilityError); + } + if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) { + return this.failTrackedRuntimeRequest(response, connId, "Codex app-server disconnected before AgentBridge could inject the runtime contract."); + } + const payload = previousHash === null ? CODEX_DEVELOPER_CONTRACT : codexContractSupersedePayload(previousHash); + const requestId = this.nextInjectionId--; + const timer = setTimeout(() => { + this.handleRuntimeContractInjectionTimeout(requestId); + }, this.runtimeInjectionTimeoutMs); + timer.unref?.(); + const runtimeInjection = { + requestId, + connId, + threadId, + contractHash: this.runtimeContractHash, + resumeResponse: response, + timer + }; + this.pendingRuntimeContractInjections.set(requestId, runtimeInjection); + const params = { + threadId, + items: [{ + type: "message", + role: "developer", + content: [{ type: "input_text", text: payload }] + }] + }; + try { + this.appServerWs.send(JSON.stringify({ + id: requestId, + method: "thread/inject_items", + params + })); + } catch (error) { + clearTimeout(timer); + this.pendingRuntimeContractInjections.delete(requestId); + return this.failTrackedRuntimeRequest(response, connId, `AgentBridge could not send thread/inject_items: ${error.message}`); + } + this.log(`Holding thread/resume for ${threadId} until runtime contract ${this.runtimeContractHash} ` + `is acknowledged (request ${requestId}${previousHash ? `, supersedes ${previousHash}` : ""})`); + return null; + } + completeRuntimeContractInjection(response, pending) { + clearTimeout(pending.timer); + this.pendingRuntimeContractInjections.delete(pending.requestId); + if (response.error) { + const detail = response.error.message ?? "unknown app-server error"; + return this.failTrackedRuntimeRequest(pending.resumeResponse, pending.connId, `Codex rejected AgentBridge thread/inject_items: ${detail}. ` + "No AGENTS.md fallback was applied."); + } + try { + persistCodexContractInjection(this.runtimeStateDir, pending.threadId, pending.contractHash); + } catch (error) { + return this.failTrackedRuntimeRequest(pending.resumeResponse, pending.connId, `AgentBridge injected the runtime contract but could not persist pair state: ${error.message}`); + } + this.log(`Runtime contract ${pending.contractHash} injected and persisted for resumed thread ${pending.threadId}`); + if (pending.connId !== this.tuiConnId) { + const key = this.pendingKey(pending.resumeResponse.id, pending.connId); + if (key) + this.pendingRequests.delete(key); + this.log(`Dropping deferred thread/resume response for retired TUI conn #${pending.connId}`); + return null; + } + try { + this.interceptServerMessage(pending.resumeResponse, pending.connId); + } catch (error) { + const message = `AgentBridge injected and persisted the runtime contract, but failed to release ` + `the resumed thread safely: ${error.message}`; + this.log(`Runtime contract resume release failed: ${message}`); + return this.runtimeErrorResponse(pending.resumeResponse.id, message); + } + return this.patchResponse(pending.resumeResponse, JSON.stringify(pending.resumeResponse)); + } + handleRuntimeContractInjectionTimeout(requestId) { + const pending = this.pendingRuntimeContractInjections.get(requestId); + if (!pending) + return; + this.pendingRuntimeContractInjections.delete(requestId); + const message = `Timed out after ${this.runtimeInjectionTimeoutMs}ms waiting for Codex ` + `thread/inject_items while resuming ${pending.threadId}. No AGENTS.md fallback was applied.`; + const response = this.failTrackedRuntimeRequest(pending.resumeResponse, pending.connId, message); + if (pending.connId !== this.tuiConnId || !this.tuiWs) + return; + try { + this.tuiWs.send(response); + } catch (error) { + this.log(`Failed to send runtime injection timeout to TUI: ${error.message}`); + } + } + cancelRuntimeContractInjections(reason, notifyTui) { + for (const pending of [...this.pendingRuntimeContractInjections.values()]) { + clearTimeout(pending.timer); + this.pendingRuntimeContractInjections.delete(pending.requestId); + const response = this.failTrackedRuntimeRequest(pending.resumeResponse, pending.connId, `AgentBridge runtime contract injection was cancelled: ${reason}.`); + if (!notifyTui || pending.connId !== this.tuiConnId || !this.tuiWs) + continue; + try { + this.tuiWs.send(response); + } catch (error) { + this.log(`Failed to send runtime injection cancellation to TUI: ${error.message}`); + } + } + } + failTrackedRuntimeRequest(successfulResponse, connId, message) { + this.log(`Runtime contract injection failed: ${message}`); + try { + this.interceptServerMessage({ + id: successfulResponse.id, + error: { + code: CodexAdapter.RUNTIME_INJECTION_ERROR_CODE, + message + } + }, connId); + } catch (error) { + this.log(`Runtime contract failure cleanup also failed: ${error.message}`); + } + return this.runtimeErrorResponse(successfulResponse.id, message); + } captureAppServerInfo(result) { const init = typeof result === "object" && result !== null ? result : {}; const userAgent = typeof init.userAgent === "string" ? init.userAgent : null; @@ -2201,13 +2758,16 @@ class CodexAdapter extends EventEmitter { if (!key || !isTrackedAppServerRequestMethod(method)) return; const pending = { method }; - if (method === "turn/start") { - const params = "params" in message && typeof message.params === "object" && message.params !== null ? message.params : undefined; + const params = "params" in message && typeof message.params === "object" && message.params !== null ? message.params : undefined; + if (method === "turn/start" || method === "thread/resume") { const threadId = params?.threadId; if (typeof threadId === "string" && threadId.length > 0) { pending.threadId = threadId; } } + if (method === "thread/start" && this.runtimeInjectionEnabled && typeof params?.developerInstructions === "string" && params.developerInstructions.includes(CODEX_DEVELOPER_CONTRACT)) { + pending.runtimeContractHash = this.runtimeContractHash; + } if (method === "thread/start" || method === "thread/resume") { pending.threadSwitchSeq = ++this.threadSwitchSeq; } @@ -2563,12 +3123,12 @@ var CLOSE_CODE_TOKEN_MISMATCH = 4005; var CLOSE_CODE_CONTRACT_MISMATCH = 4006; // src/control-token.ts -import { chmodSync as chmodSync2, readFileSync as readFileSync2 } from "fs"; -import { join as join3 } from "path"; +import { chmodSync as chmodSync2, readFileSync as readFileSync3 } from "fs"; +import { join as join4 } from "path"; import { randomUUID as randomUUID2 } from "crypto"; var CONTROL_TOKEN_FILENAME = "control-token"; function resolveControlTokenPath(stateDir) { - return join3(stateDir, CONTROL_TOKEN_FILENAME); + return join4(stateDir, CONTROL_TOKEN_FILENAME); } function generateControlToken() { return randomUUID2(); @@ -2894,7 +3454,7 @@ class TuiConnectionState { // src/daemon-lifecycle.ts import { spawn as spawn2 } from "child_process"; -import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync2, openSync as openSync2, closeSync as closeSync2, constants } from "fs"; +import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync2, openSync as openSync2, closeSync as closeSync2, constants } from "fs"; import { fileURLToPath } from "url"; // src/process-lifecycle.ts @@ -3178,7 +3738,7 @@ class DaemonLifecycle { } readStatus() { try { - const raw = readFileSync3(this.stateDir.statusFile, "utf-8"); + const raw = readFileSync4(this.stateDir.statusFile, "utf-8"); return JSON.parse(raw); } catch { return null; @@ -3189,7 +3749,7 @@ class DaemonLifecycle { } readPid() { try { - const raw = readFileSync3(this.stateDir.pidFile, "utf-8").trim(); + const raw = readFileSync4(this.stateDir.pidFile, "utf-8").trim(); if (!raw) return null; const pid = Number.parseInt(raw, 10); @@ -3223,7 +3783,7 @@ class DaemonLifecycle { } catch {} } wasKilled() { - return existsSync3(this.stateDir.killedFile); + return existsSync4(this.stateDir.killedFile); } launch() { this.stateDir.ensure(); @@ -3304,7 +3864,7 @@ class DaemonLifecycle { if (reclaimed) return false; try { - const holderPid = Number.parseInt(readFileSync3(this.stateDir.lockFile, "utf-8").trim(), 10); + const holderPid = Number.parseInt(readFileSync4(this.stateDir.lockFile, "utf-8").trim(), 10); if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) { this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`); this.releaseLock(); @@ -3473,8 +4033,8 @@ function consumeCheckpointBaton(path, fiveHourResetEpoch, log = () => {}) { } // src/config-service.ts -import { readFileSync as readFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs"; -import { join as join4 } from "path"; +import { readFileSync as readFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs"; +import { join as join5 } from "path"; var DEFAULT_BUDGET_CONFIG = { enabled: true, pollSeconds: 300, @@ -3516,6 +4076,9 @@ var DEFAULT_CONFIG = { turnCoordination: { attentionWindowSeconds: 15 }, + injection: { + runtime: true + }, idleShutdownSeconds: 30, budget: DEFAULT_BUDGET_CONFIG }; @@ -3595,7 +4158,7 @@ function hasCustomDecisionValues(config) { const d = DEFAULT_CONFIG; const b = config.budget; const db = d.budget; - return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours; + return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.injection.runtime !== d.injection.runtime || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours; } function normalizeInteger(value, fallback) { if (typeof value === "number" && Number.isFinite(value)) @@ -3744,6 +4307,7 @@ function normalizeConfig(raw) { const codex = isRecord(config.codex) ? config.codex : {}; const daemon = isRecord(config.daemon) ? config.daemon : {}; const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {}; + const injection = isRecord(config.injection) ? config.injection : {}; return { version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version, codex: { @@ -3753,6 +4317,9 @@ function normalizeConfig(raw) { turnCoordination: { attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER) }, + injection: { + runtime: normalizeBoolean(injection.runtime, DEFAULT_CONFIG.injection.runtime) + }, idleShutdownSeconds: normalizeBoundedInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER), budget: normalizeBudgetConfig(config.budget) }; @@ -3763,16 +4330,16 @@ class ConfigService { configPath; constructor(projectRoot) { const root = projectRoot ?? process.cwd(); - this.configDir = join4(root, CONFIG_DIR); - this.configPath = join4(this.configDir, CONFIG_FILE); + this.configDir = join5(root, CONFIG_DIR); + this.configPath = join5(this.configDir, CONFIG_FILE); } hasConfig() { - return existsSync4(this.configPath); + return existsSync5(this.configPath); } load() { let raw; try { - raw = readFileSync4(this.configPath, "utf-8"); + raw = readFileSync5(this.configPath, "utf-8"); } catch (err) { if (err?.code === "ENOENT") { return { state: "absent" }; @@ -3806,7 +4373,7 @@ class ConfigService { if (result.state === "parsed") return result.config; if (result.state === "corrupt") { - log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget thresholds / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them."); + log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget / runtime-injection / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them."); } return structuredClone(DEFAULT_CONFIG); } @@ -3830,7 +4397,7 @@ class ConfigService { initDefaults() { this.ensureConfigDir(); const created = []; - if (!existsSync4(this.configPath)) { + if (!existsSync5(this.configPath)) { this.save(DEFAULT_CONFIG); created.push(this.configPath); } @@ -3840,14 +4407,14 @@ class ConfigService { return this.configPath; } ensureConfigDir() { - if (!existsSync4(this.configDir)) { + if (!existsSync5(this.configDir)) { mkdirSync4(this.configDir, { recursive: true }); } } } // src/budget/budget-coordinator.ts -import { homedir as homedir2 } from "os"; +import { homedir as homedir3 } from "os"; // src/budget/budget-gate.ts function matchingGateReset(usage) { @@ -4412,8 +4979,8 @@ function computeBudgetState(claude, codex, cfg, now, runway = NO_RUNWAY) { } // src/budget/advice-cooldown.ts -import { readFileSync as readFileSync5 } from "fs"; -import { join as join5 } from "path"; +import { readFileSync as readFileSync6 } from "fs"; +import { join as join6 } from "path"; var DEFAULT_ADVICE_COOLDOWN_SEC = 1800; var COOLDOWN_FILENAME = "advice-cooldown.json"; function resolveAdviceCooldownSec(env = process.env) { @@ -4429,7 +4996,7 @@ function resolveStateDir(homeDir) { const override = process.env.BUDGET_STATE_DIR; if (override && override.trim() !== "") return override.trim(); - return join5(homeDir, ".budget-guard"); + return join6(homeDir, ".budget-guard"); } class AdviceCooldown { @@ -4437,7 +5004,7 @@ class AdviceCooldown { cooldownSec; log; constructor(options) { - this.path = join5(resolveStateDir(options.homeDir), COOLDOWN_FILENAME); + this.path = join6(resolveStateDir(options.homeDir), COOLDOWN_FILENAME); this.cooldownSec = options.cooldownSec ?? DEFAULT_ADVICE_COOLDOWN_SEC; this.log = options.log ?? (() => {}); } @@ -4453,7 +5020,7 @@ class AdviceCooldown { read() { let raw; try { - raw = readFileSync5(this.path, "utf-8"); + raw = readFileSync6(this.path, "utf-8"); } catch { return {}; } @@ -4878,7 +5445,7 @@ class BudgetCoordinator { this.onResume = options.onResume ?? (() => {}); this.resumeSignals = options.resumeSignals ?? null; this.adviceCooldown = options.adviceCooldown ?? new AdviceCooldown({ - homeDir: homedir2(), + homeDir: homedir3(), cooldownSec: resolveAdviceCooldownSec(), log: this.log }); @@ -5228,9 +5795,9 @@ class BudgetCoordinator { // src/budget/quota-source.ts import { execFile } from "child_process"; -import { existsSync as existsSync5 } from "fs"; -import { homedir as homedir3 } from "os"; -import { basename, join as join6 } from "path"; +import { existsSync as existsSync6 } from "fs"; +import { homedir as homedir4 } from "os"; +import { basename as basename2, join as join7 } from "path"; function parseBurnFields(record) { const group = {}; let any = false; @@ -5300,7 +5867,7 @@ function defaultRunner(command, args, options) { }); } function commandKind(command) { - return basename(command) === "probe.mjs" ? "probe-mjs" : "budget-probe"; + return basename2(command) === "probe.mjs" ? "probe-mjs" : "budget-probe"; } function argsFor(candidate, agent) { if (candidate.kind === "probe-mjs") @@ -5517,7 +6084,7 @@ class QuotaSource { unknownSchemaVersionsLogged = new Set; constructor(options = {}) { this.env = options.env ?? process.env; - this.homeDir = options.homeDir ?? homedir3(); + this.homeDir = options.homeDir ?? homedir4(); this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.runner = options.runner ?? defaultRunner; this.log = options.log ?? (() => {}); @@ -5549,12 +6116,12 @@ class QuotaSource { add(command, commandKind(command)); return candidates; } - const binDir = join6(this.homeDir, ".budget-guard/bin"); - const installedProbeMjs = join6(binDir, "probe.mjs"); - if (existsSync5(installedProbeMjs)) + const binDir = join7(this.homeDir, ".budget-guard/bin"); + const installedProbeMjs = join7(binDir, "probe.mjs"); + if (existsSync6(installedProbeMjs)) add(installedProbeMjs, "probe-mjs"); - const installedBudgetProbe = join6(binDir, "budget-probe"); - if (existsSync5(installedBudgetProbe)) + const installedBudgetProbe = join7(binDir, "budget-probe"); + if (existsSync6(installedBudgetProbe)) add(installedBudgetProbe, "budget-probe"); return candidates; } @@ -5617,8 +6184,8 @@ function createQuotaSource(options) { } // src/budget/pending-reader.ts -import { createHash } from "crypto"; -import { join as join7 } from "path"; +import { createHash as createHash2 } from "crypto"; +import { join as join8 } from "path"; function nodeFs2() { return __require("fs"); } @@ -5653,13 +6220,13 @@ function parsePendingPayload(value) { return { status, agent, sessionId, cwd, resetEpoch, util, warnUtil, at, sourcePath: "", contentHash: "" }; } function sha256(value) { - return createHash("sha256").update(value).digest("hex"); + return createHash2("sha256").update(value).digest("hex"); } function resolveStateDir2(homeDir) { const override = process.env.BUDGET_STATE_DIR; if (override && override.trim() !== "") return override.trim(); - return join7(homeDir, ".budget-guard"); + return join8(homeDir, ".budget-guard"); } function readPendingFile(path, log) { let raw; @@ -5685,7 +6252,7 @@ function readPendingFile(path, log) { return { ...entry, sourcePath: path, contentHash: sha256(text) }; } function listScopeFiles(stateDir, agent, log) { - const pendingDir = join7(stateDir, "pending"); + const pendingDir = join8(stateDir, "pending"); let names; try { names = nodeFs2().readdirSync(pendingDir); @@ -5693,14 +6260,14 @@ function listScopeFiles(stateDir, agent, log) { return []; } const prefix = `${agent}_`; - return names.filter((name) => name.startsWith(prefix) && name.endsWith(".json")).map((name) => join7(pendingDir, name)); + return names.filter((name) => name.startsWith(prefix) && name.endsWith(".json")).map((name) => join8(pendingDir, name)); } function readGuardPending(opts) { const log = opts.log ?? (() => {}); const stateDir = resolveStateDir2(opts.homeDir); const paths = [ ...listScopeFiles(stateDir, opts.agent, log), - join7(stateDir, `pending_${opts.agent}.json`) + join8(stateDir, `pending_${opts.agent}.json`) ]; const bySession = new Map; for (const path of paths) { @@ -5721,9 +6288,9 @@ function readGuardPending(opts) { } // src/budget/resume-injection-queue.ts -import { createHash as createHash2 } from "crypto"; -import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync, readFileSync as readFileSync6, realpathSync, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3 } from "fs"; -import { join as join8 } from "path"; +import { createHash as createHash3 } from "crypto"; +import { closeSync as closeSync3, existsSync as existsSync7, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync as readdirSync2, readFileSync as readFileSync7, realpathSync, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3 } from "fs"; +import { join as join9 } from "path"; // src/budget/resume-prompt.ts var RESUME_PROMPT = "\u989D\u5EA6\u7A97\u53E3\u5DF2\u5237\u65B0\uFF0C\u7EE7\u7EED\u4E0A\u6B21\u672A\u5B8C\u6210\u7684\u4EFB\u52A1\uFF1A\u4ECE .agent/checkpoint.md \u7684\u300C\u4E0B\u4E00\u6B65\u300D\u63A5\u7740\u505A\uFF1B\u5B8C\u6210\u540E\u505C\u4E0B\u5E76\u6807 DONE\u3002"; @@ -5960,7 +6527,7 @@ function realpathOrRaw(path) { } } function sha2562(value) { - return createHash2("sha256").update(value).digest("hex"); + return createHash3("sha256").update(value).digest("hex"); } function writeJsonWx(path, value) { let fd; @@ -5989,7 +6556,7 @@ function unlinkIfExists(path) { } function readClaimedAt(path) { try { - const parsed = JSON.parse(readFileSync6(path, "utf-8")); + const parsed = JSON.parse(readFileSync7(path, "utf-8")); const claimedAt = parsed?.claimed_at; return typeof claimedAt === "number" && Number.isFinite(claimedAt) ? claimedAt : null; } catch { @@ -5999,16 +6566,16 @@ function readClaimedAt(path) { function pruneStaleResumeArtifacts(dir, tsField, ttlSec, nowSec, log) { let names; try { - names = readdirSync(dir); + names = readdirSync2(dir); } catch { return; } for (const name of names) { if (!name.endsWith(".json")) continue; - const p = join8(dir, name); + const p = join9(dir, name); try { - const parsed = JSON.parse(readFileSync6(p, "utf-8")); + const parsed = JSON.parse(readFileSync7(p, "utf-8")); const ts = parsed?.[tsField]; if (typeof ts === "number" && Number.isFinite(ts) && nowSec - ts > ttlSec) { unlinkIfExists(p); @@ -6031,18 +6598,18 @@ function tryClaimPendingResume(opts) { cwd, contentHash ].join("\x00")); - const claimsDir = join8(opts.stateDir, "claims"); - const consumedDir = join8(opts.stateDir, "consumed"); - const claimPath = join8(claimsDir, `${identity}.json`); - const consumedPath = join8(consumedDir, `${identity}.json`); + const claimsDir = join9(opts.stateDir, "claims"); + const consumedDir = join9(opts.stateDir, "consumed"); + const claimPath = join9(claimsDir, `${identity}.json`); + const consumedPath = join9(consumedDir, `${identity}.json`); mkdirSync5(claimsDir, { recursive: true }); mkdirSync5(consumedDir, { recursive: true }); const nowSec = now(); pruneStaleResumeArtifacts(consumedDir, "consumed_at", consumedTtlSec, nowSec, opts.log); pruneStaleResumeArtifacts(claimsDir, "claimed_at", claimTtlSec, nowSec, opts.log); - if (existsSync6(consumedPath)) + if (existsSync7(consumedPath)) return { ok: false, reason: "consumed" }; - if (existsSync6(claimPath)) { + if (existsSync7(claimPath)) { const claimedAt = readClaimedAt(claimPath); if (claimedAt !== null && nowSec - claimedAt > claimTtlSec) { try { @@ -6188,10 +6755,10 @@ function routeResume(side, resumeId, deps) { // src/budget/resume-ack-sentinel.ts import { renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs"; -import { join as join9 } from "path"; +import { join as join10 } from "path"; var RESUME_ACK_DEGRADED_SENTINEL = "resume-ack-degraded.json"; function resumeAckSentinelPath(stateDir) { - return join9(stateDir, RESUME_ACK_DEGRADED_SENTINEL); + return join10(stateDir, RESUME_ACK_DEGRADED_SENTINEL); } function writeResumeAckDegradedSentinel(opts) { const now = opts.now ?? (() => Date.now()); @@ -6211,8 +6778,8 @@ function writeResumeAckDegradedSentinel(opts) { } // src/daemon-identity-ownership.ts -import { readFileSync as readFileSync7 } from "fs"; -var defaultRead2 = (path) => readFileSync7(path, "utf-8"); +import { readFileSync as readFileSync8 } from "fs"; +var defaultRead2 = (path) => readFileSync8(path, "utf-8"); function pidFileOwnedByUs(pidFilePath, ourPid, read = defaultRead2) { let raw; try { @@ -6378,125 +6945,6 @@ class ReplyRequiredTracker { } } -// src/thread-state.ts -import { - existsSync as existsSync7, - readdirSync as readdirSync2, - readFileSync as readFileSync8 -} from "fs"; -import { homedir as homedir4 } from "os"; -import { basename as basename2, join as join10 } from "path"; -function nowIso() { - return new Date().toISOString(); -} -function threadTag(identity) { - const name = identity.pairName ?? identity.pairId ?? "manual"; - return `abg:${name}:${identity.cwd}`; -} -function codexHome(env = process.env) { - return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join10(homedir4(), ".codex"); -} -function readRawCurrentThread(stateDir) { - try { - const parsed = JSON.parse(readFileSync8(stateDir.currentThreadFile, "utf-8")); - if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") { - return parsed; - } - } catch {} - return null; -} -function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) { - const sessionsDir = join10(codexHome(env), "sessions"); - if (!threadId || !existsSync7(sessionsDir)) - return null; - const exactName = `rollout-${threadId}.jsonl`; - const stack = [sessionsDir]; - let visited = 0; - while (stack.length > 0 && visited < maxEntries) { - const dir = stack.pop(); - let entries; - try { - entries = readdirSync2(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - visited++; - const path = join10(dir, entry.name); - if (entry.isDirectory()) { - stack.push(path); - continue; - } - if (!entry.isFile()) - continue; - const name = basename2(entry.name); - if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) { - return path; - } - } - } - return null; -} -function writePendingCurrentThread(identity, threadId, reason) { - const state = { - version: 1, - status: "pending", - pairId: identity.pairId, - pairName: identity.pairName, - cwd: identity.cwd, - threadId, - updatedAt: nowIso(), - reason, - tag: threadTag(identity) - }; - atomicWriteJson(identity.stateDir.currentThreadFile, state); - return state; -} -function promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env = process.env) { - const rolloutPath = findCodexRolloutFile(threadId, env); - const state = { - version: 1, - status: rolloutPath ? "current" : "pending", - pairId: identity.pairId, - pairName: identity.pairName, - cwd: identity.cwd, - threadId, - updatedAt: nowIso(), - reason, - tag: threadTag(identity), - ...rolloutPath ? { rolloutPath, rolloutVerifiedAt: nowIso() } : {} - }; - atomicWriteJson(identity.stateDir.currentThreadFile, state); - return state; -} -async function persistCurrentThreadWithRolloutRetry(identity, threadId, reason, options = {}) { - const env = options.env ?? process.env; - const attempts = options.attempts ?? 20; - const delayMs = options.delayMs ?? 250; - const shouldContinue = options.shouldContinue ?? (() => true); - if (!shouldContinue()) - return null; - writePendingCurrentThread(identity, threadId, reason); - for (let attempt = 1;attempt <= attempts; attempt++) { - if (!shouldContinue()) { - options.log?.(`Abandoned current-thread persistence for ${threadId}: a newer thread became active`); - return null; - } - const state = promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env); - if (state.status === "current") { - options.log?.(`Current Codex thread persisted: ${threadId} (${state.rolloutPath})`); - return state; - } - if (attempt < attempts) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - if (!shouldContinue()) - return null; - options.log?.(`Current Codex thread left pending because no rollout file was found: ${threadId}`); - return readRawCurrentThread(identity.stateDir) ?? writePendingCurrentThread(identity, threadId, reason); -} - // src/waiting-message.ts function formatWaitingForCodexTuiMessage(options) { const pairName = options.pairName ?? "unknown"; @@ -6619,7 +7067,12 @@ var RESUME_ACK_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_RESUME_ACK_RETRIES", 3 var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log }); var DAEMON_NONCE = randomUUID4(); var DAEMON_STARTED_AT = Date.now(); -var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile); +var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile, { + runtimeInjection: { + enabled: config.injection.runtime, + stateDir + } +}); var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`; var controlServer = null; var boundControlPort = false; diff --git a/src/app-server-protocol.ts b/src/app-server-protocol.ts index e42c6168..88239c8e 100644 --- a/src/app-server-protocol.ts +++ b/src/app-server-protocol.ts @@ -2,6 +2,7 @@ const APP_SERVER_REQUEST_METHODS = [ "initialize", "thread/start", "thread/resume", + "thread/inject_items", "thread/name/set", "thread/list", "review/start", @@ -77,6 +78,12 @@ export interface TurnStartParams { [key: string]: unknown; } +export interface ThreadInjectItemsParams { + threadId: string; + items: Array>; + [key: string]: unknown; +} + /** * turn/steer — feed additional input into the CURRENTLY RUNNING turn without * interrupting it (introduced in codex rust-v0.99; the TUI uses it when the diff --git a/src/bridge.ts b/src/bridge.ts index abde48cf..8494ff1b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -23,6 +23,15 @@ import { import type { ControlClientIdentity } from "./control-protocol"; import type { BridgeMessage } from "./types"; +// SessionStart-hook helper mode: print the runtime collaboration context and +// exit. Must run BEFORE every bridge bootstrap side effect below (env guard, +// state-dir creation, daemon client) — the hook wants a payload on stdout, +// never a bridge process. +if (process.argv.includes("--print-session-context")) { + const { runPrintSessionContext } = await import("./session-context-hook"); + process.exit(runPrintSessionContext(process.argv.slice(2))); +} + const originalEnv = { ...process.env }; const bootstrapLogger = createProcessLogger({ component: "AgentBridgeFrontend" }); const envGuardResult = guardAgentBridgeEnv({ diff --git a/src/cli.ts b/src/cli.ts index 085fdcdd..a83d978a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -81,7 +81,11 @@ async function main(command: string | undefined, restArgs: string[]) { switch (command) { case "init": const { runInit } = await import("./cli/init"); - await runInit(); + await runInit(restArgs); + break; + case "deinit": + const { runDeinit } = await import("./cli/deinit"); + await runDeinit(); break; case "dev": const { runDev } = await import("./cli/dev"); @@ -148,7 +152,13 @@ Usage: abg [--pair ] [args...] Commands: - init Install plugin, check dependencies, generate project config + init [--inject-docs] + Install plugin, check dependencies, generate project config. + Collaboration guidance is delivered at runtime (only while + the bridge runs); --inject-docs additionally writes the + legacy static sections into CLAUDE.md / AGENTS.md + deinit Remove AgentBridge sections previously injected into this + project's CLAUDE.md / AGENTS.md dev Register local marketplace + install plugin (for local dev) claude [args...] Start Claude Code with push channel enabled codex [args...] Start Codex TUI connected to AgentBridge daemon diff --git a/src/cli/deinit.ts b/src/cli/deinit.ts new file mode 100644 index 00000000..9795f92b --- /dev/null +++ b/src/cli/deinit.ts @@ -0,0 +1,71 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { MARKER_ID } from "../collaboration-content"; +import { removeMarkedSection } from "../marker-section"; + +/** + * `abg deinit` — remove the AgentBridge collaboration sections that older + * versions (or `abg init --inject-docs`) wrote into a project's CLAUDE.md and + * AGENTS.md. + * + * Deliberately narrow: it only strips the marker-delimited blocks. It does NOT + * touch `.agentbridge/` (project config), the installed plugin, or any running + * pair — collaboration guidance keeps arriving through the runtime channels + * (plugin SessionStart hook / codex developer contract) whenever the bridge is + * actually up. + */ + +/** + * Strip the AgentBridge marked section from CLAUDE.md and AGENTS.md under + * projectRoot. Returns human-readable status lines (same shape as init's + * writeCollaborationSections). Pure apart from fs, exported for unit tests. + */ +export function removeCollaborationSections(projectRoot: string): string[] { + const results: string[] = []; + + for (const name of ["CLAUDE.md", "AGENTS.md"]) { + const path = join(projectRoot, name); + + let existing: string; + try { + existing = readFileSync(path, "utf-8"); + } catch { + results.push(`${name}: not found — nothing to remove`); + continue; + } + + let outcome: { content: string; removed: boolean }; + try { + outcome = removeMarkedSection(existing, MARKER_ID); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + results.push(`${name}: skipped — ${msg}`); + continue; + } + + if (!outcome.removed) { + results.push(`${name}: no AgentBridge section found`); + continue; + } + + writeFileSync(path, outcome.content, "utf-8"); + if (outcome.content.trim() === "") { + results.push(`${name}: section removed (file is now empty — delete the file if you no longer need it)`); + } else { + results.push(`${name}: section removed`); + } + } + + return results; +} + +export async function runDeinit() { + console.log("AgentBridge Deinit\n"); + console.log("Removing collaboration sections..."); + for (const line of removeCollaborationSections(process.cwd())) { + console.log(` ${line}`); + } + console.log(""); + console.log("Done. Runtime delivery is unaffected: while a bridge is running,"); + console.log("collaboration guidance still reaches both agents automatically."); +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index a1e619ed..1b015dfd 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -527,9 +527,10 @@ function extractBundleStamp(path: string): { commit: string; codeHash: string | /** * Config parseability + whether custom values are actually in effect. A corrupt - * config.json silently reverts the user's custom budget/idle thresholds to - * defaults at startup (P1); surface that loudly here so doctor — run by someone - * already stuck — can see it instead of chasing why their thresholds "don't work". + * config.json silently reverts the user's custom budget/runtime-injection/idle + * settings to defaults at startup (P1); surface that loudly here so doctor — + * run by someone already stuck — can see it instead of chasing why a setting + * "doesn't work". */ function configParseabilityCheck(cwd: string, cli: string): DoctorCheck { const desc = new ConfigService(cwd).describeConfig(); @@ -544,9 +545,9 @@ function configParseabilityCheck(cwd: string, cli: string): DoctorCheck { return { name: "config.json", status: "warn", - detail: `unparseable at ${desc.path} (${desc.reason}) — custom thresholds NOT in effect, using defaults`, + detail: `unparseable at ${desc.path} (${desc.reason}) — custom settings NOT in effect, using defaults`, hint: - "config.json 损坏或字段类型错误:bridge 已回退到默认阈值,你的自定义 budget/idle 设置未生效。" + + "config.json 损坏或字段类型错误:bridge 已回退到默认值,你的自定义 budget/runtime-injection/idle 设置未生效。" + `修正该文件的 JSON 语法/字段类型后重启 \`${cli} claude\` 即可重新生效。`, }; } diff --git a/src/cli/init.ts b/src/cli/init.ts index f1f21c3d..4560e815 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -32,9 +32,10 @@ export interface DepCheck { hint?: string; } -export async function runInit() { +export async function runInit(args: string[] = []) { console.log("AgentBridge Init\n"); const cli = cliInvocationName(); + const injectDocs = args.includes("--inject-docs"); // Step 1: Check dependencies. Collect ALL results first, then report them // together — the old flow exited on the FIRST missing dep, so a user missing @@ -68,12 +69,24 @@ export async function runInit() { } console.log(""); - // Step 3: Write collaboration sections to CLAUDE.md and AGENTS.md - console.log("Writing collaboration sections..."); - const projectRoot = process.cwd(); - const collabResults = writeCollaborationSections(projectRoot); - for (const result of collabResults) { - console.log(` ${result}`); + // Step 3: Collaboration guidance. Runtime delivery is the default — the + // plugin SessionStart hook (Claude) and the codex proxy's developer contract + // (Codex) inject guidance only while a bridge is actually running, so the + // project keeps zero static footprint. --inject-docs preserves the legacy + // behaviour of writing marker-delimited sections into CLAUDE.md / AGENTS.md + // (e.g. for agents/tooling that never see the runtime channels); `deinit` + // removes those sections again. + if (injectDocs) { + console.log("Writing collaboration sections (--inject-docs)..."); + const projectRoot = process.cwd(); + const collabResults = writeCollaborationSections(projectRoot); + for (const result of collabResults) { + console.log(` ${result}`); + } + } else { + console.log("Collaboration guidance: delivered at runtime while the bridge is up."); + console.log(` CLAUDE.md / AGENTS.md left untouched (use "${cli} init --inject-docs" for static sections,`); + console.log(` "${cli} deinit" to remove previously injected ones).`); } console.log(""); diff --git a/src/codex-adapter.ts b/src/codex-adapter.ts index 1c1626bc..46013a12 100644 --- a/src/codex-adapter.ts +++ b/src/codex-adapter.ts @@ -13,6 +13,15 @@ import { spawn, execFileSync, type ChildProcess } from "node:child_process"; import { createInterface } from "node:readline"; import { EventEmitter } from "node:events"; import { StateDirResolver } from "./state-dir"; +import { + persistCodexContractInjection, + readCodexContractHash, +} from "./thread-state"; +import { + CODEX_DEVELOPER_CONTRACT, + codexContractSupersedePayload, + contractHash, +} from "./collaboration-contract"; import { cleanupPorts, portPidsCommand } from "./port-cleanup"; import { createProcessLogger, type ProcessLogger } from "./process-log"; import type { BridgeMessage } from "./types"; @@ -33,6 +42,7 @@ import { type AppServerServerRequestMethod, type AppServerTrackedRequestMethod, type TurnInterruptParams, + type ThreadInjectItemsParams, type TurnStartParams, type TurnSteerParams, } from "./app-server-protocol"; @@ -56,6 +66,7 @@ import { } from "./turn-notices"; import { isAllowedWsUpgrade, wsOriginRejectedResponse } from "./ws-origin-guard"; import { PendingRequestRegistry } from "./pending-request-registry"; +import { compareVersions, isStableVersion } from "./version-utils"; interface TuiSocketData { connId: number; @@ -87,6 +98,8 @@ interface PendingServerResponse { interface PendingRequest { method: AppServerTrackedRequestMethod; threadId?: string; + /** Contract merged into this thread/start request, persisted on success. */ + runtimeContractHash?: string; /** * Monotonic "thread switch" sequence (latest-issued) for thread/start and * thread/resume. Used so an out-of-order OLD response can't clobber the active @@ -95,6 +108,26 @@ interface PendingRequest { threadSwitchSeq?: number; } +interface PendingRuntimeContractInjection { + requestId: number; + connId: number; + threadId: string; + contractHash: string; + resumeResponse: AppServerResponse; + timer: ReturnType; +} + +export interface CodexAdapterOptions { + runtimeInjection?: { + /** Defaults to true. The daemon wires this from config.injection.runtime. */ + enabled?: boolean; + /** Pair-scoped state directory; injectable for hermetic tests. */ + stateDir?: StateDirResolver; + /** Internal thread/inject_items acknowledgement timeout. */ + timeoutMs?: number; + }; +} + /** * ── Codex app-server PROTOCOL-VERSION-COUPLING CHECKLIST (P1 #5) ──────────── * @@ -117,6 +150,11 @@ interface PendingRequest { * the success result shape (codex-rs v2/account.rs:254-258). * 5. thread/closed sniff — handleAppServerPayload. String-matches the * `thread/closed` notification method to explain a silent TUI exit(0). + * 6. runtime developer contract — prepareRuntimeThreadRequest merges + * thread/start.developerInstructions; resumed threads use the stable + * thread/inject_items endpoint with a raw Responses API developer message. + * Both shapes were verified on codex-cli 0.144.1 and are guarded by the + * MIN_RUNTIME_INJECTION_VERSION compatibility check. * * The captured app-server version (exposed via `appServerInfo` → * DaemonStatus → /healthz + `abg doctor`) lets an operator see, at a glance, @@ -125,6 +163,10 @@ interface PendingRequest { */ export class CodexAdapter extends EventEmitter { private static readonly RESPONSE_TRACKING_TTL_MS = 30000; + /** Oldest protocol snapshot verified to support both required carriers. */ + private static readonly MIN_RUNTIME_INJECTION_VERSION = "0.144.1"; + private static readonly RUNTIME_INJECTION_TIMEOUT_MS = 5000; + private static readonly RUNTIME_INJECTION_ERROR_CODE = -32041; private proc: ChildProcess | null = null; /** pid of the spawned `codex app-server` child; survives `stop()` nulling `proc`. */ @@ -146,6 +188,11 @@ export class CodexAdapter extends EventEmitter { private proxyPort: number; private readonly logFile: string; private readonly logger: ProcessLogger; + private readonly runtimeInjectionEnabled: boolean; + private readonly runtimeStateDir: StateDirResolver; + private readonly runtimeContractHash: string; + private readonly runtimeInjectionTimeoutMs: number; + private pendingRuntimeContractInjections = new Map(); private tuiConnId = 0; // tracks which TUI connection is "current" (primary) private connIdCounter = 0; // monotonically increasing counter for unique conn IDs // Secondary (picker) connections: each gets its own dedicated app-server WS @@ -272,12 +319,22 @@ export class CodexAdapter extends EventEmitter { private replayMethods = new Map(); private static readonly SESSION_REPLAY_TIMEOUT_MS = 5000; - constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) { + constructor( + appPort = 4500, + proxyPort = 4501, + logFile = new StateDirResolver().logFile, + options: CodexAdapterOptions = {}, + ) { super(); this.appPort = appPort; this.proxyPort = proxyPort; this.logFile = logFile; this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile }); + this.runtimeInjectionEnabled = options.runtimeInjection?.enabled ?? true; + this.runtimeStateDir = options.runtimeInjection?.stateDir ?? new StateDirResolver(); + this.runtimeContractHash = contractHash(); + this.runtimeInjectionTimeoutMs = + options.runtimeInjection?.timeoutMs ?? CodexAdapter.RUNTIME_INJECTION_TIMEOUT_MS; } get appServerUrl() { return `ws://127.0.0.1:${this.appPort}`; } @@ -443,6 +500,7 @@ export class CodexAdapter extends EventEmitter { // Cancel any outage recovery in flight this.outageQueue = []; this.clearOutageTimer(); + this.cancelRuntimeContractInjections("adapter disconnected", false); this.appServerWs?.close(); this.appServerWs = null; @@ -944,6 +1002,7 @@ export class CodexAdapter extends EventEmitter { `App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`, ); this.appServerWs = null; + this.cancelRuntimeContractInjections("app-server connection closed", !intentional); // Approval request/response ids are scoped to the current app-server session. // If the socket reconnects, replaying old approval state would forward stale ids. this.clearResponseTrackingState(); @@ -1577,6 +1636,7 @@ export class CodexAdapter extends EventEmitter { // Rewrite request id to globally unique proxy id if (parsed.id !== undefined && parsed.method) { + if (!this.prepareRuntimeThreadRequest(ws, parsed)) return; const proxyId = this.nextProxyId++; this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id }); this.trackPendingRequest(parsed, connId, proxyId); @@ -1608,6 +1668,103 @@ export class CodexAdapter extends EventEmitter { } } + /** + * Apply the native runtime carrier before a TUI thread request is forwarded. + * Returns false after sending a request-scoped JSON-RPC error to the TUI. + */ + private prepareRuntimeThreadRequest( + ws: ServerWebSocket, + message: AppServerRequest | Record, + ): boolean { + if (!this.runtimeInjectionEnabled) return true; + const method = typeof message.method === "string" ? message.method : null; + if (method !== "thread/start" && method !== "thread/resume") return true; + + const params = typeof message.params === "object" && message.params !== null && !Array.isArray(message.params) + ? message.params as Record + : {}; + + // A resume whose exact contract is already recorded needs no new protocol + // feature, so do not reject it merely because Codex was later downgraded. + if (method === "thread/resume") { + const threadId = params.threadId; + if ( + typeof threadId === "string" && threadId.length > 0 && + readCodexContractHash(this.runtimeStateDir, threadId) === this.runtimeContractHash + ) { + return true; + } + } + + const compatibilityError = this.runtimeInjectionCompatibilityError(); + if (compatibilityError) { + this.sendRuntimeRequestError(ws, message.id, compatibilityError); + return false; + } + + if (method === "thread/start") { + const existing = params.developerInstructions; + if (existing !== undefined && existing !== null && typeof existing !== "string") { + this.sendRuntimeRequestError( + ws, + message.id, + "AgentBridge cannot merge the runtime contract because " + + "thread/start.params.developerInstructions is not a string or null.", + ); + return false; + } + if (typeof existing !== "string" || existing.length === 0) { + params.developerInstructions = CODEX_DEVELOPER_CONTRACT; + } else if (!existing.includes(CODEX_DEVELOPER_CONTRACT)) { + params.developerInstructions = `${existing}\n\n${CODEX_DEVELOPER_CONTRACT}`; + } + message.params = params; + } + + return true; + } + + private runtimeInjectionCompatibilityError(): string | null { + const version = this.appServerInfo?.version ?? null; + if ( + version !== null && + isStableVersion(version) && + compareVersions(version, CodexAdapter.MIN_RUNTIME_INJECTION_VERSION) >= 0 + ) { + return null; + } + return ( + `AgentBridge runtime collaboration injection requires Codex app-server >= ` + + `${CodexAdapter.MIN_RUNTIME_INJECTION_VERSION}; detected ${version ?? "unknown"}. ` + + `Upgrade Codex, or set {"injection":{"runtime":false}} in .agentbridge/config.json ` + + `and restart AgentBridge. ` + + `AgentBridge will not silently fall back to modifying AGENTS.md.` + ); + } + + private runtimeErrorResponse(id: unknown, message: string): string { + return JSON.stringify({ + id: id as number | string, + error: { + code: CodexAdapter.RUNTIME_INJECTION_ERROR_CODE, + message, + }, + }); + } + + private sendRuntimeRequestError( + ws: ServerWebSocket, + id: unknown, + message: string, + ): void { + this.log(`Runtime contract request rejected: ${message}`); + try { + ws.send(this.runtimeErrorResponse(id, message)); + } catch (error) { + this.log(`Failed to send runtime contract error to TUI: ${(error as Error).message}`); + } + } + private detectJsonMethod(raw: string): string | undefined { try { const parsed = JSON.parse(raw); @@ -1811,6 +1968,14 @@ export class CodexAdapter extends EventEmitter { private handleAppServerResponse(parsed: AppServerResponse, raw: string): string | null { const responseId = parsed.id; const numericId = this.normalizeNumericId(responseId); + + if (!isNaN(numericId)) { + const runtimeInjection = this.pendingRuntimeContractInjections.get(numericId); + if (runtimeInjection) { + return this.completeRuntimeContractInjection(parsed, runtimeInjection); + } + } + const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined; if (mapping) { @@ -1831,6 +1996,8 @@ export class CodexAdapter extends EventEmitter { parsed.id = mapping.clientId; this.log(`app-server → TUI: response (proxy id=${numericId} → client id=${String(mapping.clientId)}, conn #${mapping.connId})`); + const runtimeResponse = this.handleMappedRuntimeContractResponse(parsed, mapping.connId); + if (runtimeResponse !== false) return runtimeResponse; const forwarded = this.patchResponse(parsed, JSON.stringify(parsed)); this.interceptServerMessage(parsed, mapping.connId); return forwarded; @@ -1910,6 +2077,243 @@ export class CodexAdapter extends EventEmitter { return null; } + /** + * Persist a successful thread/start carrier, or defer thread/resume until an + * internal developer item has been acknowledged and persisted. `false` + * means the ordinary mapped-response path should continue. + */ + private handleMappedRuntimeContractResponse( + response: AppServerResponse, + connId: number, + ): false | string | null { + if (!this.runtimeInjectionEnabled || response.error) return false; + const key = this.pendingKey(response.id, connId); + const pending = key ? this.pendingRequests.get(key) : undefined; + if (!pending) return false; + + if (pending.method === "thread/start" && pending.runtimeContractHash) { + const threadId = (response.result as { thread?: { id?: unknown } } | undefined)?.thread?.id; + if (typeof threadId !== "string" || threadId.length === 0) { + return this.failTrackedRuntimeRequest( + response, + connId, + "Codex returned a successful thread/start response without thread.id; " + + "AgentBridge could not persist runtime-contract idempotency state.", + ); + } + try { + persistCodexContractInjection( + this.runtimeStateDir, + threadId, + pending.runtimeContractHash, + ); + } catch (error) { + return this.failTrackedRuntimeRequest( + response, + connId, + `AgentBridge injected the runtime contract but could not persist pair state: ${(error as Error).message}`, + ); + } + this.log(`Runtime contract ${pending.runtimeContractHash} persisted for new thread ${threadId}`); + return false; + } + + if (pending.method !== "thread/resume" || !this.isLatestThreadSwitch(pending)) { + return false; + } + + const threadId = (response.result as { thread?: { id?: unknown } } | undefined)?.thread?.id; + if (typeof threadId !== "string" || threadId.length === 0) { + return this.failTrackedRuntimeRequest( + response, + connId, + "Codex returned a successful thread/resume response without thread.id; " + + "AgentBridge could not inject the runtime contract.", + ); + } + + const previousHash = readCodexContractHash(this.runtimeStateDir, threadId); + if (previousHash === this.runtimeContractHash) return false; + + const compatibilityError = this.runtimeInjectionCompatibilityError(); + if (compatibilityError) { + return this.failTrackedRuntimeRequest(response, connId, compatibilityError); + } + if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) { + return this.failTrackedRuntimeRequest( + response, + connId, + "Codex app-server disconnected before AgentBridge could inject the runtime contract.", + ); + } + + const payload = previousHash === null + ? CODEX_DEVELOPER_CONTRACT + : codexContractSupersedePayload(previousHash); + const requestId = this.nextInjectionId--; + const timer = setTimeout(() => { + this.handleRuntimeContractInjectionTimeout(requestId); + }, this.runtimeInjectionTimeoutMs); + timer.unref?.(); + const runtimeInjection: PendingRuntimeContractInjection = { + requestId, + connId, + threadId, + contractHash: this.runtimeContractHash, + resumeResponse: response, + timer, + }; + this.pendingRuntimeContractInjections.set(requestId, runtimeInjection); + + const params: ThreadInjectItemsParams = { + threadId, + items: [{ + type: "message", + role: "developer", + content: [{ type: "input_text", text: payload }], + }], + }; + try { + this.appServerWs.send(JSON.stringify({ + id: requestId, + method: "thread/inject_items", + params, + })); + } catch (error) { + clearTimeout(timer); + this.pendingRuntimeContractInjections.delete(requestId); + return this.failTrackedRuntimeRequest( + response, + connId, + `AgentBridge could not send thread/inject_items: ${(error as Error).message}`, + ); + } + + this.log( + `Holding thread/resume for ${threadId} until runtime contract ${this.runtimeContractHash} ` + + `is acknowledged (request ${requestId}${previousHash ? `, supersedes ${previousHash}` : ""})`, + ); + return null; + } + + private completeRuntimeContractInjection( + response: AppServerResponse, + pending: PendingRuntimeContractInjection, + ): string | null { + clearTimeout(pending.timer); + this.pendingRuntimeContractInjections.delete(pending.requestId); + + if (response.error) { + const detail = response.error.message ?? "unknown app-server error"; + return this.failTrackedRuntimeRequest( + pending.resumeResponse, + pending.connId, + `Codex rejected AgentBridge thread/inject_items: ${detail}. ` + + "No AGENTS.md fallback was applied.", + ); + } + + try { + persistCodexContractInjection( + this.runtimeStateDir, + pending.threadId, + pending.contractHash, + ); + } catch (error) { + return this.failTrackedRuntimeRequest( + pending.resumeResponse, + pending.connId, + `AgentBridge injected the runtime contract but could not persist pair state: ${(error as Error).message}`, + ); + } + + this.log( + `Runtime contract ${pending.contractHash} injected and persisted for resumed thread ${pending.threadId}`, + ); + if (pending.connId !== this.tuiConnId) { + const key = this.pendingKey(pending.resumeResponse.id, pending.connId); + if (key) this.pendingRequests.delete(key); + this.log( + `Dropping deferred thread/resume response for retired TUI conn #${pending.connId}`, + ); + return null; + } + + try { + this.interceptServerMessage(pending.resumeResponse, pending.connId); + } catch (error) { + const message = + `AgentBridge injected and persisted the runtime contract, but failed to release ` + + `the resumed thread safely: ${(error as Error).message}`; + this.log(`Runtime contract resume release failed: ${message}`); + return this.runtimeErrorResponse(pending.resumeResponse.id, message); + } + return this.patchResponse( + pending.resumeResponse, + JSON.stringify(pending.resumeResponse), + ); + } + + private handleRuntimeContractInjectionTimeout(requestId: number): void { + const pending = this.pendingRuntimeContractInjections.get(requestId); + if (!pending) return; + this.pendingRuntimeContractInjections.delete(requestId); + const message = + `Timed out after ${this.runtimeInjectionTimeoutMs}ms waiting for Codex ` + + `thread/inject_items while resuming ${pending.threadId}. No AGENTS.md fallback was applied.`; + const response = this.failTrackedRuntimeRequest( + pending.resumeResponse, + pending.connId, + message, + ); + if (pending.connId !== this.tuiConnId || !this.tuiWs) return; + try { + this.tuiWs.send(response); + } catch (error) { + this.log(`Failed to send runtime injection timeout to TUI: ${(error as Error).message}`); + } + } + + private cancelRuntimeContractInjections(reason: string, notifyTui: boolean): void { + for (const pending of [...this.pendingRuntimeContractInjections.values()]) { + clearTimeout(pending.timer); + this.pendingRuntimeContractInjections.delete(pending.requestId); + const response = this.failTrackedRuntimeRequest( + pending.resumeResponse, + pending.connId, + `AgentBridge runtime contract injection was cancelled: ${reason}.`, + ); + if (!notifyTui || pending.connId !== this.tuiConnId || !this.tuiWs) continue; + try { + this.tuiWs.send(response); + } catch (error) { + this.log(`Failed to send runtime injection cancellation to TUI: ${(error as Error).message}`); + } + } + } + + private failTrackedRuntimeRequest( + successfulResponse: AppServerResponse, + connId: number, + message: string, + ): string { + this.log(`Runtime contract injection failed: ${message}`); + try { + this.interceptServerMessage({ + id: successfulResponse.id, + error: { + code: CodexAdapter.RUNTIME_INJECTION_ERROR_CODE, + message, + }, + }, connId); + } catch (error) { + // Never let an internal thread/inject_items response escape the proxy + // with its negative id merely because cleanup/replay code threw. + this.log(`Runtime contract failure cleanup also failed: ${(error as Error).message}`); + } + return this.runtimeErrorResponse(successfulResponse.id, message); + } + /** * P1 #5: capture the app-server identity from an `initialize` response result * (version / platform) and expose it on the status getter. Logs the captured @@ -2089,15 +2493,23 @@ export class CodexAdapter extends EventEmitter { if (!key || !isTrackedAppServerRequestMethod(method)) return; const pending: PendingRequest = { method }; - if (method === "turn/start") { - const params = "params" in message && typeof message.params === "object" && message.params !== null - ? message.params as Record - : undefined; + const params = "params" in message && typeof message.params === "object" && message.params !== null + ? message.params as Record + : undefined; + if (method === "turn/start" || method === "thread/resume") { const threadId = params?.threadId; if (typeof threadId === "string" && threadId.length > 0) { pending.threadId = threadId; } } + if ( + method === "thread/start" && + this.runtimeInjectionEnabled && + typeof params?.developerInstructions === "string" && + params.developerInstructions.includes(CODEX_DEVELOPER_CONTRACT) + ) { + pending.runtimeContractHash = this.runtimeContractHash; + } // #70: stamp explicit thread switches (thread/start, thread/resume) with the // latest-issued sequence so an out-of-order OLD response can't clobber the // thread the user most recently switched to. diff --git a/src/collaboration-contract.ts b/src/collaboration-contract.ts new file mode 100644 index 00000000..492f13d0 --- /dev/null +++ b/src/collaboration-contract.ts @@ -0,0 +1,107 @@ +/** + * Runtime collaboration contract — the pluggable replacement for the static + * CLAUDE.md / AGENTS.md doc injection (collaboration-content.ts remains the + * single source of the section bodies and still serves the legacy + * `abg init --inject-docs` path). + * + * Carriers (design settled 2026-07-10, verified on codex-cli 0.144.1): + * - Codex side: the daemon's codex proxy injects CODEX_DEVELOPER_CONTRACT as + * native developer-role context — merged into + * `thread/start.params.developerInstructions` for new threads, appended via + * `thread/inject_items` (role: "developer") when first bridging an existing + * thread. Injection is idempotent per (threadId, contractHash()). + * - Claude side: the plugin SessionStart hook emits CLAUDE_SESSION_CONTEXT as + * additionalContext only while the daemon is healthy (bridge attached). + * + * HARD LIMIT (measured, not assumed): any model-visible context is written to + * the Codex thread rollout and REPLAYED on resume even when the bridge is gone + * — codex-cli 0.144.1 exposes no revocable, history-free instruction channel. + * Both payloads therefore OPEN with a self-scoping clause: collaborate only on + * live bridge evidence, never wait for the other agent because this contract + * alone says a bridge may exist. + */ + +import { createHash } from "node:crypto"; +import { AGENTS_MD_SECTION, CLAUDE_MD_SECTION } from "./collaboration-content"; + +/** + * Self-scoping preamble for the Codex-side developer contract. Wording + * requirement from the carrier investigation: "collaborate only when real + * bridged messages arrive; never wait for Claude because of this contract" — + * this is the mitigation for the irrevocable-rollout hard limit above. + */ +export const CODEX_CONTRACT_SCOPE_CLAUSE = `\ +[AgentBridge runtime contract — read this scope rule first] +This developer context is injected by AgentBridge (a local Claude<->Codex +bridge) and is BINDING ONLY while the bridge is actively attached to this +thread. The ONLY proof of attachment is bridged messages from Claude (or +AgentBridge system notices) arriving in the CURRENT session. Collaborate when +such messages arrive. NEVER wait for, block on, or address Claude merely +because this contract exists. If this thread was resumed outside the bridge +(no bridged messages in the current session), ignore everything below and +operate as a normal solo Codex session.`; + +/** + * Claude-side counterpart. The SessionStart hook only emits while the daemon + * is healthy, so staleness is bounded to "daemon died mid-session" — the + * clause tells Claude how to detect and handle exactly that. + */ +export const CLAUDE_CONTEXT_SCOPE_CLAUSE = `\ +[AgentBridge runtime context] +Injected by the AgentBridge plugin because the bridge daemon was healthy when +this session started. If the bridge later becomes unreachable (reply tool +errors, no channel messages arriving), treat collaboration as unavailable and +continue solo — do not wait for Codex.`; + +/** + * Full developer-role payload the codex proxy injects, once per + * (threadId, contractHash()). + */ +export const CODEX_DEVELOPER_CONTRACT = `${CODEX_CONTRACT_SCOPE_CLAUSE} + +${AGENTS_MD_SECTION}`; + +/** + * Full additionalContext payload the SessionStart hook emits while the daemon + * is healthy and the Codex TUI is attached. + */ +export const CLAUDE_SESSION_CONTEXT = `${CLAUDE_CONTEXT_SCOPE_CLAUSE} + +${CLAUDE_MD_SECTION}`; + +/** + * Stable fingerprint of an injected payload. The codex proxy persists + * (threadId, contractHash()) in pair state and skips re-injection while the + * hash is unchanged; a changed hash (content evolved with a new bridge + * version) triggers codexContractSupersedePayload below — a replacement + * header plus the FULL current contract, because a hash alone carries no + * meaning for the model. + * + * sha256 truncated to 12 hex chars — collision space (2^48) is far beyond the + * handful of contract versions a pair will ever see, and short enough to read + * in state files and logs. + */ +export function contractHash(content: string = CODEX_DEVELOPER_CONTRACT): string { + return createHash("sha256").update(content, "utf8").digest("hex").slice(0, 12); +} + +/** + * Payload injected when a live thread already carries an OLDER contract + * (persisted hash ≠ contractHash()). A "short delta" cannot work here — a hash + * carries no meaning for the model, and only the full text conveys what the + * rules now are — so supersede re-sends the complete current contract behind + * an explicit replacement header. This path is rare by construction: it only + * fires when the bridge is upgraded while an existing thread stays live (new + * threads always get the fresh contract via thread/start). + * + * The adapter records contractHash() (of the DEFAULT contract, not of this + * payload) after injecting. + */ +export function codexContractSupersedePayload(previousHash: string): string { + return `[AgentBridge contract update] +This REPLACES the AgentBridge runtime contract injected earlier in this thread +(hash ${previousHash}). Disregard that earlier version entirely and follow only +the contract below. + +${CODEX_DEVELOPER_CONTRACT}`; +} diff --git a/src/config-service.ts b/src/config-service.ts index 161ae707..534802e8 100644 --- a/src/config-service.ts +++ b/src/config-service.ts @@ -13,6 +13,9 @@ export interface AgentBridgeConfig { turnCoordination: { attentionWindowSeconds: number; }; + injection: { + runtime: boolean; + }; idleShutdownSeconds: number; budget: BudgetConfig; } @@ -78,6 +81,9 @@ const DEFAULT_CONFIG: AgentBridgeConfig = { turnCoordination: { attentionWindowSeconds: 15, }, + injection: { + runtime: true, + }, idleShutdownSeconds: 30, budget: DEFAULT_BUDGET_CONFIG, }; @@ -133,6 +139,9 @@ interface LegacyAgentBridgeConfig { turnCoordination?: { attentionWindowSeconds?: unknown; }; + injection?: { + runtime?: unknown; + }; idleShutdownSeconds?: unknown; budget?: unknown; } @@ -241,6 +250,7 @@ function hasCustomDecisionValues(config: AgentBridgeConfig): boolean { return ( config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || + config.injection.runtime !== d.injection.runtime || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || @@ -556,6 +566,7 @@ function normalizeConfig(raw: unknown): AgentBridgeConfig | null { const codex = isRecord(config.codex) ? config.codex : {}; const daemon = isRecord(config.daemon) ? config.daemon : {}; const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {}; + const injection = isRecord(config.injection) ? config.injection : {}; return { version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version, @@ -584,6 +595,9 @@ function normalizeConfig(raw: unknown): AgentBridgeConfig | null { Number.MAX_SAFE_INTEGER, ), }, + injection: { + runtime: normalizeBoolean(injection.runtime, DEFAULT_CONFIG.injection.runtime), + }, // A negative idleShutdownSeconds becomes a negative *1000 ms timeout, which // setTimeout clamps to 0 → the daemon self-shuts ~immediately after boot, // before the Claude client attaches (即起即死). Floor at 1 second. @@ -674,7 +688,7 @@ export class ConfigService { if (result.state === "corrupt") { log( `config.json at ${this.configPath} is unusable (${result.reason}); ` + - "falling back to defaults — your custom budget thresholds / idle-shutdown settings are NOT in effect. " + + "falling back to defaults — your custom budget / runtime-injection / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them.", ); } diff --git a/src/daemon.ts b/src/daemon.ts index 83875f6b..20d70010 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -147,7 +147,12 @@ const daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_POR const DAEMON_NONCE = randomUUID(); const DAEMON_STARTED_AT = Date.now(); -const codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile); +const codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile, { + runtimeInjection: { + enabled: config.injection.runtime, + stateDir, + }, +}); const attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`; let controlServer: ReturnType | null = null; diff --git a/src/integration-test/daemon-wiring.test.ts b/src/integration-test/daemon-wiring.test.ts index ebaeb8a8..556519b1 100644 --- a/src/integration-test/daemon-wiring.test.ts +++ b/src/integration-test/daemon-wiring.test.ts @@ -21,6 +21,7 @@ import { readControlToken, resolveControlTokenPath } from "../control-token"; import { CONTRACT_VERSION } from "../contract-version"; import { installFakeCodex } from "./fixtures/fake-codex-install"; import { RESUME_PROMPT, claudeResumePrompt } from "../budget/resume-prompt"; +import { contractHash } from "../collaboration-contract"; const DAEMON_PATH = join(process.cwd(), "src", "daemon.ts"); const DEFAULT_TEST_SLOT_START = 2500 + (process.pid % 500); @@ -112,6 +113,40 @@ describe("daemon wiring", () => { expect(waiting.content).toContain("another pair"); }, 20000); + test("config injection.runtime=false bypasses native injection for an old Codex protocol", async () => { + const harness = await startHarness({ + pairId: "main-injectoff", + pairName: "main", + projectConfig: { injection: { runtime: false } }, + extraEnv: { FAKE_CODEX_VERSION: "0.100.0" }, + }); + + await harness.attachClaude(); + await harness.connectTui(); + + const response = await fetch(`http://127.0.0.1:${harness.controlPort}/healthz`); + const status = (await response.json()) as DaemonStatus; + expect(status.bridgeReady).toBe(true); + expect(status.appServerInfo?.version).toBe("0.100.0"); + }, 20000); + + test("default runtime injection persists the new thread contract in pair state", async () => { + const harness = await startHarness({ pairId: "main-injecton", pairName: "main" }); + + await harness.attachClaude(); + await harness.connectTui(); + + const state = JSON.parse( + readFileSync(join(harness.stateDir, "codex-contracts.json"), "utf-8"), + ); + expect(state.version).toBe(1); + expect(state.injections).toHaveLength(1); + expect(state.injections[0]).toMatchObject({ + threadId: "thread-fake-1", + contractHash: contractHash(), + }); + }, 20000); + test("turnAborted event emits system_turn_aborted to the attached Claude client", async () => { const harness = await startHarness({ pairId: "main-abortabcd", pairName: "main" }); @@ -2108,6 +2143,28 @@ async function startHarness(opts: { reject(new Error("TUI ws connect error")); }; }); + const initialized = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("TUI initialize response timeout")), 5000); + tuiWs.onmessage = (event) => { + try { + const message = JSON.parse(String(event.data)); + if (message.id !== 0) return; + clearTimeout(timer); + if (message.error) { + reject(new Error(`TUI initialize rejected: ${message.error.message ?? "unknown"}`)); + } else { + resolve(); + } + } catch {} + }; + }); + tuiWs.send(JSON.stringify({ + id: 0, + method: "initialize", + params: { clientInfo: { name: "agentbridge-test-tui", version: "0" } }, + })); + await initialized; + tuiWs.send(JSON.stringify({ method: "initialized", params: {} })); tuiWs.send(JSON.stringify({ id: 1, method: "thread/start", params: {} })); // Wait until the daemon reports bridge-ready over /healthz. await waitFor(async () => { diff --git a/src/integration-test/fixtures/fake-codex.ts b/src/integration-test/fixtures/fake-codex.ts index 28ad564d..7ed01ed6 100644 --- a/src/integration-test/fixtures/fake-codex.ts +++ b/src/integration-test/fixtures/fake-codex.ts @@ -18,8 +18,9 @@ * - "minimal": healthz/readyz + WS accept + clean SIGTERM. Nothing * more — the daemon only needs the app-server to be * reachable and to die cleanly (e2e-reconnect lifecycle). - * - "handshake": minimal + auto-respond to `thread/start` so the adapter - * can set an active thread and reach "ready". + * - "handshake": minimal + initialize/thread/start/thread/inject_items + * responses so the adapter can set an active thread, + * install its runtime developer contract, and reach ready. * - "command-driven": handshake + the full turn protocol the daemon-wiring * tests drive: turn/start (+ optional log), turn/interrupt * (+ optional log, + FAKE_APP_INTERRUPT_DELAY_MS deferral), @@ -47,6 +48,8 @@ * during an in-progress boot retry); subsequent spawns * (the retry) boot normally. Lets tests reproduce a * fail-once-then-recover boot without a 10s healthz wait. + * - FAKE_CODEX_VERSION app-server version returned from initialize + * (default 0.144.1). * * Command-file commands (command-driven, one per line, file is consumed each poll): * - start-turn / complete-turn / agent-message: / close-app-server (WS only) @@ -92,6 +95,7 @@ function main(): void { } const capability = parseCapability(process.env.FAKE_CODEX_CAPABILITY); + const fakeVersion = process.env.FAKE_CODEX_VERSION || "0.144.1"; const listenIndex = process.argv.indexOf("--listen"); const listen = process.argv[listenIndex + 1]; @@ -140,7 +144,13 @@ function main(): void { let msg: { id?: unknown; method?: unknown; - params?: { turnId?: string; expectedTurnId?: unknown; input?: Array<{ text?: string }> }; + params?: { + turnId?: string; + expectedTurnId?: unknown; + input?: Array<{ text?: string }>; + developerInstructions?: unknown; + items?: unknown[]; + }; }; try { msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()); @@ -148,6 +158,18 @@ function main(): void { return; } + if (handshakeEnabled && msg.method === "initialize") { + ws.send(JSON.stringify({ + id: msg.id, + result: { + userAgent: `codex_cli_rs/${fakeVersion} (Linux fake; x86_64)`, + platformFamily: "unix", + platformOs: "linux", + }, + })); + return; + } + // handshake: auto-respond to thread/start so the adapter can detect an // active thread and emit "ready" (used by budget/ready-gate tests). if (handshakeEnabled && msg.method === "thread/start") { @@ -155,6 +177,11 @@ function main(): void { return; } + if (handshakeEnabled && msg.method === "thread/inject_items") { + ws.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (!turnProtocolEnabled) return; // Record received turn/start params (tier-override assertions), then respond diff --git a/src/marker-section.ts b/src/marker-section.ts index 415f1445..510d12be 100644 --- a/src/marker-section.ts +++ b/src/marker-section.ts @@ -58,3 +58,59 @@ export function upsertMarkedSection( const trimmed = content.endsWith("\n") ? content : content + "\n"; return trimmed + "\n" + block + "\n"; } + +/** + * Remove a marked section (markers included) from a markdown string — the + * inverse of upsertMarkedSection, used by `abg deinit`. + * + * Collapses the blank line upsert introduced around the block so that + * upsert → remove round-trips an existing file back to (at most a trailing + * newline of) its original shape. A file that contained ONLY the block + * collapses to the empty string. + * + * @returns removed=false (content untouched) when no markers are present. + * @throws on a malformed marker pair, mirroring upsertMarkedSection — a lone + * marker means we cannot know the block's true extent, and guessing + * could delete user content. + */ +export function removeMarkedSection( + content: string, + sectionId: string, +): { content: string; removed: boolean } { + const startMarker = MARKER_START(sectionId); + const endMarker = MARKER_END(sectionId); + + const startIdx = content.indexOf(startMarker); + const endIdx = content.indexOf(endMarker); + const hasStart = startIdx !== -1; + const hasEnd = endIdx !== -1; + + if (!hasStart && !hasEnd) { + return { content, removed: false }; + } + + if (!(hasStart && hasEnd && startIdx < endIdx)) { + throw new Error( + `Malformed ${sectionId} markers in file (start=${startIdx}, end=${endIdx}). ` + + `Please repair the file manually — remove the stray marker(s) or restore the pair.`, + ); + } + + let before = content.slice(0, startIdx); + let after = content.slice(endIdx + endMarker.length); + + // The whole file was just the block (case 1 of upsert) → empty string. + if (before.trim() === "" && after.trim() === "") { + return { content: "", removed: true }; + } + + // Collapse the separator blank line(s) upsert added: leave `before` ending in + // a single newline, and drop the newline(s) that immediately followed the + // end marker. + before = before.replace(/\n+$/, "\n"); + after = after.replace(/^\n+/, ""); + if (after !== "" && !before.endsWith("\n") && before !== "") { + before += "\n"; + } + return { content: before + after, removed: true }; +} diff --git a/src/session-context-hook.ts b/src/session-context-hook.ts new file mode 100644 index 00000000..2452753e --- /dev/null +++ b/src/session-context-hook.ts @@ -0,0 +1,125 @@ +/** + * SessionStart-hook helper behind `bridge-server.js --print-session-context`. + * + * The plugin's health-check.sh probes the daemon itself and calls this entry + * ONLY when the daemon is healthy AND the Codex TUI is attached — this module + * does no probing of its own. It decides one thing: whether runtime + * collaboration-context injection is enabled for the workspace, and if so + * prints the complete SessionStart hook JSON (additionalContext = short status + * line + CLAUDE_SESSION_CONTEXT) on stdout. Printing nothing tells the shell + * script to fall back to its short informational notice. + * + * This is the Claude-side runtime carrier that replaced the static CLAUDE.md + * section (see collaboration-contract.ts for the full design note). + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { CLAUDE_SESSION_CONTEXT } from "./collaboration-contract"; + +/** + * Lenient read of `injection.runtime` from a raw config.json string. Defaults + * to TRUE on every failure mode (missing file content, parse error, absent + * keys) — runtime delivery is the product default and must not silently turn + * off because a config is corrupt. + * + * The accepted spellings MUST mirror ConfigService's normalizeBoolean + * (config-service.ts) exactly: the daemon gates the Codex-side carrier through + * that normalizer, and the same config file has to yield the same decision on + * both sides — a divergence would disable one carrier while the other keeps + * injecting. + * + * Deliberately does NOT go through ConfigService: the daemon owns the schema + * (normalization, env overrides); the hook only needs this one opt-out bit and + * must never crash the SessionStart hook over unrelated config problems. + */ +export function isRuntimeInjectionEnabled(configRaw: string | null): boolean { + if (configRaw === null) return true; + try { + const parsed = JSON.parse(configRaw); + const value = parsed?.injection?.runtime; + if (typeof value === "boolean") return value; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + return true; + } catch { + return true; + } +} + +/** The additionalContext payload: status line (+ optional notice), then the contract. */ +export function buildSessionContextAdditionalContext(notice?: string): string { + const statusLine = + "AgentBridge is running. Daemon healthy, Codex TUI connected. Bridge is ready for communication." + + (notice ? ` ${notice}` : ""); + return `${statusLine}\n\n${CLAUDE_SESSION_CONTEXT}`; +} + +/** Complete hook stdout document (JSON.stringify handles all escaping). */ +export function buildSessionContextHookJson(notice?: string): string { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: buildSessionContextAdditionalContext(notice), + }, + }); +} + +/** Minimal argv parser for `--workspace --notice [--check]`. */ +export function parseSessionContextArgs(argv: string[]): { + workspace: string; + notice?: string; + checkOnly: boolean; +} { + let workspace = process.cwd(); + let notice: string | undefined; + let checkOnly = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--workspace" && argv[i + 1] !== undefined) { + workspace = argv[++i]!; + } else if (argv[i] === "--notice" && argv[i + 1] !== undefined) { + const value = argv[++i]!; + if (value.trim() !== "") notice = value; + } else if (argv[i] === "--check") { + checkOnly = true; + } + } + return { workspace, notice, checkOnly }; +} + +/** + * IO wrapper used by bridge.ts. Exit code is always 0 — the hook must never + * fail the session over context delivery; "print nothing" IS the error path + * (the shell script falls back to its short notice). + * + * `--check` mode prints a bare `enabled` / `disabled` verdict instead of the + * payload. health-check.sh gates ALL of its output on this verdict so the + * opt-out semantics live in exactly one place (isRuntimeInjectionEnabled) — + * shell must never re-implement JSON parsing. The script treats anything + * other than a literal `disabled` (including helper failure) as enabled: + * fail-open, so a broken helper cannot mute the informational notices. + */ +export function runPrintSessionContext(argv: string[]): number { + const { workspace, notice, checkOnly } = parseSessionContextArgs(argv); + + let configRaw: string | null = null; + try { + configRaw = readFileSync(join(workspace, ".agentbridge", "config.json"), "utf-8"); + } catch { + // No project config → defaults apply (injection enabled). + } + + const enabled = isRuntimeInjectionEnabled(configRaw); + + if (checkOnly) { + console.log(enabled ? "enabled" : "disabled"); + return 0; + } + + if (!enabled) { + return 0; + } + + console.log(buildSessionContextHookJson(notice)); + return 0; +} diff --git a/src/state-dir.ts b/src/state-dir.ts index bcef3d33..976705f4 100644 --- a/src/state-dir.ts +++ b/src/state-dir.ts @@ -97,6 +97,15 @@ export class StateDirResolver { return join(this.stateDir, "current-thread.json"); } + /** + * Per-pair record of the runtime collaboration contract injected into Codex + * threads. Kept separate from current-thread.json because a pair can resume + * several historical threads while only one thread is current. + */ + get codexContractStateFile(): string { + return join(this.stateDir, "codex-contracts.json"); + } + get logFile(): string { return join(this.stateDir, "agentbridge.log"); } diff --git a/src/thread-state.ts b/src/thread-state.ts index 8c6cedbc..f016bcbf 100644 --- a/src/thread-state.ts +++ b/src/thread-state.ts @@ -31,6 +31,20 @@ export interface ThreadIdentity { cwd: string; } +export interface CodexContractInjection { + threadId: string; + contractHash: string; + injectedAt: string; +} + +export interface CodexContractState { + version: 1; + injections: CodexContractInjection[]; +} + +const MAX_CODEX_CONTRACT_INJECTIONS = 256; +const CODEX_CONTRACT_HASH_RE = /^[0-9a-f]{12}$/; + function nowIso(): string { return new Date().toISOString(); } @@ -62,6 +76,81 @@ export function readRawCurrentThread(stateDir: StateDirResolver): CurrentThreadS return null; } +function parseCodexContractInjection(value: unknown): CodexContractInjection | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.threadId !== "string" || record.threadId.length === 0 || + typeof record.contractHash !== "string" || !CODEX_CONTRACT_HASH_RE.test(record.contractHash) || + typeof record.injectedAt !== "string" || record.injectedAt.length === 0 + ) { + return null; + } + return { + threadId: record.threadId, + contractHash: record.contractHash, + injectedAt: record.injectedAt, + }; +} + +/** Read the bounded per-pair runtime-contract ledger. Missing/corrupt = empty. */ +export function readCodexContractState(stateDir: StateDirResolver): CodexContractState { + try { + const parsed = JSON.parse(readFileSync(stateDir.codexContractStateFile, "utf-8")); + if (parsed?.version !== 1 || !Array.isArray(parsed.injections)) { + return { version: 1, injections: [] }; + } + return { + version: 1, + injections: parsed.injections + .map(parseCodexContractInjection) + .filter((entry: CodexContractInjection | null): entry is CodexContractInjection => entry !== null) + .slice(-MAX_CODEX_CONTRACT_INJECTIONS), + }; + } catch { + return { version: 1, injections: [] }; + } +} + +export function readCodexContractHash( + stateDir: StateDirResolver, + threadId: string, +): string | null { + const injections = readCodexContractState(stateDir).injections; + for (let index = injections.length - 1; index >= 0; index--) { + if (injections[index]?.threadId === threadId) { + return injections[index]!.contractHash; + } + } + return null; +} + +/** + * Atomically upsert the latest injected contract for a thread. The ledger is + * bounded because pair state can outlive many archived Codex threads. + */ +export function persistCodexContractInjection( + stateDir: StateDirResolver, + threadId: string, + contractHash: string, +): CodexContractState { + if (!threadId || !CODEX_CONTRACT_HASH_RE.test(contractHash)) { + throw new Error("Codex contract state requires a non-empty threadId and a 12-character lowercase hex hash"); + } + const prior = readCodexContractState(stateDir).injections.filter( + (entry) => entry.threadId !== threadId, + ); + const state: CodexContractState = { + version: 1, + injections: [ + ...prior, + { threadId, contractHash, injectedAt: nowIso() }, + ].slice(-MAX_CODEX_CONTRACT_INJECTIONS), + }; + atomicWriteJson(stateDir.codexContractStateFile, state); + return state; +} + export function findCodexRolloutFile( threadId: string, env: NodeJS.ProcessEnv = process.env, diff --git a/src/unit-test/codex-adapter.test.ts b/src/unit-test/codex-adapter.test.ts index 1b905264..9cac2261 100644 --- a/src/unit-test/codex-adapter.test.ts +++ b/src/unit-test/codex-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { execSync, spawn } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { createServer, Socket, type AddressInfo, type Server } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +8,16 @@ import { existsSync, writeFileSync } from "node:fs"; import { CodexAdapter } from "../codex-adapter"; import { TcpToUnixRelay } from "../codex-transport"; import { CLIENT_REPLY_TIMEOUT_MS, MAX_INTERRUPT_TIMEOUT_MS } from "../interrupt-timing"; +import { StateDirResolver } from "../state-dir"; +import { + CODEX_DEVELOPER_CONTRACT, + codexContractSupersedePayload, + contractHash, +} from "../collaboration-contract"; +import { + persistCodexContractInjection, + readCodexContractHash, +} from "../thread-state"; // Hermetic log sink: the constructor's default logFile resolves the REAL pair // state dir — these tests were appending into (and could rotate!) a live @@ -15,7 +25,9 @@ import { CLIENT_REPLY_TIMEOUT_MS, MAX_INTERRUPT_TIMEOUT_MS } from "../interrupt- const TEST_LOG_FILE = join(mkdtempSync(join(tmpdir(), "abg-codex-adapter-test-")), "test.log"); function createAdapter() { - return new CodexAdapter(4510, 4511, TEST_LOG_FILE) as any; + return new CodexAdapter(4510, 4511, TEST_LOG_FILE, { + runtimeInjection: { enabled: false }, + }) as any; } function sleep(ms: number) { @@ -241,6 +253,339 @@ describe("CodexAdapter app-server response handling", () => { }); }); +function createRuntimeAdapter(options: { version?: string | null; timeoutMs?: number } = {}) { + const root = mkdtempSync(join(tmpdir(), "abg-runtime-contract-test-")); + const stateDir = new StateDirResolver(join(root, "pair-state")); + const adapter = new CodexAdapter(4510, 4511, TEST_LOG_FILE, { + runtimeInjection: { + enabled: true, + stateDir, + timeoutMs: options.timeoutMs, + }, + }) as any; + const version = options.version === undefined ? "0.144.1" : options.version; + adapter.appServerInfo = version === null + ? null + : { + version, + userAgent: `codex_cli_rs/${version}`, + platformFamily: "unix", + platformOs: "linux", + }; + return { + adapter, + stateDir, + cleanup() { + adapter.cancelRuntimeContractInjections("test cleanup", false); + adapter.clearResponseTrackingState(); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +describe("CodexAdapter runtime developer-contract injection", () => { + test("thread/start preserves client developerInstructions, appends the contract, and persists its hash", () => { + const runtime = createRuntimeAdapter(); + const { adapter, stateDir } = runtime; + const appSent: string[] = []; + const tuiSent: string[] = []; + try { + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: (raw: string) => tuiSent.push(raw) } as any; + + adapter.onTuiMessage(ws, JSON.stringify({ + id: "start-client", + method: "thread/start", + params: { cwd: "/repo", developerInstructions: "CLIENT CONTRACT" }, + })); + + expect(tuiSent).toEqual([]); + expect(appSent).toHaveLength(1); + const request = JSON.parse(appSent[0]); + expect(request.params.developerInstructions).toBe( + `CLIENT CONTRACT\n\n${CODEX_DEVELOPER_CONTRACT}`, + ); + + const forwarded = adapter.handleAppServerPayload(JSON.stringify({ + id: request.id, + result: { thread: { id: "thread-new-contract" } }, + })); + expect(JSON.parse(forwarded).id).toBe("start-client"); + expect(readCodexContractHash(stateDir, "thread-new-contract")).toBe(contractHash()); + expect(adapter.activeThreadId).toBe("thread-new-contract"); + } finally { + runtime.cleanup(); + } + }); + + test("runtime injection disabled leaves thread/start untouched even on an old protocol", () => { + const adapter = createAdapter(); + const appSent: string[] = []; + adapter.appServerInfo = { + version: "0.100.0", + userAgent: "codex_cli_rs/0.100.0", + platformFamily: "unix", + platformOs: "linux", + }; + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + + adapter.onTuiMessage({ data: { connId: 1 } } as any, JSON.stringify({ + id: 1, + method: "thread/start", + params: { developerInstructions: "CLIENT ONLY" }, + })); + + expect(JSON.parse(appSent[0]).params.developerInstructions).toBe("CLIENT ONLY"); + adapter.clearResponseTrackingState(); + }); + + test("old Codex protocol receives an explicit error and no static-doc fallback", () => { + const runtime = createRuntimeAdapter({ version: "0.143.9" }); + const { adapter } = runtime; + const appSent: string[] = []; + const tuiSent: string[] = []; + try { + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: (raw: string) => tuiSent.push(raw) } as any; + + adapter.onTuiMessage(ws, JSON.stringify({ + id: "old-start", + method: "thread/start", + params: {}, + })); + + expect(appSent).toEqual([]); + expect(tuiSent).toHaveLength(1); + const error = JSON.parse(tuiSent[0]); + expect(error.id).toBe("old-start"); + expect(error.error.message).toContain("requires Codex app-server >= 0.144.1"); + expect(error.error.message).toContain("will not silently fall back"); + } finally { + runtime.cleanup(); + } + }); + + test("thread/resume is held until developer injection succeeds and state is persisted", () => { + const runtime = createRuntimeAdapter(); + const { adapter, stateDir } = runtime; + const appSent: string[] = []; + const tuiSent: string[] = []; + try { + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: (raw: string) => tuiSent.push(raw) } as any; + adapter.tuiWs = ws; + adapter.pendingServerRequests = [{ + raw: JSON.stringify({ + id: 77, + method: "item/fileChange/requestApproval", + params: { threadId: "thread-existing", file: "contract.ts" }, + }), + serverId: 77, + method: "item/fileChange/requestApproval", + threadId: "thread-existing", + }]; + + adapter.onTuiMessage(ws, JSON.stringify({ + id: "resume-client", + method: "thread/resume", + params: { threadId: "thread-existing" }, + })); + const resumeRequest = JSON.parse(appSent[0]); + const held = adapter.handleAppServerPayload(JSON.stringify({ + id: resumeRequest.id, + result: { thread: { id: "thread-existing" } }, + })); + + expect(held).toBeNull(); + expect(adapter.activeThreadId).toBeNull(); + expect(readCodexContractHash(stateDir, "thread-existing")).toBeNull(); + expect(tuiSent).toEqual([]); + expect(adapter.pendingServerRequests).toHaveLength(1); + const injection = JSON.parse(appSent[1]); + expect(injection.method).toBe("thread/inject_items"); + expect(injection.params.threadId).toBe("thread-existing"); + expect(injection.params.items[0]).toEqual({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: CODEX_DEVELOPER_CONTRACT }], + }); + + const released = adapter.handleAppServerPayload(JSON.stringify({ + id: injection.id, + result: {}, + })); + expect(JSON.parse(released).id).toBe("resume-client"); + expect(adapter.activeThreadId).toBe("thread-existing"); + expect(readCodexContractHash(stateDir, "thread-existing")).toBe(contractHash()); + expect(tuiSent).toHaveLength(1); + expect(JSON.parse(tuiSent[0]).method).toBe("item/fileChange/requestApproval"); + } finally { + runtime.cleanup(); + } + }); + + test("same (threadId, contractHash) skips injection, including after a Codex downgrade", () => { + const runtime = createRuntimeAdapter({ version: "0.100.0" }); + const { adapter, stateDir } = runtime; + const appSent: string[] = []; + try { + persistCodexContractInjection(stateDir, "thread-known", contractHash()); + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: () => {} } as any; + adapter.tuiWs = ws; + + adapter.onTuiMessage(ws, JSON.stringify({ + id: 2, + method: "thread/resume", + params: { threadId: "thread-known" }, + })); + const request = JSON.parse(appSent[0]); + const forwarded = adapter.handleAppServerPayload(JSON.stringify({ + id: request.id, + result: { thread: { id: "thread-known" } }, + })); + + expect(JSON.parse(forwarded).id).toBe(2); + expect(appSent).toHaveLength(1); + expect(adapter.activeThreadId).toBe("thread-known"); + } finally { + runtime.cleanup(); + } + }); + + test("changed hash injects the content module's full supersede payload", () => { + const runtime = createRuntimeAdapter(); + const { adapter, stateDir } = runtime; + const appSent: string[] = []; + try { + persistCodexContractInjection(stateDir, "thread-upgrade", "0123456789ab"); + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: () => {} } as any; + adapter.tuiWs = ws; + + adapter.onTuiMessage(ws, JSON.stringify({ + id: 3, + method: "thread/resume", + params: { threadId: "thread-upgrade" }, + })); + const resume = JSON.parse(appSent[0]); + expect(adapter.handleAppServerPayload(JSON.stringify({ + id: resume.id, + result: { thread: { id: "thread-upgrade" } }, + }))).toBeNull(); + + const injection = JSON.parse(appSent[1]); + expect(injection.params.items[0].content[0].text).toBe( + codexContractSupersedePayload("0123456789ab"), + ); + adapter.handleAppServerPayload(JSON.stringify({ id: injection.id, result: {} })); + expect(readCodexContractHash(stateDir, "thread-upgrade")).toBe(contractHash()); + } finally { + runtime.cleanup(); + } + }); + + test("thread/inject_items rejection terminates resume with an explicit client-id error", () => { + const runtime = createRuntimeAdapter(); + const { adapter, stateDir } = runtime; + const appSent: string[] = []; + try { + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: () => {} } as any; + adapter.tuiWs = ws; + adapter.onTuiMessage(ws, JSON.stringify({ + id: "resume-rejected", + method: "thread/resume", + params: { threadId: "thread-rejected" }, + })); + const resume = JSON.parse(appSent[0]); + adapter.handleAppServerPayload(JSON.stringify({ + id: resume.id, + result: { thread: { id: "thread-rejected" } }, + })); + const injection = JSON.parse(appSent[1]); + + const rejected = adapter.handleAppServerPayload(JSON.stringify({ + id: injection.id, + error: { code: -32601, message: "method not found" }, + })); + const error = JSON.parse(rejected); + expect(error.id).toBe("resume-rejected"); + expect(error.error.message).toContain("thread/inject_items"); + expect(error.error.message).toContain("No AGENTS.md fallback"); + expect(adapter.activeThreadId).toBeNull(); + expect(readCodexContractHash(stateDir, "thread-rejected")).toBeNull(); + expect(adapter.pendingRequests.size).toBe(0); + } finally { + runtime.cleanup(); + } + }); + + test("thread/inject_items timeout sends a terminal resume error instead of hanging", async () => { + const runtime = createRuntimeAdapter({ timeoutMs: 20 }); + const { adapter } = runtime; + const appSent: string[] = []; + const tuiSent: string[] = []; + try { + adapter.appServerWs = { + readyState: WebSocket.OPEN, + send: (raw: string) => appSent.push(raw), + } as any; + adapter.tuiConnId = 1; + const ws = { data: { connId: 1 }, send: (raw: string) => tuiSent.push(raw) } as any; + adapter.tuiWs = ws; + adapter.onTuiMessage(ws, JSON.stringify({ + id: "resume-timeout", + method: "thread/resume", + params: { threadId: "thread-timeout" }, + })); + const resume = JSON.parse(appSent[0]); + expect(adapter.handleAppServerPayload(JSON.stringify({ + id: resume.id, + result: { thread: { id: "thread-timeout" } }, + }))).toBeNull(); + + await sleep(60); + expect(tuiSent).toHaveLength(1); + const error = JSON.parse(tuiSent[0]); + expect(error.id).toBe("resume-timeout"); + expect(error.error.message).toContain("Timed out after 20ms"); + expect(adapter.pendingRuntimeContractInjections.size).toBe(0); + expect(adapter.pendingRequests.size).toBe(0); + } finally { + runtime.cleanup(); + } + }); +}); + describe("CodexAdapter turn state machine", () => { test("turnStarted emits only on first turn (idle → busy)", () => { const adapter = createAdapter(); @@ -1530,7 +1875,9 @@ describe("CodexAdapter server-to-client request passthrough", () => { }, }); - const adapter = new CodexAdapter(server.port, 4511, TEST_LOG_FILE) as any; + const adapter = new CodexAdapter(server.port, 4511, TEST_LOG_FILE, { + runtimeInjection: { enabled: false }, + }) as any; adapter.log = () => {}; adapter.connIdCounter = 1; adapter.tuiConnId = 1; diff --git a/src/unit-test/collaboration-contract.test.ts b/src/unit-test/collaboration-contract.test.ts new file mode 100644 index 00000000..60995868 --- /dev/null +++ b/src/unit-test/collaboration-contract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + CLAUDE_CONTEXT_SCOPE_CLAUSE, + CLAUDE_SESSION_CONTEXT, + CODEX_CONTRACT_SCOPE_CLAUSE, + CODEX_DEVELOPER_CONTRACT, + codexContractSupersedePayload, + contractHash, +} from "../collaboration-contract"; +import { AGENTS_MD_SECTION, CLAUDE_MD_SECTION } from "../collaboration-content"; + +describe("collaboration contract payloads", () => { + test("codex contract OPENS with the self-scoping clause (the rollout-residue mitigation)", () => { + // The clause must come first: a resumed-outside-the-bridge session reads + // top-down and needs the "ignore everything below" rule before the rules. + expect(CODEX_DEVELOPER_CONTRACT.startsWith(CODEX_CONTRACT_SCOPE_CLAUSE)).toBe(true); + }); + + test("codex contract carries the full collaboration section", () => { + expect(CODEX_DEVELOPER_CONTRACT).toContain(AGENTS_MD_SECTION); + }); + + test("scope clause states the never-wait rule verbatim requirements", () => { + // Wording requirements from the carrier investigation (2026-07-10): live + // bridge messages are the only proof of attachment; never wait for Claude + // because the contract exists. + expect(CODEX_CONTRACT_SCOPE_CLAUSE).toMatch(/NEVER wait for/); + expect(CODEX_CONTRACT_SCOPE_CLAUSE).toMatch(/CURRENT session/); + }); + + test("claude session context opens with its scope clause and carries the section", () => { + expect(CLAUDE_SESSION_CONTEXT.startsWith(CLAUDE_CONTEXT_SCOPE_CLAUSE)).toBe(true); + expect(CLAUDE_SESSION_CONTEXT).toContain(CLAUDE_MD_SECTION); + }); +}); + +describe("contractHash", () => { + test("12 lowercase hex chars", () => { + expect(contractHash()).toMatch(/^[0-9a-f]{12}$/); + }); + + test("deterministic for the same content", () => { + expect(contractHash()).toBe(contractHash(CODEX_DEVELOPER_CONTRACT)); + expect(contractHash("abc")).toBe(contractHash("abc")); + }); + + test("changes when the content changes", () => { + expect(contractHash("abc")).not.toBe(contractHash("abd")); + expect(contractHash()).not.toBe(contractHash(CODEX_DEVELOPER_CONTRACT + " ")); + }); +}); + +describe("codexContractSupersedePayload", () => { + test("names the superseded hash and re-sends the FULL current contract", () => { + const previous = "0123456789ab"; + const payload = codexContractSupersedePayload(previous); + expect(payload).toContain(previous); + // Full re-send is the point: a hash alone carries no meaning for the model. + expect(payload).toContain(CODEX_DEVELOPER_CONTRACT); + expect(payload.startsWith("[AgentBridge contract update]")).toBe(true); + }); + + test("supersede payload hash differs from the contract hash (adapter must record contractHash(), not the payload's)", () => { + expect(contractHash(codexContractSupersedePayload("0123456789ab"))).not.toBe(contractHash()); + }); +}); diff --git a/src/unit-test/config-service.test.ts b/src/unit-test/config-service.test.ts index 4b5780d8..a0410e96 100644 --- a/src/unit-test/config-service.test.ts +++ b/src/unit-test/config-service.test.ts @@ -52,6 +52,7 @@ describe("ConfigService", () => { expect(config.codex.appPort).toBe(4500); expect(config.codex.proxyPort).toBe(4501); expect(config.turnCoordination.attentionWindowSeconds).toBe(15); + expect(config.injection.runtime).toBe(true); }); test("save and load round-trips correctly", () => { @@ -129,6 +130,22 @@ describe("ConfigService", () => { expect(loaded.idleShutdownSeconds).toBe(45); }); + test("load reads injection.runtime and defaults invalid or absent values to true", () => { + const configDir = join(tempDir, ".agentbridge"); + const configPath = join(configDir, "config.json"); + mkdirSync(configDir, { recursive: true }); + + writeFileSync(configPath, JSON.stringify({ injection: { runtime: false } })); + expect(expectParsed(new ConfigService(tempDir)).injection.runtime).toBe(false); + expect(new ConfigService(tempDir).describeConfig().customValues).toBe(true); + + writeFileSync(configPath, JSON.stringify({ injection: { runtime: "off" } })); + expect(expectParsed(new ConfigService(tempDir)).injection.runtime).toBe(true); + + writeFileSync(configPath, JSON.stringify({})); + expect(expectParsed(new ConfigService(tempDir)).injection.runtime).toBe(true); + }); + test("initDefaults creates only config.json", () => { const svc = new ConfigService(tempDir); const created = svc.initDefaults(); diff --git a/src/unit-test/deinit.test.ts b/src/unit-test/deinit.test.ts new file mode 100644 index 00000000..89e9bcf6 --- /dev/null +++ b/src/unit-test/deinit.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeCollaborationSections } from "../cli/deinit"; +import { MARKER_ID } from "../collaboration-content"; +import { upsertMarkedSection } from "../marker-section"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "abg-deinit-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("removeCollaborationSections", () => { + test("strips injected sections and restores the original file", () => { + const original = "# My Project\n\nDocs.\n"; + writeFileSync(join(dir, "CLAUDE.md"), upsertMarkedSection(original, MARKER_ID, "collab"), "utf-8"); + writeFileSync(join(dir, "AGENTS.md"), upsertMarkedSection(original, MARKER_ID, "collab"), "utf-8"); + + const results = removeCollaborationSections(dir); + + expect(results).toEqual(["CLAUDE.md: section removed", "AGENTS.md: section removed"]); + expect(readFileSync(join(dir, "CLAUDE.md"), "utf-8")).toBe(original); + expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toBe(original); + }); + + test("missing files are reported, not created", () => { + const results = removeCollaborationSections(dir); + expect(results[0]).toContain("CLAUDE.md: not found"); + expect(results[1]).toContain("AGENTS.md: not found"); + }); + + test("file that was only the injected block → emptied with a delete hint", () => { + writeFileSync(join(dir, "CLAUDE.md"), upsertMarkedSection("", MARKER_ID, "collab"), "utf-8"); + + const results = removeCollaborationSections(dir); + + expect(results[0]).toContain("file is now empty"); + expect(readFileSync(join(dir, "CLAUDE.md"), "utf-8")).toBe(""); + }); + + test("files without markers are left untouched", () => { + const content = "# Plain file\n"; + writeFileSync(join(dir, "AGENTS.md"), content, "utf-8"); + + const results = removeCollaborationSections(dir); + + expect(results[1]).toContain("no AgentBridge section found"); + expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toBe(content); + }); + + test("malformed markers → skipped with the repair message, file untouched", () => { + const malformed = `# Title\n\norphaned\n`; + writeFileSync(join(dir, "CLAUDE.md"), malformed, "utf-8"); + + const results = removeCollaborationSections(dir); + + expect(results[0]).toContain("skipped"); + expect(results[0]).toContain("Malformed"); + expect(readFileSync(join(dir, "CLAUDE.md"), "utf-8")).toBe(malformed); + }); +}); diff --git a/src/unit-test/marker-section.test.ts b/src/unit-test/marker-section.test.ts index d2e8de9c..dde03a8f 100644 --- a/src/unit-test/marker-section.test.ts +++ b/src/unit-test/marker-section.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { upsertMarkedSection } from "../marker-section"; +import { removeMarkedSection, upsertMarkedSection } from "../marker-section"; const SECTION_ID = "TestSection"; const START = ``; @@ -90,3 +90,59 @@ describe("upsertMarkedSection", () => { ); }); }); + +describe("removeMarkedSection", () => { + test("no markers → untouched, removed=false", () => { + const content = "# My Project\n\nSome content here.\n"; + const result = removeMarkedSection(content, SECTION_ID); + expect(result.removed).toBe(false); + expect(result.content).toBe(content); + }); + + test("file that was ONLY the block collapses to empty string", () => { + const onlyBlock = upsertMarkedSection("", SECTION_ID, "Hello World"); + const result = removeMarkedSection(onlyBlock, SECTION_ID); + expect(result.removed).toBe(true); + expect(result.content).toBe(""); + }); + + test("upsert → remove round-trips an existing file", () => { + const original = "# My Project\n\nSome content here.\n"; + const withBlock = upsertMarkedSection(original, SECTION_ID, "Injected section"); + const result = removeMarkedSection(withBlock, SECTION_ID); + expect(result.removed).toBe(true); + expect(result.content).toBe(original); + }); + + test("upsert → remove on a file without trailing newline adds at most that newline", () => { + const original = "# My Project\n\nSome content here."; + const withBlock = upsertMarkedSection(original, SECTION_ID, "Injected section"); + const result = removeMarkedSection(withBlock, SECTION_ID); + expect(result.removed).toBe(true); + expect(result.content).toBe(original + "\n"); + }); + + test("mid-file block: keeps surrounding content, collapses separator blank lines", () => { + const existing = `# Title\n\n${START}\nBlock body\n${END}\n\n## Footer\nText.\n`; + const result = removeMarkedSection(existing, SECTION_ID); + expect(result.removed).toBe(true); + expect(result.content).toBe("# Title\n## Footer\nText.\n"); + }); + + test("different section IDs are not touched", () => { + const existing = `\nKeep this\n\n`; + const result = removeMarkedSection(existing, SECTION_ID); + expect(result.removed).toBe(false); + expect(result.content).toBe(existing); + }); + + test("malformed: orphan start marker → throws (never guess the block extent)", () => { + const existing = `# Title\n${START}\nOld notes\nUser content\n`; + expect(() => removeMarkedSection(existing, SECTION_ID)).toThrow(/Malformed .* markers/); + }); + + test("malformed: end marker before start marker → throws", () => { + const existing = `# Title\n${END}\nUser notes\n${START}\nOld block\n`; + expect(() => removeMarkedSection(existing, SECTION_ID)).toThrow(/Malformed .* markers/); + }); +}); diff --git a/src/unit-test/session-context-hook.test.ts b/src/unit-test/session-context-hook.test.ts new file mode 100644 index 00000000..049e9ae6 --- /dev/null +++ b/src/unit-test/session-context-hook.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildSessionContextAdditionalContext, + buildSessionContextHookJson, + isRuntimeInjectionEnabled, + parseSessionContextArgs, + runPrintSessionContext, +} from "../session-context-hook"; +import { CLAUDE_SESSION_CONTEXT } from "../collaboration-contract"; + +describe("isRuntimeInjectionEnabled", () => { + test("defaults to enabled: no config file", () => { + expect(isRuntimeInjectionEnabled(null)).toBe(true); + }); + + test("defaults to enabled: config without an injection block", () => { + expect(isRuntimeInjectionEnabled(JSON.stringify({ budget: { pauseAt: 90 } }))).toBe(true); + }); + + test("defaults to enabled: corrupt JSON must not silently disable delivery", () => { + expect(isRuntimeInjectionEnabled("{not json")).toBe(true); + }); + + test("mirrors ConfigService.normalizeBoolean spellings exactly", () => { + // Disable: boolean false + the env-var-ish string spellings. + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: false } }))).toBe(false); + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: "false" } }))).toBe(false); + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: "0" } }))).toBe(false); + // Enable: boolean true + spellings. + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: true } }))).toBe(true); + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: "true" } }))).toBe(true); + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: "1" } }))).toBe(true); + // Everything else keeps the default (same as normalizeBoolean's fallback) — + // including a bare NUMBER 0, which normalizeBoolean does not accept. + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: "off" } }))).toBe(true); + expect(isRuntimeInjectionEnabled(JSON.stringify({ injection: { runtime: 0 } }))).toBe(true); + }); +}); + +describe("buildSessionContextAdditionalContext", () => { + test("status line first, then the full collaboration context", () => { + const out = buildSessionContextAdditionalContext(); + expect(out.startsWith("AgentBridge is running.")).toBe(true); + expect(out).toContain(CLAUDE_SESSION_CONTEXT); + }); + + test("optional notice rides the status line", () => { + const out = buildSessionContextAdditionalContext("Update available: 0.2.0"); + const statusLine = out.split("\n")[0]!; + expect(statusLine).toContain("Update available: 0.2.0"); + }); +}); + +describe("buildSessionContextHookJson", () => { + test("emits valid single-line hook JSON with the SessionStart shape", () => { + const raw = buildSessionContextHookJson("note"); + expect(raw).not.toContain("\n"); // stdout protocol: one JSON document per line + const parsed = JSON.parse(raw); + expect(parsed.hookSpecificOutput.hookEventName).toBe("SessionStart"); + expect(parsed.hookSpecificOutput.additionalContext).toContain("AgentBridge is running."); + expect(parsed.hookSpecificOutput.additionalContext).toContain("Multi-Agent Collaboration"); + }); +}); + +describe("parseSessionContextArgs", () => { + test("reads --workspace and --notice", () => { + const args = parseSessionContextArgs(["--workspace", "/tmp/proj", "--notice", "hi there"]); + expect(args.workspace).toBe("/tmp/proj"); + expect(args.notice).toBe("hi there"); + }); + + test("blank --notice (health-check.sh always passes the flag) → undefined", () => { + const args = parseSessionContextArgs(["--workspace", "/tmp/proj", "--notice", ""]); + expect(args.notice).toBeUndefined(); + }); + + test("defaults workspace to cwd when flag absent", () => { + expect(parseSessionContextArgs([]).workspace).toBe(process.cwd()); + }); + + test("reads --check", () => { + expect(parseSessionContextArgs(["--check"]).checkOnly).toBe(true); + expect(parseSessionContextArgs([]).checkOnly).toBe(false); + }); +}); + +describe("runPrintSessionContext", () => { + let dir: string | null = null; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; + }); + + function workspaceWithConfig(config: string | null): string { + dir = mkdtempSync(join(tmpdir(), "abg-hook-")); + if (config !== null) { + mkdirSync(join(dir, ".agentbridge"), { recursive: true }); + writeFileSync(join(dir, ".agentbridge", "config.json"), config, "utf-8"); + } + return dir; + } + + test("--check prints disabled for an opt-out config (the health-check.sh gate)", () => { + const workspace = workspaceWithConfig(JSON.stringify({ injection: { runtime: false } })); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(runPrintSessionContext(["--check", "--workspace", workspace])).toBe(0); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0]![0]).toBe("disabled"); + } finally { + log.mockRestore(); + } + }); + + test("--check prints enabled without a config file and for corrupt JSON", () => { + const workspace = workspaceWithConfig("{corrupt"); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + runPrintSessionContext(["--check", "--workspace", workspace]); + expect(log.mock.calls[0]![0]).toBe("enabled"); + } finally { + log.mockRestore(); + } + }); + + test("payload mode prints nothing when disabled, full hook JSON when enabled", () => { + const workspace = workspaceWithConfig(JSON.stringify({ injection: { runtime: "false" } })); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + runPrintSessionContext(["--workspace", workspace]); + expect(log).not.toHaveBeenCalled(); + runPrintSessionContext(["--workspace", join(workspace, "nonexistent")]); + expect(log).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(log.mock.calls[0]![0] as string); + expect(parsed.hookSpecificOutput.hookEventName).toBe("SessionStart"); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/src/unit-test/thread-state.test.ts b/src/unit-test/thread-state.test.ts index 84e82fde..3808dd7e 100644 --- a/src/unit-test/thread-state.test.ts +++ b/src/unit-test/thread-state.test.ts @@ -6,7 +6,10 @@ import { StateDirResolver } from "../state-dir"; import { findCodexRolloutFile, persistCurrentThreadWithRolloutRetry, + persistCodexContractInjection, promoteCurrentThreadIfRolloutExists, + readCodexContractHash, + readCodexContractState, readRawCurrentThread, readUsableCurrentThread, writePendingCurrentThread, @@ -27,6 +30,55 @@ function identity(root: string, codexHome: string) { } describe("thread-state", () => { + test("persists runtime contract idempotency per (threadId, contractHash)", () => { + const root = tempDir("agentbridge-contract-state-"); + try { + const stateDir = new StateDirResolver(join(root, "pair-state")); + persistCodexContractInjection(stateDir, "thread-A", "aaaaaaaaaaaa"); + persistCodexContractInjection(stateDir, "thread-B", "bbbbbbbbbbbb"); + + expect(readCodexContractHash(stateDir, "thread-A")).toBe("aaaaaaaaaaaa"); + expect(readCodexContractHash(stateDir, "thread-B")).toBe("bbbbbbbbbbbb"); + expect(readCodexContractHash(stateDir, "thread-missing")).toBeNull(); + + persistCodexContractInjection(stateDir, "thread-A", "cccccccccccc"); + const state = readCodexContractState(stateDir); + expect(state.version).toBe(1); + expect(state.injections).toHaveLength(2); + expect(readCodexContractHash(stateDir, "thread-A")).toBe("cccccccccccc"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("missing or corrupt runtime contract state is treated as empty", () => { + const root = tempDir("agentbridge-contract-state-"); + try { + const stateDir = new StateDirResolver(join(root, "pair-state")); + expect(readCodexContractState(stateDir)).toEqual({ version: 1, injections: [] }); + + mkdirSync(stateDir.dir, { recursive: true }); + writeFileSync(stateDir.codexContractStateFile, "{broken", "utf-8"); + expect(readCodexContractState(stateDir)).toEqual({ version: 1, injections: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects invalid contract hashes instead of interpolating untrusted state", () => { + const root = tempDir("agentbridge-contract-state-"); + try { + const stateDir = new StateDirResolver(join(root, "pair-state")); + expect(() => persistCodexContractInjection( + stateDir, + "thread-A", + "not-a-contract-hash", + )).toThrow("12-character lowercase hex hash"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("pending current thread is not usable for resume", () => { const root = tempDir("agentbridge-thread-state-"); const codexHome = tempDir("agentbridge-codex-home-");