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