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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions plugins/codex/scripts/lib/hook-stdin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import process from "node:process";

/**
* Read Claude Code hook stdin JSON with a hard deadline.
*
* `fs.readFileSync(0)` blocks until EOF. On Windows, Claude Code sometimes
* leaves the write end open after sending the payload, so Stop hooks hang
* until the external hook timeout (see #530). Prefer this timed reader.
*
* @param {number} timeoutMs
* @returns {Promise<object>}
*/
export function readHookInput(timeoutMs = 5000) {
if (process.stdin.isTTY) {
return Promise.resolve({});
}

return new Promise((resolve) => {
let raw = "";
let settled = false;

const finish = (value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
process.stdin.removeListener("data", onData);
process.stdin.removeListener("end", onEnd);
process.stdin.removeListener("error", onError);
try {
process.stdin.pause();
} catch {
// ignore
}

const text = String(value ?? "").trim();
if (!text) {
resolve({});
return;
}
try {
resolve(JSON.parse(text));
} catch {
resolve({});
}
};

const timer = setTimeout(() => finish(raw), timeoutMs);
const onData = (chunk) => {
raw += chunk;
};
const onEnd = () => finish(raw);
const onError = () => finish(raw);

process.stdin.setEncoding("utf8");
process.stdin.on("data", onData);
process.stdin.on("end", onEnd);
process.stdin.on("error", onError);
process.stdin.resume();
});
}
50 changes: 31 additions & 19 deletions plugins/codex/scripts/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node

import fs from "node:fs";
import process from "node:process";
import path from "node:path";
import { spawnSync } from "node:child_process";
Expand All @@ -12,19 +11,16 @@ import { getConfig, listJobs } from "./lib/state.mjs";
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
import { readHookInput } from "./lib/hook-stdin.mjs";

const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";

function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) {
return {};
}
return JSON.parse(raw);
}
// Gate-off path must not wait on Windows stdin EOF (#530). Keep this short.
const DISABLED_GATE_STDIN_TIMEOUT_MS = 500;
// Gate-on still needs the payload; bound the wait so a stuck pipe cannot eat the full hook budget.
const ENABLED_GATE_STDIN_TIMEOUT_MS = 30_000;

function emitDecision(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
Expand Down Expand Up @@ -139,17 +135,34 @@ function runStopReview(cwd, input = {}) {
}
}

function main() {
const input = readHookInput();
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);

function logRunningTaskNote(workspaceRoot, input = {}) {
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input));
const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running");
const runningTaskNote = runningJob
? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.`
: null;
return runningTaskNote;
}

async function main() {
// Resolve config from env/cwd first so a disabled gate never waits on stdin EOF (#530).
const cwdGuess = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const preliminaryRoot = resolveWorkspaceRoot(cwdGuess);
const preliminaryConfig = getConfig(preliminaryRoot);

if (!preliminaryConfig.stopReviewGate) {
const input = await readHookInput(DISABLED_GATE_STDIN_TIMEOUT_MS);
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-check the gate for stdin.cwd before returning

When the hook is launched without CLAUDE_PROJECT_DIR or from a different working directory than the project in the hook payload, this early return trusts the config for cwdGuess. The subsequent short read may discover input.cwd, but the branch only logs running tasks and returns, so a project whose payload cwd has stopReviewGate: true can stop without running the review gate. This regresses the previous behavior, which loaded config after reading input.cwd; re-check the actual workspace's config before deciding the gate is disabled.

Useful? React with 👍 / 👎.

const cwd = input.cwd || cwdGuess;
const workspaceRoot = resolveWorkspaceRoot(cwd);
logNote(logRunningTaskNote(workspaceRoot, input));
return;
}

const input = await readHookInput(ENABLED_GATE_STDIN_TIMEOUT_MS);
const cwd = input.cwd || cwdGuess;
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);
const runningTaskNote = logRunningTaskNote(workspaceRoot, input);

if (!config.stopReviewGate) {
logNote(runningTaskNote);
Expand All @@ -176,9 +189,8 @@ function main() {
}

try {
main();
await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
}
56 changes: 56 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,62 @@ test("stop hook logs running tasks to stderr without blocking when the review ga
assert.match(blocked.stderr, /\/codex:cancel task-live/i);
});

test("stop hook with disabled gate exits quickly when stdin never receives EOF", async () => {
const repo = makeTempDir();
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const stateDir = resolveStateDir(repo);
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
path.join(stateDir, "state.json"),
`${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [] }, null, 2)}\n`,
"utf8"
);

const child = spawn(process.execPath, [STOP_HOOK], {
cwd: repo,
env: {
...process.env,
CLAUDE_PROJECT_DIR: repo
},
stdio: ["pipe", "pipe", "pipe"]
});

// Write a partial payload but never end stdin (Windows EOF hang repro).
child.stdin.write(`${JSON.stringify({ cwd: repo })}\n`);

const started = Date.now();
const [status, stdout, stderr] = await new Promise((resolve, reject) => {
let out = "";
let err = "";
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error("stop hook hung longer than 3s with review gate disabled"));
}, 3000);
child.stdout.on("data", (chunk) => {
out += chunk;
});
child.stderr.on("data", (chunk) => {
err += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve([code, out, err]);
});
});

assert.equal(status, 0, stderr);
assert.equal(stdout.trim(), "");
assert.ok(Date.now() - started < 2500, `expected fast exit, took ${Date.now() - started}ms`);
});

test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
Expand Down