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