diff --git a/README.md b/README.md
index 621d7db8..22a2a40e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ supported integrations:
| --- | --- | --- | --- |
|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `tokenjuice install claude-code` | `~/.claude/settings.json` |
|
| [CodeBuddy](https://codebuddy.tencent.com/) | `tokenjuice install codebuddy` | `~/.codebuddy/settings.json` |
-|
| [Codex CLI](https://github.com/openai/codex) | `tokenjuice install codex` | `~/.codex/hooks.json` |
+|
| [Codex CLI](https://github.com/openai/codex) ([integration notes](docs/codex-integration.md)) | `tokenjuice install codex` | `~/.codex/hooks.json` |
|
| [Cursor](https://cursor.com/docs/hooks) | `tokenjuice install cursor` | `~/.cursor/hooks.json` |
|
| [Droid (Factory CLI)](https://docs.factory.ai/cli/configuration/hooks-guide) | `tokenjuice install droid` | `~/.factory/settings.json` |
|
| [GitHub Copilot CLI](https://github.com/github/copilot-cli) | `tokenjuice install copilot-cli` | `~/.copilot/hooks/tokenjuice-cli.json` |
@@ -253,6 +253,7 @@ direct payload:
- [Coder Agents integration](docs/coder-agents-integration.md)
- [CodeRabbit integration](docs/coderabbit-integration.md)
- [Command Code integration](docs/command-code-integration.md)
+- [Codex CLI integration](docs/codex-integration.md)
- [Crush integration](docs/crush-integration.md)
- [Cursor integration](docs/cursor-integration.md)
- [CodeBuddy integration](docs/codebuddy-integration.md)
diff --git a/docs/codex-integration.md b/docs/codex-integration.md
new file mode 100644
index 00000000..6eefa76a
--- /dev/null
+++ b/docs/codex-integration.md
@@ -0,0 +1,61 @@
+# Codex CLI integration
+
+`tokenjuice install codex` adds a `PostToolUse` hook for Bash results to
+`~/.codex/hooks.json`. Outputs that produce a worthwhile reduction are replaced
+with compacted context; small, low-savings, and protected inspection results are
+left unchanged.
+
+## Expected replacement status
+
+With the currently tested Codex CLI, a PostToolUse hook must return
+`continue:false` to suppress the original tool result. A successful Tokenjuice
+rewrite has been observed as:
+
+```text
+PostToolUse hook (stopped)
+ hook context:
+ stop: Tokenjuice replaced the original Bash output with the compacted context above.
+```
+
+Interpret this status as a successful replacement only when both the Tokenjuice
+replacement reason and compacted hook context are present. In that case,
+`stopped` describes the hook suppressing the original result; it is not the Bash
+exit status. The authenticated regression verifies that the agent can produce
+a later assistant response after the replacement.
+
+Without `continue:false`, the currently tested Codex CLI retains the full
+original result and adds the summary beside it, which does not provide the
+intended context-token savings. Until Codex exposes a clean "replace output"
+primitive, the `stopped` label is an expected UI tradeoff for real output
+replacement.
+
+Use `tokenjuice wrap --raw -- ` when the full command output is
+required. This escape hatch reruns the command; review side effects before using
+it with commands that mutate files or external systems.
+
+## Local verification
+
+To point the real Codex home at the current checkout:
+
+```bash
+pnpm build
+node dist/cli/main.js install codex --local
+node dist/cli/main.js doctor codex --local
+```
+
+`doctor` should report `status: ok`. Use it first when the hook is disabled,
+stale, or missing.
+
+Run the authenticated live regression separately:
+
+```bash
+pnpm e2e:codex-live
+```
+
+The E2E builds and installs into an isolated temporary `CODEX_HOME`; it does not
+require the real-home local install above. It consumes Codex quota and requires
+an existing login at `$CODEX_HOME/auth.json` (or `~/.codex/auth.json`). Set
+`TOKENJUICE_CODEX_LIVE_SOURCE_HOME` to select a different authenticated home.
+It verifies that the compacted context and replacement reason are model-visible,
+a later assistant response is present, and the original marker is absent from
+the compacted context and function-call output.
diff --git a/docs/integration-playbook.md b/docs/integration-playbook.md
index 7e5118bd..a3402aee 100644
--- a/docs/integration-playbook.md
+++ b/docs/integration-playbook.md
@@ -130,6 +130,20 @@ for truncation-related debugging, verify both boundaries explicitly:
- capture truncation: if output includes `[tokenjuice: output truncated]`, rerun with a larger capture ceiling (for example `--max-capture-bytes 52428800`).
- do not treat these as the same failure mode; reducer bypass and capture-size tuning solve different problems.
+### Codex live context replacement
+
+Run the authenticated Codex regression manually:
+
+```bash
+pnpm e2e:codex-live
+```
+
+This command builds the current source, installs its hook into an isolated temporary `CODEX_HOME`, runs a fixed fake `gh` command through the real `codex exec`, and inspects the saved transcript plus Tokenjuice hook debug output. It requires an existing Codex login at `$CODEX_HOME/auth.json` (or `~/.codex/auth.json`); set `TOKENJUICE_CODEX_LIVE_SOURCE_HOME` to select a different authenticated home.
+
+The transcript must contain the compacted developer context, the model-visible replacement reason, and a later assistant response, while excluding the raw marker from the compacted context and function-call output. With the currently tested Codex CLI, replacement renders as `PostToolUse hook (stopped)` because `continue:false` is required to suppress the original result. Treat that status as expected only when the Tokenjuice replacement reason and compacted context are present; it describes hook replacement, not the Bash exit status.
+
+The command invokes a model and consumes account quota, so keep it out of normal CI. It removes the temporary Codex home and authentication symlink before reporting success.
+
## docs updates required in same PR
when adding a host integration, update:
diff --git a/docs/spec.md b/docs/spec.md
index 57c34a51..2a5837dd 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -380,7 +380,7 @@ supported host hooks:
| CodeRabbit | `tokenjuice install coderabbit` | `.coderabbit.yaml` | ✴️ Beta. Inserts marker-delimited `reviews.path_instructions` guidance that tells CodeRabbit review, finishing-touch, chat, and tool workflows to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because CodeRabbit config does not intercept review comments or command output; see `docs/coderabbit-integration.md` |
| CodeBuddy (Linux/macOS/WSL) | `tokenjuice install codebuddy` | `~/.codebuddy/settings.json` | Uses `PreToolUse` shell input rewriting (same pattern as Cursor) to route Bash commands through `tokenjuice wrap`; preserves unrelated hooks that share a matcher group with the tokenjuice entry; `tokenjuice install codebuddy --local` is available for repo-local verification; native Windows shell interception is intentionally blocked for now; see `docs/codebuddy-integration.md` |
| Command Code | `tokenjuice install command-code` | `~/.commandcode/settings.json` / `.commandcode/settings.json` | ✴️ Beta. Uses a `PostToolUse` hook with matcher `shell`; compacted context is injected through `hookSpecificOutput.additionalContext` alongside the original shell output; `tokenjuice install command-code --local` is available for repo-local verification; see `docs/command-code-integration.md` |
-| Codex CLI | `tokenjuice install codex` | `~/.codex/hooks.json` | `tokenjuice install codex --local` is available for repo-local verification |
+| Codex CLI | `tokenjuice install codex` | `~/.codex/hooks.json` | Rewrites suppress the original Bash result and inject compacted context. With the currently tested Codex CLI, this replacement renders as `PostToolUse hook (stopped)` because output suppression requires `continue:false`; when the Tokenjuice replacement reason and compacted context are present, this is a hook-replacement status rather than a Bash failure. `tokenjuice install codex --local` is available for repo-local verification; see `docs/codex-integration.md` |
| Continue | `tokenjuice install continue` | `.continue/rules/tokenjuice.md` | ✴️ Beta. Installs a workspace rule that tells Continue agents to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because Continue rules do not intercept tool output; see `docs/continue-integration.md` |
| Crush | `tokenjuice install crush` | `.crush/skills/tokenjuice/SKILL.md` | ✴️ Beta. Installs a project Agent Skill that tells Crush to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because Crush hook composition and stateful shell behavior make command rewriting unsafe; see `docs/crush-integration.md` |
| Cursor (Linux/macOS/WSL) | `tokenjuice install cursor` | `~/.cursor/hooks.json` | Uses `preToolUse` shell input rewriting to route commands through `tokenjuice wrap`; `tokenjuice install cursor --local` is available for repo-local verification; native Windows shell interception is intentionally blocked for now; see `docs/cursor-integration.md` |
diff --git a/package.json b/package.json
index 022b3379..c21e20be 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"bench:fixtures": "node scripts/bench.mjs fixtures",
"bench:verify": "node scripts/bench.mjs verify",
"contracts": "publint run --strict --pack pnpm",
+ "e2e:codex-live": "pnpm build && node scripts/codex-live-e2e.mjs",
"e2e:local": "pnpm build && node scripts/local-host-e2e.mjs",
"generate:builtin-rules": "node scripts/generate-builtin-rules.mjs",
"prepare": "pnpm repair:pnpm-bin-shims",
diff --git a/scripts/codex-live-e2e.mjs b/scripts/codex-live-e2e.mjs
new file mode 100644
index 00000000..8f7bb0c4
--- /dev/null
+++ b/scripts/codex-live-e2e.mjs
@@ -0,0 +1,315 @@
+#!/usr/bin/env node
+
+import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
+import { homedir, tmpdir } from "node:os";
+import { delimiter, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawn } from "node:child_process";
+
+const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
+const distCliPath = join(repoRoot, "dist", "cli", "main.js");
+const tempRoot = await mkdtemp(join(tmpdir(), "tokenjuice-codex-live-e2e-"));
+const rawMarker = "TOKENJUICE_CODEX_LIVE_RAW_MARKER";
+const replacementReason = "Tokenjuice replaced the original Bash output with the compacted context above.";
+const ghArgs = [
+ "pr",
+ "view",
+ "206",
+ "--json",
+ "number,title,url,isDraft,headRefName,headRefOid,mergeStateStatus,statusCheckRollup",
+];
+const ghCommand = `gh ${ghArgs.join(" ")}`;
+const ghPayload = {
+ headRefName: "fix/compound-output-contract",
+ headRefOid: "42238adab077bd3f39f862f92501bfa3d71bfae8",
+ isDraft: false,
+ mergeStateStatus: "UNKNOWN",
+ number: 206,
+ rawMarker,
+ statusCheckRollup: [
+ {
+ __typename: "CheckRun",
+ completedAt: "2026-06-18T08:25:58Z",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720696/job/82086723525",
+ name: "assign",
+ startedAt: "2026-06-18T08:25:53Z",
+ status: "COMPLETED",
+ workflowName: "Auto Assign",
+ },
+ {
+ __typename: "CheckRun",
+ completedAt: "2026-06-18T08:26:06Z",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746725443/job/82086739798",
+ name: "assign",
+ startedAt: "2026-06-18T08:26:00Z",
+ status: "COMPLETED",
+ workflowName: "Auto Assign",
+ },
+ {
+ __typename: "CheckRun",
+ completedAt: "2026-06-18T08:26:51Z",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720707/job/82086723468",
+ name: "quality",
+ startedAt: "2026-06-18T08:25:53Z",
+ status: "COMPLETED",
+ workflowName: "CI",
+ },
+ {
+ __typename: "CheckRun",
+ completedAt: "2026-06-18T08:25:59Z",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720653/job/82086723479",
+ name: "Update Release Draft",
+ startedAt: "2026-06-18T08:25:53Z",
+ status: "COMPLETED",
+ workflowName: "Release Drafter",
+ },
+ {
+ __typename: "CheckRun",
+ completedAt: "2026-06-18T08:27:14Z",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720707/job/82086898704",
+ name: "package",
+ startedAt: "2026-06-18T08:26:55Z",
+ status: "COMPLETED",
+ workflowName: "CI",
+ },
+ {
+ __typename: "StatusContext",
+ context: "AccessLint",
+ startedAt: "2026-06-18T08:27:20Z",
+ state: "PENDING",
+ targetUrl: "",
+ },
+ ],
+ title: "fix(core): preserve compound command output",
+ url: "https://github.com/vincentkoc/tokenjuice/pull/206",
+};
+
+function fail(message) {
+ throw new Error(message);
+}
+
+function assert(condition, message) {
+ if (!condition) {
+ fail(message);
+ }
+}
+
+function run(command, args, options = {}) {
+ const {
+ cwd = repoRoot,
+ env = {},
+ timeoutMs = 180_000,
+ } = options;
+
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd,
+ env: { ...process.env, ...env },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ const timer = setTimeout(() => {
+ child.kill("SIGTERM");
+ reject(new Error(`timed out after ${timeoutMs}ms: ${[command, ...args].join(" ")}`));
+ }, timeoutMs);
+
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ child.on("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ child.on("close", (code, signal) => {
+ clearTimeout(timer);
+ const exitCode = code ?? 128;
+ if (exitCode !== 0) {
+ reject(new Error([
+ `command failed: ${[command, ...args].join(" ")}`,
+ `exit: ${exitCode}${signal ? ` signal: ${signal}` : ""}`,
+ stdout ? `stdout:\n${stdout}` : "",
+ stderr ? `stderr:\n${stderr}` : "",
+ ].filter(Boolean).join("\n")));
+ return;
+ }
+ resolve({ code: exitCode, stdout, stderr });
+ });
+ });
+}
+
+async function assertFile(path, hint) {
+ try {
+ await access(path);
+ } catch {
+ fail(`${path} is missing${hint ? `; ${hint}` : ""}`);
+ }
+}
+
+async function findFiles(root, suffix) {
+ const entries = await readdir(root, { withFileTypes: true });
+ const nested = await Promise.all(entries.map(async (entry) => {
+ const path = join(root, entry.name);
+ if (entry.isDirectory()) {
+ return findFiles(path, suffix);
+ }
+ return entry.isFile() && entry.name.endsWith(suffix) ? [path] : [];
+ }));
+ return nested.flat();
+}
+
+function messageText(payload) {
+ if (payload.type !== "message" || !Array.isArray(payload.content)) {
+ return "";
+ }
+ return payload.content
+ .map((item) => typeof item?.text === "string" ? item.text : "")
+ .join("");
+}
+
+function buildFakeGhSource() {
+ return [
+ "#!/usr/bin/env node",
+ "",
+ `const expectedArgs = ${JSON.stringify(ghArgs)};`,
+ `const payload = ${JSON.stringify(ghPayload)};`,
+ "const actualArgs = process.argv.slice(2);",
+ "if (JSON.stringify(actualArgs) !== JSON.stringify(expectedArgs)) {",
+ " process.stderr.write(`unexpected fake gh arguments: ${actualArgs.join(\" \")}\\n`);",
+ " process.exit(64);",
+ "}",
+ "process.stdout.write(`${JSON.stringify(payload)}\\n`);",
+ "",
+ ].join("\n");
+}
+
+async function runLiveE2E() {
+ const sourceCodexHome = process.env.TOKENJUICE_CODEX_LIVE_SOURCE_HOME
+ ?? process.env.CODEX_HOME
+ ?? join(homedir(), ".codex");
+ const sourceAuthPath = join(sourceCodexHome, "auth.json");
+ const codexHome = join(tempRoot, "codex-home");
+ const fakeBin = join(tempRoot, "bin");
+ const fakeGhPath = join(fakeBin, "gh");
+ const transcriptRoot = join(codexHome, "sessions");
+ const debugPath = join(codexHome, "tokenjuice-hook.last.json");
+
+ await assertFile(distCliPath, "run `pnpm build` first");
+ await assertFile(sourceAuthPath, "run `codex login` before this manual live E2E");
+ await mkdir(codexHome, { recursive: true });
+ await mkdir(fakeBin, { recursive: true });
+ await symlink(sourceAuthPath, join(codexHome, "auth.json"));
+ await writeFile(fakeGhPath, buildFakeGhSource(), { encoding: "utf8", mode: 0o755 });
+ await writeFile(join(fakeBin, "gh.cmd"), "@echo off\r\nnode \"%~dp0gh\" %*\r\n", "utf8");
+
+ const env = {
+ CODEX_HOME: codexHome,
+ PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`,
+ TOKENJUICE_DEBUG: "1",
+ // This regression must exercise compaction even when the caller normally disables omission.
+ TOKENJUICE_NO_OMISSION: "",
+ };
+ const codexVersion = (await run("codex", ["--version"], { env })).stdout.trim();
+ await run(process.execPath, [distCliPath, "install", "codex", "--local"], { env });
+
+ const prompt = [
+ `Run exactly this one shell command and do not use any other tool: ${ghCommand}.`,
+ "After it returns, respond exactly TOKENJUICE_CODEX_LIVE_E2E_DONE.",
+ ].join(" ");
+ await run("codex", [
+ "exec",
+ "--sandbox",
+ "read-only",
+ "--dangerously-bypass-hook-trust",
+ "--skip-git-repo-check",
+ "--color",
+ "never",
+ prompt,
+ ], {
+ cwd: tempRoot,
+ env,
+ });
+
+ const transcriptPaths = await findFiles(transcriptRoot, ".jsonl");
+ assert(transcriptPaths.length === 1, `expected one Codex transcript, found ${transcriptPaths.length}`);
+ const transcriptPath = transcriptPaths[0];
+ assert(typeof transcriptPath === "string", "expected Codex transcript path");
+ const transcriptEntries = (await readFile(transcriptPath, "utf8"))
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+ const responseItems = transcriptEntries
+ .filter((entry) => entry.type === "response_item")
+ .map((entry) => entry.payload);
+ const summaryIndex = responseItems.findIndex((payload) =>
+ payload.type === "message"
+ && payload.role === "developer"
+ && messageText(payload).includes("#206 fix(core): preserve compound command output")
+ );
+ const summaryText = summaryIndex >= 0 ? messageText(responseItems[summaryIndex]) : undefined;
+ assert(typeof summaryText === "string", "expected Tokenjuice summary in Codex developer context");
+ assert(summaryText.includes("need raw? `tokenjuice wrap --raw -- `"), "expected raw rerun hint in Tokenjuice summary");
+ assert(!summaryText.includes(rawMarker), "raw marker leaked into Tokenjuice summary");
+ const continuedAssistantText = responseItems
+ .slice(summaryIndex + 1)
+ .filter((payload) => payload.type === "message" && payload.role === "assistant")
+ .map(messageText)
+ .find((text) => text.trim().length > 0);
+ assert(typeof continuedAssistantText === "string", "expected the assistant to continue after replacement");
+
+ const functionCallOutputs = responseItems
+ .filter((payload) => payload.type === "function_call_output")
+ .map((payload) => payload.output)
+ .filter((output) => typeof output === "string");
+ assert(functionCallOutputs.length === 1, `expected one model-visible function_call_output, found ${functionCallOutputs.length}`);
+ const functionCallOutput = functionCallOutputs[0];
+ assert(typeof functionCallOutput === "string", "expected model-visible function_call_output");
+ assert(!functionCallOutput.includes(rawMarker), "raw marker leaked into model-visible function_call_output");
+ assert(functionCallOutput === replacementReason, `unexpected model-visible function_call_output: ${functionCallOutput}`);
+
+ const debug = JSON.parse(await readFile(debugPath, "utf8"));
+ assert(debug.rewrote === true, "expected Tokenjuice hook debug rewrote:true");
+ assert(typeof debug.rawChars === "number", "expected rawChars in Tokenjuice hook debug");
+ assert(typeof debug.reducedChars === "number", "expected reducedChars in Tokenjuice hook debug");
+
+ const sessionMeta = transcriptEntries.find((entry) => entry.type === "session_meta");
+ const finalModelContextChars = summaryText.length + functionCallOutput.length;
+ return {
+ ok: true,
+ codexVersion,
+ sessionId: sessionMeta?.payload?.session_id,
+ checks: {
+ summaryPresent: true,
+ rawMarkerAbsentFromFunctionCallOutput: true,
+ replacementReasonPresent: true,
+ turnContinued: true,
+ hookRewrote: true,
+ },
+ chars: {
+ raw: debug.rawChars,
+ compressed: debug.reducedChars,
+ summaryContext: summaryText.length,
+ functionCallOutput: functionCallOutput.length,
+ finalModelContext: finalModelContextChars,
+ },
+ };
+}
+
+let report;
+try {
+ report = await runLiveE2E();
+} finally {
+ await rm(tempRoot, { recursive: true, force: true });
+}
+
+process.stdout.write(`${JSON.stringify({ ...report, cleanedUp: true }, null, 2)}\n`);
diff --git a/scripts/local-host-e2e.mjs b/scripts/local-host-e2e.mjs
index cd64adb4..0e8a1e32 100644
--- a/scripts/local-host-e2e.mjs
+++ b/scripts/local-host-e2e.mjs
@@ -20,10 +20,6 @@ function assert(condition, message) {
}
}
-function compactableOutput(prefix, count) {
- return Array.from({ length: count }, (_, index) => `${prefix}/example-${index + 1}.json`).join("\n");
-}
-
function postToolUsePayload(command, toolResponse) {
return `${JSON.stringify({
hook_event_name: "PostToolUse",
@@ -128,8 +124,18 @@ async function runCodexE2E() {
assert(report.status === "ok", `expected Codex doctor status ok, got ${doctor.stdout}`);
const payload = postToolUsePayload(
- "find src/rules -maxdepth 2 -type f | head -n 40",
- compactableOutput("src/rules", 40),
+ "git status",
+ [
+ "On branch pr-65478-security-fix",
+ "Your branch and 'origin/pr-65478-security-fix' have diverged,",
+ "and have 8 and 642 different commits each, respectively.",
+ "",
+ "Changes not staged for commit:",
+ "\tmodified: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts",
+ "\tmodified: src/agents/pi-embedded-runner/run/attempt.test.ts",
+ "",
+ "no changes added to commit",
+ ].join("\n"),
);
const hook = await run(process.execPath, [distCliPath, "codex-post-tool-use"], {
env: { CODEX_HOME: codexHome },
@@ -139,10 +145,15 @@ async function runCodexE2E() {
assert(hook.stderr === "", `expected Codex hook stderr to stay empty, got ${hook.stderr}`);
const output = JSON.parse(hook.stdout);
const additionalContext = output.hookSpecificOutput?.additionalContext;
+ assert(output.continue === false, "expected Codex hook output to replace the original tool result");
assert(output.hookSpecificOutput?.hookEventName === "PostToolUse", "expected Codex PostToolUse output");
assert(typeof additionalContext === "string", "expected Codex additionalContext");
- assert(additionalContext.includes("40 matches"), "expected Codex hook output to contain compacted match count");
- assert(additionalContext.includes("src/rules/example-1.json"), "expected Codex hook output to include compacted paths");
+ assert(additionalContext.includes("Changes not staged:"), "expected Codex hook output to retain status context");
+ assert(
+ additionalContext.includes("M: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts"),
+ "expected Codex hook output to include compacted status paths",
+ );
+ assert(!additionalContext.includes("and have 8 and 642"), "expected Codex hook output to omit noisy branch details");
assert(additionalContext.includes("tokenjuice wrap --raw -- "), "expected Codex hook output to include raw rerun hint");
assert(!hook.stdout.includes("\"decision\""), "Codex hook feedback must not emit JSON decision:block output");
diff --git a/src/hosts/codex/index.ts b/src/hosts/codex/index.ts
index 77c4abc6..67667191 100644
--- a/src/hosts/codex/index.ts
+++ b/src/hosts/codex/index.ts
@@ -735,9 +735,16 @@ function buildCodexFeedback(inlineText: string, rawRefId?: string): string {
return `${inlineText}\n\n${buildCompactionHint(rawRefId)}`;
}
+const CODEX_COMPACTION_STOP_REASON = "Tokenjuice replaced the original Bash output with the compacted context above.";
+
function buildCodexReplacementOutput(inlineText: string, rawRefId?: string): Record {
const feedback = buildCodexFeedback(inlineText, rawRefId);
return {
+ // Codex uses separate fields for the hook event and model feedback. Set both so replacement
+ // does not look like a failed tool call, while keeping the full summary in additionalContext.
+ continue: false,
+ stopReason: CODEX_COMPACTION_STOP_REASON,
+ reason: CODEX_COMPACTION_STOP_REASON,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: feedback,
diff --git a/test/hosts/codex.test.ts b/test/hosts/codex.test.ts
index d6a3643a..8f20fba9 100644
--- a/test/hosts/codex.test.ts
+++ b/test/hosts/codex.test.ts
@@ -65,12 +65,18 @@ async function captureStdio(run: () => Promise): Promise<{ code: number;
}
function parseCodexReplacementOutput(stdout: string): {
+ continue?: boolean;
+ stopReason?: string;
+ reason?: string;
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
} {
return JSON.parse(stdout) as {
+ continue?: boolean;
+ stopReason?: string;
+ reason?: string;
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
@@ -618,6 +624,9 @@ describe("runCodexPostToolUseHook", () => {
expect(code).toBe(0);
expect(stderr).toBe("");
+ expect(response.continue).toBe(false);
+ expect(response.stopReason).toBe("Tokenjuice replaced the original Bash output with the compacted context above.");
+ expect(response.reason).toBe("Tokenjuice replaced the original Bash output with the compacted context above.");
expect(response.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(response.hookSpecificOutput?.additionalContext).toContain("Changes not staged:");
expect(response.hookSpecificOutput?.additionalContext).toContain("M: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts");