@jordyvanvorselen/pi-claude-hooks0.1.1
Run Claude Code hooks from .claude/ inside pi
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-claude-hooks
Run your Claude Code hooks inside pi. The extension reads the hook configuration your team already deploys to .claude/ and runs those command hooks on pi's events. Existing hook scripts keep working unchanged, including scripts that read .tool_input.file_path or match on Edit|Write.
Quick start
Install the package:
pi install npm:@jordyvanvorselen/pi-claude-hooks
Or install from a local checkout:
pi install /path/to/pi-claude-hooks
Or try it for one run without installing:
pi -e npm:@jordyvanvorselen/pi-claude-hooks
pi -e /path/to/pi-claude-hooks
Open pi in a project that has .claude/settings.json hooks. Run /claude-hooks to see what was loaded.
Hooks only run for trusted projects. If pi has not trusted the project yet, only your user-level ~/.claude/settings.json hooks load, and you get a warning.
Config sources
Hook files are read in this order. Later files append to the earlier ones.
| Order | File | Notes |
|---|---|---|
| 1 | ~/.claude/settings.json |
Your user hooks. Turn off with loadUserSettings: false. |
| 2 | <project>/.claude/settings.json |
Team hooks. Only for trusted projects. |
| 3 | <project>/.claude/settings.local.json |
Personal project hooks. Only for trusted projects. |
| 4 | <project>/.claude/*.json |
Any other JSON file that contains hooks, for example apm-hooks.json. Glob is configurable. |
A file counts as a hook file when it has a top-level hooks object, or when it has event names such as PreToolUse at the top level (the shape APM writes to apm-hooks.json). The extra-file scan is a pi-claude-hooks extension rather than a Claude standard-settings feature; malformed matching JSON is skipped with a warning.
Hook declarations are not deduplicated. When settings.json and apm-hooks.json contain the same event, matcher and command, both declarations run. disableAllHooks is read only from these three standard settings files, with local > project > user precedence; extra files cannot override it.
Only type: "command" hooks run. http, prompt, agent and mcp_tool hooks are skipped with one warning per type.
Options
Put options in ~/.pi/agent/claude-hooks.json or <project>/.pi/claude-hooks.json. The project file overrides the user file.
{
"enabled": true,
"loadUserSettings": true,
"extraFiles": ["*.json"],
"defaultTimeoutSeconds": 600,
"sessionEndTimeoutSeconds": 1.5,
"shell": "/bin/bash",
"verbose": false,
"startupSummary": "compact"
}
| Option | Default | What it does |
|---|---|---|
enabled |
true |
Set to false to load nothing. |
loadUserSettings |
true |
Read ~/.claude/settings.json. |
extraFiles |
["*.json"] |
Filename globs, matched inside .claude/, for extra hook files. settings.json and settings.local.json are always excluded here because they load on their own. Use [] to load no extra files. |
defaultTimeoutSeconds |
600 |
Timeout for hooks without their own timeout. UserPromptSubmit hooks are capped at 30 seconds, matching Claude Code. |
sessionEndTimeoutSeconds |
1.5 |
Timeout for SessionEnd hooks so quitting pi stays fast. A hook's own timeout wins. |
shell |
/bin/bash when present |
Shell used to run hook commands. |
verbose |
false |
Log every hook run and exit code. |
Environment variables for quick overrides: PI_CLAUDE_HOOKS_EXTRA_FILES (comma separated globs), PI_CLAUDE_HOOKS_VERBOSE=1, PI_CLAUDE_HOOKS_DISABLED=1.
Startup summary
When a session starts, the extension adds a [Claude hooks] block to the chat in the same style as pi's own [Skills] and [Extensions] sections.
[Claude hooks]
SessionStart (1), PreToolUse (3), PostToolUse (1)
Press the expand shortcut (the one that expands tool output) to see every hook grouped by source file, with its matcher and a shortened command:
[Claude hooks]
~/.claude/settings.json
SessionStart [startup] bash ~/.claude/hooks/agent-state.sh session
~/projects/app/.claude/settings.json
PreToolUse [Bash] if jq -re '.tool_input.command // empty' | grep -qE '(rm\s+(-[a-z]*...
PreToolUse [Edit|Write] p=$(jq -re '.tool_input.file_path // empty') || exit 0; root=...
PostToolUse [Edit|Write] if jq -re 'select((.tool_input.file_path // "") | (test("(co...
Set startupSummary in the options file:
| Value | Behaviour |
|---|---|
"compact" |
Default. Event names with counts. Expands on demand. |
"full" |
Always show the grouped list. |
"off" |
Show nothing. |
The block is a pi custom entry. It is stored in the session file but never sent to the model. It appears once per session: resuming a session does not add another one, and nothing is shown when no hooks loaded or when pi runs without a UI (print and JSON modes).
Commands
| Command | What it does |
|---|---|
/claude-hooks |
List loaded sources and hooks per event. |
/claude-hooks-reload |
Re-read all hook files. |
/claude-hooks-verbose |
Toggle logging of every hook run. |
Event mapping
| Claude Code event | Pi event | Matcher value | Can block |
|---|---|---|---|
PreToolUse |
tool_call |
Tool name (pi and Claude names) | Yes. Blocks the tool call. |
PostToolUse |
tool_result with isError: false |
Tool name | No. Feedback goes to the model. |
PostToolUseFailure |
tool_result with isError: true |
Tool name | No. Feedback goes to the model. |
UserPromptSubmit |
input |
none | Yes. Drops the prompt. |
SessionStart |
session_start |
startup, resume, clear, fork |
No |
SessionEnd |
session_shutdown |
clear, resume, other |
No |
Stop |
agent_before_settle |
none | Yes. Appends the reason as context and runs one more turn. |
PreCompact |
session_before_compact |
manual, auto |
Yes. Cancels compaction. |
PostCompact |
session_compact |
manual, auto |
No |
Session source mapping: pi startup and reload become startup, new becomes clear, resume stays resume, fork stays fork. Session end reasons: pi new becomes clear, resume stays resume, everything else becomes other.
Matchers follow Claude Code rules and are case-sensitive. Known tool names and names separated by | or , are exact, trimmed alternatives; anything else is an unanchored, case-sensitive JavaScript regular expression. Pi and Claude tool aliases are tested as separate candidates. Empty, missing or * matches everything.
Tool name and field normalisation
Claude Code scripts see capitalised tool names and file_path style fields. Pi uses lowercase names and different field names. The extension presents both, so scripts written for either side work.
Tool names
Matchers are tested against separate, case-sensitive Pi and Claude Code names. Empty/* match all; known tool names (Bash, bash, etc.) and |/, lists are exact, trimmed alternatives. Other patterns are unanchored JavaScript regular expressions. Invalid regular expressions match nothing and produce one warning. tool_name in the stdin payload is the Claude Code name. pi_tool_name holds the pi name.
| Pi tool | Claude Code name |
|---|---|
bash |
Bash |
powershell |
Bash |
read |
Read |
write |
Write |
edit |
Edit |
grep |
Grep |
find |
Glob |
ls |
LS |
| anything else | unchanged |
tool_input fields
Pi's own fields are always present. These Claude Code fields are added next to them.
| Pi tool | Pi input | Added Claude Code fields |
|---|---|---|
edit |
path, edits: [{oldText, newText}] |
file_path = path, old_string = all oldText joined with newlines, new_string = all newText joined with newlines |
write |
path, content |
file_path = path |
read |
path, offset, limit |
file_path = path |
bash |
command, timeout |
none, command already matches |
grep |
pattern, path, glob, ignoreCase, ... |
-i = ignoreCase |
find |
pattern, path, limit |
none, matches Claude Code Glob |
updatedInput mapping back
When a PreToolUse hook returns hookSpecificOutput.updatedInput, the extension translates Claude Code fields back before pi runs the tool.
| Returned field | Applied as |
|---|---|
file_path |
path (for read, write, edit) |
old_string, new_string |
edits[0].oldText, edits[0].newText when the call has exactly one edit. With several edits the change is ignored and you get a warning. |
-i |
ignoreCase (for grep) |
| anything else | passed through as is |
Stdin payload
Every hook gets JSON on stdin:
{
"session_id": "...",
"transcript_path": "/path/to/pi/session.jsonl",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"permission_mode": "default",
"tool_name": "Edit",
"pi_tool_name": "edit",
"tool_input": { "path": "a.ts", "edits": [...], "file_path": "a.ts", "old_string": "...", "new_string": "..." },
"tool_use_id": "call_123"
}
Event specific fields: tool_response: { output, content, details, is_error } on PostToolUse, error and is_interrupt on PostToolUseFailure, prompt on UserPromptSubmit, source and model on SessionStart, reason on SessionEnd, stop_hook_active and last_assistant_message on Stop, trigger and custom_instructions on PreCompact, trigger and compaction_summary on PostCompact. output is rendered text; content and details are the original Pi values. Image blocks render as [image] in output but remain intact in content. is_interrupt is inferred from the Pi abort signal and common abort text (such as Command aborted).
Environment
Hook commands run in the project directory with your environment plus:
| Variable | Value |
|---|---|
CLAUDE_PROJECT_DIR |
Project root (pi's cwd) |
PI_SESSION_ID |
Pi session id |
PI_SESSION_FILE |
Pi session file, when the session is saved |
PI_PROVIDER, PI_MODEL |
Current model |
PI_REASONING_LEVEL |
Current thinking level |
PI_CLAUDE_HOOKS |
1, so scripts can tell they run under pi |
Exit code contract
| Exit code | PreToolUse |
PostToolUse |
UserPromptSubmit |
Stop |
PreCompact |
Others |
|---|---|---|---|---|---|---|
0 |
Parse stdout as JSON if it is a JSON object | Same | Same. Plain stdout is added as context for the model | Same | Same | Same. SessionStart plain stdout is added as context |
2 |
Block the tool. stderr is the reason the model sees | Tool already ran. stderr is appended to the tool result for the model | Drop the prompt. stderr is shown to you | Keep going. stderr is added as context and one more turn runs | Cancel compaction | stderr is shown to you |
| other | Non-blocking. stderr is shown as an error. Valid JSON on stdout still applies | Same | Same | Same | Same | Same |
A hook that hits its timeout is reported as an error and never blocks.
JSON output contract
Print a JSON object on stdout with exit code 0.
| Field | Supported | Behaviour |
|---|---|---|
continue: false, stopReason |
Yes | On PreToolUse the tool is blocked and the agent stops. On PostToolUse the run is aborted. Elsewhere it counts as a block. |
systemMessage |
Yes | Shown to you as a warning. |
suppressOutput |
Ignored | Stdout is never shown in the transcript anyway. |
decision: "block", reason |
Yes | Blocks on PreToolUse, UserPromptSubmit, Stop, PreCompact. Feeds reason to the model on PostToolUse. |
decision: "approve" |
Yes | Same as permissionDecision: "allow". |
hookSpecificOutput.permissionDecision |
Yes | deny blocks. ask opens a confirm dialog, or blocks when there is no UI. allow lets the call through. deny wins over ask wins over allow when several hooks answer. |
hookSpecificOutput.permissionDecisionReason |
Yes | Reason for the block or the dialog. |
hookSpecificOutput.updatedInput |
Yes | Replaces the tool input. See the mapping above. |
hookSpecificOutput.additionalContext |
Yes | PreToolUse and PostToolUse: appended to the tool result. UserPromptSubmit and SessionStart: sent as a hidden message before the next turn. |
Supported and unsupported
Supported: command hooks, all events in the mapping table, matchers, per-hook timeout, exit codes 0 and 2, the JSON fields listed above, stop_hook_active loop protection on Stop. Settings merge additively in user → project → local → configured extra-file order, including duplicate declarations. Extra JSON files are an explicit pi-claude-hooks extension (and support Claude's bare-event APM shape); malformed extras are skipped with a warning. disableAllHooks uses only standard settings, with local > project > user precedence.
Not supported: http, prompt, agent and mcp_tool hook types. Events pi has no equivalent for: Notification, PermissionRequest, PermissionDenied, SubagentStart, SubagentStop, PostToolBatch, PreModelSwitch, PostModelSwitch, Elicitation, worktree, teammate and task events. Hook fields if, once, async, statusMessage, args. CLAUDE_ENV_FILE. Managed settings, CLI settings, plugins, skill frontmatter hooks, and CLAUDE_CONFIG_DIR are warned about and ignored. allowManagedHooksOnly is not supported.
Pi has no permission prompts of its own, so permissionDecision: "allow" has nothing to bypass. It simply lets the call proceed.
Development
npm install
npm run lint
npm run typecheck
npm test
Run npm run lint:fix to fix lint and formatting problems.
The integration tests run the real hook commands from a Hertek project against pi-shaped tool inputs. One of those hooks needs GNU realpath -m. On macOS install coreutils with Homebrew, the tests add its gnubin directory to PATH automatically.
Versioning and releases
This package follows semantic versioning. Every pull request must raise the version in package.json. CI fails the pull request when the version is not higher than on main.
Pick the bump that matches the change:
| Change | Command | Example |
|---|---|---|
| Bug fix, docs, internal change | npm version patch --no-git-tag-version |
0.1.0 → 0.1.1 |
| New feature or setting that keeps old behaviour | npm version minor --no-git-tag-version |
0.1.1 → 0.2.0 |
| Breaking change, such as a removed or renamed setting | npm version major --no-git-tag-version |
0.2.0 → 1.0.0 |
The command updates both package.json and package-lock.json. Commit both files.
When a pull request merges, GitHub Actions runs the checks again and publishes the new version to npm. It then creates a GitHub release tagged with the version number, such as 0.1.2. GitHub generates the release notes from the merged pull requests.