@aliou/pi-dev-kit 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,169 +1,264 @@
1
- # Hooks (Events)
1
+ # Hooks and Events
2
2
 
3
- Hooks let extensions react to lifecycle events. They are registered with `pi.on(eventName, handler)`.
3
+ Hooks let extensions observe, modify, or block Pi lifecycle events. Register them with `pi.on(eventName, handler)`.
4
4
 
5
- ## Event Reference
6
-
7
- ### Session Events
8
-
9
- | Event | When | Can Cancel | Payload |
10
- |---|---|---|---|
11
- | `session_start` | Session starts, reloads, or is replaced | No | `{ reason: "startup" \| "reload" \| "new" \| "resume" \| "fork", previousSessionFile? }` |
12
- | `session_before_switch` | Before `/new` or `/resume` replaces the current session | Yes (`{ cancel: true }`) | `{ reason: "new" \| "resume", targetSessionFile? }` |
13
- | `session_before_fork` | Before forking a session | Yes (`{ cancel: true }`) | `{ entryId }` |
14
- | `session_shutdown` | Current session runtime is shutting down or being replaced | No | `{ reason: "quit" | "reload" | "new-session" | "resume" | "fork", targetSessionFile? }` |
15
- | `session_before_compact` | Before compaction | Yes (cancel or provide custom compaction) | event-specific compaction data |
16
-
17
- ### Agent Events
5
+ ```typescript
6
+ pi.on("tool_call", async (event, ctx) => {
7
+ // event is event-specific.
8
+ // ctx is ExtensionContext.
9
+ });
10
+ ```
18
11
 
19
- | Event | When | Payload |
20
- |---|---|---|
21
- | `before_agent_start` | Before agent turn starts | `{ systemPrompt, systemPromptOptions }` |
22
- | `agent_start` | Agent turn started | `{}` |
23
- | `turn_start` | Turn begins processing | `{}` |
24
- | `turn_end` | Turn finishes processing | `{}` |
25
- | `model_select` | User changed the model | `{ model: string }` |
12
+ Handlers run in extension load order. For blocking/cancelling events, the first blocking result wins.
26
13
 
27
- ### Tool Events
14
+ ## Event Lifecycle Summary
28
15
 
29
- | Event | When | Can Block | Payload |
30
- |---|---|---|---|
31
- | `tool_call` | Before a tool executes | Yes (`{ block: true, reason }`) | `{ toolName, toolCallId, input }` |
16
+ Common startup and prompt flow:
32
17
 
33
- ### Input Events
18
+ 1. `session_start`
19
+ 2. `resources_discover`
20
+ 3. user input arrives
21
+ 4. extension command check
22
+ 5. `input`
23
+ 6. skill/prompt expansion
24
+ 7. `before_agent_start`
25
+ 8. `agent_start`
26
+ 9. repeated turns:
27
+ - `turn_start`
28
+ - `context`
29
+ - `before_provider_request`
30
+ - `after_provider_response`
31
+ - message/tool lifecycle events
32
+ - `turn_end`
33
+ 10. `agent_end`
34
+ 11. `session_shutdown` on exit, reload, or session replacement
34
35
 
35
- | Event | When | Can Transform | Payload |
36
- |---|---|---|---|
37
- | `input` | User submitted a message | Yes (return transformed text) | `{ text: string }` |
36
+ Read Pi `docs/extensions.md` for the exhaustive event diagram before changing lifecycle-heavy code.
38
37
 
39
- ### Bash Events
38
+ ## Resource Events
40
39
 
41
- | Event | When | Can Modify | Payload |
42
- |---|---|---|---|
43
- | `user_bash` | Before bash command runs | Yes (return modified command/cwd/env) | `{ command, cwd }` |
40
+ ### `resources_discover`
44
41
 
45
- ## Handler Signature
42
+ Contribute skill, prompt, and theme paths after startup or reload.
46
43
 
47
44
  ```typescript
48
- pi.on("event_name", async (event, ctx) => {
49
- // event: event-specific payload
50
- // ctx: ExtensionContext (hasUI, ui methods, cwd, model, etc.)
45
+ pi.on("resources_discover", async (event) => {
46
+ return {
47
+ skillPaths: ["/path/to/skills"],
48
+ promptPaths: ["/path/to/prompts"],
49
+ themePaths: ["/path/to/themes"],
50
+ };
51
51
  });
52
52
  ```
53
53
 
54
- The handler receives the event payload and an `ExtensionContext`. The context provides access to UI methods, the current working directory, model info, and more.
54
+ `event.reason` is `"startup"` or `"reload"`.
55
55
 
56
- ## Blocking and Cancelling
56
+ ## Session Events
57
57
 
58
- Some events let you prevent the default behavior by returning an object.
58
+ | Event | Can cancel | Notes |
59
+ |---|---:|---|
60
+ | `session_start` | No | Session started, reloaded, resumed, or forked. |
61
+ | `session_before_switch` | Yes | Before `/new` or `/resume`. |
62
+ | `session_before_fork` | Yes | Before `/fork` or `/clone`; includes `position: "before" | "at"`. |
63
+ | `session_before_compact` | Yes/custom | Cancel or provide a custom compaction result. |
64
+ | `session_compact` | No | After compaction. |
65
+ | `session_before_tree` | Yes/custom | Before `/tree` navigation. |
66
+ | `session_tree` | No | After `/tree` navigation. |
67
+ | `session_shutdown` | No | Runtime teardown for quit, reload, new, resume, or fork. |
59
68
 
60
- ### Blocking Tool Calls
69
+ After a session replacement, the old runtime is torn down and extensions are rebound. Use `session_shutdown` for cleanup and `session_start` to rebuild in-memory state.
61
70
 
62
71
  ```typescript
63
- pi.on("tool_call", async (event, ctx) => {
64
- if (event.toolName === "bash" && event.input.command.includes("rm -rf /")) {
65
- // Check ctx.hasUI before prompting -- see references/modes.md
66
- if (!ctx.hasUI) {
67
- return { block: true, reason: "Blocked: dangerous command (no UI to confirm)" };
68
- }
69
-
70
- const confirmed = await ctx.ui.confirm(
71
- "Dangerous Command",
72
- `Allow: ${event.input.command}?`
73
- );
74
- if (!confirmed) {
75
- return { block: true, reason: "Blocked by user" };
76
- }
77
- }
78
- return undefined; // Allow the tool call
72
+ pi.on("session_before_switch", async (event, ctx) => {
73
+ if (event.reason !== "new") return;
74
+ if (!ctx.hasUI) return { cancel: true };
75
+
76
+ const confirmed = await ctx.ui.confirm("Clear session?", "All messages will be lost.");
77
+ if (!confirmed) return { cancel: true };
79
78
  });
80
79
  ```
81
80
 
82
- ### Cancelling Session Operations
81
+ ## Agent and Message Events
82
+
83
+ | Event | Purpose |
84
+ |---|---|
85
+ | `before_agent_start` | Inject a message or replace the system prompt for this turn. |
86
+ | `agent_start` / `agent_end` | Whole prompt lifecycle. |
87
+ | `turn_start` / `turn_end` | One provider response plus tool batch. |
88
+ | `context` | Modify copied messages before a provider call. |
89
+ | `message_start` / `message_update` / `message_end` | Observe or replace messages. |
90
+ | `before_provider_request` | Inspect/replace provider-specific payload. |
91
+ | `after_provider_response` | Inspect response status/headers before stream consumption. |
92
+ | `model_select` | Model changed. |
93
+ | `thinking_level_select` | Thinking level changed. |
94
+
95
+ ### `before_agent_start`
96
+
97
+ Use this for system prompt changes that depend on dynamic context. Per-tool `promptSnippet` and `promptGuidelines` are preferred for simple tool-local guidance.
83
98
 
84
99
  ```typescript
85
- pi.on("session_before_switch", async (event, ctx) => {
86
- if (event.reason === "new" && ctx.hasUI) {
87
- const confirmed = await ctx.ui.confirm("Clear session?", "All messages will be lost.");
88
- if (!confirmed) {
89
- return { cancel: true };
90
- }
91
- }
100
+ pi.on("before_agent_start", async (event) => {
101
+ return {
102
+ systemPrompt: `${event.systemPrompt}\n\nExtra instructions for this turn.`,
103
+ };
92
104
  });
93
105
  ```
94
106
 
95
- ### Custom Compaction
107
+ `event.systemPromptOptions` exposes structured prompt inputs such as selected tools, tool snippets, prompt guidelines, context files, and loaded skills. `ctx.getSystemPrompt()` reflects changes made by earlier `before_agent_start` handlers in the chain.
108
+
109
+ ### `message_end`
110
+
111
+ `message_end` handlers can replace a finalized message. Keep the same role.
96
112
 
97
113
  ```typescript
98
- pi.on("session_before_compact", async (event, ctx) => {
114
+ pi.on("message_end", async (event) => {
115
+ if (event.message.role !== "assistant") return;
99
116
  return {
100
- compaction: {
101
- summary: "Custom summary",
102
- firstKeptEntryId: event.preparation.firstKeptEntryId,
103
- tokensBefore: event.preparation.tokensBefore,
117
+ message: {
118
+ ...event.message,
119
+ usage: {
120
+ ...event.message.usage,
121
+ cost: { ...event.message.usage.cost, total: 0 },
122
+ },
104
123
  },
105
124
  };
106
125
  });
107
126
  ```
108
127
 
109
- ## Transforming Input
128
+ ## Tool Events
129
+
130
+ | Event | Can block/modify | Notes |
131
+ |---|---:|---|
132
+ | `tool_execution_start` | No | Tool started. |
133
+ | `tool_call` | Block/mutate input | Runs before tool execution. |
134
+ | `tool_execution_update` | No | Partial result update. |
135
+ | `tool_result` | Modify result | Runs after tool execution, before final events. |
136
+ | `tool_execution_end` | No | Tool completed. |
137
+
138
+ Tool calls from one assistant message are preflighted sequentially, then run concurrently by default. Do not assume sibling tool results are visible inside `tool_call`.
139
+
140
+ ### Blocking tool calls
141
+
142
+ Use `isToolCallEventType` for typed built-in inputs.
110
143
 
111
144
  ```typescript
112
- pi.on("input", async (event, ctx) => {
113
- if (event.text.startsWith("!")) {
114
- return event.text.slice(1).toUpperCase();
145
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
146
+
147
+ pi.on("tool_call", async (event, ctx) => {
148
+ if (!isToolCallEventType("bash", event)) return;
149
+
150
+ if (!event.input.command.includes("rm -rf")) return;
151
+
152
+ if (!ctx.hasUI) {
153
+ return { block: true, reason: "Dangerous command blocked because no UI is available." };
115
154
  }
116
- return undefined; // No transformation
155
+
156
+ const confirmed = await ctx.ui.confirm("Dangerous command", `Allow ${event.input.command}?`);
157
+ if (!confirmed) return { block: true, reason: "Blocked by user" };
117
158
  });
118
159
  ```
119
160
 
120
- ## Modifying Bash Commands
161
+ `event.input` is mutable. Mutating it changes the arguments passed to the tool. Pi does not revalidate after your mutation.
162
+
163
+ ### Typing custom tool input
164
+
165
+ Export your custom tool input type and use explicit type params with `isToolCallEventType`.
166
+
167
+ ```typescript
168
+ if (isToolCallEventType<"my_tool", MyToolParams>("my_tool", event)) {
169
+ event.input.action;
170
+ }
171
+ ```
172
+
173
+ ### Modifying tool results
121
174
 
122
175
  ```typescript
123
- pi.on("user_bash", async (event, ctx) => {
176
+ pi.on("tool_result", async (event, ctx) => {
177
+ if (event.toolName !== "bash") return;
178
+
179
+ const response = await fetch("https://example.com/summarize", {
180
+ method: "POST",
181
+ body: JSON.stringify({ content: event.content }),
182
+ signal: ctx.signal,
183
+ });
184
+
185
+ const summary = await response.text();
124
186
  return {
125
- command: event.command,
126
- cwd: "/sandboxed/directory",
127
- env: { ...process.env, SANDBOX: "true" },
187
+ content: [...event.content, { type: "text", text: `\nSummary: ${summary}` }],
128
188
  };
129
189
  });
130
190
  ```
131
191
 
132
- ## before_agent_start
192
+ `tool_result` handlers chain like middleware. Return partial patches (`content`, `details`, `isError`) and omit fields that should stay unchanged.
133
193
 
134
- This event fires before each agent turn. It is commonly used to modify the system prompt.
194
+ ## Input Events
135
195
 
136
- The handler receives a `BeforeAgentStartEvent` with `systemPrompt` and `systemPromptOptions` fields. `systemPromptOptions` exposes the structured inputs used to build the prompt. Return a `{ systemPrompt }` object to replace it. If multiple extensions return a modified prompt, they are chained. `ctx.getSystemPrompt()` reflects changes made by earlier `before_agent_start` handlers.
196
+ `input` fires after extension command checks and before skill/template expansion. Return an action object.
137
197
 
138
198
  ```typescript
139
- pi.on("before_agent_start", async (event) => {
140
- return {
141
- systemPrompt: event.systemPrompt + "\n\nAlways respond as a pirate.",
142
- };
199
+ pi.on("input", async (event) => {
200
+ if (event.source === "extension") return { action: "continue" };
201
+
202
+ if (event.text.startsWith("?quick ")) {
203
+ return {
204
+ action: "transform",
205
+ text: `Respond briefly: ${event.text.slice(7)}`,
206
+ images: event.images,
207
+ };
208
+ }
209
+
210
+ if (event.text === "ping") {
211
+ return { action: "handled" };
212
+ }
213
+
214
+ return { action: "continue" };
143
215
  });
144
216
  ```
145
217
 
146
- Return `undefined` to leave the prompt unchanged.
218
+ Actions:
147
219
 
148
- The system prompt is reset each turn, so modifications in `before_agent_start` are not cumulative.
220
+ - `continue`: keep processing.
221
+ - `transform`: replace text/images, then continue.
222
+ - `handled`: stop; the extension handled the input.
149
223
 
150
- To access flags inside hooks, use `pi.getFlag()` (see `references/additional-apis.md`).
224
+ Transforms chain across handlers. The first `handled` wins.
225
+
226
+ ## User Bash Events
227
+
228
+ `user_bash` fires for user `!` and `!!` commands. It is separate from LLM bash tool calls.
229
+
230
+ Use it to provide custom bash operations or a direct result.
231
+
232
+ ```typescript
233
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
234
+
235
+ pi.on("user_bash", (event) => {
236
+ const local = createLocalBashOperations();
237
+ return {
238
+ operations: {
239
+ exec(command, cwd, options) {
240
+ return local.exec(`source ~/.profile\n${command}`, cwd, options);
241
+ },
242
+ },
243
+ };
244
+ });
245
+ ```
151
246
 
152
- ## Bash Spawn Hook (Command Rewriting)
247
+ For transparent LLM bash tool rewriting, use a bash spawn hook instead.
153
248
 
154
- The `createBashTool(cwd, options)` function lets you replace the built-in bash tool with one that transparently rewrites commands before shell execution. Always pass an explicit cwd. This is different from `tool_call` blocking -- the agent never sees that the command was rewritten.
249
+ ## Bash Spawn Hook
155
250
 
156
- Use spawn hooks when you have a clear rewrite target (e.g. `npm` -> `pnpm`). Use `tool_call` blocking when you need to stop a command entirely or show a confirmation dialog.
251
+ `createBashTool(cwd, { spawnHook })` creates a bash tool that rewrites command/cwd/env before execution. Registering a tool named `bash` overrides the built-in bash tool.
157
252
 
158
253
  ```typescript
159
- import { createBashTool, type BashSpawnHook, type BashSpawnContext } from "@mariozechner/pi-coding-agent";
254
+ import { createBashTool, type BashSpawnContext } from "@earendil-works/pi-coding-agent";
160
255
 
161
- export default function (pi: ExtensionAPI) {
256
+ export default function hooksExtension(pi: ExtensionAPI) {
162
257
  const bashTool = createBashTool(process.cwd(), {
163
258
  spawnHook: ({ command, cwd, env }: BashSpawnContext): BashSpawnContext => ({
164
259
  command: command.replace(/^npm /, "pnpm "),
165
260
  cwd,
166
- env: { ...env, CUSTOM_VAR: "1" },
261
+ env: { ...env, CI: "1" },
167
262
  }),
168
263
  });
169
264
 
@@ -171,77 +266,55 @@ export default function (pi: ExtensionAPI) {
171
266
  }
172
267
  ```
173
268
 
174
- ### BashSpawnContext
175
-
176
- The spawn hook receives and returns a `BashSpawnContext`:
269
+ Use spawn hooks for clear rewrites or env injection. Use `tool_call` blocking for safety gates and confirmation dialogs.
177
270
 
178
- ```typescript
179
- interface BashSpawnContext {
180
- command: string; // The shell command to execute
181
- cwd: string; // Working directory
182
- env: NodeJS.ProcessEnv; // Environment variables
183
- }
184
- ```
271
+ Key points:
185
272
 
186
- You can modify any of these fields. The hook runs after pi's own processing but before the shell spawns.
273
+ - Prompt metadata is not inherited when overriding built-ins. Re-declare `promptSnippet`/`promptGuidelines` if needed.
274
+ - Tool-call blockers run before the spawn hook.
275
+ - Prefer parsed shell rewrites over broad regex replacements.
187
276
 
188
- ### Key Points
277
+ ## Provider Request Hooks
189
278
 
190
- - **Replaces the built-in bash tool.** When you call `pi.registerTool()` with a tool named `"bash"`, it replaces the default. Only one extension should do this.
191
- - **Transparent to the agent.** The agent sees the original command in the tool call UI but gets the output of the rewritten command.
192
- - **Execution order with tool_call hooks.** `tool_call` event hooks (blockers) run first. If a blocker returns `{ block: true }`, the spawn hook never fires. This means you can combine blocking hooks for commands that should be stopped entirely with spawn hooks for commands that should be rewritten.
193
- - **Prefer AST-based rewrites over regex.** A false positive rewrite corrupts a command silently. Use `@aliou/sh` or similar shell parsers to identify command names in the AST, then do surgical string replacement at the identified positions. If the parse fails, return the command unchanged.
194
- - **Compose multiple rewriters.** Chain rewriter functions that each transform the context:
279
+ Use these for debugging serialization, proxies, or cache behavior.
195
280
 
196
281
  ```typescript
197
- const rewriters: ((ctx: BashSpawnContext) => BashSpawnContext)[] = [
198
- createPackageManagerRewriter(config),
199
- createGitRebaseRewriter(),
200
- ];
201
-
202
- const spawnHook = (ctx: BashSpawnContext) => {
203
- let result = ctx;
204
- for (const rewrite of rewriters) {
205
- result = rewrite(result);
206
- }
207
- return result;
208
- };
282
+ pi.on("before_provider_request", (event) => {
283
+ console.log(JSON.stringify(event.payload, null, 2));
284
+ // return { ...event.payload, temperature: 0 };
285
+ });
209
286
 
210
- const bashTool = createBashTool(process.cwd(), { spawnHook });
211
- pi.registerTool({ ...bashTool });
287
+ pi.on("after_provider_response", (event) => {
288
+ if (event.status === 429) {
289
+ console.log("rate limited", event.headers["retry-after"]);
290
+ }
291
+ });
212
292
  ```
213
293
 
214
- ### When to Use What
294
+ Payload-level rewrites are provider-specific and are not reflected by `ctx.getSystemPrompt()`.
215
295
 
216
- | Pattern | Use When | Agent Sees |
217
- |---|---|---|
218
- | `tool_call` hook + `{ block: true }` | Command must be stopped entirely | Block reason (retries with correct command) |
219
- | `tool_call` hook + `ctx.ui.confirm()` | User confirmation needed | Block reason if denied |
220
- | Spawn hook (command rewrite) | Clear 1:1 rewrite target exists | Output of rewritten command (transparent) |
221
- | Spawn hook (env injection) | Need to set env vars for specific commands | Output with injected env (transparent) |
296
+ ## Mode Awareness
222
297
 
223
- ## Multiple Handlers
224
-
225
- Multiple extensions can register handlers for the same event. They execute in registration order. For blocking events (`tool_call`, `session_before_switch`, etc.), the first handler to return a blocking/cancelling result wins.
226
-
227
- ## Mode Awareness in Hooks
228
-
229
- Always consider what happens in Print mode when your hook uses dialog methods. See `references/modes.md` for the full behavior matrix.
230
-
231
- Common pattern for `tool_call` handlers:
298
+ Dialog methods that gate behavior need a safe no-UI default. Fire-and-forget methods are safe without guards.
232
299
 
233
300
  ```typescript
234
301
  pi.on("tool_call", async (event, ctx) => {
235
- if (shouldBlock(event)) {
236
- if (!ctx.hasUI) {
237
- return { block: true, reason: "No UI to confirm" };
238
- }
239
- // Safe to use dialogs here
240
- const choice = await ctx.ui.select("Allow?", ["Yes", "No"]);
241
- if (choice !== "Yes") {
242
- return { block: true, reason: "Blocked by user" };
243
- }
244
- }
245
- return undefined;
302
+ if (!shouldConfirm(event)) return;
303
+ if (!ctx.hasUI) return { block: true, reason: "No UI to confirm" };
304
+
305
+ const choice = await ctx.ui.select("Allow?", ["Allow", "Block"]);
306
+ if (choice !== "Allow") return { block: true, reason: "Blocked" };
246
307
  });
247
308
  ```
309
+
310
+ Read `references/modes.md` before adding UI to hooks.
311
+
312
+ ## Checklist
313
+
314
+ - [ ] Event return shape matches current Pi docs.
315
+ - [ ] Blocking hooks have safe defaults when `ctx.hasUI` is false.
316
+ - [ ] `input` hooks return `{ action: ... }`, not raw strings.
317
+ - [ ] `before_agent_start` returns `{ systemPrompt }`; it does not call `ctx.setSystemPrompt()`.
318
+ - [ ] Nested async work uses `ctx.signal` when available.
319
+ - [ ] Session replacement cleanup/rebuild is split between `session_shutdown` and `session_start`.
320
+ - [ ] Built-in tool overrides re-declare prompt metadata if needed.