@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +58 -1
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +5 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/config.d.ts +2 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +4 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/core/agent-session.d.ts +30 -2
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +181 -21
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/compaction.d.ts +30 -5
  16. package/dist/core/compaction.d.ts.map +1 -1
  17. package/dist/core/compaction.js +194 -61
  18. package/dist/core/compaction.js.map +1 -1
  19. package/dist/core/hooks/index.d.ts +5 -0
  20. package/dist/core/hooks/index.d.ts.map +1 -0
  21. package/dist/core/hooks/index.js +4 -0
  22. package/dist/core/hooks/index.js.map +1 -0
  23. package/dist/core/hooks/loader.d.ts +56 -0
  24. package/dist/core/hooks/loader.d.ts.map +1 -0
  25. package/dist/core/hooks/loader.js +158 -0
  26. package/dist/core/hooks/loader.js.map +1 -0
  27. package/dist/core/hooks/runner.d.ts +69 -0
  28. package/dist/core/hooks/runner.d.ts.map +1 -0
  29. package/dist/core/hooks/runner.js +203 -0
  30. package/dist/core/hooks/runner.js.map +1 -0
  31. package/dist/core/hooks/tool-wrapper.d.ts +16 -0
  32. package/dist/core/hooks/tool-wrapper.d.ts.map +1 -0
  33. package/dist/core/hooks/tool-wrapper.js +71 -0
  34. package/dist/core/hooks/tool-wrapper.js.map +1 -0
  35. package/dist/core/hooks/types.d.ts +220 -0
  36. package/dist/core/hooks/types.d.ts.map +1 -0
  37. package/dist/core/hooks/types.js +8 -0
  38. package/dist/core/hooks/types.js.map +1 -0
  39. package/dist/core/index.d.ts +1 -0
  40. package/dist/core/index.d.ts.map +1 -1
  41. package/dist/core/index.js +1 -0
  42. package/dist/core/index.js.map +1 -1
  43. package/dist/core/session-manager.d.ts +10 -3
  44. package/dist/core/session-manager.d.ts.map +1 -1
  45. package/dist/core/session-manager.js +78 -28
  46. package/dist/core/session-manager.js.map +1 -1
  47. package/dist/core/settings-manager.d.ts +6 -0
  48. package/dist/core/settings-manager.d.ts.map +1 -1
  49. package/dist/core/settings-manager.js +14 -0
  50. package/dist/core/settings-manager.js.map +1 -1
  51. package/dist/core/system-prompt.d.ts.map +1 -1
  52. package/dist/core/system-prompt.js +5 -3
  53. package/dist/core/system-prompt.js.map +1 -1
  54. package/dist/core/tools/truncate.d.ts +6 -2
  55. package/dist/core/tools/truncate.d.ts.map +1 -1
  56. package/dist/core/tools/truncate.js +11 -1
  57. package/dist/core/tools/truncate.js.map +1 -1
  58. package/dist/index.d.ts +1 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/main.d.ts.map +1 -1
  62. package/dist/main.js +23 -12
  63. package/dist/main.js.map +1 -1
  64. package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
  65. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  66. package/dist/modes/interactive/components/bash-execution.js +17 -6
  67. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  68. package/dist/modes/interactive/components/hook-input.d.ts +12 -0
  69. package/dist/modes/interactive/components/hook-input.d.ts.map +1 -0
  70. package/dist/modes/interactive/components/hook-input.js +46 -0
  71. package/dist/modes/interactive/components/hook-input.js.map +1 -0
  72. package/dist/modes/interactive/components/hook-selector.d.ts +16 -0
  73. package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -0
  74. package/dist/modes/interactive/components/hook-selector.js +76 -0
  75. package/dist/modes/interactive/components/hook-selector.js.map +1 -0
  76. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  77. package/dist/modes/interactive/components/tool-execution.js +12 -7
  78. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  79. package/dist/modes/interactive/interactive-mode.d.ts +37 -0
  80. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  81. package/dist/modes/interactive/interactive-mode.js +190 -7
  82. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  83. package/dist/modes/print-mode.d.ts.map +1 -1
  84. package/dist/modes/print-mode.js +15 -0
  85. package/dist/modes/print-mode.js.map +1 -1
  86. package/dist/modes/rpc/rpc-mode.d.ts +2 -1
  87. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  88. package/dist/modes/rpc/rpc-mode.js +118 -3
  89. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  90. package/dist/modes/rpc/rpc-types.d.ts +41 -0
  91. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  92. package/dist/modes/rpc/rpc-types.js.map +1 -1
  93. package/docs/compaction.md +519 -0
  94. package/docs/hooks.md +609 -0
  95. package/docs/rpc.md +870 -0
  96. package/docs/session.md +89 -0
  97. package/docs/theme.md +586 -0
  98. package/docs/truncation.md +235 -0
  99. package/docs/undercompaction.md +313 -0
  100. 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
+ ```