Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Codex plugin that ports the standalone LSP runtime from [`pi-lsp-client`](https:
|------|--------|
| `apply_patch` succeeds | parses `tool_input.command`, extracts added/updated/moved files, and checks each with LSP error diagnostics |
| `write` / `edit` / `multiedit` succeeds | checks `path`, `filePath`, or `file_path` aliases |
| diagnostics contain errors | returns Codex `PostToolUse` blocking feedback so Codex fixes the file |
| diagnostics contain errors | returns Codex `PostToolUse` blocking feedback and injects the same diagnostics as additional context so Codex fixes the file |
| no diagnostics | emits no hook output |
| unsupported extension | emits no hook output |
| missing configured language server | surfaces the install/config message through hook or MCP output |
Expand Down
2 changes: 1 addition & 1 deletion dist/codex-hook.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion dist/codex-hook.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/codex-hook.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion src/codex-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ interface DiagnosticBlock {
diagnostics: string;
}

interface PostToolUseHookOutput {
decision: "block";
reason: string;
hookSpecificOutput: {
hookEventName: "PostToolUse";
additionalContext: string;
};
}

const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]);
const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found";
const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:";
Expand Down Expand Up @@ -43,7 +52,15 @@ export async function runLspPostToolUseHook(
const reason = blocks
.map(({ filePath, diagnostics }) => `LSP diagnostics after editing ${filePath}:\n${diagnostics}`)
.join("\n\n");
return `${JSON.stringify({ decision: "block", reason })}\n`;
const output: PostToolUseHookOutput = {
decision: "block",
reason,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: reason,
},
};
return `${JSON.stringify(output)}\n`;
}

export function extractMutatedFilePaths(input: CodexPostToolUseInput): string[] {
Expand Down
60 changes: 60 additions & 0 deletions test/codex-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,72 @@ describe("codex PostToolUse hook", () => {

expect(JSON.parse(output)).toEqual({
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext:
"LSP diagnostics after editing src/broken.ts:\n" +
"error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
},
reason:
"LSP diagnostics after editing src/broken.ts:\n" +
"error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
});
});

it("injects only files with diagnostics when multiple files are edited", async () => {
const checkedFilePaths: string[] = [];
const output = await runLspPostToolUseHook(
{
tool_name: "MultiEdit",
tool_input: {
file_paths: ["src/clean.ts", "README.md", "src/broken.ts", "src/broken.ts"],
},
tool_response: { ok: true },
},
async (filePath) => {
checkedFilePaths.push(filePath);
if (filePath === "src/broken.ts") {
return "error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";
}
if (filePath === "README.md") {
return "No LSP server configured for extension: .md";
}
return "No diagnostics found";
},
);

const expectedDiagnostics =
"LSP diagnostics after editing src/broken.ts:\n" +
"error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";

expect(checkedFilePaths).toEqual(["src/clean.ts", "README.md", "src/broken.ts"]);
expect(JSON.parse(output)).toEqual({
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: expectedDiagnostics,
},
reason: expectedDiagnostics,
});
});

it("does not run diagnostics for failed mutation tool responses", async () => {
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
},
tool_response: { isError: true },
},
async () => {
throw new Error("diagnostics should not run after failed mutations");
},
);

expect(output).toBe("");
});

it("is silent for clean diagnostics and unsupported extensions", async () => {
const output = await runLspPostToolUseHook(
{
Expand Down