@mariozechner/pi-coding-agent 0.30.2 → 0.31.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 (297) hide show
  1. package/CHANGELOG.md +244 -1
  2. package/README.md +105 -84
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +5 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/file-processor.d.ts +3 -3
  7. package/dist/cli/file-processor.d.ts.map +1 -1
  8. package/dist/cli/file-processor.js +7 -10
  9. package/dist/cli/file-processor.js.map +1 -1
  10. package/dist/config.d.ts +9 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +18 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/agent-session.d.ts +73 -34
  15. package/dist/core/agent-session.d.ts.map +1 -1
  16. package/dist/core/agent-session.js +464 -210
  17. package/dist/core/agent-session.js.map +1 -1
  18. package/dist/core/auth-storage.d.ts +2 -2
  19. package/dist/core/auth-storage.d.ts.map +1 -1
  20. package/dist/core/auth-storage.js +2 -2
  21. package/dist/core/auth-storage.js.map +1 -1
  22. package/dist/core/bash-executor.d.ts +2 -2
  23. package/dist/core/bash-executor.d.ts.map +1 -1
  24. package/dist/core/bash-executor.js +2 -2
  25. package/dist/core/bash-executor.js.map +1 -1
  26. package/dist/core/compaction/branch-summarization.d.ts +84 -0
  27. package/dist/core/compaction/branch-summarization.d.ts.map +1 -0
  28. package/dist/core/compaction/branch-summarization.js +233 -0
  29. package/dist/core/compaction/branch-summarization.js.map +1 -0
  30. package/dist/core/{compaction.d.ts → compaction/compaction.d.ts} +38 -19
  31. package/dist/core/compaction/compaction.d.ts.map +1 -0
  32. package/dist/core/compaction/compaction.js +558 -0
  33. package/dist/core/compaction/compaction.js.map +1 -0
  34. package/dist/core/compaction/index.d.ts +7 -0
  35. package/dist/core/compaction/index.d.ts.map +1 -0
  36. package/dist/core/compaction/index.js +7 -0
  37. package/dist/core/compaction/index.js.map +1 -0
  38. package/dist/core/compaction/utils.d.ts +35 -0
  39. package/dist/core/compaction/utils.d.ts.map +1 -0
  40. package/dist/core/compaction/utils.js +138 -0
  41. package/dist/core/compaction/utils.js.map +1 -0
  42. package/dist/core/custom-tools/index.d.ts +2 -1
  43. package/dist/core/custom-tools/index.d.ts.map +1 -1
  44. package/dist/core/custom-tools/index.js +1 -0
  45. package/dist/core/custom-tools/index.js.map +1 -1
  46. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  47. package/dist/core/custom-tools/loader.js +13 -80
  48. package/dist/core/custom-tools/loader.js.map +1 -1
  49. package/dist/core/custom-tools/types.d.ts +84 -59
  50. package/dist/core/custom-tools/types.d.ts.map +1 -1
  51. package/dist/core/custom-tools/types.js.map +1 -1
  52. package/dist/core/custom-tools/wrapper.d.ts +15 -0
  53. package/dist/core/custom-tools/wrapper.d.ts.map +1 -0
  54. package/dist/core/custom-tools/wrapper.js +23 -0
  55. package/dist/core/custom-tools/wrapper.js.map +1 -0
  56. package/dist/core/exec.d.ts +29 -0
  57. package/dist/core/exec.d.ts.map +1 -0
  58. package/dist/core/exec.js +71 -0
  59. package/dist/core/exec.js.map +1 -0
  60. package/dist/core/export-html/index.d.ts +17 -0
  61. package/dist/core/export-html/index.d.ts.map +1 -0
  62. package/dist/core/export-html/index.js +171 -0
  63. package/dist/core/export-html/index.js.map +1 -0
  64. package/dist/core/export-html/template.css +781 -0
  65. package/dist/core/export-html/template.html +54 -0
  66. package/dist/core/export-html/template.js +1185 -0
  67. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  68. package/dist/core/export-html/vendor/marked.min.js +6 -0
  69. package/dist/core/hooks/index.d.ts +4 -4
  70. package/dist/core/hooks/index.d.ts.map +1 -1
  71. package/dist/core/hooks/index.js +3 -3
  72. package/dist/core/hooks/index.js.map +1 -1
  73. package/dist/core/hooks/loader.d.ts +40 -5
  74. package/dist/core/hooks/loader.d.ts.map +1 -1
  75. package/dist/core/hooks/loader.js +43 -10
  76. package/dist/core/hooks/loader.js.map +1 -1
  77. package/dist/core/hooks/runner.d.ts +94 -18
  78. package/dist/core/hooks/runner.d.ts.map +1 -1
  79. package/dist/core/hooks/runner.js +199 -120
  80. package/dist/core/hooks/runner.js.map +1 -1
  81. package/dist/core/hooks/tool-wrapper.d.ts +1 -1
  82. package/dist/core/hooks/tool-wrapper.d.ts.map +1 -1
  83. package/dist/core/hooks/tool-wrapper.js +36 -19
  84. package/dist/core/hooks/tool-wrapper.js.map +1 -1
  85. package/dist/core/hooks/types.d.ts +407 -96
  86. package/dist/core/hooks/types.d.ts.map +1 -1
  87. package/dist/core/hooks/types.js.map +1 -1
  88. package/dist/core/index.d.ts +4 -3
  89. package/dist/core/index.d.ts.map +1 -1
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/messages.d.ts +44 -12
  92. package/dist/core/messages.d.ts.map +1 -1
  93. package/dist/core/messages.js +82 -34
  94. package/dist/core/messages.js.map +1 -1
  95. package/dist/core/model-registry.d.ts +5 -5
  96. package/dist/core/model-registry.d.ts.map +1 -1
  97. package/dist/core/model-registry.js +7 -7
  98. package/dist/core/model-registry.js.map +1 -1
  99. package/dist/core/model-resolver.d.ts +7 -7
  100. package/dist/core/model-resolver.d.ts.map +1 -1
  101. package/dist/core/model-resolver.js +45 -14
  102. package/dist/core/model-resolver.js.map +1 -1
  103. package/dist/core/sdk.d.ts +7 -10
  104. package/dist/core/sdk.d.ts.map +1 -1
  105. package/dist/core/sdk.js +88 -32
  106. package/dist/core/sdk.js.map +1 -1
  107. package/dist/core/session-manager.d.ts +202 -36
  108. package/dist/core/session-manager.d.ts.map +1 -1
  109. package/dist/core/session-manager.js +565 -133
  110. package/dist/core/session-manager.js.map +1 -1
  111. package/dist/core/settings-manager.d.ts +9 -3
  112. package/dist/core/settings-manager.d.ts.map +1 -1
  113. package/dist/core/settings-manager.js +13 -12
  114. package/dist/core/settings-manager.js.map +1 -1
  115. package/dist/core/system-prompt.d.ts.map +1 -1
  116. package/dist/core/system-prompt.js +6 -3
  117. package/dist/core/system-prompt.js.map +1 -1
  118. package/dist/core/tools/bash.d.ts +1 -1
  119. package/dist/core/tools/bash.d.ts.map +1 -1
  120. package/dist/core/tools/bash.js.map +1 -1
  121. package/dist/core/tools/edit-diff.d.ts +33 -0
  122. package/dist/core/tools/edit-diff.d.ts.map +1 -0
  123. package/dist/core/tools/edit-diff.js +171 -0
  124. package/dist/core/tools/edit-diff.js.map +1 -0
  125. package/dist/core/tools/edit.d.ts +7 -1
  126. package/dist/core/tools/edit.d.ts.map +1 -1
  127. package/dist/core/tools/edit.js +20 -95
  128. package/dist/core/tools/edit.js.map +1 -1
  129. package/dist/core/tools/find.d.ts +1 -1
  130. package/dist/core/tools/find.d.ts.map +1 -1
  131. package/dist/core/tools/find.js.map +1 -1
  132. package/dist/core/tools/grep.d.ts +1 -1
  133. package/dist/core/tools/grep.d.ts.map +1 -1
  134. package/dist/core/tools/grep.js.map +1 -1
  135. package/dist/core/tools/index.d.ts +1 -1
  136. package/dist/core/tools/index.d.ts.map +1 -1
  137. package/dist/core/tools/index.js.map +1 -1
  138. package/dist/core/tools/ls.d.ts +1 -1
  139. package/dist/core/tools/ls.d.ts.map +1 -1
  140. package/dist/core/tools/ls.js.map +1 -1
  141. package/dist/core/tools/read.d.ts +1 -1
  142. package/dist/core/tools/read.d.ts.map +1 -1
  143. package/dist/core/tools/read.js.map +1 -1
  144. package/dist/core/tools/write.d.ts +1 -1
  145. package/dist/core/tools/write.d.ts.map +1 -1
  146. package/dist/core/tools/write.js.map +1 -1
  147. package/dist/index.d.ts +8 -7
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +5 -5
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.d.ts.map +1 -1
  152. package/dist/main.js +22 -21
  153. package/dist/main.js.map +1 -1
  154. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  155. package/dist/modes/interactive/components/assistant-message.js +3 -4
  156. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  157. package/dist/modes/interactive/components/bash-execution.d.ts +1 -1
  158. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  159. package/dist/modes/interactive/components/bash-execution.js +6 -2
  160. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  161. package/dist/modes/interactive/components/bordered-loader.d.ts +12 -0
  162. package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -0
  163. package/dist/modes/interactive/components/bordered-loader.js +30 -0
  164. package/dist/modes/interactive/components/bordered-loader.js.map +1 -0
  165. package/dist/modes/interactive/components/branch-summary-message.d.ts +14 -0
  166. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -0
  167. package/dist/modes/interactive/components/branch-summary-message.js +35 -0
  168. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -0
  169. package/dist/modes/interactive/components/compaction-summary-message.d.ts +14 -0
  170. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -0
  171. package/dist/modes/interactive/components/compaction-summary-message.js +36 -0
  172. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -0
  173. package/dist/modes/interactive/components/dynamic-border.d.ts +5 -1
  174. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  175. package/dist/modes/interactive/components/dynamic-border.js +5 -1
  176. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  177. package/dist/modes/interactive/components/footer.d.ts +12 -6
  178. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  179. package/dist/modes/interactive/components/footer.js +57 -25
  180. package/dist/modes/interactive/components/footer.js.map +1 -1
  181. package/dist/modes/interactive/components/hook-editor.d.ts +15 -0
  182. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -0
  183. package/dist/modes/interactive/components/hook-editor.js +95 -0
  184. package/dist/modes/interactive/components/hook-editor.js.map +1 -0
  185. package/dist/modes/interactive/components/hook-message.d.ts +18 -0
  186. package/dist/modes/interactive/components/hook-message.d.ts.map +1 -0
  187. package/dist/modes/interactive/components/hook-message.js +80 -0
  188. package/dist/modes/interactive/components/hook-message.js.map +1 -0
  189. package/dist/modes/interactive/components/model-selector.d.ts +3 -3
  190. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  191. package/dist/modes/interactive/components/model-selector.js +1 -1
  192. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  193. package/dist/modes/interactive/components/tool-execution.d.ts +15 -2
  194. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  195. package/dist/modes/interactive/components/tool-execution.js +70 -21
  196. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  197. package/dist/modes/interactive/components/tree-selector.d.ts +52 -0
  198. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -0
  199. package/dist/modes/interactive/components/tree-selector.js +745 -0
  200. package/dist/modes/interactive/components/tree-selector.js.map +1 -0
  201. package/dist/modes/interactive/components/user-message-selector.d.ts +3 -3
  202. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  203. package/dist/modes/interactive/components/user-message-selector.js +1 -1
  204. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  205. package/dist/modes/interactive/components/user-message.d.ts +1 -1
  206. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  207. package/dist/modes/interactive/components/user-message.js +2 -5
  208. package/dist/modes/interactive/components/user-message.js.map +1 -1
  209. package/dist/modes/interactive/interactive-mode.d.ts +29 -12
  210. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  211. package/dist/modes/interactive/interactive-mode.js +589 -208
  212. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  213. package/dist/modes/interactive/theme/dark.json +13 -1
  214. package/dist/modes/interactive/theme/light.json +13 -1
  215. package/dist/modes/interactive/theme/theme-schema.json +34 -0
  216. package/dist/modes/interactive/theme/theme.d.ts +20 -2
  217. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  218. package/dist/modes/interactive/theme/theme.js +135 -2
  219. package/dist/modes/interactive/theme/theme.js.map +1 -1
  220. package/dist/modes/print-mode.d.ts +3 -3
  221. package/dist/modes/print-mode.d.ts.map +1 -1
  222. package/dist/modes/print-mode.js +26 -20
  223. package/dist/modes/print-mode.js.map +1 -1
  224. package/dist/modes/rpc/rpc-client.d.ts +13 -10
  225. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  226. package/dist/modes/rpc/rpc-client.js +11 -10
  227. package/dist/modes/rpc/rpc-client.js.map +1 -1
  228. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  229. package/dist/modes/rpc/rpc-mode.js +88 -35
  230. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  231. package/dist/modes/rpc/rpc-types.d.ts +30 -11
  232. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  233. package/dist/modes/rpc/rpc-types.js.map +1 -1
  234. package/dist/utils/shell.d.ts +4 -2
  235. package/dist/utils/shell.d.ts.map +1 -1
  236. package/dist/utils/shell.js +36 -7
  237. package/dist/utils/shell.js.map +1 -1
  238. package/dist/utils/tools-manager.d.ts +1 -1
  239. package/dist/utils/tools-manager.d.ts.map +1 -1
  240. package/dist/utils/tools-manager.js +2 -2
  241. package/dist/utils/tools-manager.js.map +1 -1
  242. package/docs/compaction.md +388 -0
  243. package/docs/custom-tools.md +146 -43
  244. package/docs/extension-loading.md +1004 -0
  245. package/docs/hooks.md +562 -596
  246. package/docs/rpc.md +33 -19
  247. package/docs/sdk.md +93 -21
  248. package/docs/session-tree-plan.md +441 -0
  249. package/docs/session.md +172 -21
  250. package/docs/skills.md +2 -0
  251. package/docs/theme.md +31 -2
  252. package/docs/tree.md +197 -0
  253. package/docs/tui.md +343 -0
  254. package/examples/README.md +1 -9
  255. package/examples/custom-tools/hello/index.ts +4 -3
  256. package/examples/custom-tools/question/index.ts +4 -4
  257. package/examples/custom-tools/subagent/index.ts +7 -6
  258. package/examples/custom-tools/todo/index.ts +11 -5
  259. package/examples/hooks/README.md +29 -71
  260. package/examples/hooks/auto-commit-on-exit.ts +8 -9
  261. package/examples/hooks/confirm-destructive.ts +29 -30
  262. package/examples/hooks/custom-compaction.ts +20 -21
  263. package/examples/hooks/dirty-repo-guard.ts +41 -40
  264. package/examples/hooks/file-trigger.ts +10 -5
  265. package/examples/hooks/git-checkpoint.ts +16 -12
  266. package/examples/hooks/handoff.ts +150 -0
  267. package/examples/hooks/permission-gate.ts +1 -1
  268. package/examples/hooks/protected-paths.ts +1 -1
  269. package/examples/hooks/qna.ts +119 -0
  270. package/examples/hooks/snake.ts +343 -0
  271. package/examples/hooks/status-line.ts +40 -0
  272. package/examples/sdk/01-minimal.ts +1 -1
  273. package/examples/sdk/02-custom-model.ts +1 -1
  274. package/examples/sdk/03-custom-prompt.ts +1 -1
  275. package/examples/sdk/04-skills.ts +1 -1
  276. package/examples/sdk/05-tools.ts +4 -4
  277. package/examples/sdk/06-hooks.ts +1 -1
  278. package/examples/sdk/07-context-files.ts +1 -1
  279. package/examples/sdk/08-slash-commands.ts +6 -1
  280. package/examples/sdk/09-api-keys-and-oauth.ts +1 -1
  281. package/examples/sdk/10-settings.ts +1 -1
  282. package/examples/sdk/11-sessions.ts +1 -1
  283. package/examples/sdk/12-full-control.ts +4 -7
  284. package/package.json +6 -6
  285. package/dist/core/compaction.d.ts.map +0 -1
  286. package/dist/core/compaction.js +0 -412
  287. package/dist/core/compaction.js.map +0 -1
  288. package/dist/core/export-html.d.ts +0 -23
  289. package/dist/core/export-html.d.ts.map +0 -1
  290. package/dist/core/export-html.js +0 -1185
  291. package/dist/core/export-html.js.map +0 -1
  292. package/dist/modes/interactive/components/compaction.d.ts +0 -15
  293. package/dist/modes/interactive/components/compaction.d.ts.map +0 -1
  294. package/dist/modes/interactive/components/compaction.js +0 -41
  295. package/dist/modes/interactive/components/compaction.js.map +0 -1
  296. package/docs/hooks-v2.md +0 -385
  297. package/docs/session-tree.md +0 -452
package/docs/hooks.md CHANGED
@@ -1,108 +1,116 @@
1
+ > pi can create hooks. Ask it to build one for your use case.
2
+
1
3
  # Hooks
2
4
 
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.
5
+ Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more.
6
+
7
+ **Key capabilities:**
8
+ - **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify)
9
+ - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()`
10
+ - **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()`
11
+ - **Event interception** - Block or modify tool calls, inject context, customize compaction
12
+ - **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()`
4
13
 
5
14
  **Example use cases:**
6
- - Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.)
7
- - Checkpoint code state (git stash at each turn, restore on `/branch`)
8
- - Protect paths (block writes to `.env`, `node_modules/`, etc.)
9
- - Modify tool output (filter or transform results before the LLM sees them)
10
- - Inject messages from external sources (file watchers, webhooks, CI systems)
15
+ - Permission gates (confirm before `rm -rf`, `sudo`, etc.)
16
+ - Git checkpointing (stash at each turn, restore on `/branch`)
17
+ - Path protection (block writes to `.env`, `node_modules/`)
18
+ - External integrations (file watchers, webhooks, CI triggers)
19
+ - Interactive tools (games, wizards, custom dialogs)
11
20
 
12
- See [examples/hooks/](../examples/hooks/) for working implementations.
21
+ See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI.
13
22
 
14
- ## Hook Locations
23
+ ## Quick Start
15
24
 
16
- Hooks are automatically discovered from two locations:
25
+ Create `~/.pi/agent/hooks/my-hook.ts`:
17
26
 
18
- 1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
19
- 2. **Project hooks**: `<cwd>/.pi/hooks/*.ts`
27
+ ```typescript
28
+ import type { HookAPI } from "@mariozechner/pi-coding-agent";
29
+
30
+ export default function (pi: HookAPI) {
31
+ pi.on("session_start", async (_event, ctx) => {
32
+ ctx.ui.notify("Hook loaded!", "info");
33
+ });
20
34
 
21
- All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).
35
+ pi.on("tool_call", async (event, ctx) => {
36
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
37
+ const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
38
+ if (!ok) return { block: true, reason: "Blocked by user" };
39
+ }
40
+ });
41
+ }
42
+ ```
22
43
 
23
- You can also load a specific hook file directly using the `--hook` flag:
44
+ Test with `--hook` flag:
24
45
 
25
46
  ```bash
26
47
  pi --hook ./my-hook.ts
27
48
  ```
28
49
 
29
- This is useful for testing hooks without placing them in the standard directories.
50
+ ## Hook Locations
51
+
52
+ Hooks are auto-discovered from:
30
53
 
31
- ### Additional Configuration
54
+ | Location | Scope |
55
+ |----------|-------|
56
+ | `~/.pi/agent/hooks/*.ts` | Global (all projects) |
57
+ | `.pi/hooks/*.ts` | Project-local |
32
58
 
33
- You can also add explicit hook paths in `~/.pi/agent/settings.json`:
59
+ Additional paths via `settings.json`:
34
60
 
35
61
  ```json
36
62
  {
37
- "hooks": [
38
- "/path/to/custom/hook.ts"
39
- ],
40
- "hookTimeout": 30000
63
+ "hooks": ["/path/to/hook.ts"]
41
64
  }
42
65
  ```
43
66
 
44
- - `hooks`: Additional hook file paths (supports `~` expansion)
45
- - `hookTimeout`: Timeout in milliseconds for hook operations (default: 30000). Does not apply to `tool_call` events, which have no timeout since they may prompt the user.
46
-
47
67
  ## Available Imports
48
68
 
49
- Hooks can import from these packages (automatically resolved by pi):
50
-
51
69
  | Package | Purpose |
52
70
  |---------|---------|
53
- | `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, etc.) |
71
+ | `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
54
72
  | `@mariozechner/pi-coding-agent` | Additional types if needed |
55
- | `@mariozechner/pi-ai` | AI utilities (`ToolResultMessage`, etc.) |
56
- | `@mariozechner/pi-tui` | TUI components (for advanced use cases) |
57
- | `@sinclair/typebox` | Schema definitions |
73
+ | `@mariozechner/pi-ai` | AI utilities |
74
+ | `@mariozechner/pi-tui` | TUI components |
58
75
 
59
- Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
76
+ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
60
77
 
61
78
  ## Writing a Hook
62
79
 
63
- A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.
80
+ A hook exports a default function that receives `HookAPI`:
64
81
 
65
82
  ```typescript
66
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
83
+ import type { HookAPI } from "@mariozechner/pi-coding-agent";
67
84
 
68
85
  export default function (pi: HookAPI) {
69
- pi.on("session", async (event, ctx) => {
70
- ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionFile ?? "ephemeral"}`, "info");
86
+ // Subscribe to events
87
+ pi.on("event_name", async (event, ctx) => {
88
+ // Handle event
71
89
  });
72
90
  }
73
91
  ```
74
92
 
75
- ### Setup
76
-
77
- Create a hooks directory:
78
-
79
- ```bash
80
- # Global hooks
81
- mkdir -p ~/.pi/agent/hooks
82
-
83
- # Or project-local hooks
84
- mkdir -p .pi/hooks
85
- ```
86
-
87
- Then create `.ts` files directly in these directories. Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically.
93
+ Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
88
94
 
89
95
  ## Events
90
96
 
91
- ### Lifecycle
97
+ ### Lifecycle Overview
92
98
 
93
99
  ```
94
100
  pi starts
95
101
 
96
- ├─► session (reason: "start")
97
-
98
-
102
+ └─► session_start
103
+
104
+
99
105
  user sends prompt ─────────────────────────────────────────┐
100
106
  │ │
107
+ ├─► before_agent_start (can inject message) │
101
108
  ├─► agent_start │
102
109
  │ │
103
110
  │ ┌─── turn (repeats while LLM calls tools) ───┐ │
104
111
  │ │ │ │
105
112
  │ ├─► turn_start │ │
113
+ │ ├─► context (can modify messages) │ │
106
114
  │ │ │ │
107
115
  │ │ LLM responds, may call tools: │ │
108
116
  │ │ ├─► tool_call (can block) │ │
@@ -115,214 +123,222 @@ user sends prompt ────────────────────
115
123
 
116
124
  user sends another prompt ◄────────────────────────────────┘
117
125
 
118
- user branches (/branch)
119
-
120
- ├─► session (reason: "before_branch", can cancel)
121
- └─► session (reason: "branch", AFTER branch)
126
+ /new (new session) or /resume (switch session)
127
+ ├─► session_before_switch (can cancel, has reason: "new" | "resume")
128
+ └─► session_switch (has reason: "new" | "resume")
122
129
 
123
- user switches session (/resume)
124
-
125
- ├─► session (reason: "before_switch", can cancel)
126
- └─► session (reason: "switch", AFTER switch)
130
+ /branch
131
+ ├─► session_before_branch (can cancel)
132
+ └─► session_branch
127
133
 
128
- user starts new session (/new)
129
-
130
- ├─► session (reason: "before_new", can cancel)
131
- └─► session (reason: "new", AFTER new session starts)
134
+ /compact or auto-compaction
135
+ ├─► session_before_compact (can cancel or customize)
136
+ └─► session_compact
132
137
 
133
- context compaction (auto or /compact)
134
-
135
- ├─► session (reason: "before_compact", can cancel or provide custom summary)
136
- └─► session (reason: "compact", AFTER compaction)
138
+ /tree navigation
139
+ ├─► session_before_tree (can cancel or customize)
140
+ └─► session_tree
137
141
 
138
- user exits (double Ctrl+C or Ctrl+D)
139
-
140
- └─► session (reason: "shutdown")
142
+ exit (Ctrl+C, Ctrl+D)
143
+ └─► session_shutdown
141
144
  ```
142
145
 
143
- A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
146
+ ### Session Events
144
147
 
145
- ### session
148
+ #### session_start
146
149
 
147
- Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`.
150
+ Fired on initial session load.
148
151
 
149
152
  ```typescript
150
- pi.on("session", async (event, ctx) => {
151
- // event.entries: SessionEntry[] - all session entries
152
- // event.sessionFile: string | null - current session file (null with --no-session)
153
- // event.previousSessionFile: string | null - previous session file
154
- // event.reason: "start" | "before_switch" | "switch" | "before_new" | "new" |
155
- // "before_branch" | "branch" | "before_compact" | "compact" | "shutdown"
156
- // event.targetTurnIndex: number - only for "before_branch" and "branch"
157
-
158
- // Cancel a before_* action:
159
- if (event.reason === "before_new") {
160
- return { cancel: true };
161
- }
162
-
163
- // For before_branch only: create branch but skip conversation restore
164
- // (useful for checkpoint hooks that restore files separately)
165
- if (event.reason === "before_branch") {
166
- return { skipConversationRestore: true };
167
- }
153
+ pi.on("session_start", async (_event, ctx) => {
154
+ ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
168
155
  });
169
156
  ```
170
157
 
171
- **Reasons:**
172
- - `start`: Initial session load on startup
173
- - `before_switch` / `switch`: User switched sessions (`/resume`)
174
- - `before_new` / `new`: User started a new session (`/new`)
175
- - `before_branch` / `branch`: User branched the session (`/branch`)
176
- - `before_compact` / `compact`: Context compaction (auto or `/compact`)
177
- - `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)
158
+ #### session_before_switch / session_switch
178
159
 
179
- For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.
160
+ Fired when starting a new session (`/new`) or switching sessions (`/resume`).
161
+
162
+ ```typescript
163
+ pi.on("session_before_switch", async (event, ctx) => {
164
+ // event.reason - "new" (starting fresh) or "resume" (switching to existing)
165
+ // event.targetSessionFile - session we're switching to (only for "resume")
166
+
167
+ if (event.reason === "new") {
168
+ const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
169
+ if (!ok) return { cancel: true };
170
+ }
171
+
172
+ return { cancel: true }; // Cancel the switch/new
173
+ });
180
174
 
181
- #### Custom Compaction
175
+ pi.on("session_switch", async (event, ctx) => {
176
+ // event.reason - "new" or "resume"
177
+ // event.previousSessionFile - session we came from
178
+ });
179
+ ```
182
180
 
183
- The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:
181
+ #### session_before_branch / session_branch
184
182
 
185
- **How default compaction works:**
183
+ Fired when branching via `/branch`.
186
184
 
187
- When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):
185
+ ```typescript
186
+ pi.on("session_before_branch", async (event, ctx) => {
187
+ // event.entryId - ID of the entry being branched from
188
188
 
189
- ```
190
- Legend:
191
- hdr = header usr = user message ass = assistant message
192
- tool = tool result cmp = compaction entry bash = bashExecution
193
- ```
189
+ return { cancel: true }; // Cancel branch
190
+ // OR
191
+ return { skipConversationRestore: true }; // Branch but don't rewind messages
192
+ });
194
193
 
194
+ pi.on("session_branch", async (event, ctx) => {
195
+ // event.previousSessionFile - previous session file
196
+ });
195
197
  ```
196
- Session entries (before compaction):
197
198
 
198
- index: 0 1 2 3 4 5 6 7 8 9 10
199
- ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
200
- │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
201
- └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
202
- ↑ └───────┬───────┘ └────────────┬────────────┘
203
- previousSummary messagesToSummarize messagesToKeep
204
-
205
- cutPoint.firstKeptEntryIndex = 5
199
+ The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately.
206
200
 
207
- After compaction (new entry appended):
201
+ #### session_before_compact / session_compact
208
202
 
209
- index: 0 1 2 3 4 5 6 7 8 9 10 11
210
- ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
211
- │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
212
- └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
213
- └──────────┬───────────┘ └────────────────────────┬─────────────────┘
214
- not sent to LLM sent to LLM
215
-
216
- firstKeptEntryIndex = 5
217
- (stored in new cmp)
218
- ```
203
+ Fired on compaction. See [compaction.md](compaction.md) for details.
219
204
 
220
- The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
205
+ ```typescript
206
+ pi.on("session_before_compact", async (event, ctx) => {
207
+ const { preparation, branchEntries, customInstructions, signal } = event;
208
+
209
+ // Cancel:
210
+ return { cancel: true };
211
+
212
+ // Custom summary:
213
+ return {
214
+ compaction: {
215
+ summary: "...",
216
+ firstKeptEntryId: preparation.firstKeptEntryId,
217
+ tokensBefore: preparation.tokensBefore,
218
+ }
219
+ };
220
+ });
221
221
 
222
+ pi.on("session_compact", async (event, ctx) => {
223
+ // event.compactionEntry - the saved compaction
224
+ // event.fromHook - whether hook provided it
225
+ });
222
226
  ```
223
- What gets sent to the LLM as context:
224
227
 
225
- 5 6 7 8 9 10
226
- ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
227
- │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
228
- └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
229
- ↑ └─────────────────┬────────────────┘
230
- from new cmp's messages from
231
- summary firstKeptEntryIndex onwards
232
- ```
228
+ #### session_before_tree / session_tree
233
229
 
234
- **Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
230
+ Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details.
235
231
 
232
+ ```typescript
233
+ pi.on("session_before_tree", async (event, ctx) => {
234
+ const { preparation, signal } = event;
235
+ // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize
236
+ // preparation.userWantsSummary - whether user chose to summarize
237
+
238
+ return { cancel: true };
239
+ // OR provide custom summary (only used if userWantsSummary is true):
240
+ return { summary: { summary: "...", details: {} } };
241
+ });
242
+
243
+ pi.on("session_tree", async (event, ctx) => {
244
+ // event.newLeafId, oldLeafId, summaryEntry, fromHook
245
+ });
236
246
  ```
237
- Split turn example (one huge turn that exceeds keepRecentTokens):
238
247
 
239
- index: 0 1 2 3 4 5 6 7 8 9
240
- ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐
241
- │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │
242
- └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘
243
- ↑ ↑
244
- turnStartIndex = 1 firstKeptEntryIndex = 7
245
- │ │ (must be usr/ass/bash, not tool)
246
- └─────────── turn prefix ───────────────┘ (idx 1-6, summarized separately)
247
- └── kept messages (idx 7-9)
248
+ #### session_shutdown
248
249
 
249
- messagesToSummarize = [] (no complete turns before this one)
250
- messagesToKeep = [ass idx 7, tool idx 8, ass idx 9]
250
+ Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
251
251
 
252
- The default compaction generates TWO summaries that get merged:
253
- 1. History summary (previousSummary + messagesToSummarize)
254
- 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
252
+ ```typescript
253
+ pi.on("session_shutdown", async (_event, ctx) => {
254
+ // Cleanup, save state, etc.
255
+ });
255
256
  ```
256
257
 
257
- See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
258
+ ### Agent Events
258
259
 
259
- **Event fields:**
260
+ #### before_agent_start
260
261
 
261
- | Field | Description |
262
- |-------|-------------|
263
- | `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
264
- | `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
265
- | `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
266
- | `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
267
- | `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
268
- | `tokensBefore` | Current context token count (why compaction triggered). |
269
- | `model` | Model to use for summarization. |
270
- | `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
271
- | `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |
272
- | `signal` | AbortSignal for cancellation. Pass to LLM calls and check periodically. |
262
+ Fired after user submits prompt, before agent loop. Can inject a persistent message.
273
263
 
274
- Custom compaction hooks should honor the abort signal by passing it to `complete()` calls. This allows users to cancel compaction (e.g., via Ctrl+C during `/compact`).
275
-
276
- See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.
264
+ ```typescript
265
+ pi.on("before_agent_start", async (event, ctx) => {
266
+ // event.prompt - user's prompt text
267
+ // event.images - attached images (if any)
268
+
269
+ return {
270
+ message: {
271
+ customType: "my-hook",
272
+ content: "Additional context for the LLM",
273
+ display: true, // Show in TUI
274
+ }
275
+ };
276
+ });
277
+ ```
277
278
 
278
- **After compaction (`compact` event):**
279
- - `event.compactionEntry`: The saved compaction entry
280
- - `event.tokensBefore`: Token count before compaction
281
- - `event.fromHook`: Whether the compaction entry was provided by a hook
279
+ The injected message is persisted as `CustomMessageEntry` and sent to the LLM.
282
280
 
283
- ### agent_start / agent_end
281
+ #### agent_start / agent_end
284
282
 
285
283
  Fired once per user prompt.
286
284
 
287
285
  ```typescript
288
- pi.on("agent_start", async (event, ctx) => {});
286
+ pi.on("agent_start", async (_event, ctx) => {});
289
287
 
290
288
  pi.on("agent_end", async (event, ctx) => {
291
- // event.messages: AppMessage[] - new messages from this prompt
289
+ // event.messages - messages from this prompt
292
290
  });
293
291
  ```
294
292
 
295
- ### turn_start / turn_end
293
+ #### turn_start / turn_end
296
294
 
297
- Fired for each turn within an agent loop.
295
+ Fired for each turn (one LLM response + tool calls).
298
296
 
299
297
  ```typescript
300
298
  pi.on("turn_start", async (event, ctx) => {
301
- // event.turnIndex: number
302
- // event.timestamp: number
299
+ // event.turnIndex, event.timestamp
303
300
  });
304
301
 
305
302
  pi.on("turn_end", async (event, ctx) => {
306
- // event.turnIndex: number
307
- // event.message: AppMessage - assistant's response
308
- // event.toolResults: ToolResultMessage[] - tool results from this turn
303
+ // event.turnIndex
304
+ // event.message - assistant's response
305
+ // event.toolResults - tool results from this turn
306
+ });
307
+ ```
308
+
309
+ #### context
310
+
311
+ Fired before each LLM call. Modify messages non-destructively (session unchanged).
312
+
313
+ ```typescript
314
+ pi.on("context", async (event, ctx) => {
315
+ // event.messages - deep copy, safe to modify
316
+
317
+ // Filter or transform messages
318
+ const filtered = event.messages.filter(m => !shouldPrune(m));
319
+ return { messages: filtered };
309
320
  });
310
321
  ```
311
322
 
312
- ### tool_call
323
+ ### Tool Events
324
+
325
+ #### tool_call
313
326
 
314
- Fired before tool executes. **Can block.** No timeout (user prompts can take any time).
327
+ Fired before tool executes. **Can block.**
315
328
 
316
329
  ```typescript
317
330
  pi.on("tool_call", async (event, ctx) => {
318
- // event.toolName: string (built-in or custom tool name)
319
- // event.toolCallId: string
320
- // event.input: Record<string, unknown>
321
- return { block: true, reason: "..." }; // or undefined to allow
331
+ // event.toolName - "bash", "read", "write", "edit", etc.
332
+ // event.toolCallId
333
+ // event.input - tool parameters
334
+
335
+ if (shouldBlock(event)) {
336
+ return { block: true, reason: "Not allowed" };
337
+ }
322
338
  });
323
339
  ```
324
340
 
325
- Built-in tool inputs:
341
+ Tool inputs:
326
342
  - `bash`: `{ command, timeout? }`
327
343
  - `read`: `{ path, offset?, limit? }`
328
344
  - `write`: `{ path, content }`
@@ -331,559 +347,509 @@ Built-in tool inputs:
331
347
  - `find`: `{ pattern, path?, limit? }`
332
348
  - `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
333
349
 
334
- Custom tools are also intercepted with their own names and input schemas.
350
+ #### tool_result
335
351
 
336
- ### tool_result
352
+ Fired after tool executes (including errors). **Can modify result.**
337
353
 
338
- Fired after tool executes. **Can modify result.**
354
+ Check `event.isError` to distinguish successful executions from failures.
339
355
 
340
356
  ```typescript
341
357
  pi.on("tool_result", async (event, ctx) => {
342
- // event.toolName: string
343
- // event.toolCallId: string
344
- // event.input: Record<string, unknown>
345
- // event.content: (TextContent | ImageContent)[]
346
- // event.details: tool-specific (see below)
347
- // event.isError: boolean
348
-
349
- // Return modified content/details, or undefined to keep original
350
- return { content: [...], details: {...} };
358
+ // event.toolName, event.toolCallId, event.input
359
+ // event.content - array of TextContent | ImageContent
360
+ // event.details - tool-specific (see below)
361
+ // event.isError - true if the tool threw an error
362
+
363
+ if (event.isError) {
364
+ // Handle error case
365
+ }
366
+
367
+ // Modify result:
368
+ return { content: [...], details: {...}, isError: false };
351
369
  });
352
370
  ```
353
371
 
354
- The event type is a discriminated union based on `toolName`. Use the provided type guards to narrow `details` to the correct type:
372
+ Use type guards for typed details:
355
373
 
356
374
  ```typescript
357
- import { isBashToolResult, type HookAPI } from "@mariozechner/pi-coding-agent/hooks";
375
+ import { isBashToolResult } from "@mariozechner/pi-coding-agent";
358
376
 
359
- export default function (pi: HookAPI) {
360
- pi.on("tool_result", async (event, ctx) => {
361
- if (isBashToolResult(event)) {
362
- // event.details is BashToolDetails | undefined
363
- if (event.details?.truncation?.truncated) {
364
- // Access full output from temp file
365
- const fullPath = event.details.fullOutputPath;
366
- }
377
+ pi.on("tool_result", async (event, ctx) => {
378
+ if (isBashToolResult(event)) {
379
+ // event.details is BashToolDetails | undefined
380
+ if (event.details?.truncation?.truncated) {
381
+ // Full output at event.details.fullOutputPath
367
382
  }
368
- });
369
- }
383
+ }
384
+ });
370
385
  ```
371
386
 
372
- Available type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
387
+ Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
373
388
 
374
- #### Tool Details Types
389
+ ## HookContext
375
390
 
376
- Each built-in tool has a typed `details` field. Types are exported from `@mariozechner/pi-coding-agent`:
391
+ Every handler receives `ctx: HookContext`:
377
392
 
378
- | Tool | Details Type | Source |
379
- |------|-------------|--------|
380
- | `bash` | `BashToolDetails` | `src/core/tools/bash.ts` |
381
- | `read` | `ReadToolDetails` | `src/core/tools/read.ts` |
382
- | `edit` | `undefined` | - |
383
- | `write` | `undefined` | - |
384
- | `grep` | `GrepToolDetails` | `src/core/tools/grep.ts` |
385
- | `find` | `FindToolDetails` | `src/core/tools/find.ts` |
386
- | `ls` | `LsToolDetails` | `src/core/tools/ls.ts` |
393
+ ### ctx.ui
387
394
 
388
- Common fields in details:
389
- - `truncation?: TruncationResult` - present when output was truncated
390
- - `fullOutputPath?: string` - path to temp file with full output (bash only)
395
+ UI methods for user interaction. Hooks can prompt users and even render custom TUI components.
391
396
 
392
- `TruncationResult` contains:
393
- - `truncated: boolean` - whether truncation occurred
394
- - `truncatedBy: "lines" | "bytes" | null` - which limit was hit
395
- - `totalLines`, `totalBytes` - original size
396
- - `outputLines`, `outputBytes` - truncated size
397
-
398
- Custom tools use `CustomToolResultEvent` with `details: unknown`. Create your own type guard to get full type safety:
397
+ **Built-in dialogs:**
399
398
 
400
399
  ```typescript
401
- import {
402
- isBashToolResult,
403
- type CustomToolResultEvent,
404
- type HookAPI,
405
- type ToolResultEvent,
406
- } from "@mariozechner/pi-coding-agent/hooks";
400
+ // Select from options
401
+ const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
402
+ // Returns selected string or undefined if cancelled
407
403
 
408
- interface MyCustomToolDetails {
409
- someField: string;
410
- }
404
+ // Confirm dialog
405
+ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
406
+ // Returns true or false
411
407
 
412
- // Type guard that narrows both toolName and details
413
- function isMyCustomToolResult(e: ToolResultEvent): e is CustomToolResultEvent & {
414
- toolName: "my-custom-tool";
415
- details: MyCustomToolDetails;
416
- } {
417
- return e.toolName === "my-custom-tool";
418
- }
408
+ // Text input (single line)
409
+ const name = await ctx.ui.input("Name:", "placeholder");
410
+ // Returns string or undefined if cancelled
419
411
 
420
- export default function (pi: HookAPI) {
421
- pi.on("tool_result", async (event, ctx) => {
422
- // Built-in tool: use provided type guard
423
- if (isBashToolResult(event)) {
424
- if (event.details?.fullOutputPath) {
425
- console.log(`Full output at: ${event.details.fullOutputPath}`);
426
- }
427
- }
412
+ // Multi-line editor (with Ctrl+G for external editor)
413
+ const text = await ctx.ui.editor("Edit prompt:", "prefilled text");
414
+ // Returns edited text or undefined if cancelled (Escape)
415
+ // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
428
416
 
429
- // Custom tool: use your own type guard
430
- if (isMyCustomToolResult(event)) {
431
- // event.details is now MyCustomToolDetails
432
- console.log(event.details.someField);
433
- }
434
- });
435
- }
436
- ```
417
+ // Notification (non-blocking)
418
+ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
437
419
 
438
- **Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues.
420
+ // Set status text in footer (persistent until cleared)
421
+ ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
422
+ ctx.ui.setStatus("my-hook", undefined); // Clear status
439
423
 
440
- ## Context API
424
+ // Set the core input editor text (pre-fill prompts, generated content)
425
+ ctx.ui.setEditorText("Generated prompt text here...");
441
426
 
442
- Every event handler receives a context object with these methods:
427
+ // Get current editor text
428
+ const currentText = ctx.ui.getEditorText();
429
+ ```
430
+
431
+ **Status text notes:**
432
+ - Multiple hooks can set their own status using unique keys
433
+ - Statuses are displayed on a single line in the footer, sorted alphabetically by key
434
+ - Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
435
+ - Use `ctx.ui.theme` to style status text with theme colors (see below)
443
436
 
444
- ### ctx.ui.select(title, options)
437
+ **Styling with theme colors:**
445
438
 
446
- Show a selector dialog. Returns the selected option or `null` if cancelled.
439
+ Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
447
440
 
448
441
  ```typescript
449
- const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
450
- if (choice === "Option A") {
451
- // ...
452
- }
442
+ const theme = ctx.ui.theme;
443
+
444
+ // Foreground colors
445
+ ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + theme.fg("dim", " Ready"));
446
+ ctx.ui.setStatus("my-hook", theme.fg("error", "✗") + theme.fg("dim", " Failed"));
447
+ ctx.ui.setStatus("my-hook", theme.fg("accent", "●") + theme.fg("dim", " Working..."));
448
+
449
+ // Available fg colors: accent, success, error, warning, muted, dim, text, and more
450
+ // See docs/theme.md for the full list of theme colors
453
451
  ```
454
452
 
455
- ### ctx.ui.confirm(title, message)
453
+ See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a complete example.
454
+
455
+ **Custom components:**
456
456
 
457
- Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise.
457
+ Show a custom TUI component with keyboard focus:
458
458
 
459
459
  ```typescript
460
- const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
461
- if (confirmed) {
462
- // ...
463
- }
460
+ import { BorderedLoader } from "@mariozechner/pi-coding-agent";
461
+
462
+ const result = await ctx.ui.custom((tui, theme, done) => {
463
+ const loader = new BorderedLoader(tui, theme, "Working...");
464
+ loader.onAbort = () => done(null);
465
+
466
+ doWork(loader.signal).then(done).catch(() => done(null));
467
+
468
+ return loader;
469
+ });
464
470
  ```
465
471
 
466
- ### ctx.ui.input(title, placeholder?)
472
+ Your component can:
473
+ - Implement `handleInput(data: string)` to receive keyboard input
474
+ - Implement `render(width: number): string[]` to render lines
475
+ - Implement `invalidate()` to clear cached render
476
+ - Implement `dispose()` for cleanup when closed
477
+ - Call `tui.requestRender()` to trigger re-render
478
+ - Call `done(result)` when done to restore normal UI
479
+
480
+ See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API.
481
+
482
+ ### ctx.hasUI
467
483
 
468
- Show a text input dialog. Returns the input string or `null` if cancelled.
484
+ `false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`:
469
485
 
470
486
  ```typescript
471
- const name = await ctx.ui.input("Enter name:", "default value");
487
+ if (ctx.hasUI) {
488
+ const choice = await ctx.ui.select(...);
489
+ } else {
490
+ // Default behavior
491
+ }
472
492
  ```
473
493
 
474
- ### ctx.ui.notify(message, type?)
494
+ ### ctx.cwd
495
+
496
+ Current working directory.
497
+
498
+ ### ctx.sessionManager
475
499
 
476
- Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.
500
+ Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts).
477
501
 
478
502
  ```typescript
479
- ctx.ui.notify("Operation complete", "info");
480
- ctx.ui.notify("Something went wrong", "error");
503
+ // Session info
504
+ ctx.sessionManager.getCwd() // Working directory
505
+ ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions)
506
+ ctx.sessionManager.getSessionId() // Current session ID
507
+ ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session)
508
+
509
+ // Entries
510
+ ctx.sessionManager.getEntries() // All entries (excludes header)
511
+ ctx.sessionManager.getHeader() // Session header entry
512
+ ctx.sessionManager.getEntry(id) // Specific entry by ID
513
+ ctx.sessionManager.getLabel(id) // Entry label (if any)
514
+
515
+ // Tree navigation
516
+ ctx.sessionManager.getBranch() // Current branch (root to leaf)
517
+ ctx.sessionManager.getBranch(leafId) // Specific branch
518
+ ctx.sessionManager.getTree() // Full tree structure
519
+ ctx.sessionManager.getLeafId() // Current leaf entry ID
520
+ ctx.sessionManager.getLeafEntry() // Current leaf entry
481
521
  ```
482
522
 
483
- ### ctx.exec(command, args, options?)
523
+ Use `pi.sendMessage()` or `pi.appendEntry()` for writes.
484
524
 
485
- Execute a command and get the result. Supports cancellation via `AbortSignal` and timeout.
525
+ ### ctx.modelRegistry
486
526
 
487
- ```typescript
488
- const result = await ctx.exec("git", ["status"]);
489
- // result.stdout: string
490
- // result.stderr: string
491
- // result.code: number
492
- // result.killed?: boolean // True if killed by signal/timeout
527
+ Access to models and API keys:
493
528
 
494
- // With timeout (5 seconds)
495
- const result = await ctx.exec("slow-command", [], { timeout: 5000 });
529
+ ```typescript
530
+ // Get API key for a model
531
+ const apiKey = await ctx.modelRegistry.getApiKey(model);
496
532
 
497
- // With abort signal
498
- const controller = new AbortController();
499
- const result = await ctx.exec("long-command", [], { signal: controller.signal });
533
+ // Get available models
534
+ const models = ctx.modelRegistry.getAvailableModels();
500
535
  ```
501
536
 
502
- ### ctx.cwd
537
+ ### ctx.model
503
538
 
504
- The current working directory.
539
+ Current model, or `undefined` if none selected yet. Use for LLM calls in hooks:
505
540
 
506
541
  ```typescript
507
- console.log(`Working in: ${ctx.cwd}`);
542
+ if (ctx.model) {
543
+ const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
544
+ // Use with @mariozechner/pi-ai complete()
545
+ }
508
546
  ```
509
547
 
510
- ### ctx.sessionFile
548
+ ### ctx.isIdle()
511
549
 
512
- Path to the current session file, or `null` when running with `--no-session` (ephemeral mode).
550
+ Returns `true` if the agent is not currently streaming:
513
551
 
514
552
  ```typescript
515
- if (ctx.sessionFile) {
516
- console.log(`Session: ${ctx.sessionFile}`);
553
+ if (ctx.isIdle()) {
554
+ // Agent is not processing
517
555
  }
518
556
  ```
519
557
 
520
- ### ctx.hasUI
558
+ ### ctx.abort()
521
559
 
522
- Whether interactive UI is available. `false` in print and RPC modes.
560
+ Abort the current agent operation (fire-and-forget, does not wait):
523
561
 
524
562
  ```typescript
525
- if (ctx.hasUI) {
526
- const choice = await ctx.ui.select("Pick:", ["A", "B"]);
527
- } else {
528
- // Fall back to default behavior
563
+ await ctx.abort();
564
+ ```
565
+
566
+ ### ctx.hasQueuedMessages()
567
+
568
+ Check if there are messages queued (user typed while agent was streaming):
569
+
570
+ ```typescript
571
+ if (ctx.hasQueuedMessages()) {
572
+ // Skip interactive prompt, let queued message take over
573
+ return;
529
574
  }
530
575
  ```
531
576
 
532
- ## Sending Messages
577
+ ## HookCommandContext (Slash Commands Only)
533
578
 
534
- Hooks can inject messages into the agent session using `pi.send()`. This is useful for:
579
+ Slash command handlers receive `HookCommandContext`, which extends `HookContext` with session control methods. These methods are only safe in user-initiated commands because they can cause deadlocks if called from event handlers (which run inside the agent loop).
535
580
 
536
- - Waking up the agent when an external event occurs (file change, CI result, etc.)
537
- - Async debugging (inject debug output from other processes)
538
- - Triggering agent actions from external systems
581
+ ### ctx.waitForIdle()
582
+
583
+ Wait for the agent to finish streaming:
539
584
 
540
585
  ```typescript
541
- pi.send(text: string, attachments?: Attachment[]): void
586
+ await ctx.waitForIdle();
587
+ // Agent is now idle
542
588
  ```
543
589
 
544
- If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.
590
+ ### ctx.newSession(options?)
545
591
 
546
- ### Example: File Watcher
592
+ Create a new session, optionally with initialization:
547
593
 
548
594
  ```typescript
549
- import * as fs from "node:fs";
550
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
551
-
552
- export default function (pi: HookAPI) {
553
- pi.on("session", async (event, ctx) => {
554
- if (event.reason !== "start") return;
555
-
556
- // Watch a trigger file
557
- const triggerFile = "/tmp/agent-trigger.txt";
558
-
559
- fs.watch(triggerFile, () => {
560
- try {
561
- const content = fs.readFileSync(triggerFile, "utf-8").trim();
562
- if (content) {
563
- pi.send(`External trigger: ${content}`);
564
- fs.writeFileSync(triggerFile, ""); // Clear after reading
565
- }
566
- } catch {
567
- // File might not exist yet
568
- }
595
+ const result = await ctx.newSession({
596
+ parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
597
+ setup: async (sm) => {
598
+ // Initialize the new session
599
+ sm.appendMessage({
600
+ role: "user",
601
+ content: [{ type: "text", text: "Context from previous session..." }],
602
+ timestamp: Date.now(),
569
603
  });
604
+ },
605
+ });
570
606
 
571
- ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
572
- });
607
+ if (result.cancelled) {
608
+ // A hook cancelled the new session
573
609
  }
574
610
  ```
575
611
 
576
- To trigger: `echo "Run the tests" > /tmp/agent-trigger.txt`
612
+ ### ctx.branch(entryId)
577
613
 
578
- ### Example: HTTP Webhook
614
+ Branch from a specific entry, creating a new session file:
579
615
 
580
616
  ```typescript
581
- import * as http from "node:http";
582
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
617
+ const result = await ctx.branch("entry-id-123");
618
+ if (!result.cancelled) {
619
+ // Now in the branched session
620
+ }
621
+ ```
583
622
 
584
- export default function (pi: HookAPI) {
585
- pi.on("session", async (event, ctx) => {
586
- if (event.reason !== "start") return;
587
-
588
- const server = http.createServer((req, res) => {
589
- let body = "";
590
- req.on("data", chunk => body += chunk);
591
- req.on("end", () => {
592
- pi.send(body || "Webhook triggered");
593
- res.writeHead(200);
594
- res.end("OK");
595
- });
596
- });
623
+ ### ctx.navigateTree(targetId, options?)
597
624
 
598
- server.listen(3333, () => {
599
- ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
600
- });
601
- });
602
- }
625
+ Navigate to a different point in the session tree:
626
+
627
+ ```typescript
628
+ const result = await ctx.navigateTree("entry-id-456", {
629
+ summarize: true, // Summarize the abandoned branch
630
+ });
603
631
  ```
604
632
 
605
- To trigger: `curl -X POST http://localhost:3333 -d "CI build failed"`
633
+ ## HookAPI Methods
606
634
 
607
- **Note:** `pi.send()` is not supported in print mode (single-shot execution).
635
+ ### pi.on(event, handler)
608
636
 
609
- ## Examples
637
+ Subscribe to events. See [Events](#events) for all event types.
610
638
 
611
- ### Shitty Permission Gate
639
+ ### pi.sendMessage(message, triggerTurn?)
640
+
641
+ Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context.
612
642
 
613
643
  ```typescript
614
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
644
+ pi.sendMessage({
645
+ customType: "my-hook", // Your hook's identifier
646
+ content: "Message text", // string or (TextContent | ImageContent)[]
647
+ display: true, // Show in TUI
648
+ details: { ... }, // Optional metadata (not sent to LLM)
649
+ }, triggerTurn); // If true, triggers LLM response
650
+ ```
615
651
 
616
- export default function (pi: HookAPI) {
617
- const dangerousPatterns = [
618
- /\brm\s+(-rf?|--recursive)/i,
619
- /\bsudo\b/i,
620
- /\b(chmod|chown)\b.*777/i,
621
- ];
652
+ **Storage and timing:**
653
+ - The message is appended to the session file immediately as a `CustomMessageEntry`
654
+ - If the agent is currently streaming, the message is queued and appended after the current turn
655
+ - If `triggerTurn` is true and the agent is idle, a new agent loop starts
622
656
 
623
- pi.on("tool_call", async (event, ctx) => {
624
- if (event.toolName !== "bash") return undefined;
657
+ **LLM context:**
658
+ - `CustomMessageEntry` is converted to a user message when building context for the LLM
659
+ - Only `content` is sent to the LLM; `details` is for rendering/state only
625
660
 
626
- const command = event.input.command as string;
627
- const isDangerous = dangerousPatterns.some((p) => p.test(command));
661
+ **TUI display:**
662
+ - If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
663
+ - If `display: false`, the message is hidden from the TUI but still sent to the LLM
664
+ - Use `pi.registerMessageRenderer()` to customize how your messages render (see below)
628
665
 
629
- if (isDangerous) {
630
- const choice = await ctx.ui.select(
631
- `⚠️ Dangerous command:\n\n ${command}\n\nAllow?`,
632
- ["Yes", "No"]
633
- );
666
+ ### pi.appendEntry(customType, data?)
634
667
 
635
- if (choice !== "Yes") {
636
- return { block: true, reason: "Blocked by user" };
637
- }
668
+ Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context).
669
+
670
+ ```typescript
671
+ // Save state
672
+ pi.appendEntry("my-hook-state", { count: 42 });
673
+
674
+ // Restore on reload
675
+ pi.on("session_start", async (_event, ctx) => {
676
+ for (const entry of ctx.sessionManager.getEntries()) {
677
+ if (entry.type === "custom" && entry.customType === "my-hook-state") {
678
+ // Reconstruct from entry.data
638
679
  }
680
+ }
681
+ });
682
+ ```
639
683
 
640
- return undefined;
641
- });
642
- }
684
+ ### pi.registerCommand(name, options)
685
+
686
+ Register a custom slash command:
687
+
688
+ ```typescript
689
+ pi.registerCommand("stats", {
690
+ description: "Show session statistics",
691
+ handler: async (args, ctx) => {
692
+ // args = everything after /stats
693
+ const count = ctx.sessionManager.getEntries().length;
694
+ ctx.ui.notify(`${count} entries`, "info");
695
+ }
696
+ });
643
697
  ```
644
698
 
645
- ### Git Checkpointing
699
+ For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts).
700
+
701
+ To trigger LLM after command, call `pi.sendMessage(..., true)`.
646
702
 
647
- Stash code state at each turn so `/branch` can restore it.
703
+ ### pi.registerMessageRenderer(customType, renderer)
704
+
705
+ Register a custom TUI renderer for `CustomMessageEntry` messages with your `customType`. Without a custom renderer, messages display with default purple styling showing the content as-is.
648
706
 
649
707
  ```typescript
650
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
708
+ import { Text } from "@mariozechner/pi-tui";
709
+
710
+ pi.registerMessageRenderer("my-hook", (message, options, theme) => {
711
+ // message.content - the message content (string or content array)
712
+ // message.details - your custom metadata
713
+ // options.expanded - true if user pressed Ctrl+O
714
+
715
+ const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `);
716
+ const text = typeof message.content === "string"
717
+ ? message.content
718
+ : message.content.map(c => c.type === "text" ? c.text : "[image]").join("");
719
+
720
+ return new Text(prefix + theme.fg("text", text), 0, 0);
721
+ });
722
+ ```
651
723
 
652
- export default function (pi: HookAPI) {
653
- const checkpoints = new Map<number, string>();
654
-
655
- pi.on("turn_start", async (event, ctx) => {
656
- // Create a git stash entry before LLM makes changes
657
- const { stdout } = await ctx.exec("git", ["stash", "create"]);
658
- const ref = stdout.trim();
659
- if (ref) {
660
- checkpoints.set(event.turnIndex, ref);
661
- }
662
- });
724
+ **Renderer signature:**
725
+ ```typescript
726
+ type HookMessageRenderer = (
727
+ message: CustomMessageEntry,
728
+ options: { expanded: boolean },
729
+ theme: Theme
730
+ ) => Component | null;
731
+ ```
663
732
 
664
- pi.on("session", async (event, ctx) => {
665
- // Only handle before_branch events
666
- if (event.reason !== "before_branch") return;
733
+ Return `null` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details.
667
734
 
668
- const ref = checkpoints.get(event.targetTurnIndex);
669
- if (!ref) return;
735
+ ### pi.exec(command, args, options?)
670
736
 
671
- const choice = await ctx.ui.select("Restore code state?", [
672
- "Yes, restore code to that point",
673
- "No, keep current code",
674
- ]);
737
+ Execute a shell command:
675
738
 
676
- if (choice?.startsWith("Yes")) {
677
- await ctx.exec("git", ["stash", "apply", ref]);
678
- ctx.ui.notify("Code restored to checkpoint", "info");
679
- }
680
- });
739
+ ```typescript
740
+ const result = await pi.exec("git", ["status"], {
741
+ signal, // AbortSignal
742
+ timeout, // Milliseconds
743
+ });
681
744
 
682
- pi.on("agent_end", async () => {
683
- checkpoints.clear();
684
- });
685
- }
745
+ // result.stdout, result.stderr, result.code, result.killed
686
746
  ```
687
747
 
688
- ### Block Writes to Certain Paths
748
+ ## Examples
749
+
750
+ ### Permission Gate
689
751
 
690
752
  ```typescript
691
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
753
+ import type { HookAPI } from "@mariozechner/pi-coding-agent";
692
754
 
693
755
  export default function (pi: HookAPI) {
694
- const protectedPaths = [".env", ".git/", "node_modules/"];
756
+ const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
695
757
 
696
758
  pi.on("tool_call", async (event, ctx) => {
697
- if (event.toolName !== "write" && event.toolName !== "edit") {
698
- return undefined;
699
- }
700
-
701
- const path = event.input.path as string;
702
- const isProtected = protectedPaths.some((p) => path.includes(p));
759
+ if (event.toolName !== "bash") return;
703
760
 
704
- if (isProtected) {
705
- ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
706
- return { block: true, reason: `Path "${path}" is protected` };
761
+ const cmd = event.input.command as string;
762
+ if (dangerous.some(p => p.test(cmd))) {
763
+ if (!ctx.hasUI) {
764
+ return { block: true, reason: "Dangerous (no UI)" };
765
+ }
766
+ const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
767
+ if (!ok) return { block: true, reason: "Blocked by user" };
707
768
  }
708
-
709
- return undefined;
710
769
  });
711
770
  }
712
771
  ```
713
772
 
714
- ### Custom Compaction
715
-
716
- Use a different model for summarization, or implement your own compaction strategy.
773
+ ### Protected Paths
717
774
 
718
- See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) and the [Custom Compaction](#custom-compaction) section above for details.
719
-
720
- ## Mode Behavior
721
-
722
- Hooks behave differently depending on the run mode:
723
-
724
- | Mode | UI Methods | Notes |
725
- |------|-----------|-------|
726
- | Interactive | Full TUI dialogs | User can interact normally |
727
- | RPC | JSON protocol | Host application handles UI |
728
- | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
729
-
730
- In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.
775
+ ```typescript
776
+ import type { HookAPI } from "@mariozechner/pi-coding-agent";
731
777
 
732
- ## Error Handling
778
+ export default function (pi: HookAPI) {
779
+ const protectedPaths = [".env", ".git/", "node_modules/"];
733
780
 
734
- - If a hook throws an error, it's logged and the agent continues
735
- - If a `tool_call` hook throws an error, the tool is **blocked** (fail-safe)
736
- - Other events have a timeout (default 30s); timeout errors are logged but don't block
737
- - Hook errors are displayed in the UI with the hook path and error message
781
+ pi.on("tool_call", async (event, ctx) => {
782
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
738
783
 
739
- ## Debugging
784
+ const path = event.input.path as string;
785
+ if (protectedPaths.some(p => path.includes(p))) {
786
+ ctx.ui.notify(`Blocked: ${path}`, "warning");
787
+ return { block: true, reason: `Protected: ${path}` };
788
+ }
789
+ });
790
+ }
791
+ ```
740
792
 
741
- To debug a hook:
793
+ ### Git Checkpoint
742
794
 
743
- 1. Open VS Code in your hooks directory
744
- 2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
745
- 3. Set breakpoints in your hook file
746
- 4. Run `pi --hook ./my-hook.ts` in the debug terminal
795
+ ```typescript
796
+ import type { HookAPI } from "@mariozechner/pi-coding-agent";
747
797
 
748
- The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.
798
+ export default function (pi: HookAPI) {
799
+ const checkpoints = new Map<string, string>();
800
+ let currentEntryId: string | undefined;
749
801
 
750
- ---
802
+ pi.on("tool_result", async (_event, ctx) => {
803
+ const leaf = ctx.sessionManager.getLeafEntry();
804
+ if (leaf) currentEntryId = leaf.id;
805
+ });
751
806
 
752
- # Internals
807
+ pi.on("turn_start", async () => {
808
+ const { stdout } = await pi.exec("git", ["stash", "create"]);
809
+ if (stdout.trim() && currentEntryId) {
810
+ checkpoints.set(currentEntryId, stdout.trim());
811
+ }
812
+ });
753
813
 
754
- ## Discovery and Loading
814
+ pi.on("session_before_branch", async (event, ctx) => {
815
+ const ref = checkpoints.get(event.entryId);
816
+ if (!ref || !ctx.hasUI) return;
755
817
 
756
- Hooks are discovered and loaded at startup in `main.ts`:
818
+ const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?");
819
+ if (ok) {
820
+ await pi.exec("git", ["stash", "apply", ref]);
821
+ ctx.ui.notify("Code restored", "info");
822
+ }
823
+ });
757
824
 
758
- ```
759
- main.ts
760
- -> discoverAndLoadHooks(configuredPaths, cwd) [loader.ts]
761
- -> discoverHooksInDir(~/.pi/agent/hooks/) # global hooks
762
- -> discoverHooksInDir(cwd/.pi/hooks/) # project hooks
763
- -> merge with configuredPaths (deduplicated)
764
- -> for each path:
765
- -> jiti.import(path) # TypeScript support via jiti
766
- -> hookFactory(hookAPI) # calls pi.on() to register handlers
767
- -> returns LoadedHook { path, handlers: Map<eventType, handlers[]> }
825
+ pi.on("agent_end", () => checkpoints.clear());
826
+ }
768
827
  ```
769
828
 
770
- ## Tool Wrapping
829
+ ### Custom Command
771
830
 
772
- Tools (built-in and custom) are wrapped with hook callbacks after tool discovery/selection, before the agent is created:
831
+ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence.
773
832
 
774
- ```
775
- main.ts
776
- -> wrapToolsWithHooks(tools, hookRunner) [tool-wrapper.ts]
777
- -> returns new tools with wrapped execute() functions
778
- ```
779
-
780
- The wrapped `execute()` function:
833
+ ## Mode Behavior
781
834
 
782
- 1. Checks `hookRunner.hasHandlers("tool_call")`
783
- 2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
784
- 3. If result has `block: true`, throws an error
785
- 4. Otherwise, calls the original `tool.execute()`
786
- 5. Checks `hookRunner.hasHandlers("tool_result")`
787
- 6. If yes, calls `hookRunner.emit(event)` (with timeout)
788
- 7. Returns (possibly modified) result
835
+ | Mode | UI Methods | Notes |
836
+ |------|-----------|-------|
837
+ | Interactive | Full TUI | Normal operation |
838
+ | RPC | JSON protocol | Host handles UI |
839
+ | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
789
840
 
790
- ## HookRunner
841
+ In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`.
791
842
 
792
- The `HookRunner` class manages hook execution:
843
+ ## Error Handling
793
844
 
794
- ```typescript
795
- class HookRunner {
796
- constructor(hooks: LoadedHook[], cwd: string, timeout?: number)
845
+ - Hook errors are logged, agent continues
846
+ - `tool_call` errors block the tool (fail-safe)
847
+ - Errors display in UI with hook path and message
848
+ - If a hook hangs, use Ctrl+C to abort
797
849
 
798
- setUIContext(ctx: HookUIContext, hasUI: boolean): void
799
- setSessionFile(path: string | null): void
800
- onError(listener): () => void
801
- hasHandlers(eventType: string): boolean
802
- emit(event: HookEvent): Promise<Result>
803
- emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
804
- }
805
- ```
850
+ ## Debugging
806
851
 
807
- Key behaviors:
808
- - `emit()` has a timeout (default 30s) for safety
809
- - `emitToolCall()` has **no timeout** (user prompts can take any time)
810
- - Errors in `emit()` are caught, logged via `onError()`, and execution continues
811
- - Errors in `emitToolCall()` propagate, causing the tool to be blocked (fail-safe)
812
-
813
- ## Event Flow
814
-
815
- ```
816
- Mode initialization:
817
- -> hookRunner.setUIContext(ctx, hasUI)
818
- -> hookRunner.setSessionFile(path)
819
- -> hookRunner.emit({ type: "session", reason: "start", ... })
820
-
821
- User sends prompt:
822
- -> AgentSession.prompt()
823
- -> hookRunner.emit({ type: "agent_start" })
824
- -> hookRunner.emit({ type: "turn_start", turnIndex })
825
- -> agent loop:
826
- -> LLM generates tool calls
827
- -> For each tool call:
828
- -> wrappedTool.execute()
829
- -> hookRunner.emitToolCall({ type: "tool_call", ... })
830
- -> [if not blocked] originalTool.execute()
831
- -> hookRunner.emit({ type: "tool_result", ... })
832
- -> LLM generates response
833
- -> hookRunner.emit({ type: "turn_end", ... })
834
- -> [repeat if more tool calls]
835
- -> hookRunner.emit({ type: "agent_end", messages })
836
-
837
- Branch:
838
- -> AgentSession.branch()
839
- -> hookRunner.emit({ type: "session", reason: "before_branch", ... }) # can cancel
840
- -> [if not cancelled: branch happens]
841
- -> hookRunner.emit({ type: "session", reason: "branch", ... })
842
-
843
- Session switch:
844
- -> AgentSession.switchSession()
845
- -> hookRunner.emit({ type: "session", reason: "before_switch", ... }) # can cancel
846
- -> [if not cancelled: switch happens]
847
- -> hookRunner.emit({ type: "session", reason: "switch", ... })
848
-
849
- Clear:
850
- -> AgentSession.reset()
851
- -> hookRunner.emit({ type: "session", reason: "before_new", ... }) # can cancel
852
- -> [if not cancelled: new session starts]
853
- -> hookRunner.emit({ type: "session", reason: "new", ... })
854
-
855
- Shutdown (interactive mode):
856
- -> handleCtrlC() or handleCtrlD()
857
- -> hookRunner.emit({ type: "session", reason: "shutdown", ... })
858
- -> process.exit(0)
859
- ```
860
-
861
- ## UI Context by Mode
862
-
863
- Each mode provides its own `HookUIContext` implementation:
864
-
865
- **Interactive Mode** (`interactive-mode.ts`):
866
- - `select()` -> `HookSelectorComponent` (TUI list selector)
867
- - `confirm()` -> `HookSelectorComponent` with Yes/No options
868
- - `input()` -> `HookInputComponent` (TUI text input)
869
- - `notify()` -> Adds text to chat container
870
-
871
- **RPC Mode** (`rpc-mode.ts`):
872
- - All methods send JSON requests via stdout
873
- - Waits for JSON responses via stdin
874
- - Host application renders UI and sends responses
875
-
876
- **Print Mode** (`print-mode.ts`):
877
- - All methods return null/false immediately
878
- - `notify()` is a no-op
879
-
880
- ## File Structure
881
-
882
- ```
883
- packages/coding-agent/src/core/hooks/
884
- ├── index.ts # Public exports
885
- ├── types.ts # Event types, HookAPI, contexts
886
- ├── loader.ts # jiti-based hook loading
887
- ├── runner.ts # HookRunner class
888
- └── tool-wrapper.ts # Tool wrapping for interception
889
- ```
852
+ 1. Open VS Code in hooks directory
853
+ 2. Open JavaScript Debug Terminal (Ctrl+Shift+P "JavaScript Debug Terminal")
854
+ 3. Set breakpoints
855
+ 4. Run `pi --hook ./my-hook.ts`