Format on Edit
Run biome on every TS/JS file the agent writes. One script, three hosts.
Hook script
#!/usr/bin/env bun
// .hooks/format.ts
import { defineHook, run } from "@pivanov/agent-hooks-bridge";
const FORMATTED_EXTS = /\.(ts|tsx|js|jsx|mjs|cjs|json)$/;
const hooks = defineHook({
PostToolUse: async (event) => {
if (event.tool !== "Edit" && event.tool !== "Write") {
return { decision: "allow" };
}
const file = event.tool_input.file_path;
if (typeof file !== "string" || !FORMATTED_EXTS.test(file)) {
return { decision: "allow" };
}
const proc = Bun.spawn(["biome", "check", "--write", file], {
stderr: "pipe",
stdout: "pipe",
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return {
decision: "allow",
additional_context: `biome could not auto-fix ${file}:\n${stderr.slice(0, 500)}`,
};
}
return {
decision: "allow",
additional_context: `biome auto-fixed ${file}`,
};
},
});
await run(hooks);chmod +x .hooks/format.ts and:
bunx @pivanov/agent-hooks-bridge install ./.hooks/format.ts --events PostToolUseWhat happens cross-host
Same script, three different stdin shapes:
{
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "src/foo.ts"
}
}{
"hook_event_name": "afterFileEdit",
"file_path": "src/foo.ts",
"edits": [
{
"old_string": "...",
"new_string": "..."
}
]
}{
"hook_event_name": "PostToolUse",
"tool_name": "apply_patch",
"turn_id": "...",
"tool_input": {
"patch": "..."
}
}And three different stdouts:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "biome auto-fixed src/foo.ts"
}
}(empty){
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "biome auto-fixed src/foo.ts"
}
}Cursor's afterFileEdit is observational; no response fields are accepted. The biome run still happens (the handler runs to completion), but the model doesn't see the additional_context message; the bridge logs the drop to stderr. To surface format activity to the model on Cursor, accumulate it across runs and emit it from a SessionStart hook on the next session.
Codex aliases apply_patch to Edit, so event.tool === "Edit" matches. But event.tool_input.file_path is undefined; Codex's apply_patch sends a unified patch string instead of a file path.
To handle Codex robustly, parse the patch:
PostToolUse: async (event) => {
if (event.tool !== "Edit" && event.tool !== "Write") {
return { decision: "allow" };
}
let file: string | null = null;
if (typeof event.tool_input.file_path === "string") {
file = event.tool_input.file_path;
} else if (typeof event.tool_input.patch === "string") {
const m = event.tool_input.patch.match(/^\*\*\* (?:Update|Add) File: (.+)$/m);
file = m?.[1] ?? null;
}
if (file === null || !FORMATTED_EXTS.test(file)) {
return { decision: "allow" };
}
// ...
}Wire-level tool_input keeps the host's native shape. The bridge unifies event.tool and event names; per-host fields inside tool_input are your handler's responsibility.