@fyeeme/pi-hooks 1.0.0 → 1.0.2

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