@fyeeme/pi-hooks 1.0.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2025-05-31
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - Claude Code-compatible hooks runner for pi
16
+ - SessionStart hooks via `before_agent_start` with context injection
17
+ - PreToolUse hooks via `tool_call` with queued context injection
18
+ - Stop hooks via `session_shutdown`
19
+ - All additionalContext injected via `context` event into last user message
20
+ - Config loaded from `.pi/hooks.json` or `PI_HOOKS_CONFIG` env var
21
+ - Glob matching for tool name matchers
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # pi-hooks
2
+
3
+ A Claude Code-compatible hooks runner for [pi](https://pi.dev). Reads `.pi/hooks.json` from your project 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
+ ## Install
6
+
7
+ Requires the [pi](https://pi.dev) CLI.
8
+
9
+ ### From npm (recommended)
10
+
11
+ ```bash
12
+ # Global (user) install — available in every project
13
+ pi install npm:@fyeeme/pi-hooks
14
+
15
+ # Project-local — written to .pi/settings.json, shareable with your team
16
+ pi install -l npm:@fyeeme/pi-hooks
17
+
18
+ # Pinned version — skipped by `pi update`
19
+ pi install npm:@fyeeme/pi-hooks@1.0.0
20
+
21
+ # Try it once without saving (current run only)
22
+ pi -e npm:@fyeeme/pi-hooks
23
+ ```
24
+
25
+ ### From GitHub
26
+
27
+ Source: [`fyeeme/pi-packages`](https://github.com/fyeeme/pi-packages).
28
+
29
+ ```bash
30
+ # HTTPS shorthand
31
+ pi install git:github.com/fyeeme/pi-packages
32
+ # Pin to a tag or commit (skipped by `pi update`)
33
+ pi install git:github.com/fyeeme/pi-packages@v1.0.0
34
+ # Raw URL form
35
+ pi install https://github.com/fyeeme/pi-packages
36
+ ```
37
+
38
+ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of source types, scopes, and `pi update` behavior.
39
+
40
+ ## Configuration
41
+
42
+ Create `.pi/hooks.json` in your project root:
43
+
44
+ ```json
45
+ {
46
+ "hooks": {
47
+ "SessionStart": [
48
+ {
49
+ "matcher": "",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "serena-hooks activate --client=claude-code"
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "PreToolUse": [
59
+ {
60
+ "matcher": "",
61
+ "hooks": [
62
+ {
63
+ "type": "command",
64
+ "command": "serena-hooks remind --client=claude-code"
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "matcher": "plugin_serena_serena_*",
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": "serena-hooks auto-approve --client=claude-code"
74
+ }
75
+ ]
76
+ }
77
+ ],
78
+ "Stop": [
79
+ {
80
+ "matcher": "",
81
+ "hooks": [
82
+ {
83
+ "type": "command",
84
+ "command": "serena-hooks cleanup --client=claude-code"
85
+ }
86
+ ]
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## Event Mapping
94
+
95
+ | hooks.json event | pi event | Notes |
96
+ |---|---|---|
97
+ | `SessionStart` | `session_start` | Runs on startup. `additionalContext` sent via `sendUserMessage`. |
98
+ | `PreToolUse` (empty matcher) | `tool_call` | Runs before every tool with real `tool_name`. `additionalContext` injected before next LLM call. |
99
+ | `PreToolUse` (pattern matcher) | `tool_call` | Glob match against pi tool name (e.g. `plugin_serena_serena_*`). |
100
+ | `Stop` | `session_shutdown` | Runs on exit. |
101
+
102
+ ## Protocol
103
+
104
+ Commands receive Claude Code-compatible JSON on stdin:
105
+
106
+ ```json
107
+ { "type": "session_start", "session_id": "...", "transcript_path": "..." }
108
+ { "type": "pre_tool_use", "session_id": "...", "tool_name": "bash", "tool_input": {} }
109
+ { "type": "stop", "session_id": "..." }
110
+ ```
111
+
112
+ Commands may return JSON on stdout:
113
+
114
+ ```json
115
+ { "hookSpecificOutput": { "additionalContext": "..." } }
116
+ ```
117
+
118
+ The `additionalContext` is injected into the pi conversation.
119
+
120
+ ## MCP Tool Names
121
+
122
+ Pi names MCP tools as `<serverName>_<toolName>` (not `mcp__server__tool` like Claude Code). Check your actual tool names with `/mcp` in pi to set the correct `matcher`.
123
+
124
+ ## Config Override
125
+
126
+ Set `PI_HOOKS_CONFIG` env var to point to a custom config path.
package/index.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * pi-hooks
3
+ *
4
+ * Claude Code-compatible hooks runner for pi.
5
+ *
6
+ * Reads `.pi/hooks.json` (or `PI_HOOKS_CONFIG` env) and maps:
7
+ * SessionStart → before_agent_start (first turn)
8
+ * PreToolUse → tool_call
9
+ * Stop → session_shutdown
10
+ *
11
+ * All additionalContext—from both SessionStart and PreToolUse—is injected
12
+ * into the last user message via the context event. This guarantees the LLM
13
+ * sees and acts on the context without extra turns, fake user messages, or
14
+ * system prompt passivity.
15
+ *
16
+ * Sequence guarantee:
17
+ * emitBeforeAgentStart() is awaited by agent-session.ts before
18
+ * _runAgentPrompt() starts, so before_agent_start always completes
19
+ * before the first context event fires. No race condition.
20
+ */
21
+
22
+ import { readFileSync, existsSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { spawn } from "node:child_process";
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+
27
+ // ============================================================================
28
+ // Config schema
29
+ // ============================================================================
30
+
31
+ interface HookEntry {
32
+ type: "command";
33
+ command: string;
34
+ }
35
+
36
+ interface HookGroup {
37
+ matcher: string;
38
+ hooks: HookEntry[];
39
+ }
40
+
41
+ interface HooksConfig {
42
+ hooks: {
43
+ SessionStart?: HookGroup[];
44
+ PreToolUse?: HookGroup[];
45
+ Stop?: HookGroup[];
46
+ };
47
+ }
48
+
49
+ interface HookOutput {
50
+ hookSpecificOutput?: {
51
+ additionalContext?: string;
52
+ };
53
+ }
54
+
55
+ // ============================================================================
56
+ // Glob matching
57
+ // ============================================================================
58
+
59
+ export function globMatch(pattern: string, value: string): boolean {
60
+ if (pattern === "") return true;
61
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
62
+ const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
63
+ return new RegExp(`^${regexStr}$`).test(value);
64
+ }
65
+
66
+ // ============================================================================
67
+ // Config loader - cached per session via ??=
68
+ // ============================================================================
69
+
70
+ export function loadConfig(cwd: string): HooksConfig | null {
71
+ const envPath = process.env.PI_HOOKS_CONFIG;
72
+ const candidates = envPath
73
+ ? [envPath]
74
+ : [join(cwd, ".pi", "hooks.json"), join(process.env.HOME ?? "", ".pi", "hooks.json")];
75
+
76
+ for (const p of candidates) {
77
+ if (!existsSync(p)) continue;
78
+ try {
79
+ return JSON.parse(readFileSync(p, "utf-8")) as HooksConfig;
80
+ } catch (err) {
81
+ console.error(`[hooks] failed to parse ${p}: ${err}`);
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // ============================================================================
88
+ // Session ID
89
+ // ============================================================================
90
+
91
+ function getSessionId(ctx: ExtensionContext): string {
92
+ try {
93
+ const sm = ctx.sessionManager as { getSessionFile?: () => string | undefined };
94
+ const file = sm.getSessionFile?.();
95
+ if (file) return file;
96
+ } catch { /* ignore */ }
97
+ return `${ctx.cwd}:${Date.now()}`;
98
+ }
99
+
100
+ // ============================================================================
101
+ // Command runner - pipes JSON to stdin, captures stdout JSON
102
+ // ============================================================================
103
+
104
+ async function runCommand(command: string, cwd: string, stdinJson: unknown): Promise<HookOutput | null> {
105
+ return new Promise((resolve) => {
106
+ const proc = spawn(command, [], {
107
+ shell: true,
108
+ cwd,
109
+ stdio: ["pipe", "pipe", "inherit"],
110
+ });
111
+
112
+ let stdout = "";
113
+ proc.stdout?.on("data", (chunk: Buffer) => {
114
+ stdout += chunk.toString();
115
+ });
116
+
117
+ const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
118
+
119
+ proc.on("close", (code) => {
120
+ clearTimeout(timer);
121
+ if (code !== 0) console.error(`[hooks] exited ${code}: ${command}`);
122
+ try {
123
+ const trimmed = stdout.trim();
124
+ resolve(trimmed ? (JSON.parse(trimmed) as HookOutput) : null);
125
+ } catch {
126
+ resolve(null);
127
+ }
128
+ });
129
+
130
+ proc.on("error", (err) => {
131
+ clearTimeout(timer);
132
+ console.error(`[hooks] spawn error: ${command}: ${err}`);
133
+ resolve(null);
134
+ });
135
+
136
+ try {
137
+ proc.stdin?.write(JSON.stringify(stdinJson));
138
+ proc.stdin?.end();
139
+ } catch { /* already exited */ }
140
+ });
141
+ }
142
+
143
+ // ============================================================================
144
+ // Run matching hook groups, return collected additionalContext strings
145
+ // ============================================================================
146
+
147
+ async function runGroups(
148
+ groups: HookGroup[] | undefined,
149
+ toolName: string,
150
+ cwd: string,
151
+ stdinJson: unknown,
152
+ ): Promise<string[]> {
153
+ const contexts: string[] = [];
154
+ if (!groups) return contexts;
155
+ for (const group of groups) {
156
+ if (!globMatch(group.matcher, toolName)) continue;
157
+ for (const hook of group.hooks) {
158
+ if (hook.type !== "command") continue;
159
+ const out = await runCommand(hook.command, cwd, stdinJson);
160
+ const ctx = out?.hookSpecificOutput?.additionalContext;
161
+ if (ctx) contexts.push(ctx);
162
+ }
163
+ }
164
+ return contexts;
165
+ }
166
+
167
+ // ============================================================================
168
+ // Extension
169
+ // ============================================================================
170
+
171
+ export default function (pi: ExtensionAPI): void {
172
+ let config: HooksConfig | null | undefined;
173
+
174
+ // SessionStart additionalContext waiting to be injected on the first LLM call.
175
+ // Set by before_agent_start (which is awaited before the agent loop starts),
176
+ // consumed once by the context handler.
177
+ let activateContext: string | null = null;
178
+ let activated = false;
179
+
180
+ // PreToolUse additionalContext queued by tool_call, injected before each LLM call.
181
+ const pendingContexts: string[] = [];
182
+
183
+ // -------------------------------------------------------------------------
184
+ // before_agent_start: SessionStart hooks (first turn only).
185
+ //
186
+ // Runs the hook and stores additionalContext for injection. Does NOT return
187
+ // a message or modify the system prompt—injection happens in context so all
188
+ // LLM message modification is in one place and the activate context is
189
+ // treated identically to remind context by the LLM.
190
+ //
191
+ // Sequencing: agent-session.ts awaits emitBeforeAgentStart() before calling
192
+ // _runAgentPrompt(), so this handler always completes before context fires.
193
+ // -------------------------------------------------------------------------
194
+ pi.on("before_agent_start", async (_event, ctx) => {
195
+ config ??= loadConfig(ctx.cwd);
196
+ if (!config || activated) return;
197
+ activated = true;
198
+
199
+ const stdin = {
200
+ type: "session_start",
201
+ session_id: getSessionId(ctx),
202
+ transcript_path: getSessionId(ctx),
203
+ };
204
+ const contexts = await runGroups(config.hooks.SessionStart, "", ctx.cwd, stdin);
205
+ if (contexts.length > 0) {
206
+ activateContext = contexts.join("\n\n");
207
+ }
208
+ });
209
+
210
+ // -------------------------------------------------------------------------
211
+ // context: inject all pending contexts before each LLM call.
212
+ //
213
+ // Combines activate (SessionStart) and remind (PreToolUse) contexts.
214
+ // Appends to the last user message's content array—never adds a new message—
215
+ // so there are no consecutive-user-message issues and no extra turns.
216
+ // The injected text is invisible to the display layer (UI shows original).
217
+ // -------------------------------------------------------------------------
218
+ pi.on("context", (event) => {
219
+ const toInject: string[] = [];
220
+
221
+ if (activateContext !== null) {
222
+ toInject.push(activateContext);
223
+ activateContext = null;
224
+ }
225
+
226
+ if (pendingContexts.length > 0) {
227
+ toInject.push(...pendingContexts.splice(0));
228
+ }
229
+
230
+ if (toInject.length === 0) return;
231
+
232
+ const text = toInject.join("\n\n");
233
+ const messages = [...event.messages];
234
+ const lastUserIdx = messages.findLastIndex((m) => (m as { role: string }).role === "user");
235
+
236
+ if (lastUserIdx >= 0) {
237
+ // Append to the last user message's content array. pi-hooks operates on
238
+ // messages structurally (any role:"user" message whose content is an
239
+ // array); it does not depend on a specific message variant type, so we
240
+ // bridge through `unknown` rather than asserting an incompatible shape.
241
+ const last = messages[lastUserIdx] as unknown as {
242
+ role: string;
243
+ content: unknown[];
244
+ };
245
+ if (Array.isArray(last.content)) {
246
+ messages[lastUserIdx] = {
247
+ ...last,
248
+ content: [...last.content, { type: "text" as const, text }],
249
+ } as unknown as (typeof messages)[number];
250
+ }
251
+ } else {
252
+ (messages as unknown[]).push({ role: "user", content: [{ type: "text" as const, text }] });
253
+ }
254
+
255
+ return { messages: messages as typeof event.messages };
256
+ });
257
+
258
+ // -------------------------------------------------------------------------
259
+ // tool_call: PreToolUse hooks.
260
+ // Empty-matcher groups (remind) run for every tool with the real tool_name.
261
+ // Non-empty-matcher groups (auto-approve) run only for matching tools.
262
+ // -------------------------------------------------------------------------
263
+ pi.on("tool_call", async (event, ctx) => {
264
+ config ??= loadConfig(ctx.cwd);
265
+ if (!config?.hooks.PreToolUse) return;
266
+
267
+ const sessionId = getSessionId(ctx);
268
+ const stdin = {
269
+ type: "pre_tool_use",
270
+ session_id: sessionId,
271
+ tool_name: event.toolName,
272
+ tool_input: event.input ?? {},
273
+ };
274
+
275
+ // Empty-matcher groups: remind (runs for every tool).
276
+ const emptyGroups = config.hooks.PreToolUse.filter((g) => g.matcher === "");
277
+ const remindContexts = await runGroups(emptyGroups, event.toolName, ctx.cwd, stdin);
278
+ if (remindContexts.length > 0) pendingContexts.push(...remindContexts);
279
+
280
+ // Non-empty-matcher groups: auto-approve etc. (filtered by toolName).
281
+ const specificGroups = config.hooks.PreToolUse.filter((g) => g.matcher !== "");
282
+ await runGroups(specificGroups, event.toolName, ctx.cwd, stdin);
283
+ });
284
+
285
+ // -------------------------------------------------------------------------
286
+ // session_shutdown: Stop hooks.
287
+ // -------------------------------------------------------------------------
288
+ pi.on("session_shutdown", async (_event, ctx) => {
289
+ config ??= loadConfig(ctx.cwd);
290
+ if (!config) return;
291
+ const stdin = { type: "stop", session_id: getSessionId(ctx) };
292
+ await runGroups(config.hooks.Stop, "", ctx.cwd, stdin);
293
+ });
294
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@fyeeme/pi-hooks",
3
+ "version": "1.0.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.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/fyeeme/pi-packages"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fyeeme/pi-packages/issues"
17
+ },
18
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "hooks",
26
+ "claude-code",
27
+ "serena",
28
+ "automation",
29
+ "lifecycle"
30
+ ],
31
+ "files": [
32
+ "index.ts",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "scripts": {
43
+ "test": "vitest --run",
44
+ "typecheck": "tsc"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": ">=0.77.0"
48
+ },
49
+ "devDependencies": {
50
+ "@earendil-works/pi-coding-agent": "0.77.0",
51
+ "@types/node": "22.19.19",
52
+ "typescript": "5.9.3"
53
+ }
54
+ }