@fyeeme/pi-hooks 1.0.1 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +91 -16
  3. package/index.ts +419 -136
  4. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ 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
+
21
+ ## [1.0.2] - 2026-08-08
22
+
23
+ ### Breaking Changes
24
+
25
+ - Matchers are now **regex** (Claude Code compatible) instead of globs. Convert patterns like `plugin_serena_serena_*` → `plugin_serena_serena_.*`. `""`/`"*"` still match all; invalid regex falls back to literal.
26
+ - Minimum supported pi is now **0.84.1** (peer dependency `>=0.84.1`). Required because a denied PreToolUse now returns `terminate: true`, which was added to pi's `tool_call` event in 0.84.1 (#7715).
27
+
28
+ ### Added
29
+
30
+ - PreToolUse blocking: `permissionDecision: "deny"` and exit code 2 now block the tool via pi's `{ block: true, reason, terminate: true }` (requires pi >= 0.84.1). `terminate` skips the follow-up LLM call only in an all-terminating batch; the block always applies.
31
+ - Claude Code-compatible stdin fields on every hook: `hook_event_name`, `cwd`, `permission_mode`, plus a real `session_id` (pi session UUID) and `transcript_path` (conversation JSONL path).
32
+ - SessionStart `matcher` now matches the session source (`startup`/`resume`/`clear`/...), mapped from the pi `session_start` reason.
33
+ - Per-hook `timeout` (seconds, default 60) and parallel execution of matching hooks.
34
+
35
+ ### Changed
36
+
37
+ - SessionStart hooks now bind to the `session_start` event (was `before_agent_start`) so source matchers work; `additionalContext` is still injected via the `context` event.
38
+ - Config load result (including failure) is now cached per session — a missing/unreadable file no longer triggers a disk read on every event.
39
+ - Hook subprocesses are killed as a process group (grandchildren no longer orphaned); stdout is capped at 10 MB; multi-byte stdout is decoded once via `Buffer.concat` (no mojibake).
40
+ - `session_id` uses the platform `sessionManager.getSessionId()` instead of a `Date.now()` fallback; user-config lookup uses `os.homedir()` (Windows-compatible).
41
+
42
+ ### Fixed
43
+
44
+ - Crash: writing stdin to a hook that ignores it raised an uncaught `EPIPE` and killed the whole pi process (stdin/stdout now swallow stream errors).
45
+ - Crash: unbounded stdout accumulation hit the V8 string limit (`RangeError`) and killed pi within ~0.3s.
46
+ - Crash: a syntactically valid but misshapen `hooks.json` (e.g. `{}`, `{ "hooks": null }`) threw `TypeError` inside awaited handlers; configs are now validated/normalized.
47
+ - Dropped `additionalContext` from non-empty-matcher PreToolUse groups.
48
+ - Repeated `[hooks] failed to parse` log spam on every event when the config file was unreadable.
49
+
10
50
  ## [1.0.1] - 2025-07-15
11
51
 
12
52
  ### Fixed
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
  {
@@ -66,7 +75,7 @@ Create `.pi/hooks.json` in your project root:
66
75
  ]
67
76
  },
68
77
  {
69
- "matcher": "plugin_serena_serena_*",
78
+ "matcher": "plugin_serena_serena_.*",
70
79
  "hooks": [
71
80
  {
72
81
  "type": "command",
@@ -94,33 +103,99 @@ Create `.pi/hooks.json` in your project root:
94
103
 
95
104
  | hooks.json event | pi event | Notes |
96
105
  |---|---|---|
97
- | `SessionStart` | `session_start` | Runs on startup. `additionalContext` sent via `sendUserMessage`. |
98
- | `PreToolUse` (empty matcher) | `tool_call` | Runs before every tool with real `tool_name`. `additionalContext` injected before next LLM call. |
99
- | `PreToolUse` (pattern matcher) | `tool_call` | Glob match against pi tool name (e.g. `plugin_serena_serena_*`). |
100
- | `Stop` | `session_shutdown` | Runs on exit. |
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. |
107
+ | `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
+ | `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
+
110
+ ## Matcher semantics
111
+
112
+ `matcher` is a **regex** (Claude Code compatible), tested against the full tool name (PreToolUse) or session source (SessionStart):
113
+
114
+ - `""` or `"*"` — match all
115
+ - `"Edit|Write"` — match either
116
+ - `"Notebook.*"` — prefix match
117
+ - `"plugin_serena_serena_.*"` — all serena tools
118
+
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).
101
120
 
102
121
  ## Protocol
103
122
 
104
- Commands receive Claude Code-compatible JSON on stdin:
123
+ Commands receive Claude Code-compatible JSON on stdin (`session_id` is the pi session UUID; `transcript_path` is the conversation JSONL path):
105
124
 
106
125
  ```json
107
- { "type": "session_start", "session_id": "...", "transcript_path": "..." }
108
- { "type": "pre_tool_use", "session_id": "...", "tool_name": "bash", "tool_input": {} }
109
- { "type": "stop", "session_id": "..." }
126
+ { "hook_event_name": "SessionStart", "session_id": "<uuid>", "transcript_path": "/path/to/session.jsonl", "cwd": "/proj", "permission_mode": "default", "source": "startup" }
127
+ { "hook_event_name": "PreToolUse", "session_id": "<uuid>", "transcript_path": "...", "cwd": "/proj", "permission_mode": "default", "tool_name": "bash", "tool_input": {} }
128
+ { "hook_event_name": "Stop", "session_id": "<uuid>", "transcript_path": "...", "cwd": "/proj", "permission_mode": "default" }
110
129
  ```
111
130
 
112
- Commands may return JSON on stdout:
131
+ Commands may return JSON on stdout, or control flow via exit codes:
113
132
 
114
133
  ```json
115
- { "hookSpecificOutput": { "additionalContext": "..." } }
134
+ { "hookSpecificOutput": { "additionalContext": "context injected into the conversation" } }
135
+ { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "blocked" } }
116
136
  ```
117
137
 
118
- The `additionalContext` is injected into the pi conversation.
138
+ - 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.
140
+ - exit code **2** (Stop) → ignored (pi cannot block exit).
141
+ - other non-zero → logged, execution continues.
142
+ - non-JSON stdout → logged as a warning, ignored.
143
+ - each hook may set `"timeout"` (seconds, default 60); matching hooks run in **parallel**.
144
+
145
+ The `additionalContext` is injected into the pi conversation (appended to the last user message, never as a new turn).
119
146
 
120
147
  ## MCP Tool Names
121
148
 
122
- Pi names MCP tools as `<serverName>_<toolName>` (not `mcp__server__tool` like Claude Code). Check your actual tool names with `/mcp` in pi to set the correct `matcher`.
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`.
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.
123
198
 
124
199
  ## Config Override
125
200
 
126
- 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,34 +3,54 @@
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:
7
- * SessionStart → before_agent_start (first turn)
8
- * PreToolUse → tool_call
9
- * Stop → session_shutdown
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)
10
13
  *
11
- * All additionalContext—from both SessionStart and PreToolUse—is injected
12
- * into the last user message via the context event. This guarantees the LLM
13
- * sees and acts on the context without extra turns, fake user messages, or
14
- * system prompt passivity.
14
+ * and maps:
15
+ * SessionStart → session_start (source = mapped reason)
16
+ * PreToolUse → tool_call (can block via {block:true})
17
+ * Stop → session_shutdown (cleanup only; cannot block exit)
15
18
  *
16
- * Sequence guarantee:
17
- * emitBeforeAgentStart() is awaited by agent-session.ts before
18
- * _runAgentPrompt() starts, so before_agent_start always completes
19
- * before the first context event fires. No race condition.
19
+ * All additionalContext — from both SessionStart and PreToolUse — is injected
20
+ * into the last user message via the context event, so the LLM sees and acts on
21
+ * it without extra turns, fake user messages, or system-prompt passivity.
22
+ *
23
+ * Compatibility notes (vs Claude Code hooks protocol):
24
+ * - matchers are **regex** (CC semantics): "" / "*" match all; `Edit|Write`
25
+ * alternation and `Notebook.*` work. Invalid regex falls back to literal.
26
+ * - PreToolUse `permissionDecision: "deny"` and exit code 2 block the tool
27
+ * via pi's `{ block: true, reason, terminate: true }`. The tool is always
28
+ * blocked; `terminate` additionally tries to skip the automatic follow-up
29
+ * model call, but only takes effect when this is the only/last call in an
30
+ * all-terminating batch (pi >= 0.84.1, #7715). In a multi-tool batch the
31
+ * block still applies but the agent may continue. ("allow"/"ask" are
32
+ * no-ops; pi applies its own permission flow.)
33
+ * - Stop `decision: "block"` is NOT honored — pi's session_shutdown is
34
+ * notification-only and cannot prevent exit.
35
+ * - per-hook `timeout` (seconds) is honored; default 60s.
20
36
  */
21
37
 
22
38
  import { readFileSync, existsSync } from "node:fs";
23
39
  import { join } from "node:path";
24
40
  import { spawn } from "node:child_process";
41
+ import { homedir } from "node:os";
42
+ import { CONFIG_DIR_NAME, formatSize, getAgentDir, truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
25
43
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
44
 
27
45
  // ============================================================================
28
- // Config schema
46
+ // Config schema + validation
29
47
  // ============================================================================
30
48
 
31
49
  interface HookEntry {
32
50
  type: "command";
33
51
  command: string;
52
+ /** Per-hook timeout in seconds (Claude Code compatible). Default 60. */
53
+ timeout?: number;
34
54
  }
35
55
 
36
56
  interface HookGroup {
@@ -46,139 +66,394 @@ interface HooksConfig {
46
66
  };
47
67
  }
48
68
 
49
- interface HookOutput {
50
- hookSpecificOutput?: {
51
- additionalContext?: string;
52
- };
69
+ const HOOK_EVENTS = ["SessionStart", "PreToolUse", "Stop"] as const;
70
+ type HookEventName = (typeof HOOK_EVENTS)[number];
71
+
72
+ /**
73
+ * Validate and normalize a parsed config. Returns a safe HooksConfig (missing
74
+ * events treated as empty) or null if the top-level shape is wrong. Never
75
+ * throws — a malformed file degrades to "no hooks" rather than crashing the
76
+ * awaited handler that dereferences `config.hooks.*`.
77
+ */
78
+ export function normalizeConfig(raw: unknown): HooksConfig | null {
79
+ if (typeof raw !== "object" || raw === null) return null;
80
+ const root = raw as Record<string, unknown>;
81
+ const hooksField = root.hooks;
82
+ // `{}` or `{"hooks": null}` → no hooks configured (not an error).
83
+ if (hooksField === undefined || hooksField === null) return { hooks: {} };
84
+ if (typeof hooksField !== "object" || Array.isArray(hooksField)) return null;
85
+
86
+ const out: HooksConfig = { hooks: {} };
87
+ const hooks = hooksField as Record<string, unknown>;
88
+ for (const evt of HOOK_EVENTS) {
89
+ const v = hooks[evt];
90
+ if (!Array.isArray(v)) continue; // missing or non-array event → ignored
91
+ const groups: HookGroup[] = [];
92
+ for (const g of v) {
93
+ if (!g || typeof g !== "object") continue;
94
+ const gr = g as Record<string, unknown>;
95
+ // Validate inner shape so a malformed group/entry can't reach matchTool
96
+ // (e.g. a missing matcher must not silently match the literal
97
+ // "undefined") or produce a NaN timeout.
98
+ if (typeof gr.matcher !== "string") continue;
99
+ const ghooks = gr.hooks;
100
+ if (!Array.isArray(ghooks)) continue;
101
+ const entries: HookEntry[] = [];
102
+ for (const h of ghooks) {
103
+ if (!h || typeof h !== "object") continue;
104
+ const he = h as Record<string, unknown>;
105
+ if (he.type !== "command" || typeof he.command !== "string") continue;
106
+ const timeout =
107
+ typeof he.timeout === "number" && he.timeout > 0 ? he.timeout : undefined;
108
+ entries.push({ type: "command", command: he.command, ...(timeout === undefined ? {} : { timeout }) });
109
+ }
110
+ if (entries.length > 0) groups.push({ matcher: gr.matcher, hooks: entries });
111
+ }
112
+ if (groups.length > 0) out.hooks[evt] = groups;
113
+ }
114
+ return out;
53
115
  }
54
116
 
55
117
  // ============================================================================
56
- // Glob matching
118
+ // Matcher (Claude Code regex semantics)
57
119
  // ============================================================================
58
120
 
59
- export function globMatch(pattern: string, value: string): boolean {
60
- if (pattern === "") return true;
61
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
62
- const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
63
- return new RegExp(`^${regexStr}$`).test(value);
121
+ /**
122
+ * Claude Code matcher: "" or "*" match all; otherwise the pattern is a regex
123
+ * tested against the full value (anchored). Invalid regex falls back to a
124
+ * literal exact match so a bad pattern never throws inside an event handler.
125
+ */
126
+ /** Compiled matcher cache (avoid recompiling per event); null = invalid regex. */
127
+ const matcherCache = new Map<string, RegExp | null>();
128
+
129
+ export function matchTool(pattern: string, value: string): boolean {
130
+ if (pattern === "" || pattern === "*") return true;
131
+ let regex = matcherCache.get(pattern);
132
+ if (regex === undefined) {
133
+ try {
134
+ regex = new RegExp(`^(?:${pattern})$`);
135
+ } catch {
136
+ regex = null; // invalid regex matches nothing (CC has no literal fallback)
137
+ console.warn(`[hooks] invalid matcher regex "${pattern}" — will never match`);
138
+ }
139
+ matcherCache.set(pattern, regex);
140
+ }
141
+ return regex !== null && regex.test(value);
64
142
  }
65
143
 
66
144
  // ============================================================================
67
- // Config loader - cached per session via ??=
145
+ // Config loader (failure is cached, not re-read every event)
68
146
  // ============================================================================
69
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
+
70
153
  export function loadConfig(cwd: string): HooksConfig | null {
71
154
  const envPath = process.env.PI_HOOKS_CONFIG;
155
+ // os.homedir() is cross-platform (HOME on POSIX, USERPROFILE on Windows).
156
+ // getAgentDir() additionally honors PI_CODING_AGENT_DIR.
72
157
  const candidates = envPath
73
158
  ? [envPath]
74
- : [join(cwd, ".pi", "hooks.json"), join(process.env.HOME ?? "", ".pi", "hooks.json")];
75
-
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;
76
172
  for (const p of candidates) {
77
173
  if (!existsSync(p)) continue;
174
+ let parsed: unknown;
78
175
  try {
79
- return JSON.parse(readFileSync(p, "utf-8")) as HooksConfig;
176
+ parsed = JSON.parse(readFileSync(p, "utf-8"));
80
177
  } catch (err) {
81
178
  console.error(`[hooks] failed to parse ${p}: ${err}`);
179
+ continue;
180
+ }
181
+ const cfg = normalizeConfig(parsed);
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;
82
188
  }
189
+ if (envPath || hasAnyHook(cfg)) return cfg;
190
+ hookless ??= cfg;
83
191
  }
84
- return null;
192
+ return hookless;
85
193
  }
86
194
 
87
195
  // ============================================================================
88
- // Session ID
196
+ // Session id / transcript path (CC-compatible field semantics)
89
197
  // ============================================================================
90
198
 
91
199
  function getSessionId(ctx: ExtensionContext): string {
92
- try {
93
- const sm = ctx.sessionManager as { getSessionFile?: () => string | undefined };
94
- const file = sm.getSessionFile?.();
95
- if (file) return file;
96
- } catch { /* ignore */ }
97
- return `${ctx.cwd}:${Date.now()}`;
200
+ // Platform-stable UUID (session-manager.getSessionId()). Never falls back to
201
+ // a time-based value, so session_id is stable across events in one session.
202
+ return ctx.sessionManager.getSessionId();
203
+ }
204
+
205
+ function getTranscriptPath(ctx: ExtensionContext): string {
206
+ // The conversation JSONL file path (CC transcript_path semantics).
207
+ return ctx.sessionManager.getSessionFile() ?? "";
208
+ }
209
+
210
+ function buildStdin(
211
+ hookEventName: HookEventName,
212
+ ctx: ExtensionContext,
213
+ extra: Record<string, unknown> = {},
214
+ ): Record<string, unknown> {
215
+ return {
216
+ session_id: getSessionId(ctx),
217
+ transcript_path: getTranscriptPath(ctx),
218
+ cwd: ctx.cwd,
219
+ permission_mode: "default",
220
+ hook_event_name: hookEventName,
221
+ ...extra,
222
+ };
223
+ }
224
+
225
+ // ============================================================================
226
+ // Hook output parsing (control flow + context)
227
+ // ============================================================================
228
+
229
+ interface HookOutput {
230
+ hookSpecificOutput?: {
231
+ additionalContext?: string;
232
+ permissionDecision?: "allow" | "deny" | "ask";
233
+ permissionDecisionReason?: string;
234
+ };
235
+ reason?: string;
236
+ }
237
+
238
+ export interface HookResult {
239
+ /** additionalContext to inject, if any. */
240
+ context: string | null;
241
+ /** Block reason (shown to the LLM), or null when not blocking. */
242
+ block: string | null;
243
+ }
244
+
245
+ function emptyResult(): HookResult {
246
+ return { context: null, block: null };
247
+ }
248
+
249
+ /**
250
+ * Parse a hook's stdout + exit code into a normalized result. Handles the two
251
+ * Claude Code blocking signals: exit code 2 and `permissionDecision: "deny"`.
252
+ */
253
+ export function parseHookOutput(command: string, stdout: string, exitCode: number | null): HookResult {
254
+ let output: HookOutput | null = null;
255
+ if (stdout) {
256
+ try {
257
+ output = JSON.parse(stdout) as HookOutput;
258
+ } catch {
259
+ // Non-JSON stdout (e.g. a stray `echo`/`console.log`) is a common
260
+ // misconfiguration — surface it instead of failing silently.
261
+ console.error(`[hooks] non-JSON stdout from ${command}: ${stdout.slice(0, 200)}`);
262
+ }
263
+ }
264
+
265
+ const context = output?.hookSpecificOutput?.additionalContext ?? null;
266
+ const deny = exitCode === 2 || output?.hookSpecificOutput?.permissionDecision === "deny";
267
+ const reason =
268
+ output?.hookSpecificOutput?.permissionDecisionReason ?? output?.reason ?? "blocked by hook";
269
+ return { context, block: deny ? reason : null };
98
270
  }
99
271
 
100
272
  // ============================================================================
101
- // Command runner - pipes JSON to stdin, captures stdout JSON
273
+ // Command runner
102
274
  // ============================================================================
103
275
 
104
- async function runCommand(command: string, cwd: string, stdinJson: unknown): Promise<HookOutput | null> {
276
+ const DEFAULT_TIMEOUT_SECONDS = 60;
277
+ const MAX_STDOUT_BYTES = 10 * 1024 * 1024;
278
+ const KILL_GRACE_MS = 5_000;
279
+
280
+ async function runCommand(
281
+ command: string,
282
+ cwd: string,
283
+ stdinText: string,
284
+ timeoutMs: number,
285
+ signal?: AbortSignal,
286
+ ): Promise<HookResult> {
105
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
+ }
294
+ const isWin = process.platform === "win32";
106
295
  const proc = spawn(command, [], {
107
296
  shell: true,
108
297
  cwd,
298
+ // detached (non-Windows) → own process group so we can kill the tree.
299
+ detached: !isWin,
109
300
  stdio: ["pipe", "pipe", "inherit"],
110
301
  });
111
302
 
112
- let stdout = "";
303
+ const chunks: Buffer[] = [];
304
+ let bytes = 0;
305
+ let killed = false;
113
306
  let settled = false;
114
- const finish = (result: HookOutput | null) => {
307
+ let sigkillTimer: NodeJS.Timeout | undefined;
308
+
309
+ const finish = (result: HookResult) => {
115
310
  if (settled) return;
116
311
  settled = true;
117
312
  clearTimeout(sigtermTimer);
118
313
  clearTimeout(sigkillTimer);
314
+ signal?.removeEventListener("abort", onAbort);
119
315
  resolve(result);
120
316
  };
121
317
 
318
+ const killTree = (sig: "SIGTERM" | "SIGKILL") => {
319
+ try {
320
+ if (!isWin && proc.pid) process.kill(-proc.pid, sig);
321
+ else proc.kill(sig);
322
+ } catch {
323
+ /* already dead */
324
+ }
325
+ };
326
+
327
+ // Swallow stream errors (e.g. EPIPE when a hook ignores stdin) so they
328
+ // never become an uncaughtException that crashes the whole pi process.
329
+ const swallow = () => {};
330
+ proc.stdin?.on("error", swallow);
331
+ proc.stdout?.on("error", swallow);
332
+
333
+ // Escalating kill shared by the stdout-over-limit and timeout paths:
334
+ // SIGTERM, then SIGKILL after a grace window, then force-resolve. The
335
+ // force-resolve is essential — `close` may never fire if a grandchild
336
+ // inherited the stdout pipe and outlives the killed shell, which would
337
+ // otherwise leave this promise (and the awaiting handler) pending
338
+ // forever. Sets `killed` so any buffered output is discarded rather than
339
+ // parsed and applied.
340
+ const killAndFinish = () => {
341
+ if (settled) return;
342
+ killed = true;
343
+ clearTimeout(sigtermTimer);
344
+ killTree("SIGTERM");
345
+ sigkillTimer = setTimeout(() => {
346
+ killTree("SIGKILL");
347
+ finish(emptyResult());
348
+ }, KILL_GRACE_MS);
349
+ };
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
+
356
+ // Accumulate raw buffers; decode once at the end to avoid splitting
357
+ // multi-byte UTF-8 characters across chunks (silent mojibake).
122
358
  proc.stdout?.on("data", (chunk: Buffer) => {
123
- stdout += chunk.toString();
359
+ if (killed) return;
360
+ bytes += chunk.length;
361
+ if (bytes > MAX_STDOUT_BYTES) {
362
+ console.error(`[hooks] stdout exceeded ${formatSize(MAX_STDOUT_BYTES)}, killing: ${command}`);
363
+ killAndFinish();
364
+ return;
365
+ }
366
+ chunks.push(chunk);
124
367
  });
125
368
 
126
- let sigkillTimer: NodeJS.Timeout | undefined;
127
-
128
- const sigtermTimer = setTimeout(() => {
129
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
130
- // escalate to SIGKILL after a 5s grace window
131
- sigkillTimer = setTimeout(() => {
132
- try { proc.kill("SIGKILL"); } catch { /* already dead */ }
133
- // force-resolve: a process that survives SIGKILL is unrecoverable
134
- finish(null);
135
- }, 5_000);
136
- }, 10_000);
369
+ const sigtermTimer = setTimeout(killAndFinish, timeoutMs);
137
370
 
138
371
  proc.on("close", (code) => {
139
- if (code !== 0) console.error(`[hooks] exited ${code}: ${command}`);
140
- try {
141
- const trimmed = stdout.trim();
142
- finish(trimmed ? (JSON.parse(trimmed) as HookOutput) : null);
143
- } catch {
144
- finish(null);
372
+ if (killed) {
373
+ finish(emptyResult());
374
+ return;
145
375
  }
376
+ // Exit code 2 is the documented PreToolUse "deny" signal, not an error —
377
+ // parseHookOutput honors it as a block, so don't log it as a failure.
378
+ if (code !== 0 && code !== 2 && code !== null) console.error(`[hooks] exited ${code}: ${command}`);
379
+ const stdout = Buffer.concat(chunks).toString("utf8").trim();
380
+ finish(parseHookOutput(command, stdout, code));
146
381
  });
147
382
 
148
383
  proc.on("error", (err) => {
149
384
  console.error(`[hooks] spawn error: ${command}: ${err}`);
150
- finish(null);
385
+ finish(emptyResult());
151
386
  });
152
387
 
153
388
  try {
154
- proc.stdin?.write(JSON.stringify(stdinJson));
389
+ proc.stdin?.write(stdinText);
155
390
  proc.stdin?.end();
156
- } catch { /* already exited */ }
391
+ } catch {
392
+ /* sync throw only; async stream errors handled by 'error' listeners */
393
+ }
157
394
  });
158
395
  }
159
396
 
160
397
  // ============================================================================
161
- // Run matching hook groups, return collected additionalContext strings
398
+ // Run matching hook groups (parallel), collecting context + block decision
162
399
  // ============================================================================
163
400
 
164
401
  async function runGroups(
165
402
  groups: HookGroup[] | undefined,
166
403
  toolName: string,
167
404
  cwd: string,
168
- stdinJson: unknown,
169
- ): Promise<string[]> {
170
- const contexts: string[] = [];
171
- if (!groups) return contexts;
405
+ stdinText: string,
406
+ signal?: AbortSignal,
407
+ ): Promise<{ contexts: string[]; block: string | null }> {
408
+ if (!groups) return { contexts: [], block: null };
409
+
410
+ const commands: Array<{ command: string; timeoutMs: number }> = [];
172
411
  for (const group of groups) {
173
- if (!globMatch(group.matcher, toolName)) continue;
412
+ if (!matchTool(group.matcher, toolName)) continue;
174
413
  for (const hook of group.hooks) {
175
- if (hook.type !== "command") continue;
176
- const out = await runCommand(hook.command, cwd, stdinJson);
177
- const ctx = out?.hookSpecificOutput?.additionalContext;
178
- if (ctx) contexts.push(ctx);
414
+ commands.push({
415
+ command: hook.command,
416
+ timeoutMs: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
417
+ });
179
418
  }
180
419
  }
181
- return contexts;
420
+ if (commands.length === 0) return { contexts: [], block: null };
421
+
422
+ // Run concurrently (Claude Code runs matching hooks in parallel) but cap
423
+ // simultaneous subprocesses; preserve submission order of context.
424
+ const MAX_CONCURRENT = 8;
425
+ const results: HookResult[] = [];
426
+ for (let i = 0; i < commands.length; i += MAX_CONCURRENT) {
427
+ const batch = commands.slice(i, i + MAX_CONCURRENT);
428
+ results.push(...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, signal)))));
429
+ }
430
+
431
+ const contexts: string[] = [];
432
+ let block: string | null = null;
433
+ for (const r of results) {
434
+ if (r.context) contexts.push(r.context);
435
+ if (r.block && block === null) block = r.block;
436
+ }
437
+ return { contexts, block };
438
+ }
439
+
440
+ // ============================================================================
441
+ // SessionStart reason → Claude Code source mapping
442
+ // ============================================================================
443
+
444
+ function mapSessionSource(reason: string | undefined): string {
445
+ switch (reason) {
446
+ case "new":
447
+ return "clear"; // CC SessionStart "clear" matcher
448
+ case "resume":
449
+ return "resume";
450
+ case "fork":
451
+ return "resume"; // fork continues history — closer to CC "resume" than "startup"
452
+ case "reload":
453
+ case "startup":
454
+ default:
455
+ return "startup";
456
+ }
182
457
  }
183
458
 
184
459
  // ============================================================================
@@ -186,51 +461,51 @@ async function runGroups(
186
461
  // ============================================================================
187
462
 
188
463
  export default function (pi: ExtensionAPI): void {
464
+ // undefined = not loaded yet; null = loaded but no config. The single
465
+ // variable distinguishes both, so a missing/unreadable file is not
466
+ // re-read on every event.
189
467
  let config: HooksConfig | null | undefined;
468
+ const getConfig = (cwd: string): HooksConfig | null => {
469
+ if (config === undefined) {
470
+ config = loadConfig(cwd);
471
+ }
472
+ return config;
473
+ };
190
474
 
191
- // SessionStart additionalContext waiting to be injected on the first LLM call.
192
- // Set by before_agent_start (which is awaited before the agent loop starts),
193
- // consumed once by the context handler.
475
+ // SessionStart additionalContext waiting to be injected on the first LLM
476
+ // call. session_start fires before the first context event, so this is set
477
+ // in time for injection. Consumed once by the context handler.
194
478
  let activateContext: string | null = null;
195
- let activated = false;
196
479
 
197
- // PreToolUse additionalContext queued by tool_call, injected before each LLM call.
480
+ // PreToolUse additionalContext queued by tool_call, injected before each
481
+ // LLM call.
198
482
  const pendingContexts: string[] = [];
199
483
 
200
484
  // -------------------------------------------------------------------------
201
- // before_agent_start: SessionStart hooks (first turn only).
202
- //
203
- // Runs the hook and stores additionalContext for injection. Does NOT return
204
- // a message or modify the system prompt—injection happens in context so all
205
- // LLM message modification is in one place and the activate context is
206
- // treated identically to remind context by the LLM.
485
+ // session_start: SessionStart hooks.
207
486
  //
208
- // Sequencing: agent-session.ts awaits emitBeforeAgentStart() before calling
209
- // _runAgentPrompt(), so this handler always completes before context fires.
487
+ // Bound to session_start (not before_agent_start) so the matcher can match
488
+ // the session source (startup/resume/clear/...). session_start fires before
489
+ // the first context event, so activateContext is ready in time.
210
490
  // -------------------------------------------------------------------------
211
- pi.on("before_agent_start", async (_event, ctx) => {
212
- config ??= loadConfig(ctx.cwd);
213
- if (!config || activated) return;
214
- activated = true;
215
-
216
- const stdin = {
217
- type: "session_start",
218
- session_id: getSessionId(ctx),
219
- transcript_path: getSessionId(ctx),
220
- };
221
- const contexts = await runGroups(config.hooks.SessionStart, "", ctx.cwd, stdin);
222
- if (contexts.length > 0) {
223
- activateContext = contexts.join("\n\n");
224
- }
491
+ pi.on("session_start", async (event, ctx) => {
492
+ // "reload" is a runtime rebind, not a session-source event — SessionStart
493
+ // hooks must not re-run mid-session.
494
+ if (event.reason === "reload") return;
495
+ const cfg = getConfig(ctx.cwd);
496
+ if (!cfg?.hooks.SessionStart) return;
497
+ const source = mapSessionSource(event.reason);
498
+ const stdin = buildStdin("SessionStart", ctx, { source });
499
+ const { contexts } = await runGroups(cfg.hooks.SessionStart, source, ctx.cwd, JSON.stringify(stdin));
500
+ if (contexts.length > 0) activateContext = contexts.join("\n\n");
225
501
  });
226
502
 
227
503
  // -------------------------------------------------------------------------
228
504
  // context: inject all pending contexts before each LLM call.
229
505
  //
230
- // Combines activate (SessionStart) and remind (PreToolUse) contexts.
231
- // Appends to the last user message's content array—never adds a new message—
232
- // so there are no consecutive-user-message issues and no extra turns.
233
- // The injected text is invisible to the display layer (UI shows original).
506
+ // Appends to the last user message's content array — never adds a new
507
+ // message — so there are no consecutive-user-message issues and no extra
508
+ // turns. Handles both array and (defensively) string content shapes.
234
509
  // -------------------------------------------------------------------------
235
510
  pi.on("context", (event) => {
236
511
  const toInject: string[] = [];
@@ -246,25 +521,35 @@ export default function (pi: ExtensionAPI): void {
246
521
 
247
522
  if (toInject.length === 0) return;
248
523
 
249
- 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;
250
532
  const messages = [...event.messages];
251
533
  const lastUserIdx = messages.findLastIndex((m) => (m as { role: string }).role === "user");
252
534
 
253
535
  if (lastUserIdx >= 0) {
254
- // Append to the last user message's content array. pi-hooks operates on
255
- // messages structurally (any role:"user" message whose content is an
256
- // array); it does not depend on a specific message variant type, so we
257
- // bridge through `unknown` rather than asserting an incompatible shape.
258
- const last = messages[lastUserIdx] as unknown as {
259
- role: string;
260
- content: unknown[];
261
- };
262
- if (Array.isArray(last.content)) {
536
+ const last = messages[lastUserIdx] as unknown as { role: string; content: unknown };
537
+ const c = last.content;
538
+ if (typeof c === "string") {
263
539
  messages[lastUserIdx] = {
264
540
  ...last,
265
- content: [...last.content, { type: "text" as const, text }],
541
+ content: [
542
+ { type: "text" as const, text: c },
543
+ { type: "text" as const, text },
544
+ ],
545
+ } as unknown as (typeof messages)[number];
546
+ } else if (Array.isArray(c)) {
547
+ messages[lastUserIdx] = {
548
+ ...last,
549
+ content: [...c, { type: "text" as const, text }],
266
550
  } as unknown as (typeof messages)[number];
267
551
  }
552
+ // non-string/non-array content: leave untouched (nothing to append to).
268
553
  } else {
269
554
  (messages as unknown[]).push({ role: "user", content: [{ type: "text" as const, text }] });
270
555
  }
@@ -273,39 +558,37 @@ export default function (pi: ExtensionAPI): void {
273
558
  });
274
559
 
275
560
  // -------------------------------------------------------------------------
276
- // tool_call: PreToolUse hooks.
277
- // Empty-matcher groups (remind) run for every tool with the real tool_name.
278
- // Non-empty-matcher groups (auto-approve) run only for matching tools.
561
+ // tool_call: PreToolUse hooks. Honors deny (permissionDecision/exit 2) by
562
+ // returning { block: true, reason, terminate: true } (terminate skips the
563
+ // automatic follow-up LLM call; requires pi >= 0.84.1). additionalContext is
564
+ // queued for the next context event. All matching groups run, regardless of matcher.
279
565
  // -------------------------------------------------------------------------
280
566
  pi.on("tool_call", async (event, ctx) => {
281
- config ??= loadConfig(ctx.cwd);
282
- if (!config?.hooks.PreToolUse) return;
567
+ const cfg = getConfig(ctx.cwd);
568
+ if (!cfg?.hooks.PreToolUse) return;
283
569
 
284
- const sessionId = getSessionId(ctx);
285
- const stdin = {
286
- type: "pre_tool_use",
287
- session_id: sessionId,
570
+ const stdin = buildStdin("PreToolUse", ctx, {
288
571
  tool_name: event.toolName,
289
572
  tool_input: event.input ?? {},
290
- };
291
-
292
- // Empty-matcher groups: remind (runs for every tool).
293
- const emptyGroups = config.hooks.PreToolUse.filter((g) => g.matcher === "");
294
- const remindContexts = await runGroups(emptyGroups, event.toolName, ctx.cwd, stdin);
295
- if (remindContexts.length > 0) pendingContexts.push(...remindContexts);
573
+ });
296
574
 
297
- // Non-empty-matcher groups: auto-approve etc. (filtered by toolName).
298
- const specificGroups = config.hooks.PreToolUse.filter((g) => g.matcher !== "");
299
- await runGroups(specificGroups, event.toolName, ctx.cwd, 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);
578
+ // A block + terminate skips this round's follow-up LLM call, so queued
579
+ // context would leak into the next user prompt — drop it on block.
580
+ if (block) return { block: true, reason: block, terminate: true };
581
+ if (contexts.length > 0) pendingContexts.push(...contexts);
300
582
  });
301
583
 
302
584
  // -------------------------------------------------------------------------
303
- // session_shutdown: Stop hooks.
585
+ // session_shutdown: Stop hooks (cleanup only). CC's Stop `decision: "block"`
586
+ // is intentionally not honored — pi cannot prevent exit from here.
304
587
  // -------------------------------------------------------------------------
305
588
  pi.on("session_shutdown", async (_event, ctx) => {
306
- config ??= loadConfig(ctx.cwd);
307
- if (!config) return;
308
- const stdin = { type: "stop", session_id: getSessionId(ctx) };
309
- await runGroups(config.hooks.Stop, "", ctx.cwd, stdin);
589
+ const cfg = getConfig(ctx.cwd);
590
+ if (!cfg) return;
591
+ const stdin = buildStdin("Stop", ctx);
592
+ await runGroups(cfg.hooks.Stop, "", ctx.cwd, JSON.stringify(stdin));
310
593
  });
311
594
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-hooks",
3
- "version": "1.0.1",
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",
@@ -44,10 +44,10 @@
44
44
  "typecheck": "tsc"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": ">=0.77.0"
47
+ "@earendil-works/pi-coding-agent": ">=0.84.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.77.0",
50
+ "@earendil-works/pi-coding-agent": "0.84.1",
51
51
  "@types/node": "22.19.19",
52
52
  "typescript": "5.9.3"
53
53
  }