@fyeeme/pi-hooks 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +61 -19
  3. package/index.ts +18 -6
  4. package/package.json +52 -52
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.6] - 2026-10-01
11
+
12
+ ### Changed
13
+
14
+ - Peer dependency floor raised to `@earendil-works/pi-coding-agent >= 0.99.0`; dev toolchain pinned to 0.99.2 (typecheck and tests pass against 0.99.2 unchanged).
15
+
16
+ ### Breaking Changes
17
+
18
+ - The hooks config file is now `hook.json` (was `hooks.json`), in all three locations: `~/.pi/agent/hook.json` (user-global), `<project>/.pi/hook.json`, and the home-directory fallback `~/.pi/hook.json`. Rename your existing config file; `PI_HOOKS_CONFIG` is unaffected.
19
+
20
+ ### Fixed
21
+
22
+ - README: pi registers MCP tools as `mcp__<server>__<tool>` (same as Claude Code), not `<server>_<tool>` — fixed the matcher guidance and the Serena example config (`serena_.*` → `mcp__serena__.*`); a wrong matcher silently never matched, disabling `auto-approve`.
23
+
24
+ ### Added
25
+
26
+ - Ship `pi.yml`, a pi-adapted Serena context (based on upstream `claude-code.yml`): symbol-first steering via `serena start-mcp-server --context pi`, plus trimming of the six Serena tools that duplicate pi built-ins. See README "Recommended: steer at the context level, not only via hooks".
27
+ - Document the `denyAsContext` per-hook flag (shipped in 1.0.4) in the README protocol section.
28
+
29
+ ## [1.0.5] - 2026-09-16
30
+
31
+ ### Fixed
32
+
33
+ - A bare exit code 2 with no parseable JSON on stdout is no longer treated as a deny: it is logged as a suspected broken hook command and the tool call proceeds. A crash or misconfiguration (e.g. `python3` exiting 2 for "can't open file") previously hard-blocked every tool call for the rest of the session. Structured JSON denies (`permissionDecision: "deny"`, or exit 2 carrying a deny payload) are unchanged.
34
+
8
35
  ## [1.0.4] - 2026-09-12
9
36
 
10
37
  ### Added
package/README.md CHANGED
@@ -5,12 +5,14 @@ A Claude Code-compatible hooks runner for [pi](https://pi.dev). Reads your hooks
5
5
  **Config resolution order** (first file that defines at least one hook wins):
6
6
 
7
7
  1. `PI_HOOKS_CONFIG` env var (exclusive single source when set)
8
- 2. `~/.pi/agent/hooks.json` — user-global, **top priority**
9
- 3. `<project>/.pi/hooks.json` — project-local fallback
10
- 4. `~/.pi/hooks.json` — legacy home location fallback
8
+ 2. `~/.pi/agent/hook.json` — user-global, **top priority**
9
+ 3. `<project>/.pi/hook.json` — project-local fallback
10
+ 4. `~/.pi/hook.json` — home-directory fallback
11
11
 
12
12
  A file that parses but defines no hooks (e.g. `{}`, or a leftover file in an older schema) does not shadow lower-priority files — the chain falls through. Note the chain is winner-take-all: configs are never merged.
13
13
 
14
+ > **Breaking change from 1.0.5:** the config file was renamed `hooks.json` → `hook.json` (all three locations). Rename your existing file; `PI_HOOKS_CONFIG` is unaffected.
15
+
14
16
  ## Install
15
17
 
16
18
  Requires the [pi](https://pi.dev) CLI.
@@ -48,7 +50,7 @@ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of sourc
48
50
 
49
51
  ## Configuration
50
52
 
51
- Create `~/.pi/agent/hooks.json` (user-global, highest file priority — runs in every project) or `.pi/hooks.json` in a project root (used only when no global config defines hooks):
53
+ Create `~/.pi/agent/hook.json` (user-global, highest file priority — runs in every project) or `.pi/hook.json` in a project root (used only when no global config defines hooks):
52
54
 
53
55
  ```json
54
56
  {
@@ -70,12 +72,13 @@ Create `~/.pi/agent/hooks.json` (user-global, highest file priority — runs in
70
72
  "hooks": [
71
73
  {
72
74
  "type": "command",
73
- "command": "serena-hooks remind --client=claude-code"
75
+ "command": "serena-hooks remind --client=claude-code",
76
+ "denyAsContext": true
74
77
  }
75
78
  ]
76
79
  },
77
80
  {
78
- "matcher": "plugin_serena_serena_.*",
81
+ "matcher": "mcp__serena__.*",
79
82
  "hooks": [
80
83
  {
81
84
  "type": "command",
@@ -101,9 +104,9 @@ Create `~/.pi/agent/hooks.json` (user-global, highest file priority — runs in
101
104
 
102
105
  ## Event Mapping
103
106
 
104
- | hooks.json event | pi event | Notes |
107
+ | hook.json event | pi event | Notes |
105
108
  |---|---|---|
106
- | `SessionStart` | `session_start` | Runs on session start/reload/switch. `matcher` matches the source (`startup`/`resume`/`clear`/...); empty matcher matches all. `additionalContext` is injected into the first user message via the `context` event. |
109
+ | `SessionStart` | `session_start` | Runs when a session starts, resumes, forks, or switches — **not** on `reload` (a runtime rebind must not re-run hooks mid-session). `matcher` matches the mapped Claude Code source: `startup` (default), `resume` (resume + fork), `clear` (new session); empty matcher matches all. `additionalContext` is injected into the first user message via the `context` event. |
107
110
  | `PreToolUse` | `tool_call` | Runs before each tool. `matcher` is a **regex** against the pi tool name. `additionalContext` is injected before the next LLM call. `permissionDecision: "deny"` or exit code 2 blocks the tool (`terminate: true`; in a single-tool / all-terminating batch this also skips the follow-up LLM call — requires pi >= 0.84.1). |
108
111
  | `Stop` | `session_shutdown` | Runs on exit/reload/session switch. Cleanup only — `decision: "block"` is **not** honored (pi cannot prevent exit). Stop hooks are awaited, so a slow hook delays exit up to its `timeout` (default 60s); keep them fast. |
109
112
 
@@ -114,9 +117,9 @@ Create `~/.pi/agent/hooks.json` (user-global, highest file priority — runs in
114
117
  - `""` or `"*"` — match all
115
118
  - `"Edit|Write"` — match either
116
119
  - `"Notebook.*"` — prefix match
117
- - `"plugin_serena_serena_.*"` — all serena tools
120
+ - `"mcp__serena__.*"` — all tools of the `serena` MCP server
118
121
 
119
- > **Breaking change from 1.0.x:** matchers were previously interpreted as **globs** (`*`/`?`). If you upgraded, convert patterns like `plugin_serena_serena_*` → `plugin_serena_serena_.*`. Invalid regex matches nothing and warns once at first use (never throws).
122
+ > **Breaking change from 1.0.x:** matchers were previously interpreted as **globs** (`*`/`?`). If you upgraded, convert patterns like `mcp__serena__*` → `mcp__serena__.*`. Invalid regex matches nothing and warns once per pattern (never throws).
120
123
 
121
124
  ## Protocol
122
125
 
@@ -136,21 +139,25 @@ Commands may return JSON on stdout, or control flow via exit codes:
136
139
  ```
137
140
 
138
141
  - exit code **0** with `additionalContext` → context injected.
139
- - exit code **2** (PreToolUse) → tool call blocked (`terminate: true`); reason fed to the model. `terminate` skips the follow-up LLM call only when the denied call is in an all-terminating batch (pi >= 0.84.1, #7715); in a multi-tool batch the block always applies but the agent may continue.
142
+ - exit code **2** (PreToolUse) **with a JSON deny payload** (`permissionDecision: "deny"`) → tool call blocked (`terminate: true`); reason fed to the model. `terminate` skips the follow-up LLM call only when the denied call is in an all-terminating batch (pi >= 0.84.1, #7715); in a multi-tool batch the block always applies but the agent may continue.
143
+ - exit code **2** without parseable JSON (e.g. a broken command like `python3` failing to open a script) → treated as a crash, not a deny: warning on stderr, tool call proceeds. This keeps a misconfigured hook from hard-blocking every tool call.
140
144
  - exit code **2** (Stop) → ignored (pi cannot block exit).
141
145
  - other non-zero → logged, execution continues.
142
146
  - non-JSON stdout → logged as a warning, ignored.
143
- - each hook may set `"timeout"` (seconds, default 60); matching hooks run in **parallel**.
147
+ - each hook may set `"timeout"` (seconds, default 60); matching hooks run in **parallel** (capped at 8 concurrent).
148
+ - pressing Esc mid-turn kills running hook processes (SIGTERM → SIGKILL escalation, same as the timeout path); an already-aborted turn does not spawn hooks at all.
149
+ - safety caps: hook stdout is capped at 10 MB (the hook is killed beyond that); injected `additionalContext` is capped at 50 KB / 2000 lines with a truncation notice.
150
+ - each hook may set `"denyAsContext": true` — demote a deny (`permissionDecision: "deny"`, or exit 2 carrying a JSON deny payload) to `additionalContext`: the tool call proceeds and the nudge text is injected before the next LLM call (appended to the last user message) instead of blocking. Intended for nudge-style hooks like `serena-hooks remind`, where hard-blocking a read burst stops the agent dead. Injected text: the hook's `additionalContext` if present, otherwise the deny reason.
144
151
 
145
152
  The `additionalContext` is injected into the pi conversation (appended to the last user message, never as a new turn).
146
153
 
147
154
  ## MCP Tool Names
148
155
 
149
- Pi names MCP tools as `<serverName>_<toolName>` (not `mcp__server__tool` like Claude Code), so target them with regex like `plugin_serena_serena_.*`. Check your actual tool names with `/mcp` in pi to set the correct `matcher`.
156
+ Pi names MCP tools `mcp__<server>__<tool>` (same scheme as Claude Code), so target them with regex like `mcp__serena__.*`. Check your actual tool names with `/mcp` in pi to set the correct `matcher`.
150
157
 
151
158
  ## Using pi-hooks with Serena
152
159
 
153
- [Serena](https://github.com/oraios/serena) ships a `serena-hooks` CLI (Claude Code compatible) whose four subcommands map cleanly onto pi-hooks events. With Serena's MCP server running in pi (confirm with `/mcp` — you should see a `serena` server), drop this into `~/.pi/agent/hooks.json` (global) or the project's `.pi/hooks.json`:
160
+ [Serena](https://github.com/oraios/serena) ships a `serena-hooks` CLI (Claude Code compatible) whose four subcommands map cleanly onto pi-hooks events. With Serena's MCP server running in pi (confirm with `/mcp` — you should see a `serena` server), drop this into `~/.pi/agent/hook.json` (global) or the project's `.pi/hook.json`:
154
161
 
155
162
  ```json
156
163
  {
@@ -164,10 +171,16 @@ Pi names MCP tools as `<serverName>_<toolName>` (not `mcp__server__tool` like Cl
164
171
  "PreToolUse": [
165
172
  {
166
173
  "matcher": "",
167
- "hooks": [{ "type": "command", "command": "serena-hooks remind --client=claude-code" }]
174
+ "hooks": [
175
+ {
176
+ "type": "command",
177
+ "command": "serena-hooks remind --client=claude-code",
178
+ "denyAsContext": true
179
+ }
180
+ ]
168
181
  },
169
182
  {
170
- "matcher": "serena_.*",
183
+ "matcher": "mcp__serena__.*",
171
184
  "hooks": [{ "type": "command", "command": "serena-hooks auto-approve --client=claude-code" }]
172
185
  }
173
186
  ],
@@ -186,16 +199,45 @@ What each hook does:
186
199
  | Event | Command | Role |
187
200
  |---|---|---|
188
201
  | `SessionStart` | `activate` | Prompts the agent to activate the project and read Serena's instructions at session start. |
189
- | `PreToolUse` (`""`) | `remind` | Nudges the agent to prefer Serena's symbolic tools over raw `read`/`grep`. Runs before every tool call. |
190
- | `PreToolUse` (`serena_.*`) | `auto-approve` | Auto-approves Serena tool calls while the client is in a permissive permission mode. |
202
+ | `PreToolUse` (`""`) | `remind` | Nudges the agent to prefer Serena's symbolic tools over raw `read`/`grep`. Runs before every tool call. Set `denyAsContext: true` so its deny becomes a context nudge instead of a hard block (recommended). |
203
+ | `PreToolUse` (`mcp__serena__.*`) | `auto-approve` | Auto-approves Serena tool calls while the client is in a permissive permission mode. |
191
204
  | `Stop` | `cleanup` | Clears per-session hook state on exit. |
192
205
 
193
- **Get the matcher prefix right.** pi exposes an MCP server's tools as `<server>_<tool>`. With Serena registered as the `serena` MCP server (the default), tools are named `serena_find_symbol`, `serena_read_file`, … → use `serena_.*`. If you installed Serena as a pi **plugin** instead, the names are `plugin_serena_serena_*` → use `plugin_serena_serena_.*`. Run `/mcp` in pi to confirm your exact prefix.
206
+ **Get the matcher prefix right.** pi registers an MCP server's tools as `mcp__<server>__<tool>`. With Serena registered as the `serena` MCP server in `mcp.json` (the default), tools are named `mcp__serena__find_symbol`, `mcp__serena__find_referencing_symbols`, … → use `mcp__serena__.*`. Run `/mcp` in pi to confirm your exact prefix.
194
207
 
195
208
  > ⚠️ **`auto-approve` is currently inert under pi-hooks.** `serena-hooks auto-approve` only emits its approval when stdin reports a permissive `permission_mode` (`acceptEdits` or `auto`), but pi-hooks always sends `permission_mode: "default"` today. The hook still runs but stays silent, so pi's own permission flow applies. `activate`, `remind`, and `cleanup` are unaffected. This will resolve once pi-hooks forwards the real permission mode.
196
209
 
197
210
  `--client=claude-code` is correct for pi: pi-hooks speaks the Claude Code hooks protocol, so Serena treats pi as a Claude Code client.
198
211
 
212
+ ### Recommended: steer at the context level, not only via hooks
213
+
214
+ `serena-hooks remind` is a **fallback**, not the main steering mechanism. Serena gates it behind a burst counter (8 consecutive `read`/`grep` calls in the currently installed release line, 3 in upstream master) plus a 120-second silence window after every nudge — so most `read` calls legitimately produce no output. The primary layer is Serena's **context system**: `serena start-mcp-server --context <name>` injects a behavior-constraining prompt and trims tools that duplicate the host agent's built-ins.
215
+
216
+ This package ships [`pi.yml`](./pi.yml) — a pi-adapted context based on Serena's own `claude-code.yml`: symbol-first read/edit rules written against pi's tool names (`read`/`bash`/`edit`), and `excluded_tools` trimming the six Serena tools that duplicate pi built-ins (`read_file`, `execute_shell_command`, `find_file`, `list_dir`, `search_for_pattern`, `create_text_file`). Install and wire it up:
217
+
218
+ ```bash
219
+ cp pi.yml ~/.serena/contexts/ # user contexts dir; a same-named context overrides the built-ins
220
+ ```
221
+
222
+ ```jsonc
223
+ // ~/.pi/agent/mcp.json
224
+ "serena": {
225
+ "type": "stdio",
226
+ "command": "serena",
227
+ "args": ["start-mcp-server", "--project-from-cwd", "--context", "pi"]
228
+ }
229
+ ```
230
+
231
+ Optionally control how the model reaches Serena's tools with `exposure` / `toolExposure` (see pi's MCP docs for 0.99+ semantics): `"exposure": "codemode"` has the model batch Serena calls inside codemode scripts, `"exposure": "deferred"` loads them one by one via `tool_search`, and `toolExposure` can single out tools (e.g. `"initial_instructions": "direct"`).
232
+
233
+ What changes with `pi.yml` (`single_project: true` + `--project-from-cwd`):
234
+
235
+ - the project auto-activates at startup; `activate_project` and `get_current_config` are dropped from the toolset (the SessionStart `activate` hook still helps — it nudges the agent to read the manual via `initial_instructions`);
236
+ - the six duplicate tools disappear, removing the temptation to use them;
237
+ - the symbol-first rules ride in the system prompt instead of arriving as after-the-fact nudges.
238
+
239
+ Keep the hooks as a fallback: `remind` with `denyAsContext: true` (catches bursts the prompt didn't prevent), `activate`, and `cleanup` all stay useful; `auto-approve` remains inert under pi (see warning above).
240
+
199
241
  ## Config Override
200
242
 
201
243
  Set `PI_HOOKS_CONFIG` env var to point to a custom config path (exclusive single source; when set, no other location is consulted).
package/index.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * least one hook wins; a valid-but-hookless file falls through to the next
8
8
  * candidate instead of silently disabling everything below it):
9
9
  * 1. PI_HOOKS_CONFIG env (exclusive single source when set)
10
- * 2. ~/.pi/agent/hooks.json (user-global, via getAgentDir())
11
- * 3. <cwd>/.pi/hooks.json (project-local)
12
- * 4. ~/.pi/hooks.json (legacy home location)
10
+ * 2. ~/.pi/agent/hook.json (user-global, via getAgentDir())
11
+ * 3. <cwd>/.pi/hook.json (project-local)
12
+ * 4. ~/.pi/hook.json (legacy home location)
13
13
  *
14
14
  * and maps:
15
15
  * SessionStart → session_start (source = mapped reason)
@@ -167,9 +167,9 @@ export function loadConfig(cwd: string): HooksConfig | null {
167
167
  const candidates = envPath
168
168
  ? [envPath]
169
169
  : [
170
- join(getAgentDir(), "hooks.json"), // ~/.pi/agent/hooks.json — user-global, top priority
171
- join(cwd, CONFIG_DIR_NAME, "hooks.json"), // project-local
172
- join(homedir(), CONFIG_DIR_NAME, "hooks.json"), // legacy home
170
+ join(getAgentDir(), "hook.json"), // ~/.pi/agent/hook.json — user-global, top priority
171
+ join(cwd, CONFIG_DIR_NAME, "hook.json"), // project-local
172
+ join(homedir(), CONFIG_DIR_NAME, "hook.json"), // legacy home
173
173
  ];
174
174
 
175
175
  // First valid config that defines hooks wins. A candidate that parses but
@@ -284,6 +284,18 @@ export function parseHookOutput(command: string, stdout: string, exitCode: numbe
284
284
  }
285
285
  }
286
286
 
287
+ // A bare exit code 2 with no parseable stdout is indistinguishable from a
288
+ // broken hook command (e.g. `python3` exits 2 for "can't open file"), and
289
+ // taking it as a deny hard-blocks EVERY tool call for the rest of the
290
+ // session. Treat the unstructured case as a suspected crash instead: warn
291
+ // and allow. A structured JSON deny still blocks, whatever the exit code.
292
+ if (exitCode === 2 && output === null) {
293
+ console.error(
294
+ `[hooks] exit 2 without a JSON deny payload from ${command} — likely a broken command, not a deny; allowing the call`,
295
+ );
296
+ return { context: null, block: null };
297
+ }
298
+
287
299
  const context = output?.hookSpecificOutput?.additionalContext ?? null;
288
300
  const deny = exitCode === 2 || output?.hookSpecificOutput?.permissionDecision === "deny";
289
301
  const reason =
package/package.json CHANGED
@@ -1,54 +1,54 @@
1
1
  {
2
- "name": "@fyeeme/pi-hooks",
3
- "version": "1.0.4",
4
- "description": "Claude Code-compatible hooks runner for pi. Reads hooks config (priority: ~/.pi/agent/hooks.json, then project .pi/hooks.json) and maps SessionStart, PreToolUse, and Stop events to pi lifecycle events.",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "fyeeme",
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "repository": {
12
- "type": "git",
13
- "url": "https://github.com/fyeeme/pi-packages"
14
- },
15
- "bugs": {
16
- "url": "https://github.com/fyeeme/pi-packages/issues"
17
- },
18
- "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
- "engines": {
20
- "node": ">=18"
21
- },
22
- "keywords": [
23
- "pi-package",
24
- "pi",
25
- "hooks",
26
- "claude-code",
27
- "serena",
28
- "automation",
29
- "lifecycle"
30
- ],
31
- "files": [
32
- "index.ts",
33
- "README.md",
34
- "LICENSE",
35
- "CHANGELOG.md"
36
- ],
37
- "pi": {
38
- "extensions": [
39
- "./index.ts"
40
- ]
41
- },
42
- "scripts": {
43
- "test": "vitest --run",
44
- "typecheck": "tsc"
45
- },
46
- "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": ">=0.84.1"
48
- },
49
- "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.84.1",
51
- "@types/node": "22.19.19",
52
- "typescript": "5.9.3"
53
- }
2
+ "name": "@fyeeme/pi-hooks",
3
+ "version": "1.0.6",
4
+ "description": "Claude Code-compatible hooks runner for pi. Reads hooks config (priority: ~/.pi/agent/hook.json, then project .pi/hook.json) and maps SessionStart, PreToolUse, and Stop events to pi lifecycle events.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/fyeeme/pi-packages"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fyeeme/pi-packages/issues"
17
+ },
18
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "hooks",
26
+ "claude-code",
27
+ "serena",
28
+ "automation",
29
+ "lifecycle"
30
+ ],
31
+ "files": [
32
+ "index.ts",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "scripts": {
43
+ "test": "vitest --run",
44
+ "typecheck": "tsc"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": ">=0.99.0"
48
+ },
49
+ "devDependencies": {
50
+ "@earendil-works/pi-coding-agent": "0.99.2",
51
+ "@types/node": "22.19.19",
52
+ "typescript": "5.9.3"
53
+ }
54
54
  }