@fyeeme/pi-hooks 1.0.1 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +31 -13
- package/index.ts +363 -132
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ 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
|
+
|
|
10
39
|
## [1.0.1] - 2025-07-15
|
|
11
40
|
|
|
12
41
|
### Fixed
|
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`
|
|
98
|
-
| `PreToolUse`
|
|
99
|
-
| `
|
|
100
|
-
|
|
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
|
-
{ "
|
|
108
|
-
{ "
|
|
109
|
-
{ "
|
|
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
|
-
|
|
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)
|
|
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 →
|
|
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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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,72 +58,219 @@ interface HooksConfig {
|
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
110
|
+
// Matcher (Claude Code regex semantics)
|
|
57
111
|
// ============================================================================
|
|
58
112
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
167
|
+
// Session id / transcript path (CC-compatible field semantics)
|
|
89
168
|
// ============================================================================
|
|
90
169
|
|
|
91
170
|
function getSessionId(ctx: ExtensionContext): string {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
};
|
|
98
194
|
}
|
|
99
195
|
|
|
100
196
|
// ============================================================================
|
|
101
|
-
//
|
|
197
|
+
// Hook output parsing (control flow + context)
|
|
102
198
|
// ============================================================================
|
|
103
199
|
|
|
104
|
-
|
|
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 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// Command runner
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
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
|
-
|
|
267
|
+
const chunks: Buffer[] = [];
|
|
268
|
+
let bytes = 0;
|
|
269
|
+
let killed = false;
|
|
113
270
|
let settled = false;
|
|
114
|
-
|
|
271
|
+
let sigkillTimer: NodeJS.Timeout | undefined;
|
|
272
|
+
|
|
273
|
+
const finish = (result: HookResult) => {
|
|
115
274
|
if (settled) return;
|
|
116
275
|
settled = true;
|
|
117
276
|
clearTimeout(sigtermTimer);
|
|
@@ -119,66 +278,139 @@ async function runCommand(command: string, cwd: string, stdinJson: unknown): Pro
|
|
|
119
278
|
resolve(result);
|
|
120
279
|
};
|
|
121
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).
|
|
122
316
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
123
|
-
|
|
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);
|
|
124
325
|
});
|
|
125
326
|
|
|
126
|
-
|
|
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);
|
|
327
|
+
const sigtermTimer = setTimeout(killAndFinish, timeoutMs);
|
|
137
328
|
|
|
138
329
|
proc.on("close", (code) => {
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
finish(trimmed ? (JSON.parse(trimmed) as HookOutput) : null);
|
|
143
|
-
} catch {
|
|
144
|
-
finish(null);
|
|
330
|
+
if (killed) {
|
|
331
|
+
finish(emptyResult());
|
|
332
|
+
return;
|
|
145
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));
|
|
146
339
|
});
|
|
147
340
|
|
|
148
341
|
proc.on("error", (err) => {
|
|
149
342
|
console.error(`[hooks] spawn error: ${command}: ${err}`);
|
|
150
|
-
finish(
|
|
343
|
+
finish(emptyResult());
|
|
151
344
|
});
|
|
152
345
|
|
|
153
346
|
try {
|
|
154
|
-
proc.stdin?.write(
|
|
347
|
+
proc.stdin?.write(stdinText);
|
|
155
348
|
proc.stdin?.end();
|
|
156
|
-
} catch {
|
|
349
|
+
} catch {
|
|
350
|
+
/* sync throw only; async stream errors handled by 'error' listeners */
|
|
351
|
+
}
|
|
157
352
|
});
|
|
158
353
|
}
|
|
159
354
|
|
|
160
355
|
// ============================================================================
|
|
161
|
-
// Run matching hook groups,
|
|
356
|
+
// Run matching hook groups (parallel), collecting context + block decision
|
|
162
357
|
// ============================================================================
|
|
163
358
|
|
|
164
359
|
async function runGroups(
|
|
165
360
|
groups: HookGroup[] | undefined,
|
|
166
361
|
toolName: string,
|
|
167
362
|
cwd: string,
|
|
168
|
-
|
|
169
|
-
): Promise<string[]> {
|
|
170
|
-
|
|
171
|
-
|
|
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 }> = [];
|
|
172
368
|
for (const group of groups) {
|
|
173
|
-
if (!
|
|
369
|
+
if (!matchTool(group.matcher, toolName)) continue;
|
|
174
370
|
for (const hook of group.hooks) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
371
|
+
commands.push({
|
|
372
|
+
command: hook.command,
|
|
373
|
+
timeoutMs: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
374
|
+
});
|
|
179
375
|
}
|
|
180
376
|
}
|
|
181
|
-
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
|
+
}
|
|
182
414
|
}
|
|
183
415
|
|
|
184
416
|
// ============================================================================
|
|
@@ -186,51 +418,51 @@ async function runGroups(
|
|
|
186
418
|
// ============================================================================
|
|
187
419
|
|
|
188
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.
|
|
189
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
|
+
};
|
|
190
431
|
|
|
191
|
-
// SessionStart additionalContext waiting to be injected on the first LLM
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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.
|
|
194
435
|
let activateContext: string | null = null;
|
|
195
|
-
let activated = false;
|
|
196
436
|
|
|
197
|
-
// PreToolUse additionalContext queued by tool_call, injected before each
|
|
437
|
+
// PreToolUse additionalContext queued by tool_call, injected before each
|
|
438
|
+
// LLM call.
|
|
198
439
|
const pendingContexts: string[] = [];
|
|
199
440
|
|
|
200
441
|
// -------------------------------------------------------------------------
|
|
201
|
-
//
|
|
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.
|
|
442
|
+
// session_start: SessionStart hooks.
|
|
207
443
|
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
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.
|
|
210
447
|
// -------------------------------------------------------------------------
|
|
211
|
-
pi.on("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const contexts = await runGroups(config.hooks.SessionStart, "", ctx.cwd, stdin);
|
|
222
|
-
if (contexts.length > 0) {
|
|
223
|
-
activateContext = contexts.join("\n\n");
|
|
224
|
-
}
|
|
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");
|
|
225
458
|
});
|
|
226
459
|
|
|
227
460
|
// -------------------------------------------------------------------------
|
|
228
461
|
// context: inject all pending contexts before each LLM call.
|
|
229
462
|
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// 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.
|
|
234
466
|
// -------------------------------------------------------------------------
|
|
235
467
|
pi.on("context", (event) => {
|
|
236
468
|
const toInject: string[] = [];
|
|
@@ -251,20 +483,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
251
483
|
const lastUserIdx = messages.findLastIndex((m) => (m as { role: string }).role === "user");
|
|
252
484
|
|
|
253
485
|
if (lastUserIdx >= 0) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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)) {
|
|
263
497
|
messages[lastUserIdx] = {
|
|
264
498
|
...last,
|
|
265
|
-
content: [...
|
|
499
|
+
content: [...c, { type: "text" as const, text }],
|
|
266
500
|
} as unknown as (typeof messages)[number];
|
|
267
501
|
}
|
|
502
|
+
// non-string/non-array content: leave untouched (nothing to append to).
|
|
268
503
|
} else {
|
|
269
504
|
(messages as unknown[]).push({ role: "user", content: [{ type: "text" as const, text }] });
|
|
270
505
|
}
|
|
@@ -273,39 +508,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
273
508
|
});
|
|
274
509
|
|
|
275
510
|
// -------------------------------------------------------------------------
|
|
276
|
-
// tool_call: PreToolUse hooks.
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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.
|
|
279
515
|
// -------------------------------------------------------------------------
|
|
280
516
|
pi.on("tool_call", async (event, ctx) => {
|
|
281
|
-
|
|
282
|
-
if (!
|
|
517
|
+
const cfg = getConfig(ctx.cwd);
|
|
518
|
+
if (!cfg?.hooks.PreToolUse) return;
|
|
283
519
|
|
|
284
|
-
const
|
|
285
|
-
const stdin = {
|
|
286
|
-
type: "pre_tool_use",
|
|
287
|
-
session_id: sessionId,
|
|
520
|
+
const stdin = buildStdin("PreToolUse", ctx, {
|
|
288
521
|
tool_name: event.toolName,
|
|
289
522
|
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);
|
|
523
|
+
});
|
|
296
524
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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);
|
|
300
530
|
});
|
|
301
531
|
|
|
302
532
|
// -------------------------------------------------------------------------
|
|
303
|
-
// 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.
|
|
304
535
|
// -------------------------------------------------------------------------
|
|
305
536
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
306
|
-
|
|
307
|
-
if (!
|
|
308
|
-
const stdin =
|
|
309
|
-
await runGroups(
|
|
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));
|
|
310
541
|
});
|
|
311
542
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-hooks",
|
|
3
|
-
"version": "1.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.
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.84.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@earendil-works/pi-coding-agent": "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
|
}
|