@mariozechner/pi-coding-agent 0.16.0 → 0.18.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 +38 -0
- package/README.md +58 -1
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +5 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +30 -2
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +181 -21
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction.d.ts +30 -5
- package/dist/core/compaction.d.ts.map +1 -1
- package/dist/core/compaction.js +194 -61
- package/dist/core/compaction.js.map +1 -1
- package/dist/core/hooks/index.d.ts +5 -0
- package/dist/core/hooks/index.d.ts.map +1 -0
- package/dist/core/hooks/index.js +4 -0
- package/dist/core/hooks/index.js.map +1 -0
- package/dist/core/hooks/loader.d.ts +56 -0
- package/dist/core/hooks/loader.d.ts.map +1 -0
- package/dist/core/hooks/loader.js +158 -0
- package/dist/core/hooks/loader.js.map +1 -0
- package/dist/core/hooks/runner.d.ts +69 -0
- package/dist/core/hooks/runner.d.ts.map +1 -0
- package/dist/core/hooks/runner.js +203 -0
- package/dist/core/hooks/runner.js.map +1 -0
- package/dist/core/hooks/tool-wrapper.d.ts +16 -0
- package/dist/core/hooks/tool-wrapper.d.ts.map +1 -0
- package/dist/core/hooks/tool-wrapper.js +71 -0
- package/dist/core/hooks/tool-wrapper.js.map +1 -0
- package/dist/core/hooks/types.d.ts +220 -0
- package/dist/core/hooks/types.d.ts.map +1 -0
- package/dist/core/hooks/types.js +8 -0
- package/dist/core/hooks/types.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/session-manager.d.ts +10 -3
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +78 -28
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +6 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +14 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +5 -3
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/truncate.d.ts +6 -2
- package/dist/core/tools/truncate.d.ts.map +1 -1
- package/dist/core/tools/truncate.js +11 -1
- package/dist/core/tools/truncate.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +23 -12
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +17 -6
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/hook-input.d.ts +12 -0
- package/dist/modes/interactive/components/hook-input.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-input.js +46 -0
- package/dist/modes/interactive/components/hook-input.js.map +1 -0
- package/dist/modes/interactive/components/hook-selector.d.ts +16 -0
- package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-selector.js +76 -0
- package/dist/modes/interactive/components/hook-selector.js.map +1 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +12 -7
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +37 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +190 -7
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +15 -0
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +118 -3
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +41 -0
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/docs/compaction.md +519 -0
- package/docs/hooks.md +609 -0
- package/docs/rpc.md +870 -0
- package/docs/session.md +89 -0
- package/docs/theme.md +586 -0
- package/docs/truncation.md +235 -0
- package/docs/undercompaction.md +313 -0
- package/package.json +18 -6
package/docs/hooks.md
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# Hooks
|
|
2
|
+
|
|
3
|
+
Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more.
|
|
4
|
+
|
|
5
|
+
## Hook Locations
|
|
6
|
+
|
|
7
|
+
Hooks are automatically discovered from two locations:
|
|
8
|
+
|
|
9
|
+
1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
|
|
10
|
+
2. **Project hooks**: `<cwd>/.pi/hooks/*.ts`
|
|
11
|
+
|
|
12
|
+
All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).
|
|
13
|
+
|
|
14
|
+
### Additional Configuration
|
|
15
|
+
|
|
16
|
+
You can also add explicit hook paths in `~/.pi/agent/settings.json`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"hooks": [
|
|
21
|
+
"/path/to/custom/hook.ts"
|
|
22
|
+
],
|
|
23
|
+
"hookTimeout": 30000
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `hooks`: Additional hook file paths (supports `~` expansion)
|
|
28
|
+
- `hookTimeout`: Timeout in milliseconds for non-interactive hook operations (default: 30000)
|
|
29
|
+
|
|
30
|
+
## Writing a Hook
|
|
31
|
+
|
|
32
|
+
A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
36
|
+
|
|
37
|
+
export default function (pi: HookAPI) {
|
|
38
|
+
pi.on("session_start", async (event, ctx) => {
|
|
39
|
+
ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info");
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Setup
|
|
45
|
+
|
|
46
|
+
Create a hooks directory and initialize it:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Global hooks
|
|
50
|
+
mkdir -p ~/.pi/agent/hooks
|
|
51
|
+
cd ~/.pi/agent/hooks
|
|
52
|
+
npm init -y
|
|
53
|
+
npm install @mariozechner/pi-coding-agent
|
|
54
|
+
|
|
55
|
+
# Or project-local hooks
|
|
56
|
+
mkdir -p .pi/hooks
|
|
57
|
+
cd .pi/hooks
|
|
58
|
+
npm init -y
|
|
59
|
+
npm install @mariozechner/pi-coding-agent
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
|
|
63
|
+
|
|
64
|
+
## Events
|
|
65
|
+
|
|
66
|
+
### Lifecycle
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
pi starts
|
|
70
|
+
│
|
|
71
|
+
├─► session_start
|
|
72
|
+
│
|
|
73
|
+
▼
|
|
74
|
+
user sends prompt ─────────────────────────────────────────┐
|
|
75
|
+
│ │
|
|
76
|
+
├─► agent_start │
|
|
77
|
+
│ │
|
|
78
|
+
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
|
|
79
|
+
│ │ │ │
|
|
80
|
+
│ ├─► turn_start │ │
|
|
81
|
+
│ │ │ │
|
|
82
|
+
│ │ LLM responds, may call tools: │ │
|
|
83
|
+
│ │ ├─► tool_call (can block) │ │
|
|
84
|
+
│ │ │ tool executes │ │
|
|
85
|
+
│ │ └─► tool_result (can modify) │ │
|
|
86
|
+
│ │ │ │
|
|
87
|
+
│ └─► turn_end │ │
|
|
88
|
+
│ │
|
|
89
|
+
└─► agent_end │
|
|
90
|
+
│
|
|
91
|
+
user sends another prompt ◄────────────────────────────────┘
|
|
92
|
+
|
|
93
|
+
user branches or switches session
|
|
94
|
+
│
|
|
95
|
+
└─► session_switch
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
|
|
99
|
+
|
|
100
|
+
### session_start
|
|
101
|
+
|
|
102
|
+
Fired once when pi starts.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
pi.on("session_start", async (event, ctx) => {
|
|
106
|
+
// ctx.sessionFile: string | null
|
|
107
|
+
// ctx.hasUI: boolean
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### session_switch
|
|
112
|
+
|
|
113
|
+
Fired when session changes (`/branch` or session switch).
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
pi.on("session_switch", async (event, ctx) => {
|
|
117
|
+
// event.newSessionFile: string
|
|
118
|
+
// event.previousSessionFile: string
|
|
119
|
+
// event.reason: "branch" | "switch"
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### agent_start / agent_end
|
|
124
|
+
|
|
125
|
+
Fired once per user prompt.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
pi.on("agent_start", async (event, ctx) => {});
|
|
129
|
+
|
|
130
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
131
|
+
// event.messages: AppMessage[] - new messages from this prompt
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### turn_start / turn_end
|
|
136
|
+
|
|
137
|
+
Fired for each turn within an agent loop.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
141
|
+
// event.turnIndex: number
|
|
142
|
+
// event.timestamp: number
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
146
|
+
// event.turnIndex: number
|
|
147
|
+
// event.message: AppMessage - assistant's response
|
|
148
|
+
// event.toolResults: AppMessage[]
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### tool_call
|
|
153
|
+
|
|
154
|
+
Fired before tool executes. **Can block.**
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
158
|
+
// event.toolName: "bash" | "read" | "write" | "edit" | "ls" | "find" | "grep"
|
|
159
|
+
// event.toolCallId: string
|
|
160
|
+
// event.input: Record<string, unknown>
|
|
161
|
+
return { block: true, reason: "..." }; // or undefined to allow
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Tool inputs:
|
|
166
|
+
- `bash`: `{ command, timeout? }`
|
|
167
|
+
- `read`: `{ path, offset?, limit? }`
|
|
168
|
+
- `write`: `{ path, content }`
|
|
169
|
+
- `edit`: `{ path, oldText, newText }`
|
|
170
|
+
- `ls`: `{ path?, limit? }`
|
|
171
|
+
- `find`: `{ pattern, path?, limit? }`
|
|
172
|
+
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
|
|
173
|
+
|
|
174
|
+
### tool_result
|
|
175
|
+
|
|
176
|
+
Fired after tool executes. **Can modify result.**
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
180
|
+
// event.toolName, event.toolCallId, event.input
|
|
181
|
+
// event.result: string
|
|
182
|
+
// event.isError: boolean
|
|
183
|
+
return { result: "modified" }; // or undefined to keep original
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### branch
|
|
188
|
+
|
|
189
|
+
Fired when user branches via `/branch`.
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
pi.on("branch", async (event, ctx) => {
|
|
193
|
+
// event.targetTurnIndex: number
|
|
194
|
+
// event.entries: SessionEntry[]
|
|
195
|
+
return { skipConversationRestore: true }; // or undefined
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Context API
|
|
200
|
+
|
|
201
|
+
Every event handler receives a context object with these methods:
|
|
202
|
+
|
|
203
|
+
### ctx.ui.select(title, options)
|
|
204
|
+
|
|
205
|
+
Show a selector dialog. Returns the selected option or `null` if cancelled.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
|
|
209
|
+
if (choice === "Option A") {
|
|
210
|
+
// ...
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### ctx.ui.confirm(title, message)
|
|
215
|
+
|
|
216
|
+
Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise.
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
|
|
220
|
+
if (confirmed) {
|
|
221
|
+
// ...
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### ctx.ui.input(title, placeholder?)
|
|
226
|
+
|
|
227
|
+
Show a text input dialog. Returns the input string or `null` if cancelled.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
const name = await ctx.ui.input("Enter name:", "default value");
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### ctx.ui.notify(message, type?)
|
|
234
|
+
|
|
235
|
+
Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
ctx.ui.notify("Operation complete", "info");
|
|
239
|
+
ctx.ui.notify("Something went wrong", "error");
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### ctx.exec(command, args)
|
|
243
|
+
|
|
244
|
+
Execute a command and get the result.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
const result = await ctx.exec("git", ["status"]);
|
|
248
|
+
// result.stdout: string
|
|
249
|
+
// result.stderr: string
|
|
250
|
+
// result.code: number
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### ctx.cwd
|
|
254
|
+
|
|
255
|
+
The current working directory.
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
console.log(`Working in: ${ctx.cwd}`);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### ctx.sessionFile
|
|
262
|
+
|
|
263
|
+
Path to the session file, or `null` if running with `--no-session`.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
if (ctx.sessionFile) {
|
|
267
|
+
console.log(`Session: ${ctx.sessionFile}`);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### ctx.hasUI
|
|
272
|
+
|
|
273
|
+
Whether interactive UI is available. `false` in print and RPC modes.
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
if (ctx.hasUI) {
|
|
277
|
+
const choice = await ctx.ui.select("Pick:", ["A", "B"]);
|
|
278
|
+
} else {
|
|
279
|
+
// Fall back to default behavior
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Sending Messages
|
|
284
|
+
|
|
285
|
+
Hooks can inject messages into the agent session using `pi.send()`. This is useful for:
|
|
286
|
+
|
|
287
|
+
- Waking up the agent when an external event occurs (file change, CI result, etc.)
|
|
288
|
+
- Async debugging (inject debug output from other processes)
|
|
289
|
+
- Triggering agent actions from external systems
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
pi.send(text: string, attachments?: Attachment[]): void
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.
|
|
296
|
+
|
|
297
|
+
### Example: File Watcher
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import * as fs from "node:fs";
|
|
301
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
302
|
+
|
|
303
|
+
export default function (pi: HookAPI) {
|
|
304
|
+
pi.on("session_start", async (event, ctx) => {
|
|
305
|
+
// Watch a trigger file
|
|
306
|
+
const triggerFile = "/tmp/agent-trigger.txt";
|
|
307
|
+
|
|
308
|
+
fs.watch(triggerFile, () => {
|
|
309
|
+
try {
|
|
310
|
+
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
|
311
|
+
if (content) {
|
|
312
|
+
pi.send(`External trigger: ${content}`);
|
|
313
|
+
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// File might not exist yet
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
To trigger: `echo "Run the tests" > /tmp/agent-trigger.txt`
|
|
326
|
+
|
|
327
|
+
### Example: HTTP Webhook
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
import * as http from "node:http";
|
|
331
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
332
|
+
|
|
333
|
+
export default function (pi: HookAPI) {
|
|
334
|
+
pi.on("session_start", async (event, ctx) => {
|
|
335
|
+
const server = http.createServer((req, res) => {
|
|
336
|
+
let body = "";
|
|
337
|
+
req.on("data", chunk => body += chunk);
|
|
338
|
+
req.on("end", () => {
|
|
339
|
+
pi.send(body || "Webhook triggered");
|
|
340
|
+
res.writeHead(200);
|
|
341
|
+
res.end("OK");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
server.listen(3333, () => {
|
|
346
|
+
ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
To trigger: `curl -X POST http://localhost:3333 -d "CI build failed"`
|
|
353
|
+
|
|
354
|
+
**Note:** `pi.send()` is not supported in print mode (single-shot execution).
|
|
355
|
+
|
|
356
|
+
## Examples
|
|
357
|
+
|
|
358
|
+
### Shitty Permission Gate
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
362
|
+
|
|
363
|
+
export default function (pi: HookAPI) {
|
|
364
|
+
const dangerousPatterns = [
|
|
365
|
+
/\brm\s+(-rf?|--recursive)/i,
|
|
366
|
+
/\bsudo\b/i,
|
|
367
|
+
/\b(chmod|chown)\b.*777/i,
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
371
|
+
if (event.toolName !== "bash") return undefined;
|
|
372
|
+
|
|
373
|
+
const command = event.input.command as string;
|
|
374
|
+
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
|
375
|
+
|
|
376
|
+
if (isDangerous) {
|
|
377
|
+
const choice = await ctx.ui.select(
|
|
378
|
+
`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`,
|
|
379
|
+
["Yes", "No"]
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (choice !== "Yes") {
|
|
383
|
+
return { block: true, reason: "Blocked by user" };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return undefined;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Git Checkpointing
|
|
393
|
+
|
|
394
|
+
Stash code state at each turn so `/branch` can restore it.
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
398
|
+
|
|
399
|
+
export default function (pi: HookAPI) {
|
|
400
|
+
const checkpoints = new Map<number, string>();
|
|
401
|
+
|
|
402
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
403
|
+
// Create a git stash entry before LLM makes changes
|
|
404
|
+
const { stdout } = await ctx.exec("git", ["stash", "create"]);
|
|
405
|
+
const ref = stdout.trim();
|
|
406
|
+
if (ref) {
|
|
407
|
+
checkpoints.set(event.turnIndex, ref);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
pi.on("branch", async (event, ctx) => {
|
|
412
|
+
const ref = checkpoints.get(event.targetTurnIndex);
|
|
413
|
+
if (!ref) return undefined;
|
|
414
|
+
|
|
415
|
+
const choice = await ctx.ui.select("Restore code state?", [
|
|
416
|
+
"Yes, restore code to that point",
|
|
417
|
+
"No, keep current code",
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
if (choice?.startsWith("Yes")) {
|
|
421
|
+
await ctx.exec("git", ["stash", "apply", ref]);
|
|
422
|
+
ctx.ui.notify("Code restored to checkpoint", "info");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return undefined;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
pi.on("agent_end", async () => {
|
|
429
|
+
checkpoints.clear();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Block Writes to Certain Paths
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
438
|
+
|
|
439
|
+
export default function (pi: HookAPI) {
|
|
440
|
+
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
441
|
+
|
|
442
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
443
|
+
if (event.toolName !== "write" && event.toolName !== "edit") {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const path = event.input.path as string;
|
|
448
|
+
const isProtected = protectedPaths.some((p) => path.includes(p));
|
|
449
|
+
|
|
450
|
+
if (isProtected) {
|
|
451
|
+
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
|
452
|
+
return { block: true, reason: `Path "${path}" is protected` };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return undefined;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Mode Behavior
|
|
461
|
+
|
|
462
|
+
Hooks behave differently depending on the run mode:
|
|
463
|
+
|
|
464
|
+
| Mode | UI Methods | Notes |
|
|
465
|
+
|------|-----------|-------|
|
|
466
|
+
| Interactive | Full TUI dialogs | User can interact normally |
|
|
467
|
+
| RPC | JSON protocol | Host application handles UI |
|
|
468
|
+
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
|
|
469
|
+
|
|
470
|
+
In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.
|
|
471
|
+
|
|
472
|
+
## Error Handling
|
|
473
|
+
|
|
474
|
+
- If a hook throws an error, it's logged and the agent continues
|
|
475
|
+
- If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)
|
|
476
|
+
- Hook errors are displayed in the UI with the hook path and error message
|
|
477
|
+
|
|
478
|
+
## Debugging
|
|
479
|
+
|
|
480
|
+
To debug a hook:
|
|
481
|
+
|
|
482
|
+
1. Open VS Code in your hooks directory
|
|
483
|
+
2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
|
|
484
|
+
3. Set breakpoints in your hook file
|
|
485
|
+
4. Run `pi --hook ./my-hook.ts` in the debug terminal
|
|
486
|
+
|
|
487
|
+
The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
# Internals
|
|
492
|
+
|
|
493
|
+
## Discovery and Loading
|
|
494
|
+
|
|
495
|
+
Hooks are discovered and loaded at startup in `main.ts`:
|
|
496
|
+
|
|
497
|
+
```
|
|
498
|
+
main.ts
|
|
499
|
+
-> discoverAndLoadHooks(configuredPaths, cwd) [loader.ts]
|
|
500
|
+
-> discoverHooksInDir(~/.pi/agent/hooks/) # global hooks
|
|
501
|
+
-> discoverHooksInDir(cwd/.pi/hooks/) # project hooks
|
|
502
|
+
-> merge with configuredPaths (deduplicated)
|
|
503
|
+
-> for each path:
|
|
504
|
+
-> jiti.import(path) # TypeScript support via jiti
|
|
505
|
+
-> hookFactory(hookAPI) # calls pi.on() to register handlers
|
|
506
|
+
-> returns LoadedHook { path, handlers: Map<eventType, handlers[]> }
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Tool Wrapping
|
|
510
|
+
|
|
511
|
+
Tools are wrapped with hook callbacks before the agent is created:
|
|
512
|
+
|
|
513
|
+
```
|
|
514
|
+
main.ts
|
|
515
|
+
-> wrapToolsWithHooks(tools, hookRunner) [tool-wrapper.ts]
|
|
516
|
+
-> returns new tools with wrapped execute() functions
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The wrapped `execute()` function:
|
|
520
|
+
|
|
521
|
+
1. Checks `hookRunner.hasHandlers("tool_call")`
|
|
522
|
+
2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
|
|
523
|
+
3. If result has `block: true`, throws an error
|
|
524
|
+
4. Otherwise, calls the original `tool.execute()`
|
|
525
|
+
5. Checks `hookRunner.hasHandlers("tool_result")`
|
|
526
|
+
6. If yes, calls `hookRunner.emit(event)` (with timeout)
|
|
527
|
+
7. Returns (possibly modified) result
|
|
528
|
+
|
|
529
|
+
## HookRunner
|
|
530
|
+
|
|
531
|
+
The `HookRunner` class manages hook execution:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
class HookRunner {
|
|
535
|
+
constructor(hooks: LoadedHook[], cwd: string, timeout?: number)
|
|
536
|
+
|
|
537
|
+
setUIContext(ctx: HookUIContext, hasUI: boolean): void
|
|
538
|
+
setSessionFile(path: string | null): void
|
|
539
|
+
onError(listener): () => void
|
|
540
|
+
hasHandlers(eventType: string): boolean
|
|
541
|
+
emit(event: HookEvent): Promise<Result>
|
|
542
|
+
emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Key behaviors:
|
|
547
|
+
- `emit()` has a timeout (default 30s) for safety
|
|
548
|
+
- `emitToolCall()` has **no timeout** (user prompts can take any amount of time)
|
|
549
|
+
- Errors in `emit()` are caught and reported via `onError()`
|
|
550
|
+
- Errors in `emitToolCall()` propagate (causing tool to be blocked)
|
|
551
|
+
|
|
552
|
+
## Event Flow
|
|
553
|
+
|
|
554
|
+
```
|
|
555
|
+
Mode initialization:
|
|
556
|
+
-> hookRunner.setUIContext(ctx, hasUI)
|
|
557
|
+
-> hookRunner.setSessionFile(path)
|
|
558
|
+
-> hookRunner.emit({ type: "session_start" })
|
|
559
|
+
|
|
560
|
+
User sends prompt:
|
|
561
|
+
-> AgentSession.prompt()
|
|
562
|
+
-> hookRunner.emit({ type: "agent_start" })
|
|
563
|
+
-> hookRunner.emit({ type: "turn_start", turnIndex })
|
|
564
|
+
-> agent loop:
|
|
565
|
+
-> LLM generates tool calls
|
|
566
|
+
-> For each tool call:
|
|
567
|
+
-> wrappedTool.execute()
|
|
568
|
+
-> hookRunner.emitToolCall({ type: "tool_call", ... })
|
|
569
|
+
-> [if not blocked] originalTool.execute()
|
|
570
|
+
-> hookRunner.emit({ type: "tool_result", ... })
|
|
571
|
+
-> LLM generates response
|
|
572
|
+
-> hookRunner.emit({ type: "turn_end", ... })
|
|
573
|
+
-> [repeat if more tool calls]
|
|
574
|
+
-> hookRunner.emit({ type: "agent_end", messages })
|
|
575
|
+
|
|
576
|
+
Branch or session switch:
|
|
577
|
+
-> AgentSession.branch() or AgentSession.switchSession()
|
|
578
|
+
-> hookRunner.emit({ type: "session_switch", ... })
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
## UI Context by Mode
|
|
582
|
+
|
|
583
|
+
Each mode provides its own `HookUIContext` implementation:
|
|
584
|
+
|
|
585
|
+
**Interactive Mode** (`interactive-mode.ts`):
|
|
586
|
+
- `select()` -> `HookSelectorComponent` (TUI list selector)
|
|
587
|
+
- `confirm()` -> `HookSelectorComponent` with Yes/No options
|
|
588
|
+
- `input()` -> `HookInputComponent` (TUI text input)
|
|
589
|
+
- `notify()` -> Adds text to chat container
|
|
590
|
+
|
|
591
|
+
**RPC Mode** (`rpc-mode.ts`):
|
|
592
|
+
- All methods send JSON requests via stdout
|
|
593
|
+
- Waits for JSON responses via stdin
|
|
594
|
+
- Host application renders UI and sends responses
|
|
595
|
+
|
|
596
|
+
**Print Mode** (`print-mode.ts`):
|
|
597
|
+
- All methods return null/false immediately
|
|
598
|
+
- `notify()` is a no-op
|
|
599
|
+
|
|
600
|
+
## File Structure
|
|
601
|
+
|
|
602
|
+
```
|
|
603
|
+
packages/coding-agent/src/core/hooks/
|
|
604
|
+
├── index.ts # Public exports
|
|
605
|
+
├── types.ts # Event types, HookAPI, contexts
|
|
606
|
+
├── loader.ts # jiti-based hook loading
|
|
607
|
+
├── runner.ts # HookRunner class
|
|
608
|
+
└── tool-wrapper.ts # Tool wrapping for interception
|
|
609
|
+
```
|