@mariozechner/pi-coding-agent 0.17.0 → 0.18.1

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