import { readFileSync } from "node:fs"; import { stdin as processStdin, stdout as processStdout } from "node:process"; import { type CommentCheckRequest, extractCommentCheckRequests, isRecord, type ToolResultContent, type ToolResultLike, toHookInput, } from "./core.js"; import { type CommentCheckerRunner, runCommentChecker } from "./runner.js"; export type CodexPostToolUseInput = { session_id: string; turn_id: string; transcript_path: string | null; cwd: string; hook_event_name: "PostToolUse"; model: string; permission_mode: string; tool_name: string; tool_input: Record; tool_response: unknown; tool_use_id: string; }; export type CodexHookOptions = { run?: CommentCheckerRunner; }; const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000; const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200; const CONTEXT_PRESSURE_MARKERS = [ "context compacted", "context_length_exceeded", "skill descriptions were shortened", "context_too_large", "codex ran out of room in the model's context window", "your input exceeds the context window", "long threads and multiple compactions", ] as const; export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] { return extractCommentCheckRequests(toToolResultLike(input)); } export async function runCommentCheckerPostToolUse( input: CodexPostToolUseInput, options: CodexHookOptions = {}, ): Promise { const requests = extractCodexCommentCheckRequests(input); if (requests.length === 0) return ""; const runner = options.run ?? runCommentChecker; const warnings: Array<{ filePath: string; message: string }> = []; for (const request of requests) { const context = { sessionId: input.session_id, cwd: input.cwd, ...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }), }; const result = await runner(toHookInput(request, context)); if (result.status === "missing" || result.status === "pass") continue; if (result.status === "error") continue; const message = normalizeHookText(result.message); if (message.length > 0) { warnings.push({ filePath: request.filePath, message }); } } if (warnings.length === 0) return ""; return JSON.stringify({ decision: "block", reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)), }); } export async function runCodexHookCli(): Promise { const input = await readStdin(); if (input.trim().length === 0) return; const parsed = parseCodexPostToolUseInput(input); if (!parsed) return; const output = await runCommentCheckerPostToolUse(parsed); if (output.length > 0) { processStdout.write(output); processStdout.write("\n"); } } export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined { let parsed: unknown; try { parsed = JSON.parse(input); } catch { return undefined; } return isCodexPostToolUseInput(parsed) ? parsed : undefined; } function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike { return { toolName: input.tool_name, input: normalizeToolInput(input.tool_name, input.tool_input), content: normalizeToolResponse(input.tool_response), isError: isErrorResponse(input.tool_response), details: isRecord(input.tool_response) ? input.tool_response : undefined, }; } function normalizeToolInput(toolName: string, toolInput: Record): Record { if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { return { ...toolInput, input: toolInput["command"], patch: toolInput["command"], }; } return toolInput; } function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] { if (typeof toolResponse === "string") { return [{ type: "text", text: toolResponse }]; } if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") { return [{ type: "text", text: toolResponse["text"] }]; } return []; } function isErrorResponse(toolResponse: unknown): boolean { return isRecord(toolResponse) && toolResponse["is_error"] === true; } function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string { return warnings .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) .join("\n\n"); } function normalizeHookText(value: string): string { return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); } function hookFeedbackLimit(transcriptPath: string | null): number { return isContextPressureTranscript(transcriptPath) ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; } function isContextPressureTranscript(transcriptPath: string | null): boolean { if (transcriptPath === null) return false; try { return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); } catch (error) { if (error instanceof Error) return false; throw error; } } function hasContextPressureMarker(text: string): boolean { const normalizedText = text.toLowerCase(); return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); } function limitHookText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`; if (marker.length >= maxChars) return marker.slice(0, maxChars); const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, ""); return `${head}${marker}`; } function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput { return ( isRecord(value) && value["hook_event_name"] === "PostToolUse" && typeof value["session_id"] === "string" && typeof value["turn_id"] === "string" && (typeof value["transcript_path"] === "string" || value["transcript_path"] === null) && typeof value["cwd"] === "string" && typeof value["model"] === "string" && typeof value["permission_mode"] === "string" && typeof value["tool_name"] === "string" && isRecord(value["tool_input"]) && typeof value["tool_use_id"] === "string" ); } function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ""; processStdin.setEncoding("utf-8"); processStdin.on("data", (chunk: string) => { data += chunk; }); processStdin.once("error", reject); processStdin.once("end", () => { resolve(data); }); }); }