run
import { run } from "@pivanov/agent-hooks-bridge";Reads stdin, resolves the host, parses the native payload into a unified event, calls your handler, serializes the response, sets the exit code.
Signature
const run: (handlers: THandlerMap, options?: IRunOptions) => Promise<void>;
interface IRunOptions {
host?: "claude" | "cursor" | "codex";
stdin?: string;
argv?: readonly string[];
exit?: (code: number) => never;
write?: (chunk: string) => void;
maxBytes?: number;
cursorAskFallback?: "allow" | "deny" | "ask";
}Defaults
await run(hooks);Equivalent to:
host: resolved fromoptions.host, then--host <id>inprocess.argv, then stdin detectionstdin: read fromprocess.stdinuntil EOF, capped at 1 MiBargv:process.argv.slice(2)exit:process.exitwrite:process.stdout.writemaxBytes:1_048_576cursorAskFallback:"deny"(the Cursoraskregression that started in 2.4.21 and is still present on 3.2.16 auto-downgrades)
cursorAskFallback
Cursor's permission: "ask" has been broken since 2.4.21 and is still broken on 3.2.16 (last verified). The visible behavior changed across the regression window: 2.4.21 through 2.x silently treats ask as deny; 3.x silently treats it as allow. Either way the hook author's intent is dropped.
By default the bridge reads cursor_version from stdin and downgrades decision: "ask" to "deny" on every version >= 2.4.21, with a stderr warning. That's the safer default on both eras. Pass { cursorAskFallback: "ask" } to opt out and let ask pass through unchanged (it will still be silently mishandled, but the bridge stays out of the way):
await run(hooks, { cursorAskFallback: "ask" });The downgrade only fires on Cursor permission events (beforeShellExecution, beforeMCPExecution, beforeReadFile, preToolUse). Other events are unaffected by this option.
Host resolution order
options.hostif provided--host <id>token inoptions.argv(orprocess.argv)- Stdin scoring (see Host Detection)
If none resolve, run() writes a stderr error and exits 1.
Forcing a host
await run(hooks, { host: "claude" });Programmatic use (testing, embedding)
stdin, argv, stdout writes, and exit are all injectable.
let captured = "";
let exitCode = -1;
await run(hooks, {
stdin: JSON.stringify(payload),
argv: ["--host", "claude"],
write: (chunk) => {
captured += chunk;
},
exit: ((code: number) => {
exitCode = code;
throw new Error("__exit__");
}) as never,
}).catch((err) => {
if (!(err instanceof Error) || err.message !== "__exit__") throw err;
});
assert.equal(JSON.parse(captured).hookSpecificOutput.permissionDecision, "deny");
assert.equal(exitCode, 2);process.exit is (code) => never. The override should also unwind the stack; throwing a sentinel and catching it keeps test code linear.
Exit codes
| Outcome | Exit |
|---|---|
Handler returns decision: "deny" | 2 |
Handler returns "allow" / "ask" / nothing | 0 |
| No handler registered for the event | 0 |
| stdin parse failure | 1 |
| Host resolution failure | 1 |
| Adapter parse / serialize throws | 1 |
| Handler throws | 1 |
stderr always carries a [agent-hooks-bridge] … line for any non-zero exit.
Stdout protocol
stdout carries the host's wire JSON only. The bridge writes:
- One JSON object followed by
\n, or - Nothing (when the response is empty).
Logs, prompts, ANSI sequences, and any non-JSON output never appear on stdout. All human-facing output goes to stderr via the internal warn() helper.
Stderr channel
[agent-hooks-bridge]-prefixed lines on stderr cover dropped fields, version-specific regressions, and bridge errors. Warnings don't affect exit code; only the conditions in the table above do.
Example successful run with a dropped field:
$ ./hook.ts --host cursor < cursor-prompt-payload.json
{"continue":true,"user_message":"..."}
[agent-hooks-bridge] 'additional_context' is not supported on cursor's beforeSubmitPrompt response; dropped; cursor's prompt-submit event cannot inject context; consider sessionStart
$ echo $?
0stdout reaches the host. stderr reaches the agent logs and the user.