@fyeeme/pi-hooks 1.0.2 → 1.0.3

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
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.3] - 2026-09-09
11
+
12
+ ### Fixed
13
+
14
+ - 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.
15
+ - Hook `additionalContext` injected into the prompt is capped at 50KB/2000 lines with a truncation notice.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+
10
21
  ## [1.0.2] - 2026-08-08
11
22
 
12
23
  ### Breaking Changes
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
  // ============================================================================
@@ -137,13 +145,30 @@ export function matchTool(pattern: string, value: string): boolean {
137
145
  // Config loader (failure is cached, not re-read every event)
138
146
  // ============================================================================
139
147
 
148
+ /** True when the normalized config defines at least one runnable hook. */
149
+ function hasAnyHook(cfg: HooksConfig): boolean {
150
+ return (Object.values(cfg.hooks) as HookGroup[][]).some((groups) => groups.length > 0);
151
+ }
152
+
140
153
  export function loadConfig(cwd: string): HooksConfig | null {
141
154
  const envPath = process.env.PI_HOOKS_CONFIG;
142
155
  // os.homedir() is cross-platform (HOME on POSIX, USERPROFILE on Windows).
156
+ // getAgentDir() additionally honors PI_CODING_AGENT_DIR.
143
157
  const candidates = envPath
144
158
  ? [envPath]
145
- : [join(cwd, CONFIG_DIR_NAME, "hooks.json"), join(homedir(), CONFIG_DIR_NAME, "hooks.json")];
146
-
159
+ : [
160
+ join(getAgentDir(), "hooks.json"), // ~/.pi/agent/hooks.json — user-global, top priority
161
+ join(cwd, CONFIG_DIR_NAME, "hooks.json"), // project-local
162
+ join(homedir(), CONFIG_DIR_NAME, "hooks.json"), // legacy home
163
+ ];
164
+
165
+ // First valid config that defines hooks wins. A candidate that parses but
166
+ // normalizes to zero hooks (e.g. `{}`, or a file in an older/unrelated
167
+ // schema) is remembered as a fallback and the chain continues — otherwise a
168
+ // stale higher-priority file would silently disable a usable config below
169
+ // it. PI_HOOKS_CONFIG is an explicit pointer: whatever it yields (even
170
+ // empty) is the answer; no fall-through applies.
171
+ let hookless: HooksConfig | null = null;
147
172
  for (const p of candidates) {
148
173
  if (!existsSync(p)) continue;
149
174
  let parsed: unknown;
@@ -154,13 +179,17 @@ export function loadConfig(cwd: string): HooksConfig | null {
154
179
  continue;
155
180
  }
156
181
  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": { ... } })`);
182
+ if (!cfg) {
183
+ // Valid JSON but wrong shape (e.g. copied from CC with events as
184
+ // objects): warn and fall through to the next candidate instead of
185
+ // silently returning null while a usable config exists.
186
+ console.error(`[hooks] invalid hooks shape in ${p} (expected { "hooks": { ... } })`);
187
+ continue;
188
+ }
189
+ if (envPath || hasAnyHook(cfg)) return cfg;
190
+ hookless ??= cfg;
162
191
  }
163
- return null;
192
+ return hookless;
164
193
  }
165
194
 
166
195
  // ============================================================================
@@ -253,8 +282,15 @@ async function runCommand(
253
282
  cwd: string,
254
283
  stdinText: string,
255
284
  timeoutMs: number,
285
+ signal?: AbortSignal,
256
286
  ): Promise<HookResult> {
257
287
  return new Promise((resolve) => {
288
+ // Already-aborted caller signal (Esc before the hook started): skip the
289
+ // spawn entirely — a dead turn must not launch new work.
290
+ if (signal?.aborted) {
291
+ resolve(emptyResult());
292
+ return;
293
+ }
258
294
  const isWin = process.platform === "win32";
259
295
  const proc = spawn(command, [], {
260
296
  shell: true,
@@ -275,6 +311,7 @@ async function runCommand(
275
311
  settled = true;
276
312
  clearTimeout(sigtermTimer);
277
313
  clearTimeout(sigkillTimer);
314
+ signal?.removeEventListener("abort", onAbort);
278
315
  resolve(result);
279
316
  };
280
317
 
@@ -311,6 +348,11 @@ async function runCommand(
311
348
  }, KILL_GRACE_MS);
312
349
  };
313
350
 
351
+ // Esc during the turn aborts ctx.signal: kill the hook tree through the
352
+ // same SIGTERM→SIGKILL escalation as the timeout path.
353
+ const onAbort = () => killAndFinish();
354
+ signal?.addEventListener("abort", onAbort, { once: true });
355
+
314
356
  // Accumulate raw buffers; decode once at the end to avoid splitting
315
357
  // multi-byte UTF-8 characters across chunks (silent mojibake).
316
358
  proc.stdout?.on("data", (chunk: Buffer) => {
@@ -361,6 +403,7 @@ async function runGroups(
361
403
  toolName: string,
362
404
  cwd: string,
363
405
  stdinText: string,
406
+ signal?: AbortSignal,
364
407
  ): Promise<{ contexts: string[]; block: string | null }> {
365
408
  if (!groups) return { contexts: [], block: null };
366
409
 
@@ -382,7 +425,7 @@ async function runGroups(
382
425
  const results: HookResult[] = [];
383
426
  for (let i = 0; i < commands.length; i += MAX_CONCURRENT) {
384
427
  const batch = commands.slice(i, i + MAX_CONCURRENT);
385
- results.push(...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs)))));
428
+ results.push(...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, signal)))));
386
429
  }
387
430
 
388
431
  const contexts: string[] = [];
@@ -478,7 +521,14 @@ export default function (pi: ExtensionAPI): void {
478
521
 
479
522
  if (toInject.length === 0) return;
480
523
 
481
- const text = toInject.join("\n\n");
524
+ // Cap injected context (doc output-truncation rationale: unbounded text
525
+ // overflows the model context and breaks compaction). The hook's stdout
526
+ // kill switch is 10MB; the model only ever sees the first 50KB/2000 lines.
527
+ const joined = toInject.join("\n\n");
528
+ const capped = truncateHead(joined, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
529
+ const text = capped.truncated
530
+ ? `${capped.content}\n\n[hooks: additionalContext truncated to ${capped.outputLines}/${capped.totalLines} lines]`
531
+ : capped.content;
482
532
  const messages = [...event.messages];
483
533
  const lastUserIdx = messages.findLastIndex((m) => (m as { role: string }).role === "user");
484
534
 
@@ -522,7 +572,9 @@ export default function (pi: ExtensionAPI): void {
522
572
  tool_input: event.input ?? {},
523
573
  });
524
574
 
525
- const { contexts, block } = await runGroups(cfg.hooks.PreToolUse, event.toolName, ctx.cwd, JSON.stringify(stdin));
575
+ // ctx.signal: Esc mid-turn kills running hook processes (same escalation
576
+ // as the timeout path); undefined outside an active turn is harmless.
577
+ const { contexts, block } = await runGroups(cfg.hooks.PreToolUse, event.toolName, ctx.cwd, JSON.stringify(stdin), ctx.signal);
526
578
  // A block + terminate skips this round's follow-up LLM call, so queued
527
579
  // context would leak into the next user prompt — drop it on block.
528
580
  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.3",
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",