@mariozechner/pi-coding-agent 0.30.1 → 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.
- package/CHANGELOG.md +251 -2
- package/README.md +105 -84
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +5 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/file-processor.d.ts +3 -3
- package/dist/cli/file-processor.d.ts.map +1 -1
- package/dist/cli/file-processor.js +7 -10
- package/dist/cli/file-processor.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +73 -34
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +464 -210
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-storage.d.ts +2 -7
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +4 -52
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/bash-executor.d.ts +2 -2
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +2 -2
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/compaction/branch-summarization.d.ts +84 -0
- package/dist/core/compaction/branch-summarization.d.ts.map +1 -0
- package/dist/core/compaction/branch-summarization.js +233 -0
- package/dist/core/compaction/branch-summarization.js.map +1 -0
- package/dist/core/{compaction.d.ts → compaction/compaction.d.ts} +38 -19
- package/dist/core/compaction/compaction.d.ts.map +1 -0
- package/dist/core/compaction/compaction.js +558 -0
- package/dist/core/compaction/compaction.js.map +1 -0
- package/dist/core/compaction/index.d.ts +7 -0
- package/dist/core/compaction/index.d.ts.map +1 -0
- package/dist/core/compaction/index.js +7 -0
- package/dist/core/compaction/index.js.map +1 -0
- package/dist/core/compaction/utils.d.ts +35 -0
- package/dist/core/compaction/utils.d.ts.map +1 -0
- package/dist/core/compaction/utils.js +138 -0
- package/dist/core/compaction/utils.js.map +1 -0
- package/dist/core/custom-tools/index.d.ts +2 -1
- package/dist/core/custom-tools/index.d.ts.map +1 -1
- package/dist/core/custom-tools/index.js +1 -0
- package/dist/core/custom-tools/index.js.map +1 -1
- package/dist/core/custom-tools/loader.d.ts.map +1 -1
- package/dist/core/custom-tools/loader.js +13 -80
- package/dist/core/custom-tools/loader.js.map +1 -1
- package/dist/core/custom-tools/types.d.ts +84 -59
- package/dist/core/custom-tools/types.d.ts.map +1 -1
- package/dist/core/custom-tools/types.js.map +1 -1
- package/dist/core/custom-tools/wrapper.d.ts +15 -0
- package/dist/core/custom-tools/wrapper.d.ts.map +1 -0
- package/dist/core/custom-tools/wrapper.js +23 -0
- package/dist/core/custom-tools/wrapper.js.map +1 -0
- package/dist/core/exec.d.ts +29 -0
- package/dist/core/exec.d.ts.map +1 -0
- package/dist/core/exec.js +71 -0
- package/dist/core/exec.js.map +1 -0
- package/dist/core/export-html/index.d.ts +17 -0
- package/dist/core/export-html/index.d.ts.map +1 -0
- package/dist/core/export-html/index.js +171 -0
- package/dist/core/export-html/index.js.map +1 -0
- package/dist/core/export-html/template.css +781 -0
- package/dist/core/export-html/template.html +54 -0
- package/dist/core/export-html/template.js +1185 -0
- package/dist/core/export-html/vendor/highlight.min.js +1213 -0
- package/dist/core/export-html/vendor/marked.min.js +6 -0
- package/dist/core/hooks/index.d.ts +4 -4
- package/dist/core/hooks/index.d.ts.map +1 -1
- package/dist/core/hooks/index.js +3 -3
- package/dist/core/hooks/index.js.map +1 -1
- package/dist/core/hooks/loader.d.ts +40 -5
- package/dist/core/hooks/loader.d.ts.map +1 -1
- package/dist/core/hooks/loader.js +43 -10
- package/dist/core/hooks/loader.js.map +1 -1
- package/dist/core/hooks/runner.d.ts +94 -18
- package/dist/core/hooks/runner.d.ts.map +1 -1
- package/dist/core/hooks/runner.js +199 -120
- package/dist/core/hooks/runner.js.map +1 -1
- package/dist/core/hooks/tool-wrapper.d.ts +1 -1
- package/dist/core/hooks/tool-wrapper.d.ts.map +1 -1
- package/dist/core/hooks/tool-wrapper.js +36 -19
- package/dist/core/hooks/tool-wrapper.js.map +1 -1
- package/dist/core/hooks/types.d.ts +407 -96
- package/dist/core/hooks/types.d.ts.map +1 -1
- package/dist/core/hooks/types.js.map +1 -1
- package/dist/core/index.d.ts +4 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/messages.d.ts +44 -12
- package/dist/core/messages.d.ts.map +1 -1
- package/dist/core/messages.js +82 -34
- package/dist/core/messages.js.map +1 -1
- package/dist/core/model-registry.d.ts +5 -5
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +7 -7
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +7 -7
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +45 -14
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/sdk.d.ts +7 -10
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +88 -32
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +202 -36
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +565 -133
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +9 -3
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +13 -12
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +6 -3
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit-diff.d.ts +33 -0
- package/dist/core/tools/edit-diff.d.ts.map +1 -0
- package/dist/core/tools/edit-diff.js +171 -0
- package/dist/core/tools/edit-diff.js.map +1 -0
- package/dist/core/tools/edit.d.ts +7 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +20 -95
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/index.d.ts +1 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/ls.d.ts +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/read.d.ts +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/write.d.ts +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -25
- package/dist/main.js.map +1 -1
- package/dist/migrations.d.ts +28 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +125 -0
- package/dist/migrations.js.map +1 -0
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +3 -4
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +6 -2
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/bordered-loader.d.ts +12 -0
- package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -0
- package/dist/modes/interactive/components/bordered-loader.js +30 -0
- package/dist/modes/interactive/components/bordered-loader.js.map +1 -0
- package/dist/modes/interactive/components/branch-summary-message.d.ts +14 -0
- package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/branch-summary-message.js +35 -0
- package/dist/modes/interactive/components/branch-summary-message.js.map +1 -0
- package/dist/modes/interactive/components/compaction-summary-message.d.ts +14 -0
- package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/compaction-summary-message.js +36 -0
- package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -0
- package/dist/modes/interactive/components/dynamic-border.d.ts +5 -1
- package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
- package/dist/modes/interactive/components/dynamic-border.js +5 -1
- package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +12 -6
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +57 -25
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/hook-editor.d.ts +15 -0
- package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-editor.js +95 -0
- package/dist/modes/interactive/components/hook-editor.js.map +1 -0
- package/dist/modes/interactive/components/hook-message.d.ts +18 -0
- package/dist/modes/interactive/components/hook-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-message.js +80 -0
- package/dist/modes/interactive/components/hook-message.js.map +1 -0
- package/dist/modes/interactive/components/model-selector.d.ts +3 -3
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +1 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +15 -2
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +70 -21
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts +52 -0
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -0
- package/dist/modes/interactive/components/tree-selector.js +745 -0
- package/dist/modes/interactive/components/tree-selector.js.map +1 -0
- package/dist/modes/interactive/components/user-message-selector.d.ts +3 -3
- package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message-selector.js +1 -1
- package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
- package/dist/modes/interactive/components/user-message.d.ts +1 -1
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message.js +2 -5
- package/dist/modes/interactive/components/user-message.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +29 -12
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +589 -208
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +13 -1
- package/dist/modes/interactive/theme/light.json +13 -1
- package/dist/modes/interactive/theme/theme-schema.json +34 -0
- package/dist/modes/interactive/theme/theme.d.ts +20 -2
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +135 -2
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/print-mode.d.ts +3 -3
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +26 -20
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +13 -10
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +11 -10
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +88 -35
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +30 -11
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/utils/shell.d.ts +4 -2
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +36 -7
- package/dist/utils/shell.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +2 -2
- package/dist/utils/tools-manager.js.map +1 -1
- package/docs/compaction.md +388 -0
- package/docs/custom-tools.md +146 -43
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +562 -596
- package/docs/rpc.md +33 -19
- package/docs/sdk.md +93 -21
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +172 -21
- package/docs/skills.md +2 -0
- package/docs/theme.md +31 -2
- package/docs/tree.md +197 -0
- package/docs/tui.md +343 -0
- package/examples/README.md +1 -9
- package/examples/custom-tools/hello/index.ts +4 -3
- package/examples/custom-tools/question/index.ts +4 -4
- package/examples/custom-tools/subagent/index.ts +7 -6
- package/examples/custom-tools/todo/index.ts +11 -5
- package/examples/hooks/README.md +29 -71
- package/examples/hooks/auto-commit-on-exit.ts +8 -9
- package/examples/hooks/confirm-destructive.ts +29 -30
- package/examples/hooks/custom-compaction.ts +20 -21
- package/examples/hooks/dirty-repo-guard.ts +41 -40
- package/examples/hooks/file-trigger.ts +10 -5
- package/examples/hooks/git-checkpoint.ts +16 -12
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +1 -1
- package/examples/hooks/protected-paths.ts +1 -1
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/03-custom-prompt.ts +1 -1
- package/examples/sdk/04-skills.ts +1 -1
- package/examples/sdk/05-tools.ts +4 -4
- package/examples/sdk/06-hooks.ts +1 -1
- package/examples/sdk/07-context-files.ts +1 -1
- package/examples/sdk/08-slash-commands.ts +6 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +1 -1
- package/examples/sdk/10-settings.ts +1 -1
- package/examples/sdk/11-sessions.ts +1 -1
- package/examples/sdk/12-full-control.ts +4 -7
- package/package.json +6 -6
- package/dist/core/compaction.d.ts.map +0 -1
- package/dist/core/compaction.js +0 -412
- package/dist/core/compaction.js.map +0 -1
- package/dist/core/export-html.d.ts +0 -23
- package/dist/core/export-html.d.ts.map +0 -1
- package/dist/core/export-html.js +0 -1185
- package/dist/core/export-html.js.map +0 -1
- package/dist/modes/interactive/components/compaction.d.ts +0 -15
- package/dist/modes/interactive/components/compaction.d.ts.map +0 -1
- package/dist/modes/interactive/components/compaction.js +0 -41
- package/dist/modes/interactive/components/compaction.js.map +0 -1
- package/docs/hooks-v2.md +0 -385
- 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
|
|
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
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
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
|
-
##
|
|
23
|
+
## Quick Start
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
Create `~/.pi/agent/hooks/my-hook.ts`:
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
Test with `--hook` flag:
|
|
24
45
|
|
|
25
46
|
```bash
|
|
26
47
|
pi --hook ./my-hook.ts
|
|
27
48
|
```
|
|
28
49
|
|
|
29
|
-
|
|
50
|
+
## Hook Locations
|
|
51
|
+
|
|
52
|
+
Hooks are auto-discovered from:
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
| Location | Scope |
|
|
55
|
+
|----------|-------|
|
|
56
|
+
| `~/.pi/agent/hooks/*.ts` | Global (all projects) |
|
|
57
|
+
| `.pi/hooks/*.ts` | Project-local |
|
|
32
58
|
|
|
33
|
-
|
|
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`,
|
|
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
|
|
56
|
-
| `@mariozechner/pi-tui` | TUI components
|
|
57
|
-
| `@sinclair/typebox` | Schema definitions |
|
|
73
|
+
| `@mariozechner/pi-ai` | AI utilities |
|
|
74
|
+
| `@mariozechner/pi-tui` | TUI components |
|
|
58
75
|
|
|
59
|
-
Node.js built-
|
|
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
|
|
80
|
+
A hook exports a default function that receives `HookAPI`:
|
|
64
81
|
|
|
65
82
|
```typescript
|
|
66
|
-
import type { HookAPI } from "@mariozechner/pi-coding-agent
|
|
83
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
|
67
84
|
|
|
68
85
|
export default function (pi: HookAPI) {
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
// Subscribe to events
|
|
87
|
+
pi.on("event_name", async (event, ctx) => {
|
|
88
|
+
// Handle event
|
|
71
89
|
});
|
|
72
90
|
}
|
|
73
91
|
```
|
|
74
92
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
└─► session (reason: "switch", AFTER switch)
|
|
130
|
+
/branch
|
|
131
|
+
├─► session_before_branch (can cancel)
|
|
132
|
+
└─► session_branch
|
|
127
133
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
└─► session (reason: "compact", AFTER compaction)
|
|
138
|
+
/tree navigation
|
|
139
|
+
├─► session_before_tree (can cancel or customize)
|
|
140
|
+
└─► session_tree
|
|
137
141
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
└─► session (reason: "shutdown")
|
|
142
|
+
exit (Ctrl+C, Ctrl+D)
|
|
143
|
+
└─► session_shutdown
|
|
141
144
|
```
|
|
142
145
|
|
|
143
|
-
|
|
146
|
+
### Session Events
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
#### session_start
|
|
146
149
|
|
|
147
|
-
Fired on session
|
|
150
|
+
Fired on initial session load.
|
|
148
151
|
|
|
149
152
|
```typescript
|
|
150
|
-
pi.on("
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
#### session_before_branch / session_branch
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
Fired when branching via `/branch`.
|
|
186
184
|
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
#### session_before_compact / session_compact
|
|
208
202
|
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
messagesToKeep = [ass idx 7, tool idx 8, ass idx 9]
|
|
250
|
+
Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
```typescript
|
|
253
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
254
|
+
// Cleanup, save state, etc.
|
|
255
|
+
});
|
|
255
256
|
```
|
|
256
257
|
|
|
257
|
-
|
|
258
|
+
### Agent Events
|
|
258
259
|
|
|
259
|
-
|
|
260
|
+
#### before_agent_start
|
|
260
261
|
|
|
261
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
286
|
+
pi.on("agent_start", async (_event, ctx) => {});
|
|
289
287
|
|
|
290
288
|
pi.on("agent_end", async (event, ctx) => {
|
|
291
|
-
// event.messages
|
|
289
|
+
// event.messages - messages from this prompt
|
|
292
290
|
});
|
|
293
291
|
```
|
|
294
292
|
|
|
295
|
-
|
|
293
|
+
#### turn_start / turn_end
|
|
296
294
|
|
|
297
|
-
Fired for each turn
|
|
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
|
|
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
|
|
307
|
-
// event.message
|
|
308
|
-
// event.toolResults
|
|
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
|
-
###
|
|
323
|
+
### Tool Events
|
|
324
|
+
|
|
325
|
+
#### tool_call
|
|
313
326
|
|
|
314
|
-
Fired before tool executes. **Can block.**
|
|
327
|
+
Fired before tool executes. **Can block.**
|
|
315
328
|
|
|
316
329
|
```typescript
|
|
317
330
|
pi.on("tool_call", async (event, ctx) => {
|
|
318
|
-
// event.toolName
|
|
319
|
-
// event.toolCallId
|
|
320
|
-
// event.input
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
+
#### tool_result
|
|
335
351
|
|
|
336
|
-
|
|
352
|
+
Fired after tool executes (including errors). **Can modify result.**
|
|
337
353
|
|
|
338
|
-
|
|
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
|
|
343
|
-
// event.
|
|
344
|
-
// event.
|
|
345
|
-
// event.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
372
|
+
Use type guards for typed details:
|
|
355
373
|
|
|
356
374
|
```typescript
|
|
357
|
-
import { isBashToolResult
|
|
375
|
+
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
358
376
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
387
|
+
Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
|
|
373
388
|
|
|
374
|
-
|
|
389
|
+
## HookContext
|
|
375
390
|
|
|
376
|
-
|
|
391
|
+
Every handler receives `ctx: HookContext`:
|
|
377
392
|
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
404
|
+
// Confirm dialog
|
|
405
|
+
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
|
|
406
|
+
// Returns true or false
|
|
411
407
|
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
+
// Set the core input editor text (pre-fill prompts, generated content)
|
|
425
|
+
ctx.ui.setEditorText("Generated prompt text here...");
|
|
441
426
|
|
|
442
|
-
|
|
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
|
-
|
|
437
|
+
**Styling with theme colors:**
|
|
445
438
|
|
|
446
|
-
|
|
439
|
+
Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
|
|
447
440
|
|
|
448
441
|
```typescript
|
|
449
|
-
const
|
|
450
|
-
|
|
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
|
-
|
|
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
|
|
457
|
+
Show a custom TUI component with keyboard focus:
|
|
458
458
|
|
|
459
459
|
```typescript
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
+
`false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`:
|
|
469
485
|
|
|
470
486
|
```typescript
|
|
471
|
-
|
|
487
|
+
if (ctx.hasUI) {
|
|
488
|
+
const choice = await ctx.ui.select(...);
|
|
489
|
+
} else {
|
|
490
|
+
// Default behavior
|
|
491
|
+
}
|
|
472
492
|
```
|
|
473
493
|
|
|
474
|
-
### ctx.
|
|
494
|
+
### ctx.cwd
|
|
495
|
+
|
|
496
|
+
Current working directory.
|
|
497
|
+
|
|
498
|
+
### ctx.sessionManager
|
|
475
499
|
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
ctx.
|
|
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
|
-
|
|
523
|
+
Use `pi.sendMessage()` or `pi.appendEntry()` for writes.
|
|
484
524
|
|
|
485
|
-
|
|
525
|
+
### ctx.modelRegistry
|
|
486
526
|
|
|
487
|
-
|
|
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
|
-
|
|
495
|
-
|
|
529
|
+
```typescript
|
|
530
|
+
// Get API key for a model
|
|
531
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
496
532
|
|
|
497
|
-
//
|
|
498
|
-
const
|
|
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.
|
|
537
|
+
### ctx.model
|
|
503
538
|
|
|
504
|
-
|
|
539
|
+
Current model, or `undefined` if none selected yet. Use for LLM calls in hooks:
|
|
505
540
|
|
|
506
541
|
```typescript
|
|
507
|
-
|
|
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.
|
|
548
|
+
### ctx.isIdle()
|
|
511
549
|
|
|
512
|
-
|
|
550
|
+
Returns `true` if the agent is not currently streaming:
|
|
513
551
|
|
|
514
552
|
```typescript
|
|
515
|
-
if (ctx.
|
|
516
|
-
|
|
553
|
+
if (ctx.isIdle()) {
|
|
554
|
+
// Agent is not processing
|
|
517
555
|
}
|
|
518
556
|
```
|
|
519
557
|
|
|
520
|
-
### ctx.
|
|
558
|
+
### ctx.abort()
|
|
521
559
|
|
|
522
|
-
|
|
560
|
+
Abort the current agent operation (fire-and-forget, does not wait):
|
|
523
561
|
|
|
524
562
|
```typescript
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
##
|
|
577
|
+
## HookCommandContext (Slash Commands Only)
|
|
533
578
|
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
581
|
+
### ctx.waitForIdle()
|
|
582
|
+
|
|
583
|
+
Wait for the agent to finish streaming:
|
|
539
584
|
|
|
540
585
|
```typescript
|
|
541
|
-
|
|
586
|
+
await ctx.waitForIdle();
|
|
587
|
+
// Agent is now idle
|
|
542
588
|
```
|
|
543
589
|
|
|
544
|
-
|
|
590
|
+
### ctx.newSession(options?)
|
|
545
591
|
|
|
546
|
-
|
|
592
|
+
Create a new session, optionally with initialization:
|
|
547
593
|
|
|
548
594
|
```typescript
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
572
|
-
|
|
607
|
+
if (result.cancelled) {
|
|
608
|
+
// A hook cancelled the new session
|
|
573
609
|
}
|
|
574
610
|
```
|
|
575
611
|
|
|
576
|
-
|
|
612
|
+
### ctx.branch(entryId)
|
|
577
613
|
|
|
578
|
-
|
|
614
|
+
Branch from a specific entry, creating a new session file:
|
|
579
615
|
|
|
580
616
|
```typescript
|
|
581
|
-
|
|
582
|
-
|
|
617
|
+
const result = await ctx.branch("entry-id-123");
|
|
618
|
+
if (!result.cancelled) {
|
|
619
|
+
// Now in the branched session
|
|
620
|
+
}
|
|
621
|
+
```
|
|
583
622
|
|
|
584
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
633
|
+
## HookAPI Methods
|
|
606
634
|
|
|
607
|
-
|
|
635
|
+
### pi.on(event, handler)
|
|
608
636
|
|
|
609
|
-
|
|
637
|
+
Subscribe to events. See [Events](#events) for all event types.
|
|
610
638
|
|
|
611
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
if (!ref) return;
|
|
735
|
+
### pi.exec(command, args, options?)
|
|
670
736
|
|
|
671
|
-
|
|
672
|
-
"Yes, restore code to that point",
|
|
673
|
-
"No, keep current code",
|
|
674
|
-
]);
|
|
737
|
+
Execute a shell command:
|
|
675
738
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
739
|
+
```typescript
|
|
740
|
+
const result = await pi.exec("git", ["status"], {
|
|
741
|
+
signal, // AbortSignal
|
|
742
|
+
timeout, // Milliseconds
|
|
743
|
+
});
|
|
681
744
|
|
|
682
|
-
|
|
683
|
-
checkpoints.clear();
|
|
684
|
-
});
|
|
685
|
-
}
|
|
745
|
+
// result.stdout, result.stderr, result.code, result.killed
|
|
686
746
|
```
|
|
687
747
|
|
|
688
|
-
|
|
748
|
+
## Examples
|
|
749
|
+
|
|
750
|
+
### Permission Gate
|
|
689
751
|
|
|
690
752
|
```typescript
|
|
691
|
-
import type { HookAPI } from "@mariozechner/pi-coding-agent
|
|
753
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
|
692
754
|
|
|
693
755
|
export default function (pi: HookAPI) {
|
|
694
|
-
const
|
|
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 !== "
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
###
|
|
715
|
-
|
|
716
|
-
Use a different model for summarization, or implement your own compaction strategy.
|
|
773
|
+
### Protected Paths
|
|
717
774
|
|
|
718
|
-
|
|
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
|
-
|
|
778
|
+
export default function (pi: HookAPI) {
|
|
779
|
+
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
733
780
|
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
+
### Git Checkpoint
|
|
742
794
|
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
+
### Custom Command
|
|
771
830
|
|
|
772
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
+
## Error Handling
|
|
793
844
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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`
|