Skip to content

Latest commit

 

History

History
590 lines (421 loc) · 21.7 KB

File metadata and controls

590 lines (421 loc) · 21.7 KB

Hooks reference

This document describes the current pi-yaml-hooks behavior as implemented in this repository.

Contents

Hook file shape

A hook file must parse to an object with a top-level hooks: array. It may also define an optional top-level imports: array.

imports:
  - ./hooks.d
  - my-shared-hooks

hooks:
  - id: example
    event: session.idle
    scope: all
    runIn: current
    conditions:
      - matchesCodeFiles
    actions:
      - notify: "Done"

Each action entry must define exactly one action key.

/hooks command autocomplete

On PI versions that expose ctx.ui.addAutocompleteProvider, pi-yaml-hooks registers a guarded autocomplete provider for the built-in /hooks-* commands. The provider is capability-detected at runtime, so older supported PI versions continue to load without this UI feature.

Autocomplete suggestions are deterministic and intentionally lightweight: command names are static; event names use the supported event list; config paths and the current log path are resolved when the provider builds suggestions. Hook ID suggestions are loaded lazily from the current global/project snapshot and memoized by snapshot signature, so edits to root or imported hook files refresh suggestions after the next snapshot change.

Useful completions include:

  • /hooks-status, /hooks-validate, /hooks-trust, /hooks-reload, /hooks-tail-log
  • loaded hook IDs such as audit-write
  • event names such as session.idle, tool.before.bash, and tool.after.write
  • global and project hook config paths
  • the resolved log path and a ready-to-run tail -F command produced by /hooks-tail-log

Agent-start awareness

At agent start, pi-yaml-hooks appends a short hook-awareness note to the system prompt. It summarizes the loaded hook count, current project trust state, and the main PI-specific limitations that matter while authoring or debugging hooks.

This prompt injection is part of the current compatibility surface for the documented peer range ^0.74.0 on the @earendil-works scope.

Set PI_YAML_HOOKS_PROMPT_AWARENESS=0 to disable this prompt injection.

Optional user_bash interception

Set PI_YAML_HOOKS_ENABLE_USER_BASH=1 to run human ! / !! shell commands through tool.before.bash hooks before PI executes them.

  • this mode is opt-in and disabled by default
  • it applies only pre-bash safety hooks
  • it does not synthesize tool.after.* or file.changed for user_bash
  • headless confirm behavior stays fail-closed

Enabling this feature expands the trust surface: hooks in trusted projects can observe, block, and potentially exfiltrate the typed command text. See What trust grants when user_bash is enabled in the main README for a full breakdown of the risks.

imports

imports composes hook files before the current file's own hooks are merged.

  • imports load before local hooks
  • import order is preserved
  • directory imports expand files in lexical order, but only *.yaml / *.yml entries are loaded; dotfiles (e.g. .DS_Store) and other extensions are skipped
  • package imports (bare specifiers like hook-pack) use Node module resolution from the importing file, but are disabled by default; set PI_YAML_HOOKS_ALLOW_PACKAGE_IMPORTS=1 to opt in
  • imports declared inside the global hooks.yaml are refused by default; set PI_YAML_HOOKS_ALLOW_GLOBAL_IMPORTS=1 to opt in
  • imports declared inside a project hooks.yaml whose target (after symlink resolution) falls outside the project's trust anchor are refused by default; set PI_YAML_HOOKS_ALLOW_PROJECT_IMPORTS_OUTSIDE_TRUST_ANCHOR=1 to opt in
  • duplicate imports are skipped by canonical path
  • cycles and missing imports produce load errors
  • import recursion is bounded at depth 32; deeper chains fail with invalid_imports
  • imported files inherit the importing root scope (global or project)

Trust expansion

Trust on PI is anchored at the repo or worktree anchor, not at every imported file. Once that anchor is trusted, all of the project root's imports: are loaded transitively under that same trust decision. Three safety rails keep that expansion narrow:

  1. The global hooks file (which always loads) cannot pull in additional files unless PI_YAML_HOOKS_ALLOW_GLOBAL_IMPORTS=1 is set, so a global hook cannot silently extend its own footprint.
  2. Bare-specifier imports that resolve through node_modules are gated behind PI_YAML_HOOKS_ALLOW_PACKAGE_IMPORTS=1, so an arbitrary npm dependency cannot register hooks just by being installed.
  3. Project imports cannot escape the project's trust anchor (its repo or worktree anchor, or the discovered project root). The check follows symlinks, so a link that lives inside the project but points outside is still refused. Set PI_YAML_HOOKS_ALLOW_PROJECT_IMPORTS_OUTSIDE_TRUST_ANCHOR=1 to opt in.

All three gates fail closed with a [PIYAMLHOOKS] error message so operators see exactly which import was refused and which env var to set. When an import bypass env var is enabled, pi-yaml-hooks emits a one-time warning naming the affected trust boundary.

Load order and precedence

pi-yaml-hooks discovers at most:

  • one global root hook file
  • one trusted project root hook file

The order is always:

  1. global imports, then global root hooks
  2. project imports, then project root hooks

Without overrides, hooks from both files stay active.

Hook fields

Field Required Type Exact behavior
id no string Stable hook name used by later-file overrides. Strongly recommended for any hook you may replace or disable later.
event yes string One of the supported hook events listed below.
actions yes array Non-empty list of action objects. Actions run in order.
action no stop Accepted only on tool.before.* hooks. On PI it does not add much beyond the normal pre-tool block behavior.
conditions no array Additional filters. All conditions must pass.
scope no all, main, child Filters which session lineage the hook itself runs in. Defaults to all.
runIn no current, main Compatibility field for action targeting. Defaults to current. On PI, non-bash actions (i.e. tool, notify, confirm, setStatus) with runIn: main are rejected at load time and the hook is dropped. See the PI-specific notes below before relying on it.
async no boolean or object Queues the hook for background execution. true keeps serialized per-event behavior. { group?, concurrency? } lets hooks share a named async queue with optional bounded concurrency. Only allowed on non-tool.before hooks, not on session.idle, and only for bash-only hooks.
override no string Replaces a previously loaded hook with the given id.
disable no boolean When used with override, removes the targeted earlier hook instead of replacing it.

Supported events

Tool events

Event When it fires Can block?
tool.before.* Before every tool call yes
tool.before.<name> Before a specific tool call yes
tool.after.* After every tool call no
tool.after.<name> After a specific tool call no

On stock PI, built-in tool names are:

  • bash
  • read
  • edit
  • write
  • grep
  • find
  • ls

Custom tool names can also match if the host emits them.

Session and file events

Event When it fires Notes
file.changed After recognized file mutations Synthesized by pi-yaml-hooks; see below for exact sources
session.created On PI startup or a genuinely new session Does not fire on resume, reload, or fork re-entry
session.idle When the agent loop ends and there are no pending messages Includes accumulated file changes since the last successful idle dispatch
session.deleted On shutdown and before session switches Best-effort and lossy by design; PI may provide a reason (quit, reload, new, resume, or fork), which is forwarded on the envelope when available

Exact file.changed behavior

file.changed is synthesized from the tool result payload.

On stock PI, pi-yaml-hooks can synthesize it from:

  • write
  • edit
  • bash, but only when the command text looks like one of these operations:
    • rm or git rm
    • mv or git mv
    • cp or git cp
    • touch
    • mkdir

For direct write and edit tool calls, pi-yaml-hooks reports the target path as a modify change. Recognized touch, cp, and mkdir shell commands are reported as create changes for their target paths.

If you install custom tools named multiedit, patch, or apply_patch, the runtime can also synthesize file.changed from them.

Conditions

Conditions are ANDed together. If any condition fails, the hook does not run.

matchesCodeFiles

conditions:
  - matchesCodeFiles

This passes when at least one known code or config file extension is present in the event's file list.

Practical note:

  • it is most useful on file.changed, tool.after.<mutation>, and session.idle
  • on events with no file context, it will not match

Path conditions are accepted on these events:

  • file.changed
  • session.idle
  • tool.after.*
  • tool.after.<name>

For tool.after.* and tool.after.<name>, path conditions only match when pi-yaml-hooks can infer changed paths from the tool result. Stock PI path context is available for write, edit, and recognized mutation-shaped bash commands. Non-mutating tools such as read, grep, find, and ls have no changed paths, so path conditions on those events do not match.

matchesAnyPath

conditions:
  - matchesAnyPath:
      - "src/**/*.ts"
      - "package.json"

This passes when any changed path matches any listed glob.

matchesAllPaths

conditions:
  - matchesAllPaths:
      - "src/**"

This passes when every changed path matches at least one glob in the list.

Important detail: this is an allowlist over paths, not a per-path intersection of all patterns.

If you want an intersection such as "all changed paths are under src/ and all are *.ts", write two separate conditions:

conditions:
  - matchesAllPaths: "src/**"
  - matchesAllPaths: "**/*.ts"

Path normalization rules

For path conditions:

  • paths inside the current project are matched as project-relative paths like src/index.ts
  • absolute paths outside the project stay absolute
  • path separators are normalized to forward slashes

Actions

bash

Short form:

actions:
  - bash: "echo hi"

Long form:

actions:
  - bash:
      command: "./script.sh"
      timeout: 15000

Exact behavior:

  • the command runs through bash -c
  • default timeout is 60000 ms
  • hook context JSON is written to the process stdin
  • stdout and stderr are captured up to PI_YAML_HOOKS_MAX_OUTPUT_BYTES bytes total per stream buffer, default 1048576
  • on tool.before.*, exit code 2 blocks the tool call
  • exit code 124 indicates the bash process exceeded its timeout; 127 indicates a spawn error (e.g. bash binary missing); both are logged as hook failures but do not block
  • other non-zero exits are logged as hook failures but do not block

tool

actions:
  - tool:
      name: read
      args:
        path: README.md

Exact PI behavior:

  • this does not imperatively execute the tool
  • it sends a follow-up message into the current PI session saying to use that tool with those arguments
  • cross-session targeting is not available on PI

notify

Short form:

actions:
  - notify: "Done"

Long form:

actions:
  - notify:
      text: "Build finished"
      level: success

Levels:

  • info
  • success
  • warning
  • error

On PI, success is mapped to info because the UI API does not expose a separate success level.

confirm

actions:
  - confirm:
      title: "Run command?"
      message: "Continue?"

Exact behavior:

  • message is required
  • title is optional; PI uses Confirm when omitted
  • if the user rejects on a tool.before.* hook, the tool call is blocked
  • on non-blocking events, rejection does not abort the event and later actions can still run
  • in headless PI, confirm denies by default unless PI_YAML_HOOKS_CONFIRM_AUTO_APPROVE=1

setStatus

Short form:

actions:
  - setStatus: "Watching changes"

Long form:

actions:
  - setStatus:
      text: "Working"

Exact behavior:

  • this updates a PI status-bar slot when a UI surface exists
  • status entries are keyed per hook as pi-yaml-hooks:<hook-id-or-fallback>@<source-file>
  • when id is present, it contributes to a stable per-hook key without colliding with the same id reused in another file
  • when id is absent, pi-yaml-hooks falls back to a deterministic source-location key so hooks in the same file do not collide
  • the parser currently requires a non-empty status string

command

actions:
  - command: "/something"

This is rejected at load time on PI. The hook is dropped from the active hook map.

scope versus runIn

These fields do different things.

scope

scope filters where the hook itself is allowed to fire.

scope: all
scope: main
scope: child

Exact behavior:

  • all means every session
  • main means only the root session in the current lineage
  • child means only non-root sessions

runIn

runIn is a compatibility field intended to target another session.

runIn: current
runIn: main

Current PI caveats:

  • runIn: main on non-bash actions is rejected at load time
  • tool: actions still go to the current session because PI only exposes current-session prompt injection
  • bash actions currently run with the current event context; do not rely on runIn to change the bash process session context

Practical guidance: prefer scope for real routing decisions and treat runIn as compatibility metadata unless you have verified the exact behavior you want.

Async hooks

- event: tool.after.write
  async: true
  actions:
    - bash: "./slow-hook.sh"

Exact rules:

  • async: true is allowed only for non-tool.before hooks
  • async: true is not allowed on session.idle
  • async: true combined with action: stop is rejected at load time; the async queue runs after the dispatch loop has returned, so a stop directive could not block anything
  • async hooks must contain only bash actions; command, tool, notify, confirm, and setStatus actions are rejected at load time because they either have no timeout, require the live UI session, or block the agent turn, all of which would stall or misroute the async queue
  • async: true keeps the legacy serialized event + session queue
  • async: { group: <name> } makes hooks in the same session share a named queue
  • async: { group: <name>, concurrency: N } allows up to N hooks from that named queue to run at once; omit it to keep serialized behavior
  • concurrency requires group, and every hook in the same group must use the same concurrency value

Use async for slow post-processing that should not block the agent turn.

Overrides and disable behavior

Overrides target hooks that were already loaded earlier.

That means the main supported pattern is:

  • define a hook in the global file
  • replace or disable it in the project file

Replace an earlier hook

Global file:

hooks:
  - id: idle-message
    event: session.idle
    actions:
      - notify: "Global idle"

Project file:

hooks:
  - override: idle-message
    event: session.idle
    actions:
      - notify: "Project idle"

Disable an earlier hook

hooks:
  - override: idle-message
    disable: true

Important detail: overrides resolve against hooks loaded from earlier files. Same-file override entries are not a reliable authoring pattern.

Bash hook stdin contract

Every bash action receives JSON on stdin.

Example shape for a file.changed hook:

{
  "session_id": "session-123",
  "event": "file.changed",
  "cwd": "/Users/me/project",
  "files": ["src/index.ts"],
  "changes": [
    {"operation": "modify", "path": "src/index.ts"}
  ],
  "tool_name": "edit",
  "tool_args": {
    "path": "src/index.ts"
  }
}

Fields are omitted when unavailable.

tool_args is shallow-cloned with sensitive keys (password, token, api_key, secret, authorization, auth, private_key, bearer) redacted before serialization, and the JSON encoding is capped at 64 KiB. When the cap is exceeded, tool_args collapses to a placeholder of the form:

{
  "_pi_hooks_tool_args_truncated": true,
  "_pi_hooks_tool_args_original_byte_length": 123456,
  "_pi_hooks_tool_args_max_byte_length": 65536,
  "note": "<truncated>"
}

If the entire stdin payload still exceeds PI_YAML_HOOKS_MAX_STDIN_BYTES (default 262144, 256 KiB), large fields are dropped or replaced and a _pi_hooks_truncated: true marker is added at the top level.

Change objects use one of these shapes:

{"operation": "create", "path": "..."}
{"operation": "modify", "path": "..."}
{"operation": "delete", "path": "..."}
{"operation": "rename", "fromPath": "old", "toPath": "new"}

Bash environment variables

These environment variables are injected into every bash hook:

Variable Legacy alias Meaning
PI_PROJECT_DIR OPENCODE_PROJECT_DIR Current project directory
PI_WORKTREE_DIR OPENCODE_WORKTREE_DIR Git worktree root when resolvable
PI_SESSION_ID OPENCODE_SESSION_ID Current session id
PI_GIT_COMMON_DIR OPENCODE_GIT_COMMON_DIR Git common dir for worktrees when resolvable

The process working directory is the current project directory.

By default, bash hooks inherit the full PI process environment for backwards compatibility. Set PI_YAML_HOOKS_ENV_ALLOWLIST to a comma-separated list to opt into filtered inheritance. In allowlist mode, only named inherited variables are passed; PATH and HOME are not special and must be listed explicitly if a hook needs them. The PI/OPENCODE context variables above are always injected.

Async hook lanes are bounded: each lane keeps at most PI_YAML_HOOKS_ASYNC_MAX_PENDING pending runs (default 1000) and drops additional queued runs with a warning. Set PI_YAML_HOOKS_ASYNC_WATCHDOG_MS to a positive millisecond value to log a watchdog_timeout warning for a still-running async hook; it does not cancel the hook, and the lane remains occupied until it settles.

PI compatibility smoke-check checklist

Use the repeatable runtime checklist in maintaining.md for real PI verification before widening SDK support or changing session, UI, prompt, command, or tool-event behavior. The local harness lives in scripts/smoke/ and creates an evidence file for future release updates.

For a real PI run in the documented peer range, verify these compatibility-sensitive surfaces:

  • before_agent_start appends the hook-awareness note when PI_YAML_HOOKS_PROMPT_AWARENESS is not 0
  • headless mode still mentions degraded UI actions in that prompt note
  • /hooks-status, /hooks-validate, and /hooks-reload work and emit structured diagnostics when PI supports custom messages
  • tool.before.bash, tool.after.read, tool.after.write, and synthesized file.changed events reach smoke hooks
  • tool: actions produce a follow-up prompt in the current PI session, not imperative tool execution
  • PI_YAML_HOOKS_ENABLE_USER_BASH=1 routes human ! / !! commands through tool.before.bash only
  • /new triggers lossy cleanup via session.deleted and a fresh session.created
  • /resume and /fork do not re-fire session.created for an existing session re-entry
  • /new, /resume, /fork, and /quit do not double-run session.deleted cleanup when PI emits both session_before_switch and session_shutdown
  • Future PI minor lines (e.g. 0.75.x) remain gated until npm run compat:sdk-matrix:future and the runtime smoke both pass, including the no-builtin-tools check

Unsupported and advisory cases

Case Behavior
command: action hard load error; hook is dropped
runIn: main with tool:, notify:, confirm:, or setStatus: hard load error; hook is dropped
tool.before.multiedit, tool.before.patch, tool.before.apply_patch without matching custom tools advisory only; they will not fire on stock PI
session.deleted supported but lossy
confirm: in headless mode deny by default

Debug logging

When you start PI with:

PI_YAML_HOOKS_DEBUG=1 pi

pi-yaml-hooks writes persistent NDJSON logs to:

~/.pi/agent/logs/pi-yaml-hooks.ndjson

For the full environment-variable reference (debug logging, log level, log file, stderr mirroring, and other knobs), see setup.md. The easiest way to inspect the log is:

./scripts/tail-hook-log.sh

For focused debugging, filter by hook or event:

./scripts/tail-hook-log.sh --hook load-writer-skill-when-markdown-changes
./scripts/tail-hook-log.sh --event session.idle

Best next steps