@fyeeme/pi-hooks 1.0.2 → 1.0.4

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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,22 @@ 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]
8
+ ## [1.0.4] - 2026-09-12
9
+
10
+ ### Added
11
+
12
+ - `denyAsContext` per-hook flag: a deny (`permissionDecision: "deny"` or exit code 2) is demoted to `additionalContext` — the tool call proceeds and the nudge text is injected before the next LLM call instead of blocking the agent (e.g. `serena-hooks remind` stops killing read bursts).
13
+
14
+ ## [1.0.3] - 2026-09-09
15
+
16
+ ### Fixed
17
+
18
+ - PreToolUse hook processes are killed through the existing SIGTERM→SIGKILL escalation when the turn is aborted (`ctx.signal`); an already-aborted turn skips spawning entirely.
19
+ - Hook `additionalContext` injected into the prompt is capped at 50KB/2000 lines with a truncation notice.
20
+
21
+ ### Changed
22
+
23
+ - Config resolution now prioritizes `~/.pi/agent/hooks.json` (user-global, resolved via `getAgentDir()` so `PI_CODING_AGENT_DIR` is honored) above the project `.pi/hooks.json`; the legacy `~/.pi/hooks.json` location remains the last fallback. A candidate that parses but defines no hooks (e.g. `{}`, or a leftover file in an older schema) no longer shadows lower-priority files — the chain falls through to the next candidate (`PI_HOOKS_CONFIG` remains an exclusive single source when set). Configs are still winner-take-all, never merged.
9
24
 
10
25
  ## [1.0.2] - 2026-08-08
11
26
 
package/README.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # pi-hooks
2
2
 
3
- A Claude Code-compatible hooks runner for [pi](https://pi.dev). Reads `.pi/hooks.json` from your project and maps `SessionStart`, `PreToolUse`, and `Stop` events to pi lifecycle events — matching Claude Code's hooks protocol including stdin JSON and stdout `additionalContext` capture.
3
+ A Claude Code-compatible hooks runner for [pi](https://pi.dev). Reads your hooks configuration and maps `SessionStart`, `PreToolUse`, and `Stop` events to pi lifecycle events — matching Claude Code's hooks protocol including stdin JSON and stdout `additionalContext` capture.
4
+
5
+ **Config resolution order** (first file that defines at least one hook wins):
6
+
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
11
+
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.
4
13
 
5
14
  ## Install
6
15
 
@@ -39,7 +48,7 @@ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of sourc
39
48
 
40
49
  ## Configuration
41
50
 
42
- Create `.pi/hooks.json` in your project root:
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):
43
52
 
44
53
  ```json
45
54
  {
@@ -139,6 +148,54 @@ The `additionalContext` is injected into the pi conversation (appended to the la
139
148
 
140
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`.
141
150
 
151
+ ## Using pi-hooks with Serena
152
+
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`:
154
+
155
+ ```json
156
+ {
157
+ "hooks": {
158
+ "SessionStart": [
159
+ {
160
+ "matcher": "",
161
+ "hooks": [{ "type": "command", "command": "serena-hooks activate --client=claude-code" }]
162
+ }
163
+ ],
164
+ "PreToolUse": [
165
+ {
166
+ "matcher": "",
167
+ "hooks": [{ "type": "command", "command": "serena-hooks remind --client=claude-code" }]
168
+ },
169
+ {
170
+ "matcher": "serena_.*",
171
+ "hooks": [{ "type": "command", "command": "serena-hooks auto-approve --client=claude-code" }]
172
+ }
173
+ ],
174
+ "Stop": [
175
+ {
176
+ "matcher": "",
177
+ "hooks": [{ "type": "command", "command": "serena-hooks cleanup --client=claude-code" }]
178
+ }
179
+ ]
180
+ }
181
+ }
182
+ ```
183
+
184
+ What each hook does:
185
+
186
+ | Event | Command | Role |
187
+ |---|---|---|
188
+ | `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. |
191
+ | `Stop` | `cleanup` | Clears per-session hook state on exit. |
192
+
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.
194
+
195
+ > ⚠️ **`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
+
197
+ `--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
+
142
199
  ## Config Override
143
200
 
144
- Set `PI_HOOKS_CONFIG` env var to point to a custom config path.
201
+ 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
@@ -3,7 +3,15 @@
3
3
  *
4
4
  * Claude Code-compatible hooks runner for pi.
5
5
  *
6
- * Reads `.pi/hooks.json` (or `PI_HOOKS_CONFIG` env) and maps:
6
+ * Reads hooks config with the following priority (first file that defines at
7
+ * least one hook wins; a valid-but-hookless file falls through to the next
8
+ * candidate instead of silently disabling everything below it):
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)
13
+ *
14
+ * and maps:
7
15
  * SessionStart → session_start (source = mapped reason)
8
16
  * PreToolUse → tool_call (can block via {block:true})
9
17
  * Stop → session_shutdown (cleanup only; cannot block exit)
@@ -31,7 +39,7 @@ import { readFileSync, existsSync } from "node:fs";
31
39
  import { join } from "node:path";
32
40
  import { spawn } from "node:child_process";
33
41
  import { homedir } from "node:os";
34
- import { CONFIG_DIR_NAME, formatSize } from "@earendil-works/pi-coding-agent";
42
+ import { CONFIG_DIR_NAME, formatSize, getAgentDir, truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
35
43
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
36
44
 
37
45
  // ============================================================================
@@ -43,6 +51,11 @@ interface HookEntry {
43
51
  command: string;
44
52
  /** Per-hook timeout in seconds (Claude Code compatible). Default 60. */
45
53
  timeout?: number;
54
+ /** Demote deny (`permissionDecision: "deny"` or exit code 2) to
55
+ * `additionalContext`: the tool call proceeds and the nudge text is
56
+ * injected before the next LLM call instead of blocking the agent
57
+ * (e.g. `serena-hooks remind`). */
58
+ denyAsContext?: boolean;
46
59
  }
47
60
 
48
61
  interface HookGroup {
@@ -97,7 +110,12 @@ export function normalizeConfig(raw: unknown): HooksConfig | null {
97
110
  if (he.type !== "command" || typeof he.command !== "string") continue;
98
111
  const timeout =
99
112
  typeof he.timeout === "number" && he.timeout > 0 ? he.timeout : undefined;
100
- entries.push({ type: "command", command: he.command, ...(timeout === undefined ? {} : { timeout }) });
113
+ entries.push({
114
+ type: "command",
115
+ command: he.command,
116
+ ...(timeout === undefined ? {} : { timeout }),
117
+ ...(he.denyAsContext === true ? { denyAsContext: true } : {}),
118
+ });
101
119
  }
102
120
  if (entries.length > 0) groups.push({ matcher: gr.matcher, hooks: entries });
103
121
  }
@@ -137,13 +155,30 @@ export function matchTool(pattern: string, value: string): boolean {
137
155
  // Config loader (failure is cached, not re-read every event)
138
156
  // ============================================================================
139
157
 
158
+ /** True when the normalized config defines at least one runnable hook. */
159
+ function hasAnyHook(cfg: HooksConfig): boolean {
160
+ return (Object.values(cfg.hooks) as HookGroup[][]).some((groups) => groups.length > 0);
161
+ }
162
+
140
163
  export function loadConfig(cwd: string): HooksConfig | null {
141
164
  const envPath = process.env.PI_HOOKS_CONFIG;
142
165
  // os.homedir() is cross-platform (HOME on POSIX, USERPROFILE on Windows).
166
+ // getAgentDir() additionally honors PI_CODING_AGENT_DIR.
143
167
  const candidates = envPath
144
168
  ? [envPath]
145
- : [join(cwd, CONFIG_DIR_NAME, "hooks.json"), join(homedir(), CONFIG_DIR_NAME, "hooks.json")];
146
-
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
173
+ ];
174
+
175
+ // First valid config that defines hooks wins. A candidate that parses but
176
+ // normalizes to zero hooks (e.g. `{}`, or a file in an older/unrelated
177
+ // schema) is remembered as a fallback and the chain continues — otherwise a
178
+ // stale higher-priority file would silently disable a usable config below
179
+ // it. PI_HOOKS_CONFIG is an explicit pointer: whatever it yields (even
180
+ // empty) is the answer; no fall-through applies.
181
+ let hookless: HooksConfig | null = null;
147
182
  for (const p of candidates) {
148
183
  if (!existsSync(p)) continue;
149
184
  let parsed: unknown;
@@ -154,13 +189,17 @@ export function loadConfig(cwd: string): HooksConfig | null {
154
189
  continue;
155
190
  }
156
191
  const cfg = normalizeConfig(parsed);
157
- if (cfg) return cfg;
158
- // Valid JSON but wrong shape (e.g. copied from CC with events as objects):
159
- // warn and fall through to the next candidate instead of silently
160
- // returning null while a usable home config exists.
161
- console.error(`[hooks] invalid hooks shape in ${p} (expected { "hooks": { ... } })`);
192
+ if (!cfg) {
193
+ // Valid JSON but wrong shape (e.g. copied from CC with events as
194
+ // objects): warn and fall through to the next candidate instead of
195
+ // silently returning null while a usable config exists.
196
+ console.error(`[hooks] invalid hooks shape in ${p} (expected { "hooks": { ... } })`);
197
+ continue;
198
+ }
199
+ if (envPath || hasAnyHook(cfg)) return cfg;
200
+ hookless ??= cfg;
162
201
  }
163
- return null;
202
+ return hookless;
164
203
  }
165
204
 
166
205
  // ============================================================================
@@ -213,6 +252,18 @@ export interface HookResult {
213
252
  block: string | null;
214
253
  }
215
254
 
255
+ /**
256
+ * Demote a deny to a pure context hint (`denyAsContext` per-hook flag): the
257
+ * tool call proceeds and the nudge text rides along before the next LLM call
258
+ * instead of blocking the agent. Prefers the hook's own `additionalContext`
259
+ * (e.g. serena-hooks remind's "Consider using Serena's symbolic tools...")
260
+ * and falls back to the block reason.
261
+ */
262
+ export function applyDenyAsContext(result: HookResult): HookResult {
263
+ if (!result.block) return result;
264
+ return { context: result.context ?? result.block, block: null };
265
+ }
266
+
216
267
  function emptyResult(): HookResult {
217
268
  return { context: null, block: null };
218
269
  }
@@ -253,8 +304,16 @@ async function runCommand(
253
304
  cwd: string,
254
305
  stdinText: string,
255
306
  timeoutMs: number,
307
+ denyAsContext: boolean,
308
+ signal?: AbortSignal,
256
309
  ): Promise<HookResult> {
257
310
  return new Promise((resolve) => {
311
+ // Already-aborted caller signal (Esc before the hook started): skip the
312
+ // spawn entirely — a dead turn must not launch new work.
313
+ if (signal?.aborted) {
314
+ resolve(emptyResult());
315
+ return;
316
+ }
258
317
  const isWin = process.platform === "win32";
259
318
  const proc = spawn(command, [], {
260
319
  shell: true,
@@ -275,6 +334,7 @@ async function runCommand(
275
334
  settled = true;
276
335
  clearTimeout(sigtermTimer);
277
336
  clearTimeout(sigkillTimer);
337
+ signal?.removeEventListener("abort", onAbort);
278
338
  resolve(result);
279
339
  };
280
340
 
@@ -311,6 +371,11 @@ async function runCommand(
311
371
  }, KILL_GRACE_MS);
312
372
  };
313
373
 
374
+ // Esc during the turn aborts ctx.signal: kill the hook tree through the
375
+ // same SIGTERM→SIGKILL escalation as the timeout path.
376
+ const onAbort = () => killAndFinish();
377
+ signal?.addEventListener("abort", onAbort, { once: true });
378
+
314
379
  // Accumulate raw buffers; decode once at the end to avoid splitting
315
380
  // multi-byte UTF-8 characters across chunks (silent mojibake).
316
381
  proc.stdout?.on("data", (chunk: Buffer) => {
@@ -335,7 +400,8 @@ async function runCommand(
335
400
  // parseHookOutput honors it as a block, so don't log it as a failure.
336
401
  if (code !== 0 && code !== 2 && code !== null) console.error(`[hooks] exited ${code}: ${command}`);
337
402
  const stdout = Buffer.concat(chunks).toString("utf8").trim();
338
- finish(parseHookOutput(command, stdout, code));
403
+ const result = parseHookOutput(command, stdout, code);
404
+ finish(denyAsContext ? applyDenyAsContext(result) : result);
339
405
  });
340
406
 
341
407
  proc.on("error", (err) => {
@@ -361,16 +427,18 @@ async function runGroups(
361
427
  toolName: string,
362
428
  cwd: string,
363
429
  stdinText: string,
430
+ signal?: AbortSignal,
364
431
  ): Promise<{ contexts: string[]; block: string | null }> {
365
432
  if (!groups) return { contexts: [], block: null };
366
433
 
367
- const commands: Array<{ command: string; timeoutMs: number }> = [];
434
+ const commands: Array<{ command: string; timeoutMs: number; denyAsContext: boolean }> = [];
368
435
  for (const group of groups) {
369
436
  if (!matchTool(group.matcher, toolName)) continue;
370
437
  for (const hook of group.hooks) {
371
438
  commands.push({
372
439
  command: hook.command,
373
440
  timeoutMs: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
441
+ denyAsContext: hook.denyAsContext === true,
374
442
  });
375
443
  }
376
444
  }
@@ -382,7 +450,9 @@ async function runGroups(
382
450
  const results: HookResult[] = [];
383
451
  for (let i = 0; i < commands.length; i += MAX_CONCURRENT) {
384
452
  const batch = commands.slice(i, i + MAX_CONCURRENT);
385
- results.push(...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs)))));
453
+ results.push(
454
+ ...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, c.denyAsContext, signal)))),
455
+ );
386
456
  }
387
457
 
388
458
  const contexts: string[] = [];
@@ -478,7 +548,14 @@ export default function (pi: ExtensionAPI): void {
478
548
 
479
549
  if (toInject.length === 0) return;
480
550
 
481
- const text = toInject.join("\n\n");
551
+ // Cap injected context (doc output-truncation rationale: unbounded text
552
+ // overflows the model context and breaks compaction). The hook's stdout
553
+ // kill switch is 10MB; the model only ever sees the first 50KB/2000 lines.
554
+ const joined = toInject.join("\n\n");
555
+ const capped = truncateHead(joined, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
556
+ const text = capped.truncated
557
+ ? `${capped.content}\n\n[hooks: additionalContext truncated to ${capped.outputLines}/${capped.totalLines} lines]`
558
+ : capped.content;
482
559
  const messages = [...event.messages];
483
560
  const lastUserIdx = messages.findLastIndex((m) => (m as { role: string }).role === "user");
484
561
 
@@ -522,7 +599,9 @@ export default function (pi: ExtensionAPI): void {
522
599
  tool_input: event.input ?? {},
523
600
  });
524
601
 
525
- const { contexts, block } = await runGroups(cfg.hooks.PreToolUse, event.toolName, ctx.cwd, JSON.stringify(stdin));
602
+ // ctx.signal: Esc mid-turn kills running hook processes (same escalation
603
+ // as the timeout path); undefined outside an active turn is harmless.
604
+ const { contexts, block } = await runGroups(cfg.hooks.PreToolUse, event.toolName, ctx.cwd, JSON.stringify(stdin), ctx.signal);
526
605
  // A block + terminate skips this round's follow-up LLM call, so queued
527
606
  // context would leak into the next user prompt — drop it on block.
528
607
  if (block) return { block: true, reason: block, terminate: true };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-hooks",
3
- "version": "1.0.2",
4
- "description": "Claude Code-compatible hooks runner for pi. Reads .pi/hooks.json and maps SessionStart, PreToolUse, and Stop events to pi lifecycle events.",
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
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "fyeeme",