omp packages

@dianel/pi-hooks-rules1.1.1

Manage Pi tool hooks through /hooks and inject rules automatically

dianel Published 2 days ago MIT ~535 downloads/mo

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.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-hooks-rules

A Pi 0.84.3+ package (compatible with Pi 1.0 and 1.3) for Node.js 22.19+ that provides:

  • /hooks interactive management and command-line actions.
  • Global and trusted-project hook configuration.
  • Node-based pre-tool blocking and post-tool validation hooks.
  • Lifecycle hooks for session start/shutdown, prompt input, user ! commands, agent start, final settlement, and compaction.
  • Per-session hook status, tests, and recent-run inspection.
  • Automatic injection of user and trusted-project Markdown rules.

Install

pi install npm:@dianel/pi-hooks-rules

Restart Pi or run /reload, then open /hooks.

Commands

/hooks
/hooks list
/hooks show <id>
/hooks add
/hooks enable|disable|toggle <id>
/hooks remove <id>
/hooks test <id>
/hooks logs [id]
/hooks reload

Demo

The /hooks interface:

Hooks manager

Hook configuration

Hooks are configured as JSON and run around Pi tool calls and lifecycle events. The package ships Node-only defaults in hooks.json.

Configuration files

Scope Path Loading and priority
Bundled Installed package hooks.json Always loaded first; supplies the built-in hooks
Global ~/.pi/agent/hooks-rules.json Optional; loaded after bundled defaults
Project .pi/hooks-rules.json Optional; loaded only when the project is trusted and has a Pi trust marker

The merge order is bundled → global → project. A later definition with the same id replaces the complete earlier definition; fields are not merged. An overrides entry changes only enabled, which lets you disable a bundled hook without copying its command or matcher. Unknown override IDs and invalid JSON stop hook loading and are reported by /hooks reload.

Do not edit the installed hooks.json: package upgrades replace it. Put personal defaults in the global file and project policy in the project file.

File format

{
  "version": 1,
  "hooks": [
    {
      "id": "protect-sensitive-files",
      "event": "tool_call",
      "tools": ["write", "edit"],
      "command": "${node}",
      "args": ["${configDir}/hooks/protect-sensitive-files.mjs"],
      "timeoutMs": 5000,
      "enabled": true
    }
  ],
  "overrides": {}
}
Field Required Rules
version yes Must be the number 1.
hooks yes Array of hook definitions. IDs must be unique within one file.
overrides no Object of { "hook-id": { "enabled": boolean } }; defaults to {}.
id yes Non-empty; matches [a-z0-9._-]+ (case-insensitive).
event yes A tool event from Selecting an event or a lifecycle event from Lifecycle events.
tools tool events only Required for tool_call and tool_result: non-empty string array; matching is case-insensitive. Use "*" for every tool. Omit it for lifecycle events.
command yes One executable name or path, without newlines.
args no String array; each item is passed as one argument. Defaults to [].
timeoutMs no Integer from 100 to 120000; defaults to 5000.
enabled no Boolean; defaults to true.

Selecting an event

Event Runs Failure behavior Use it for
tool_call Before the selected tool executes A denial or non-zero exit blocks the tool call Secret checks, destructive-operation guards, policy gates
tool_result After the selected tool returns A non-zero exit marks the result as failed and adds the hook error to the result; it cannot undo the tool. The tool's structuredContent is kept, so codemode scripts still receive the original structured result Syntax checks, formatters, result validation, diagnostics

A tool_call payload has tool_name and tool_input. A tool_result payload also has tool_response and tool_error. Choose tool_call when prevention matters; choose tool_result when the tool must run before validation.

Lifecycle events

Lifecycle hooks run for every occurrence of their event; they have no tools selector. Their payload always contains hook_event_name, cwd, session_id, and transcript_path, plus the fields below. A "denial" is the same decision JSON used by tool_call hooks.

Event Extra payload fields Denial Non-zero exit Use it for
session_start reason (startup, reload, new, resume, fork), previous_session_file Ignored Warning Environment checks, session context
input prompt, source, streaming_behavior, image_count Drops the prompt and shows the reason Drops the prompt Prompt policy
user_bash tool_name: "Bash", tool_input.command, exclude_from_context Blocks the !/!! command and records the reason as its output Blocks the command Reusing command guards for user shell commands
before_agent_start message_type (user or custom); prompt for user runs, message for custom runs Ignored Warning Per-run context
agent_before_settle outcome, stop_hook_active Continues the run with the reason as the next instruction Warning; the run finishes Stop checks such as "tests still fail"
session_before_compact reason (manual, threshold, overflow), will_retry, custom_instructions Cancels compaction Warning; compaction continues Preserving full history
session_shutdown reason (quit, reload, new, resume, fork), target_session_file Ignored Warning Cleanup, notifications

On Pi 1.2+, before_agent_start also fires when pi.sendMessage(..., { triggerTurn: true }) starts a run while idle. These hooks and rules run for both trigger types. User runs receive message_type: "user" and the original prompt; custom runs receive message_type: "custom" and the starting message, with no prompt. message is the JSON-serialized Pi custom message, including customType, content, display, and any details; its content can be a string or an array of text/image parts. Older Pi user events are reported as message_type: "user" too. /hooks test uses a user-triggered sample.

Prompt-only before_agent_start scripts should check the type before using prompt:

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { message_type, prompt } = JSON.parse(raw);
if (message_type !== "user") process.exit(0);
// Apply prompt-specific logic here.
  • Context injection. A successful session_start or before_agent_start hook can print { "hookSpecificOutput": { "additionalContext": "..." } }. The text is added as a hidden context message: session_start context once on the next run (user or custom), before_agent_start context on every run.
  • Settle continuations. agent_before_settle hooks run only for runs that completed normally, never for aborted or failed ones. A denial must include a non-empty reason; it is shown in the transcript and sent to the model. One run can be continued at most 3 times; stop_hook_active is true after the first continuation so a hook can avoid repeating itself.
  • Compaction. Cancelling an overflow compaction also stops the overflow retry. A failing compaction hook never cancels compaction.
  • Shutdown. session_shutdown hooks run while Pi is exiting or replacing the session; keep them short.

Lifecycle examples

The configuration below goes in ~/.pi/agent/hooks-rules.json or a trusted project's .pi/hooks-rules.json; the scripts go in a hooks/ directory next to it. Use only the entries you need.

{
  "version": 1,
  "hooks": [
    {
      "id": "user-bash-destructive-guard",
      "event": "user_bash",
      "command": "${node}",
      "args": ["${hooksDir}/destructive-command-guard.mjs"]
    },
    {
      "id": "user-bash-secret-guard",
      "event": "user_bash",
      "command": "${node}",
      "args": ["${hooksDir}/secret-guard.mjs"]
    },
    {
      "id": "git-branch-context",
      "event": "session_start",
      "command": "${node}",
      "args": ["${configDir}/hooks/git-branch-context.mjs"]
    },
    {
      "id": "prompt-private-key-guard",
      "event": "input",
      "command": "${node}",
      "args": ["${configDir}/hooks/prompt-private-key-guard.mjs"]
    },
    {
      "id": "tests-before-finish",
      "event": "agent_before_settle",
      "command": "${node}",
      "args": ["${configDir}/hooks/tests-before-finish.mjs"],
      "timeoutMs": 120000
    },
    {
      "id": "manual-compaction-only",
      "event": "session_before_compact",
      "command": "${node}",
      "args": ["${configDir}/hooks/manual-compaction-only.mjs"]
    },
    {
      "id": "session-log",
      "event": "session_shutdown",
      "command": "${node}",
      "args": ["${configDir}/hooks/session-log.mjs", "${configDir}/session-log.txt"],
      "timeoutMs": 1000
    }
  ]
}

user_bash payloads use the Bash tool_call shape, so the bundled guards also check ! and !! commands without changes.

hooks/git-branch-context.mjs adds the current branch to the first run of each session. The same output works for before_agent_start, which adds it to every run:

import { execFileSync } from "node:child_process";

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { cwd } = JSON.parse(raw);

let branch;
try {
  branch = execFileSync("git", ["branch", "--show-current"], {
    cwd,
    encoding: "utf8",
    stdio: ["ignore", "pipe", "ignore"],
  }).trim();
} catch {
  process.exit(0); // Not a Git repository: add no context.
}
process.stdout.write(JSON.stringify({
  hookSpecificOutput: { additionalContext: `Current Git branch: ${branch || "(detached HEAD)"}` },
}));

hooks/prompt-private-key-guard.mjs drops a prompt that contains a private key:

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { prompt } = JSON.parse(raw);

if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(prompt)) {
  process.stdout.write(JSON.stringify({
    decision: "block",
    reason: "The prompt contains a private key. Remove it and refer to the key file path instead.",
  }));
}

hooks/tests-before-finish.mjs keeps the agent working while npm test fails. Its reason, including the end of the test output, becomes the model's next instruction; the plugin stops after 3 continuations. A hook that times out is reported as a failure and lets the run finish:

import { spawnSync } from "node:child_process";

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { cwd } = JSON.parse(raw);

const result = spawnSync("npm test", { cwd, shell: true, encoding: "utf8" });
if (result.status !== 0) {
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim().slice(-2000);
  process.stdout.write(JSON.stringify({
    decision: "block",
    reason: `npm test failed. Fix the failures before finishing.\n${output}`,
  }));
}

hooks/manual-compaction-only.mjs cancels automatic threshold compaction but allows /compact and overflow recovery:

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { reason } = JSON.parse(raw);

if (reason === "threshold") {
  process.stdout.write(JSON.stringify({
    decision: "block",
    reason: "Automatic compaction is disabled; run /compact when needed.",
  }));
}

hooks/session-log.mjs appends one line per session end to the file passed as its first argument:

import { appendFileSync } from "node:fs";

let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const { reason, session_id } = JSON.parse(raw);

appendFileSync(process.argv[2], `${new Date().toISOString()} ${reason} ${session_id ?? "-"}\n`);

Selecting tools

tools contains exact Pi tool names, not regular expressions. Names are normalized to lowercase, so Bash and bash match the same tool. "*" is the only wildcard and matches every tool; values such as "bash*" do not perform prefix matching.

Common tool groups are:

  • Command tools: bash, powershell, cmd
  • File tools: read, write, edit
  • Search and other tools: grep, find, ls, or the exact name of a custom Pi tool

The package does not impose a fixed allow-list. A hook can match any tool name emitted by Pi, but a narrow list is safer than "*" for policy hooks.

Command types and placeholders

Hooks are started as spawn(command, args); the command is not passed through a shell. Keep the executable in command and pass every argument as a separate args item.

Type Example Notes
Node script (recommended) "command": "${node}" ${node} resolves to the current Node executable. Put the script path in args.
Bash script "command": "bash" Use args: ["${configDir}/hooks/check.sh"]; Bash must be available.
PowerShell script "command": "powershell" Use args: ["-NoProfile", "-File", "${configDir}/hooks/check.ps1"].
Direct executable "command": "git-check" The executable must be on PATH, or use an absolute path.

If a command contains a path separator and is relative, it is resolved relative to the configuration file directory. The following placeholders are available in command and every args value:

Placeholder Resolves to
${node} Current Node executable (process.execPath)
${hooksDir} Installed package hooks/ directory
${agentDir} Pi global agent directory
${configDir} Directory containing this configuration file
${cwd} Current project working directory

For example, a project hook can be stored at .pi/hooks/check.mjs and invoked with ${configDir}/hooks/check.mjs; a global hook uses the same form relative to ~/.pi/agent. Shell operators such as &&, |, and > are not interpreted unless you explicitly invoke a shell.

Hook input and output

Each hook receives one JSON document on stdin. Typical input looks like this:

{
  "tool_name": "Write",
  "tool_input": {
    "path": "src/app.ts",
    "content": "..."
  },
  "tool_response": [],
  "tool_error": false
}

tool_response and tool_error are present for tool_result hooks. For convenience, the package also exposes path as file_path; for Edit, its edits[].oldText and edits[].newText are joined as old_string and new_string. The original tool input remains available.

A hook should write diagnostics to stderr and, when returning a decision, write JSON to stdout. The standard blocking response for a tool_call hook is:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Explain the policy violation"
  },
  "systemMessage": "Short message for the user"
}

The plugin also accepts the legacy { "decision": "block", "reason": "..." } shape. A valid denial blocks even with exit code 0 or an empty reason; any non-zero exit blocks tool_call hooks. A tool_result hook with a non-zero exit adds an error message to the returned result. Hook output is allowed to contain diagnostic lines before its final JSON result.

Built-in hooks

ID Event Tools Default timeout Purpose
secret-guard tool_call bash, powershell, write, edit 5s Blocks recognized API keys, tokens, private keys, and credential URLs.
destructive-command-guard tool_call bash, powershell, cmd 5s Best-effort guard for recognized destructive Git operations and unsafe recursive deletion; allows recognized disposable targets.
syntax-format-check tool_result write, edit 10s Checks JavaScript, JSON, shell, and Python syntax; runs local Prettier when available.

The destructive guard is a best-effort accident guard, not a security boundary. It blocks only recognized destructive Git operations and explicit recursive deletion whose targets are not known disposable artifacts. Unknown commands, and dynamic commands or arguments that are not part of a recognized recursive deletion, pass through; unsupported shell syntax, external scripts, and parser uncertainty also pass through. The Git checks cover common forms such as forced push, reset --hard, forced clean, checkout/restore of ., branch deletion, and stash drop/clear; Git preview forms such as clean -n and push --dry-run are allowed. Literal shell wrappers and command substitutions are inspected when their contents can be read. PowerShell -WhatIf and non-recursive deletion are not blocked. Cmd control-flow bodies are not inspected; when a Bash command declares multiple pending heredocs, the guard skips the remaining source instead of interpreting it.

Disable or re-enable a bundled hook with a minimal override:

{
  "version": 1,
  "hooks": [],
  "overrides": {
    "secret-guard": { "enabled": false },
    "syntax-format-check": { "enabled": true }
  }
}

To change a bundled hook's command, event, or tools, define a complete replacement with the same ID at a higher-priority scope. Prefer a new ID for custom behavior so package upgrades remain safe.

Standard workflow

  1. Choose the scope. Use the global file for personal policy; use .pi/hooks-rules.json for a trusted project policy.
  2. Create the file. Keep version: 1, use a unique ID, and start with the narrowest event and tool list.
  3. Choose the executable. Prefer ${node} plus a script path; pass arguments separately and set an explicit timeout for slow checks.
  4. Implement the hook. Read one JSON document from stdin, write useful diagnostics to stderr, return a decision JSON only when needed, and exit non-zero on failure.
  5. Reload and inspect. Run /hooks reload, then /hooks list and /hooks show <id>.
  6. Test both paths. Run /hooks test <id> and exercise an allowed case and a blocked/failing case appropriate to the event.
  7. Review execution. Use /hooks logs [id] to inspect status, exit code, and duration. Commit project configuration and hook scripts when the policy is shared.

Rules

The package does not ship personal rules. Add Markdown files to either location:

  • Global: ~/.pi/agent/rules/
  • Project: .pi/rules/

Rules without front matter load at the start of each agent turn. On Pi 1.0 they are added as the auto_loaded_rules system prompt section, so the rest of Pi's structured prompt and other extensions' prompt changes stay intact; hosts without prompt sections receive them appended to the system prompt. If an extension that runs earlier replaces the whole system prompt (returns systemPrompt), the rules are appended to that replacement instead. An extension that replaces the whole prompt after this one runs overrides the rules for that turn; Pi provides no way to combine the two, so load such extensions before this package. Path-scoped rules are not loaded at session startup: they are added after Pi reads, writes, or edits a matching path. Matching checks the path relative to the working directory and its ancestors, so a rule such as openspec/** also works when Pi starts inside the openspec/ directory. Shell commands do not trigger path-scoped injection; the path must be accessed through read, write, or edit. Unmatched paths do not inject a rule:

---
paths:
  - "xxx/**"
  - "**/xxx/**"
---

Project hooks and rules load only when the project also contains a Pi trust-triggering resource and the project is trusted. The check uses Pi's own trust detection, so it follows the running Pi version; on Pi 1.0 the resources are .pi/settings.json, .pi/mcp.json, .pi/extensions/, .pi/skills/, .pi/prompts/, .pi/themes/, .pi/SYSTEM.md, .pi/APPEND_SYSTEM.md, or project/ancestor .agents/skills/. This prevents a repository containing only hidden hook/rule files from bypassing Pi's trust prompt.

Security

Pi extensions and configured hook commands execute with the user's permissions. Review package source and project configuration before granting trust or enabling third-party commands.