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