@mariozechner/pi-coding-agent 0.34.1 → 0.35.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 (251) hide show
  1. package/CHANGELOG.md +224 -18
  2. package/README.md +233 -105
  3. package/dist/cli/args.d.ts +3 -4
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +13 -18
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/config.d.ts +2 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +3 -3
  10. package/dist/config.js.map +1 -1
  11. package/dist/core/agent-session.d.ts +39 -50
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +166 -197
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  16. package/dist/core/compaction/branch-summarization.js +3 -3
  17. package/dist/core/compaction/branch-summarization.js.map +1 -1
  18. package/dist/core/compaction/compaction.d.ts +1 -1
  19. package/dist/core/compaction/compaction.d.ts.map +1 -1
  20. package/dist/core/compaction/compaction.js +6 -5
  21. package/dist/core/compaction/compaction.js.map +1 -1
  22. package/dist/core/event-bus.d.ts +9 -0
  23. package/dist/core/event-bus.d.ts.map +1 -0
  24. package/dist/core/event-bus.js +25 -0
  25. package/dist/core/event-bus.js.map +1 -0
  26. package/dist/core/exec.d.ts +1 -1
  27. package/dist/core/exec.d.ts.map +1 -1
  28. package/dist/core/exec.js +1 -1
  29. package/dist/core/exec.js.map +1 -1
  30. package/dist/core/extensions/index.d.ts +10 -0
  31. package/dist/core/extensions/index.d.ts.map +1 -0
  32. package/dist/core/extensions/index.js +9 -0
  33. package/dist/core/extensions/index.js.map +1 -0
  34. package/dist/core/extensions/loader.d.ts +21 -0
  35. package/dist/core/extensions/loader.d.ts.map +1 -0
  36. package/dist/core/extensions/loader.js +400 -0
  37. package/dist/core/extensions/loader.js.map +1 -0
  38. package/dist/core/extensions/runner.d.ts +88 -0
  39. package/dist/core/extensions/runner.d.ts.map +1 -0
  40. package/dist/core/{hooks → extensions}/runner.js +52 -141
  41. package/dist/core/extensions/runner.js.map +1 -0
  42. package/dist/core/extensions/types.d.ts +461 -0
  43. package/dist/core/extensions/types.d.ts.map +1 -0
  44. package/dist/core/{hooks → extensions}/types.js +7 -4
  45. package/dist/core/extensions/types.js.map +1 -0
  46. package/dist/core/extensions/wrapper.d.ts +25 -0
  47. package/dist/core/extensions/wrapper.d.ts.map +1 -0
  48. package/dist/core/{hooks/tool-wrapper.js → extensions/wrapper.js} +39 -24
  49. package/dist/core/extensions/wrapper.js.map +1 -0
  50. package/dist/core/index.d.ts +2 -2
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +3 -2
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/messages.d.ts +7 -7
  55. package/dist/core/messages.d.ts.map +1 -1
  56. package/dist/core/messages.js +4 -4
  57. package/dist/core/messages.js.map +1 -1
  58. package/dist/core/prompt-templates.d.ts +40 -0
  59. package/dist/core/prompt-templates.d.ts.map +1 -0
  60. package/dist/core/{slash-commands.js → prompt-templates.js} +31 -31
  61. package/dist/core/prompt-templates.js.map +1 -0
  62. package/dist/core/sdk.d.ts +29 -52
  63. package/dist/core/sdk.d.ts.map +1 -1
  64. package/dist/core/sdk.js +111 -211
  65. package/dist/core/sdk.js.map +1 -1
  66. package/dist/core/session-manager.d.ts +17 -17
  67. package/dist/core/session-manager.d.ts.map +1 -1
  68. package/dist/core/session-manager.js +25 -10
  69. package/dist/core/session-manager.js.map +1 -1
  70. package/dist/core/settings-manager.d.ts +3 -6
  71. package/dist/core/settings-manager.d.ts.map +1 -1
  72. package/dist/core/settings-manager.js +4 -11
  73. package/dist/core/settings-manager.js.map +1 -1
  74. package/dist/core/system-prompt.d.ts.map +1 -1
  75. package/dist/core/system-prompt.js +4 -2
  76. package/dist/core/system-prompt.js.map +1 -1
  77. package/dist/index.d.ts +4 -5
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +5 -6
  80. package/dist/index.js.map +1 -1
  81. package/dist/main.d.ts.map +1 -1
  82. package/dist/main.js +36 -33
  83. package/dist/main.js.map +1 -1
  84. package/dist/migrations.d.ts +7 -2
  85. package/dist/migrations.d.ts.map +1 -1
  86. package/dist/migrations.js +93 -4
  87. package/dist/migrations.js.map +1 -1
  88. package/dist/modes/interactive/components/bordered-loader.d.ts +1 -1
  89. package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -1
  90. package/dist/modes/interactive/components/bordered-loader.js +1 -1
  91. package/dist/modes/interactive/components/bordered-loader.js.map +1 -1
  92. package/dist/modes/interactive/components/branch-summary-message.d.ts +1 -1
  93. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
  94. package/dist/modes/interactive/components/branch-summary-message.js +1 -1
  95. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
  96. package/dist/modes/interactive/components/compaction-summary-message.d.ts +1 -1
  97. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  98. package/dist/modes/interactive/components/compaction-summary-message.js +1 -1
  99. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  100. package/dist/modes/interactive/components/custom-editor.d.ts +2 -2
  101. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  102. package/dist/modes/interactive/components/custom-editor.js +4 -4
  103. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  104. package/dist/modes/interactive/components/custom-message.d.ts +18 -0
  105. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -0
  106. package/dist/modes/interactive/components/{hook-message.js → custom-message.js} +3 -3
  107. package/dist/modes/interactive/components/custom-message.js.map +1 -0
  108. package/dist/modes/interactive/components/dynamic-border.d.ts +2 -2
  109. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  110. package/dist/modes/interactive/components/dynamic-border.js +2 -2
  111. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  112. package/dist/modes/interactive/components/{hook-editor.d.ts → extension-editor.d.ts} +3 -3
  113. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -0
  114. package/dist/modes/interactive/components/{hook-editor.js → extension-editor.js} +4 -4
  115. package/dist/modes/interactive/components/extension-editor.js.map +1 -0
  116. package/dist/modes/interactive/components/{hook-input.d.ts → extension-input.d.ts} +3 -3
  117. package/dist/modes/interactive/components/extension-input.d.ts.map +1 -0
  118. package/dist/modes/interactive/components/{hook-input.js → extension-input.js} +3 -3
  119. package/dist/modes/interactive/components/extension-input.js.map +1 -0
  120. package/dist/modes/interactive/components/{hook-selector.d.ts → extension-selector.d.ts} +3 -3
  121. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -0
  122. package/dist/modes/interactive/components/{hook-selector.js → extension-selector.js} +3 -3
  123. package/dist/modes/interactive/components/extension-selector.js.map +1 -0
  124. package/dist/modes/interactive/components/footer.d.ts +3 -3
  125. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  126. package/dist/modes/interactive/components/footer.js +8 -8
  127. package/dist/modes/interactive/components/footer.js.map +1 -1
  128. package/dist/modes/interactive/components/tool-execution.d.ts +3 -3
  129. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  130. package/dist/modes/interactive/components/tool-execution.js +9 -9
  131. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  132. package/dist/modes/interactive/interactive-mode.d.ts +37 -44
  133. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  134. package/dist/modes/interactive/interactive-mode.js +143 -189
  135. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  136. package/dist/modes/print-mode.d.ts.map +1 -1
  137. package/dist/modes/print-mode.js +10 -33
  138. package/dist/modes/print-mode.js.map +1 -1
  139. package/dist/modes/rpc/rpc-client.d.ts +3 -3
  140. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  141. package/dist/modes/rpc/rpc-client.js +3 -3
  142. package/dist/modes/rpc/rpc-client.js.map +1 -1
  143. package/dist/modes/rpc/rpc-mode.d.ts +2 -2
  144. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  145. package/dist/modes/rpc/rpc-mode.js +33 -57
  146. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  147. package/dist/modes/rpc/rpc-types.d.ts +16 -16
  148. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  149. package/dist/modes/rpc/rpc-types.js.map +1 -1
  150. package/docs/extensions.md +1053 -0
  151. package/docs/rpc.md +4 -4
  152. package/docs/sdk.md +62 -93
  153. package/docs/session.md +22 -19
  154. package/docs/skills.md +1 -1
  155. package/docs/tui.md +1 -1
  156. package/examples/README.md +9 -15
  157. package/examples/extensions/README.md +141 -0
  158. package/examples/{hooks → extensions}/auto-commit-on-exit.ts +3 -3
  159. package/examples/extensions/chalk-logger.ts +26 -0
  160. package/examples/{hooks → extensions}/confirm-destructive.ts +3 -3
  161. package/examples/{hooks → extensions}/custom-compaction.ts +6 -6
  162. package/examples/{hooks → extensions}/dirty-repo-guard.ts +8 -4
  163. package/examples/{hooks → extensions}/file-trigger.ts +3 -3
  164. package/examples/{hooks → extensions}/git-checkpoint.ts +3 -3
  165. package/examples/{hooks → extensions}/handoff.ts +3 -3
  166. package/examples/extensions/hello.ts +25 -0
  167. package/examples/{hooks → extensions}/permission-gate.ts +3 -3
  168. package/examples/{hooks → extensions}/pirate.ts +5 -5
  169. package/examples/{hooks → extensions}/plan-mode.ts +6 -6
  170. package/examples/{hooks → extensions}/protected-paths.ts +3 -3
  171. package/examples/{hooks → extensions}/qna.ts +3 -3
  172. package/examples/{custom-tools/question/index.ts → extensions/question.ts} +13 -17
  173. package/examples/{hooks → extensions}/snake.ts +3 -3
  174. package/examples/{hooks → extensions}/status-line.ts +3 -3
  175. package/examples/{custom-tools → extensions}/subagent/README.md +15 -15
  176. package/examples/{custom-tools → extensions}/subagent/index.ts +22 -43
  177. package/examples/{custom-tools/todo/index.ts → extensions/todo.ts} +122 -39
  178. package/examples/{hooks → extensions}/tools.ts +5 -5
  179. package/examples/extensions/with-deps/index.ts +40 -0
  180. package/examples/extensions/with-deps/package-lock.json +31 -0
  181. package/examples/extensions/with-deps/package.json +16 -0
  182. package/examples/sdk/01-minimal.ts +1 -1
  183. package/examples/sdk/05-tools.ts +7 -41
  184. package/examples/sdk/06-extensions.ts +81 -0
  185. package/examples/sdk/08-prompt-templates.ts +42 -0
  186. package/examples/sdk/12-full-control.ts +10 -29
  187. package/examples/sdk/README.md +5 -5
  188. package/package.json +4 -4
  189. package/dist/core/custom-tools/index.d.ts +0 -7
  190. package/dist/core/custom-tools/index.d.ts.map +0 -1
  191. package/dist/core/custom-tools/index.js +0 -6
  192. package/dist/core/custom-tools/index.js.map +0 -1
  193. package/dist/core/custom-tools/loader.d.ts +0 -30
  194. package/dist/core/custom-tools/loader.d.ts.map +0 -1
  195. package/dist/core/custom-tools/loader.js +0 -276
  196. package/dist/core/custom-tools/loader.js.map +0 -1
  197. package/dist/core/custom-tools/types.d.ts +0 -144
  198. package/dist/core/custom-tools/types.d.ts.map +0 -1
  199. package/dist/core/custom-tools/types.js +0 -8
  200. package/dist/core/custom-tools/types.js.map +0 -1
  201. package/dist/core/custom-tools/wrapper.d.ts +0 -15
  202. package/dist/core/custom-tools/wrapper.d.ts.map +0 -1
  203. package/dist/core/custom-tools/wrapper.js +0 -23
  204. package/dist/core/custom-tools/wrapper.js.map +0 -1
  205. package/dist/core/hooks/index.d.ts +0 -6
  206. package/dist/core/hooks/index.d.ts.map +0 -1
  207. package/dist/core/hooks/index.js +0 -6
  208. package/dist/core/hooks/index.js.map +0 -1
  209. package/dist/core/hooks/loader.d.ts +0 -146
  210. package/dist/core/hooks/loader.d.ts.map +0 -1
  211. package/dist/core/hooks/loader.js +0 -275
  212. package/dist/core/hooks/loader.js.map +0 -1
  213. package/dist/core/hooks/runner.d.ts +0 -173
  214. package/dist/core/hooks/runner.d.ts.map +0 -1
  215. package/dist/core/hooks/runner.js.map +0 -1
  216. package/dist/core/hooks/tool-wrapper.d.ts +0 -17
  217. package/dist/core/hooks/tool-wrapper.d.ts.map +0 -1
  218. package/dist/core/hooks/tool-wrapper.js.map +0 -1
  219. package/dist/core/hooks/types.d.ts +0 -767
  220. package/dist/core/hooks/types.d.ts.map +0 -1
  221. package/dist/core/hooks/types.js.map +0 -1
  222. package/dist/core/slash-commands.d.ts +0 -40
  223. package/dist/core/slash-commands.d.ts.map +0 -1
  224. package/dist/core/slash-commands.js.map +0 -1
  225. package/dist/modes/interactive/components/hook-editor.d.ts.map +0 -1
  226. package/dist/modes/interactive/components/hook-editor.js.map +0 -1
  227. package/dist/modes/interactive/components/hook-input.d.ts.map +0 -1
  228. package/dist/modes/interactive/components/hook-input.js.map +0 -1
  229. package/dist/modes/interactive/components/hook-message.d.ts +0 -18
  230. package/dist/modes/interactive/components/hook-message.d.ts.map +0 -1
  231. package/dist/modes/interactive/components/hook-message.js.map +0 -1
  232. package/dist/modes/interactive/components/hook-selector.d.ts.map +0 -1
  233. package/dist/modes/interactive/components/hook-selector.js.map +0 -1
  234. package/docs/custom-tools.md +0 -514
  235. package/docs/extension-loading.md +0 -1004
  236. package/docs/hooks.md +0 -979
  237. package/docs/session-tree-plan.md +0 -441
  238. package/examples/custom-tools/README.md +0 -114
  239. package/examples/custom-tools/hello/index.ts +0 -21
  240. package/examples/hooks/README.md +0 -60
  241. package/examples/hooks/todo/index.ts +0 -134
  242. package/examples/sdk/06-hooks.ts +0 -61
  243. package/examples/sdk/08-slash-commands.ts +0 -42
  244. /package/examples/{custom-tools → extensions}/subagent/agents/planner.md +0 -0
  245. /package/examples/{custom-tools → extensions}/subagent/agents/reviewer.md +0 -0
  246. /package/examples/{custom-tools → extensions}/subagent/agents/scout.md +0 -0
  247. /package/examples/{custom-tools → extensions}/subagent/agents/worker.md +0 -0
  248. /package/examples/{custom-tools → extensions}/subagent/agents.ts +0 -0
  249. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/implement-and-review.md +0 -0
  250. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/implement.md +0 -0
  251. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/scout-and-plan.md +0 -0
package/docs/hooks.md DELETED
@@ -1,979 +0,0 @@
1
- > pi can create hooks. Ask it to build one for your use case.
2
-
3
- # Hooks
4
-
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()`
13
-
14
- **Example use cases:**
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)
20
-
21
- See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI.
22
-
23
- ## Quick Start
24
-
25
- Create `~/.pi/agent/hooks/my-hook.ts`:
26
-
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
- });
34
-
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
- ```
43
-
44
- Test with `--hook` flag:
45
-
46
- ```bash
47
- pi --hook ./my-hook.ts
48
- ```
49
-
50
- ## Hook Locations
51
-
52
- Hooks are auto-discovered from:
53
-
54
- | Location | Scope |
55
- |----------|-------|
56
- | `~/.pi/agent/hooks/*.ts` | Global (all projects) |
57
- | `.pi/hooks/*.ts` | Project-local |
58
-
59
- Additional paths via `settings.json`:
60
-
61
- ```json
62
- {
63
- "hooks": ["/path/to/hook.ts"]
64
- }
65
- ```
66
-
67
- ## Available Imports
68
-
69
- | Package | Purpose |
70
- |---------|---------|
71
- | `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
72
- | `@mariozechner/pi-coding-agent` | Additional types if needed |
73
- | `@mariozechner/pi-ai` | AI utilities |
74
- | `@mariozechner/pi-tui` | TUI components |
75
-
76
- Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
77
-
78
- ## Writing a Hook
79
-
80
- A hook exports a default function that receives `HookAPI`:
81
-
82
- ```typescript
83
- import type { HookAPI } from "@mariozechner/pi-coding-agent";
84
-
85
- export default function (pi: HookAPI) {
86
- // Subscribe to events
87
- pi.on("event_name", async (event, ctx) => {
88
- // Handle event
89
- });
90
- }
91
- ```
92
-
93
- Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
94
-
95
- ## Events
96
-
97
- ### Lifecycle Overview
98
-
99
- ```
100
- pi starts
101
-
102
- └─► session_start
103
-
104
-
105
- user sends prompt ─────────────────────────────────────────┐
106
- │ │
107
- ├─► before_agent_start (can inject message, append to system prompt) │
108
- ├─► agent_start │
109
- │ │
110
- │ ┌─── turn (repeats while LLM calls tools) ───┐ │
111
- │ │ │ │
112
- │ ├─► turn_start │ │
113
- │ ├─► context (can modify messages) │ │
114
- │ │ │ │
115
- │ │ LLM responds, may call tools: │ │
116
- │ │ ├─► tool_call (can block) │ │
117
- │ │ │ tool executes │ │
118
- │ │ └─► tool_result (can modify) │ │
119
- │ │ │ │
120
- │ └─► turn_end │ │
121
- │ │
122
- └─► agent_end │
123
-
124
- user sends another prompt ◄────────────────────────────────┘
125
-
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")
129
-
130
- /branch
131
- ├─► session_before_branch (can cancel)
132
- └─► session_branch
133
-
134
- /compact or auto-compaction
135
- ├─► session_before_compact (can cancel or customize)
136
- └─► session_compact
137
-
138
- /tree navigation
139
- ├─► session_before_tree (can cancel or customize)
140
- └─► session_tree
141
-
142
- exit (Ctrl+C, Ctrl+D)
143
- └─► session_shutdown
144
- ```
145
-
146
- ### Session Events
147
-
148
- #### session_start
149
-
150
- Fired on initial session load.
151
-
152
- ```typescript
153
- pi.on("session_start", async (_event, ctx) => {
154
- ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
155
- });
156
- ```
157
-
158
- #### session_before_switch / session_switch
159
-
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
- });
174
-
175
- pi.on("session_switch", async (event, ctx) => {
176
- // event.reason - "new" or "resume"
177
- // event.previousSessionFile - session we came from
178
- });
179
- ```
180
-
181
- #### session_before_branch / session_branch
182
-
183
- Fired when branching via `/branch`.
184
-
185
- ```typescript
186
- pi.on("session_before_branch", async (event, ctx) => {
187
- // event.entryId - ID of the entry being branched from
188
-
189
- return { cancel: true }; // Cancel branch
190
- // OR
191
- return { skipConversationRestore: true }; // Branch but don't rewind messages
192
- });
193
-
194
- pi.on("session_branch", async (event, ctx) => {
195
- // event.previousSessionFile - previous session file
196
- });
197
- ```
198
-
199
- The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately.
200
-
201
- #### session_before_compact / session_compact
202
-
203
- Fired on compaction. See [compaction.md](compaction.md) for details.
204
-
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
-
222
- pi.on("session_compact", async (event, ctx) => {
223
- // event.compactionEntry - the saved compaction
224
- // event.fromHook - whether hook provided it
225
- });
226
- ```
227
-
228
- #### session_before_tree / session_tree
229
-
230
- Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details.
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
- });
246
- ```
247
-
248
- #### session_shutdown
249
-
250
- Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
251
-
252
- ```typescript
253
- pi.on("session_shutdown", async (_event, ctx) => {
254
- // Cleanup, save state, etc.
255
- });
256
- ```
257
-
258
- ### Agent Events
259
-
260
- #### before_agent_start
261
-
262
- Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.
263
-
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
- // Inject a persistent message (stored in session, sent to LLM)
271
- message: {
272
- customType: "my-hook",
273
- content: "Additional context for the LLM",
274
- display: true, // Show in TUI
275
- },
276
- // Append to system prompt for this turn only
277
- systemPromptAppend: "Extra instructions for this turn...",
278
- };
279
- });
280
- ```
281
-
282
- **message**: Persisted as `CustomMessageEntry` and sent to the LLM. Multiple hooks can each return a message; all are injected in order.
283
-
284
- **systemPromptAppend**: Appended to the base system prompt for this agent run only. Multiple hooks can each return `systemPromptAppend` strings, which are concatenated. This is useful for dynamic instructions based on hook state (e.g., plan mode, persona toggles).
285
-
286
- See [examples/hooks/pirate.ts](../examples/hooks/pirate.ts) for an example using `systemPromptAppend`.
287
-
288
- #### agent_start / agent_end
289
-
290
- Fired once per user prompt.
291
-
292
- ```typescript
293
- pi.on("agent_start", async (_event, ctx) => {});
294
-
295
- pi.on("agent_end", async (event, ctx) => {
296
- // event.messages - messages from this prompt
297
- });
298
- ```
299
-
300
- #### turn_start / turn_end
301
-
302
- Fired for each turn (one LLM response + tool calls).
303
-
304
- ```typescript
305
- pi.on("turn_start", async (event, ctx) => {
306
- // event.turnIndex, event.timestamp
307
- });
308
-
309
- pi.on("turn_end", async (event, ctx) => {
310
- // event.turnIndex
311
- // event.message - assistant's response
312
- // event.toolResults - tool results from this turn
313
- });
314
- ```
315
-
316
- #### context
317
-
318
- Fired before each LLM call. Modify messages non-destructively (session unchanged).
319
-
320
- ```typescript
321
- pi.on("context", async (event, ctx) => {
322
- // event.messages - deep copy, safe to modify
323
-
324
- // Filter or transform messages
325
- const filtered = event.messages.filter(m => !shouldPrune(m));
326
- return { messages: filtered };
327
- });
328
- ```
329
-
330
- ### Tool Events
331
-
332
- #### tool_call
333
-
334
- Fired before tool executes. **Can block.**
335
-
336
- ```typescript
337
- pi.on("tool_call", async (event, ctx) => {
338
- // event.toolName - "bash", "read", "write", "edit", etc.
339
- // event.toolCallId
340
- // event.input - tool parameters
341
-
342
- if (shouldBlock(event)) {
343
- return { block: true, reason: "Not allowed" };
344
- }
345
- });
346
- ```
347
-
348
- Tool inputs:
349
- - `bash`: `{ command, timeout? }`
350
- - `read`: `{ path, offset?, limit? }`
351
- - `write`: `{ path, content }`
352
- - `edit`: `{ path, oldText, newText }`
353
- - `ls`: `{ path?, limit? }`
354
- - `find`: `{ pattern, path?, limit? }`
355
- - `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
356
-
357
- #### tool_result
358
-
359
- Fired after tool executes (including errors). **Can modify result.**
360
-
361
- Check `event.isError` to distinguish successful executions from failures.
362
-
363
- ```typescript
364
- pi.on("tool_result", async (event, ctx) => {
365
- // event.toolName, event.toolCallId, event.input
366
- // event.content - array of TextContent | ImageContent
367
- // event.details - tool-specific (see below)
368
- // event.isError - true if the tool threw an error
369
-
370
- if (event.isError) {
371
- // Handle error case
372
- }
373
-
374
- // Modify result:
375
- return { content: [...], details: {...}, isError: false };
376
- });
377
- ```
378
-
379
- Use type guards for typed details:
380
-
381
- ```typescript
382
- import { isBashToolResult } from "@mariozechner/pi-coding-agent";
383
-
384
- pi.on("tool_result", async (event, ctx) => {
385
- if (isBashToolResult(event)) {
386
- // event.details is BashToolDetails | undefined
387
- if (event.details?.truncation?.truncated) {
388
- // Full output at event.details.fullOutputPath
389
- }
390
- }
391
- });
392
- ```
393
-
394
- Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
395
-
396
- ## HookContext
397
-
398
- Every handler receives `ctx: HookContext`:
399
-
400
- ### ctx.ui
401
-
402
- UI methods for user interaction. Hooks can prompt users and even render custom TUI components.
403
-
404
- **Built-in dialogs:**
405
-
406
- ```typescript
407
- // Select from options
408
- const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
409
- // Returns selected string or undefined if cancelled
410
-
411
- // Confirm dialog
412
- const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
413
- // Returns true or false
414
-
415
- // Text input (single line)
416
- const name = await ctx.ui.input("Name:", "placeholder");
417
- // Returns string or undefined if cancelled
418
-
419
- // Multi-line editor (with Ctrl+G for external editor)
420
- const text = await ctx.ui.editor("Edit prompt:", "prefilled text");
421
- // Returns edited text or undefined if cancelled (Escape)
422
- // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
423
-
424
- // Notification (non-blocking)
425
- ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
426
-
427
- // Set status text in footer (persistent until cleared)
428
- ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
429
- ctx.ui.setStatus("my-hook", undefined); // Clear status
430
-
431
- // Set a multi-line widget (displayed above editor, below "Working..." indicator)
432
- ctx.ui.setWidget("my-todos", [
433
- theme.fg("accent", "Plan Progress:"),
434
- theme.fg("success", "☑ ") + theme.fg("muted", theme.strikethrough("Read files")),
435
- theme.fg("muted", "☐ ") + "Modify code",
436
- theme.fg("muted", "☐ ") + "Run tests",
437
- ]);
438
- ctx.ui.setWidget("my-todos", undefined); // Clear widget
439
-
440
- // Set the terminal window/tab title
441
- ctx.ui.setTitle("pi - my-project");
442
-
443
- // Set the core input editor text (pre-fill prompts, generated content)
444
- ctx.ui.setEditorText("Generated prompt text here...");
445
-
446
- // Get current editor text
447
- const currentText = ctx.ui.getEditorText();
448
- ```
449
-
450
- **Status text notes:**
451
- - Multiple hooks can set their own status using unique keys
452
- - Statuses are displayed on a single line in the footer, sorted alphabetically by key
453
- - Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
454
- - Use `ctx.ui.theme` to style status text with theme colors (see below)
455
-
456
- **Widget notes:**
457
- - Widgets are multi-line displays shown above the editor (below "Working..." indicator)
458
- - Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically)
459
- - `setWidget()` accepts either a string array or a component factory function
460
- - Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`)
461
- - **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets.
462
-
463
- **Terminal title notes:**
464
- - Uses OSC escape sequence (works in most modern terminals like iTerm2, Terminal.app, Windows Terminal)
465
- - Useful for showing project name, current task, or session status in the terminal tab/window title
466
-
467
- **Custom widget components:**
468
-
469
- For more complex widgets, pass a factory function to `setWidget()`:
470
-
471
- ```typescript
472
- ctx.ui.setWidget("my-widget", (tui, theme) => {
473
- // Return any Component that implements render(width): string[]
474
- return new MyCustomComponent(tui, theme);
475
- });
476
-
477
- // Clear the widget
478
- ctx.ui.setWidget("my-widget", undefined);
479
- ```
480
-
481
- Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor.
482
-
483
- **Styling with theme colors:**
484
-
485
- Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
486
-
487
- ```typescript
488
- const theme = ctx.ui.theme;
489
-
490
- // Foreground colors
491
- ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + theme.fg("dim", " Ready"));
492
- ctx.ui.setStatus("my-hook", theme.fg("error", "✗") + theme.fg("dim", " Failed"));
493
- ctx.ui.setStatus("my-hook", theme.fg("accent", "●") + theme.fg("dim", " Working..."));
494
-
495
- // Available fg colors: accent, success, error, warning, muted, dim, text, and more
496
- // See docs/theme.md for the full list of theme colors
497
- ```
498
-
499
- See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a complete example.
500
-
501
- **Custom components:**
502
-
503
- Show a custom TUI component with keyboard focus:
504
-
505
- ```typescript
506
- import { BorderedLoader } from "@mariozechner/pi-coding-agent";
507
-
508
- const result = await ctx.ui.custom((tui, theme, done) => {
509
- const loader = new BorderedLoader(tui, theme, "Working...");
510
- loader.onAbort = () => done(null);
511
-
512
- doWork(loader.signal).then(done).catch(() => done(null));
513
-
514
- return loader; // Return the component directly, do NOT wrap in Box/Container
515
- });
516
- ```
517
-
518
- **Important:** Return your component directly from the callback. Do not wrap it in a `Box` or `Container`, as this breaks input handling.
519
-
520
- Your component can:
521
- - Implement `handleInput(data: string)` to receive keyboard input
522
- - Implement `render(width: number): string[]` to render lines
523
- - Implement `invalidate()` to clear cached render
524
- - Implement `dispose()` for cleanup when closed
525
- - Call `tui.requestRender()` to trigger re-render
526
- - Call `done(result)` when done to restore normal UI
527
-
528
- 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.
529
-
530
- ### ctx.hasUI
531
-
532
- `false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`:
533
-
534
- ```typescript
535
- if (ctx.hasUI) {
536
- const choice = await ctx.ui.select(...);
537
- } else {
538
- // Default behavior
539
- }
540
- ```
541
-
542
- ### ctx.cwd
543
-
544
- Current working directory.
545
-
546
- ### ctx.sessionManager
547
-
548
- Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts).
549
-
550
- ```typescript
551
- // Session info
552
- ctx.sessionManager.getCwd() // Working directory
553
- ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions)
554
- ctx.sessionManager.getSessionId() // Current session ID
555
- ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session)
556
-
557
- // Entries
558
- ctx.sessionManager.getEntries() // All entries (excludes header)
559
- ctx.sessionManager.getHeader() // Session header entry
560
- ctx.sessionManager.getEntry(id) // Specific entry by ID
561
- ctx.sessionManager.getLabel(id) // Entry label (if any)
562
-
563
- // Tree navigation
564
- ctx.sessionManager.getBranch() // Current branch (root to leaf)
565
- ctx.sessionManager.getBranch(leafId) // Specific branch
566
- ctx.sessionManager.getTree() // Full tree structure
567
- ctx.sessionManager.getLeafId() // Current leaf entry ID
568
- ctx.sessionManager.getLeafEntry() // Current leaf entry
569
- ```
570
-
571
- Use `pi.sendMessage()` or `pi.appendEntry()` for writes.
572
-
573
- ### ctx.modelRegistry
574
-
575
- Access to models and API keys:
576
-
577
- ```typescript
578
- // Get API key for a model
579
- const apiKey = await ctx.modelRegistry.getApiKey(model);
580
-
581
- // Get available models
582
- const models = ctx.modelRegistry.getAvailableModels();
583
- ```
584
-
585
- ### ctx.model
586
-
587
- Current model, or `undefined` if none selected yet. Use for LLM calls in hooks:
588
-
589
- ```typescript
590
- if (ctx.model) {
591
- const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
592
- // Use with @mariozechner/pi-ai complete()
593
- }
594
- ```
595
-
596
- ### ctx.isIdle()
597
-
598
- Returns `true` if the agent is not currently streaming:
599
-
600
- ```typescript
601
- if (ctx.isIdle()) {
602
- // Agent is not processing
603
- }
604
- ```
605
-
606
- ### ctx.abort()
607
-
608
- Abort the current agent operation (fire-and-forget, does not wait):
609
-
610
- ```typescript
611
- await ctx.abort();
612
- ```
613
-
614
- ### ctx.hasPendingMessages()
615
-
616
- Check if there are messages pending (user typed while agent was streaming):
617
-
618
- ```typescript
619
- if (ctx.hasPendingMessages()) {
620
- // Skip interactive prompt, let pending messages take over
621
- return;
622
- }
623
- ```
624
-
625
- ## HookCommandContext (Slash Commands Only)
626
-
627
- 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).
628
-
629
- ### ctx.waitForIdle()
630
-
631
- Wait for the agent to finish streaming:
632
-
633
- ```typescript
634
- await ctx.waitForIdle();
635
- // Agent is now idle
636
- ```
637
-
638
- ### ctx.newSession(options?)
639
-
640
- Create a new session, optionally with initialization:
641
-
642
- ```typescript
643
- const result = await ctx.newSession({
644
- parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
645
- setup: async (sm) => {
646
- // Initialize the new session
647
- sm.appendMessage({
648
- role: "user",
649
- content: [{ type: "text", text: "Context from previous session..." }],
650
- timestamp: Date.now(),
651
- });
652
- },
653
- });
654
-
655
- if (result.cancelled) {
656
- // A hook cancelled the new session
657
- }
658
- ```
659
-
660
- ### ctx.branch(entryId)
661
-
662
- Branch from a specific entry, creating a new session file:
663
-
664
- ```typescript
665
- const result = await ctx.branch("entry-id-123");
666
- if (!result.cancelled) {
667
- // Now in the branched session
668
- }
669
- ```
670
-
671
- ### ctx.navigateTree(targetId, options?)
672
-
673
- Navigate to a different point in the session tree:
674
-
675
- ```typescript
676
- const result = await ctx.navigateTree("entry-id-456", {
677
- summarize: true, // Summarize the abandoned branch
678
- });
679
- ```
680
-
681
- ## HookAPI Methods
682
-
683
- ### pi.on(event, handler)
684
-
685
- Subscribe to events. See [Events](#events) for all event types.
686
-
687
- ### pi.sendMessage(message, options?)
688
-
689
- Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context.
690
-
691
- ```typescript
692
- pi.sendMessage({
693
- customType: "my-hook", // Your hook's identifier
694
- content: "Message text", // string or (TextContent | ImageContent)[]
695
- display: true, // Show in TUI
696
- details: { ... }, // Optional metadata (not sent to LLM)
697
- }, {
698
- triggerTurn: true, // If true and agent is idle, triggers LLM response
699
- deliverAs: "steer", // "steer" (default) or "followUp" when agent is streaming
700
- });
701
- ```
702
-
703
- **Storage and timing:**
704
- - The message is appended to the session file immediately as a `CustomMessageEntry`
705
- - If the agent is currently streaming:
706
- - `deliverAs: "steer"` (default): Delivered after current tool execution, interrupts remaining tools
707
- - `deliverAs: "followUp"`: Delivered only after agent finishes all work
708
- - If `triggerTurn` is true and the agent is idle, a new agent loop starts
709
-
710
- **LLM context:**
711
- - `CustomMessageEntry` is converted to a user message when building context for the LLM
712
- - Only `content` is sent to the LLM; `details` is for rendering/state only
713
-
714
- **TUI display:**
715
- - If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
716
- - If `display: false`, the message is hidden from the TUI but still sent to the LLM
717
- - Use `pi.registerMessageRenderer()` to customize how your messages render (see below)
718
-
719
- ### pi.appendEntry(customType, data?)
720
-
721
- Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context).
722
-
723
- ```typescript
724
- // Save state
725
- pi.appendEntry("my-hook-state", { count: 42 });
726
-
727
- // Restore on reload
728
- pi.on("session_start", async (_event, ctx) => {
729
- for (const entry of ctx.sessionManager.getEntries()) {
730
- if (entry.type === "custom" && entry.customType === "my-hook-state") {
731
- // Reconstruct from entry.data
732
- }
733
- }
734
- });
735
- ```
736
-
737
- ### pi.registerCommand(name, options)
738
-
739
- Register a custom slash command:
740
-
741
- ```typescript
742
- pi.registerCommand("stats", {
743
- description: "Show session statistics",
744
- handler: async (args, ctx) => {
745
- // args = everything after /stats
746
- const count = ctx.sessionManager.getEntries().length;
747
- ctx.ui.notify(`${count} entries`, "info");
748
- }
749
- });
750
- ```
751
-
752
- For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts).
753
-
754
- To trigger LLM after command, call `pi.sendMessage(..., { triggerTurn: true })`.
755
-
756
- ### pi.registerMessageRenderer(customType, renderer)
757
-
758
- 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.
759
-
760
- ```typescript
761
- import { Text } from "@mariozechner/pi-tui";
762
-
763
- pi.registerMessageRenderer("my-hook", (message, options, theme) => {
764
- // message.content - the message content (string or content array)
765
- // message.details - your custom metadata
766
- // options.expanded - true if user pressed Ctrl+O
767
-
768
- const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `);
769
- const text = typeof message.content === "string"
770
- ? message.content
771
- : message.content.map(c => c.type === "text" ? c.text : "[image]").join("");
772
-
773
- return new Text(prefix + theme.fg("text", text), 0, 0);
774
- });
775
- ```
776
-
777
- **Renderer signature:**
778
- ```typescript
779
- type HookMessageRenderer = (
780
- message: CustomMessageEntry,
781
- options: { expanded: boolean },
782
- theme: Theme
783
- ) => Component | null;
784
- ```
785
-
786
- 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.
787
-
788
- ### pi.exec(command, args, options?)
789
-
790
- Execute a shell command:
791
-
792
- ```typescript
793
- const result = await pi.exec("git", ["status"], {
794
- signal, // AbortSignal
795
- timeout, // Milliseconds
796
- });
797
-
798
- // result.stdout, result.stderr, result.code, result.killed
799
- ```
800
-
801
- ### pi.getActiveTools()
802
-
803
- Get the names of currently active tools:
804
-
805
- ```typescript
806
- const toolNames = pi.getActiveTools();
807
- // ["read", "bash", "edit", "write"]
808
- ```
809
-
810
- ### pi.getAllTools()
811
-
812
- Get all configured tools (built-in via --tools or default, plus custom tools):
813
-
814
- ```typescript
815
- const allTools = pi.getAllTools();
816
- // ["read", "bash", "edit", "write", "my-custom-tool"]
817
- ```
818
-
819
- ### pi.setActiveTools(toolNames)
820
-
821
- Set the active tools by name. Changes take effect on the next agent turn.
822
- Note: This will invalidate prompt caching for the next request.
823
-
824
- ```typescript
825
- // Switch to read-only mode (plan mode)
826
- pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
827
-
828
- // Restore full access
829
- pi.setActiveTools(["read", "bash", "edit", "write"]);
830
- ```
831
-
832
- Both built-in and custom tools can be enabled/disabled. Unknown tool names are ignored.
833
-
834
- ### pi.registerFlag(name, options)
835
-
836
- Register a CLI flag for this hook. Flag values are accessible via `pi.getFlag()`.
837
-
838
- ```typescript
839
- pi.registerFlag("plan", {
840
- description: "Start in plan mode (read-only)",
841
- type: "boolean", // or "string"
842
- default: false,
843
- });
844
- ```
845
-
846
- ### pi.getFlag(name)
847
-
848
- Get the value of a CLI flag registered by this hook.
849
-
850
- ```typescript
851
- if (pi.getFlag("plan") === true) {
852
- // plan mode enabled via --plan flag
853
- }
854
- ```
855
-
856
- ### pi.registerShortcut(shortcut, options)
857
-
858
- Register a keyboard shortcut for this hook. The handler is called when the shortcut is pressed.
859
-
860
- ```typescript
861
- pi.registerShortcut("shift+p", {
862
- description: "Toggle plan mode",
863
- handler: async (ctx) => {
864
- // toggle mode
865
- ctx.ui.notify("Plan mode toggled");
866
- },
867
- });
868
- ```
869
-
870
- Shortcut format: `modifier+key` where modifier can be `shift`, `ctrl`, `alt`, or combinations like `ctrl+shift`.
871
-
872
- ## Examples
873
-
874
- ### Permission Gate
875
-
876
- ```typescript
877
- import type { HookAPI } from "@mariozechner/pi-coding-agent";
878
-
879
- export default function (pi: HookAPI) {
880
- const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
881
-
882
- pi.on("tool_call", async (event, ctx) => {
883
- if (event.toolName !== "bash") return;
884
-
885
- const cmd = event.input.command as string;
886
- if (dangerous.some(p => p.test(cmd))) {
887
- if (!ctx.hasUI) {
888
- return { block: true, reason: "Dangerous (no UI)" };
889
- }
890
- const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
891
- if (!ok) return { block: true, reason: "Blocked by user" };
892
- }
893
- });
894
- }
895
- ```
896
-
897
- ### Protected Paths
898
-
899
- ```typescript
900
- import type { HookAPI } from "@mariozechner/pi-coding-agent";
901
-
902
- export default function (pi: HookAPI) {
903
- const protectedPaths = [".env", ".git/", "node_modules/"];
904
-
905
- pi.on("tool_call", async (event, ctx) => {
906
- if (event.toolName !== "write" && event.toolName !== "edit") return;
907
-
908
- const path = event.input.path as string;
909
- if (protectedPaths.some(p => path.includes(p))) {
910
- ctx.ui.notify(`Blocked: ${path}`, "warning");
911
- return { block: true, reason: `Protected: ${path}` };
912
- }
913
- });
914
- }
915
- ```
916
-
917
- ### Git Checkpoint
918
-
919
- ```typescript
920
- import type { HookAPI } from "@mariozechner/pi-coding-agent";
921
-
922
- export default function (pi: HookAPI) {
923
- const checkpoints = new Map<string, string>();
924
- let currentEntryId: string | undefined;
925
-
926
- pi.on("tool_result", async (_event, ctx) => {
927
- const leaf = ctx.sessionManager.getLeafEntry();
928
- if (leaf) currentEntryId = leaf.id;
929
- });
930
-
931
- pi.on("turn_start", async () => {
932
- const { stdout } = await pi.exec("git", ["stash", "create"]);
933
- if (stdout.trim() && currentEntryId) {
934
- checkpoints.set(currentEntryId, stdout.trim());
935
- }
936
- });
937
-
938
- pi.on("session_before_branch", async (event, ctx) => {
939
- const ref = checkpoints.get(event.entryId);
940
- if (!ref || !ctx.hasUI) return;
941
-
942
- const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?");
943
- if (ok) {
944
- await pi.exec("git", ["stash", "apply", ref]);
945
- ctx.ui.notify("Code restored", "info");
946
- }
947
- });
948
-
949
- pi.on("agent_end", () => checkpoints.clear());
950
- }
951
- ```
952
-
953
- ### Custom Command
954
-
955
- See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence.
956
-
957
- ## Mode Behavior
958
-
959
- | Mode | UI Methods | Notes |
960
- |------|-----------|-------|
961
- | Interactive | Full TUI | Normal operation |
962
- | RPC | JSON protocol | Host handles UI |
963
- | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
964
-
965
- 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`.
966
-
967
- ## Error Handling
968
-
969
- - Hook errors are logged, agent continues
970
- - `tool_call` errors block the tool (fail-safe)
971
- - Errors display in UI with hook path and message
972
- - If a hook hangs, use Ctrl+C to abort
973
-
974
- ## Debugging
975
-
976
- 1. Open VS Code in hooks directory
977
- 2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal")
978
- 3. Set breakpoints
979
- 4. Run `pi --hook ./my-hook.ts`