@mariozechner/pi-coding-agent 0.22.5 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +54 -2
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +5 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +9 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +64 -11
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/custom-tools/index.d.ts +6 -0
  12. package/dist/core/custom-tools/index.d.ts.map +1 -0
  13. package/dist/core/custom-tools/index.js +5 -0
  14. package/dist/core/custom-tools/index.js.map +1 -0
  15. package/dist/core/custom-tools/loader.d.ts +24 -0
  16. package/dist/core/custom-tools/loader.d.ts.map +1 -0
  17. package/dist/core/custom-tools/loader.js +233 -0
  18. package/dist/core/custom-tools/loader.js.map +1 -0
  19. package/dist/core/custom-tools/types.d.ts +81 -0
  20. package/dist/core/custom-tools/types.d.ts.map +1 -0
  21. package/dist/core/custom-tools/types.js +8 -0
  22. package/dist/core/custom-tools/types.js.map +1 -0
  23. package/dist/core/hooks/index.d.ts +1 -1
  24. package/dist/core/hooks/index.d.ts.map +1 -1
  25. package/dist/core/hooks/index.js.map +1 -1
  26. package/dist/core/hooks/types.d.ts +14 -19
  27. package/dist/core/hooks/types.d.ts.map +1 -1
  28. package/dist/core/hooks/types.js.map +1 -1
  29. package/dist/core/index.d.ts +1 -0
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +1 -0
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/settings-manager.d.ts +3 -0
  34. package/dist/core/settings-manager.d.ts.map +1 -1
  35. package/dist/core/settings-manager.js +7 -0
  36. package/dist/core/settings-manager.js.map +1 -1
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/main.d.ts.map +1 -1
  42. package/dist/main.js +22 -3
  43. package/dist/main.js.map +1 -1
  44. package/dist/modes/interactive/components/tool-execution.d.ts +4 -1
  45. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  46. package/dist/modes/interactive/components/tool-execution.js +71 -20
  47. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  48. package/dist/modes/interactive/interactive-mode.d.ts +11 -2
  49. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.js +66 -12
  51. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  52. package/dist/modes/print-mode.d.ts.map +1 -1
  53. package/dist/modes/print-mode.js +26 -2
  54. package/dist/modes/print-mode.js.map +1 -1
  55. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  56. package/dist/modes/rpc/rpc-mode.js +27 -2
  57. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  58. package/docs/custom-tools.md +354 -0
  59. package/docs/hooks.md +54 -34
  60. package/examples/custom-tools/README.md +101 -0
  61. package/examples/custom-tools/hello.ts +20 -0
  62. package/examples/custom-tools/question.ts +83 -0
  63. package/examples/custom-tools/todo.ts +192 -0
  64. package/examples/hooks/README.md +79 -0
  65. package/examples/hooks/file-trigger.ts +36 -0
  66. package/examples/hooks/git-checkpoint.ts +48 -0
  67. package/examples/hooks/permission-gate.ts +38 -0
  68. package/examples/hooks/protected-paths.ts +30 -0
  69. package/package.json +7 -6
@@ -0,0 +1,354 @@
1
+ # Custom Tools
2
+
3
+ Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI.
4
+
5
+ ## Quick Start
6
+
7
+ Create a file `~/.pi/agent/tools/hello.ts`:
8
+
9
+ ```typescript
10
+ import { Type } from "@sinclair/typebox";
11
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
12
+
13
+ const factory: CustomToolFactory = (pi) => ({
14
+ name: "hello",
15
+ label: "Hello",
16
+ description: "A simple greeting tool",
17
+ parameters: Type.Object({
18
+ name: Type.String({ description: "Name to greet" }),
19
+ }),
20
+
21
+ async execute(toolCallId, params) {
22
+ return {
23
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
24
+ details: { greeted: params.name },
25
+ };
26
+ },
27
+ });
28
+
29
+ export default factory;
30
+ ```
31
+
32
+ The tool is automatically discovered and available in your next pi session.
33
+
34
+ ## Tool Locations
35
+
36
+ | Location | Scope | Auto-discovered |
37
+ |----------|-------|-----------------|
38
+ | `~/.pi/agent/tools/*.ts` | Global (all projects) | Yes |
39
+ | `.pi/tools/*.ts` | Project-local | Yes |
40
+ | `settings.json` `customTools` array | Configured paths | Yes |
41
+ | `--tool <path>` CLI flag | One-off/debugging | No |
42
+
43
+ **Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
44
+
45
+ **Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
46
+
47
+ ## Available Imports
48
+
49
+ Custom tools can import from these packages (automatically resolved by pi):
50
+
51
+ | Package | Purpose |
52
+ |---------|---------|
53
+ | `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
54
+ | `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
55
+ | `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
56
+ | `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
57
+
58
+ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
59
+
60
+ ## Tool Definition
61
+
62
+ ```typescript
63
+ import { Type } from "@sinclair/typebox";
64
+ import { StringEnum } from "@mariozechner/pi-ai";
65
+ import { Text } from "@mariozechner/pi-tui";
66
+ import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
67
+
68
+ const factory: CustomToolFactory = (pi) => ({
69
+ name: "my_tool",
70
+ label: "My Tool",
71
+ description: "What this tool does (be specific for LLM)",
72
+ parameters: Type.Object({
73
+ // Use StringEnum for string enums (Google API compatible)
74
+ action: StringEnum(["list", "add", "remove"] as const),
75
+ text: Type.Optional(Type.String()),
76
+ }),
77
+
78
+ async execute(toolCallId, params, signal, onUpdate) {
79
+ // signal - AbortSignal for cancellation
80
+ // onUpdate - Callback for streaming partial results
81
+ return {
82
+ content: [{ type: "text", text: "Result for LLM" }],
83
+ details: { /* structured data for rendering */ },
84
+ };
85
+ },
86
+
87
+ // Optional: Session lifecycle callback
88
+ onSession(event) { /* reconstruct state from entries */ },
89
+
90
+ // Optional: Custom rendering
91
+ renderCall(args, theme) { /* return Component */ },
92
+ renderResult(result, options, theme) { /* return Component */ },
93
+
94
+ // Optional: Cleanup on session end
95
+ dispose() { /* save state, close connections */ },
96
+ });
97
+
98
+ export default factory;
99
+ ```
100
+
101
+ **Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
102
+
103
+ ## ToolAPI Object
104
+
105
+ The factory receives a `ToolAPI` object (named `pi` by convention):
106
+
107
+ ```typescript
108
+ interface ToolAPI {
109
+ cwd: string; // Current working directory
110
+ exec(command: string, args: string[]): Promise<ExecResult>;
111
+ ui: {
112
+ select(title: string, options: string[]): Promise<string | null>;
113
+ confirm(title: string, message: string): Promise<boolean>;
114
+ input(title: string, placeholder?: string): Promise<string | null>;
115
+ notify(message: string, type?: "info" | "warning" | "error"): void;
116
+ };
117
+ hasUI: boolean; // false in --print or --mode rpc
118
+ }
119
+ ```
120
+
121
+ Always check `pi.hasUI` before using UI methods.
122
+
123
+ ## Session Lifecycle
124
+
125
+ Tools can implement `onSession` to react to session changes:
126
+
127
+ ```typescript
128
+ interface ToolSessionEvent {
129
+ entries: SessionEntry[]; // All session entries
130
+ sessionFile: string | null; // Current session file
131
+ previousSessionFile: string | null; // Previous session file
132
+ reason: "start" | "switch" | "branch" | "clear";
133
+ }
134
+ ```
135
+
136
+ **Reasons:**
137
+ - `start`: Initial session load on startup
138
+ - `switch`: User switched to a different session (`/session`)
139
+ - `branch`: User branched from a previous message (`/branch`)
140
+ - `clear`: User cleared the session (`/clear`)
141
+
142
+ ### State Management Pattern
143
+
144
+ Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.
145
+
146
+ ```typescript
147
+ interface MyToolDetails {
148
+ items: string[];
149
+ }
150
+
151
+ const factory: CustomToolFactory = (pi) => {
152
+ // In-memory state
153
+ let items: string[] = [];
154
+
155
+ // Reconstruct state from session entries
156
+ const reconstructState = (event: ToolSessionEvent) => {
157
+ items = [];
158
+ for (const entry of event.entries) {
159
+ if (entry.type !== "message") continue;
160
+ const msg = entry.message;
161
+ if (msg.role !== "toolResult") continue;
162
+ if (msg.toolName !== "my_tool") continue;
163
+
164
+ const details = msg.details as MyToolDetails | undefined;
165
+ if (details) {
166
+ items = details.items;
167
+ }
168
+ }
169
+ };
170
+
171
+ return {
172
+ name: "my_tool",
173
+ label: "My Tool",
174
+ description: "...",
175
+ parameters: Type.Object({ ... }),
176
+
177
+ onSession: reconstructState,
178
+
179
+ async execute(toolCallId, params) {
180
+ // Modify items...
181
+ items.push("new item");
182
+
183
+ return {
184
+ content: [{ type: "text", text: "Added item" }],
185
+ // Store current state in details for reconstruction
186
+ details: { items: [...items] },
187
+ };
188
+ },
189
+ };
190
+ };
191
+ ```
192
+
193
+ This pattern ensures:
194
+ - When user branches, state is correct for that point in history
195
+ - When user switches sessions, state matches that session
196
+ - When user clears, state resets
197
+
198
+ ## Custom Rendering
199
+
200
+ Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
201
+
202
+ ### How It Works
203
+
204
+ Tool output is wrapped in a `Box` component that handles:
205
+ - Padding (1 character horizontal, 1 line vertical)
206
+ - Background color based on state (pending/success/error)
207
+
208
+ Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding.
209
+
210
+ ### renderCall
211
+
212
+ Renders the tool call (before/during execution):
213
+
214
+ ```typescript
215
+ renderCall(args, theme) {
216
+ let text = theme.fg("toolTitle", theme.bold("my_tool "));
217
+ text += theme.fg("muted", args.action);
218
+ if (args.text) {
219
+ text += " " + theme.fg("dim", `"${args.text}"`);
220
+ }
221
+ return new Text(text, 0, 0);
222
+ }
223
+ ```
224
+
225
+ Called when:
226
+ - Tool call starts (may have partial args during streaming)
227
+ - Args are updated during streaming
228
+
229
+ ### renderResult
230
+
231
+ Renders the tool result:
232
+
233
+ ```typescript
234
+ renderResult(result, { expanded, isPartial }, theme) {
235
+ const { details } = result;
236
+
237
+ // Handle streaming/partial results
238
+ if (isPartial) {
239
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
240
+ }
241
+
242
+ // Handle errors
243
+ if (details?.error) {
244
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
245
+ }
246
+
247
+ // Normal result
248
+ let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");
249
+
250
+ // Support expanded view (Ctrl+O)
251
+ if (expanded && details?.items) {
252
+ for (const item of details.items) {
253
+ text += "\n" + theme.fg("dim", ` ${item}`);
254
+ }
255
+ }
256
+
257
+ return new Text(text, 0, 0);
258
+ }
259
+ ```
260
+
261
+ **Options:**
262
+ - `expanded`: User pressed Ctrl+O to expand
263
+ - `isPartial`: Result is from `onUpdate` (streaming), not final
264
+
265
+ ### Best Practices
266
+
267
+ 1. **Use `Text` with padding `(0, 0)`** - The Box handles padding
268
+ 2. **Use `\n` for multi-line content** - Not multiple Text components
269
+ 3. **Handle `isPartial`** - Show progress during streaming
270
+ 4. **Support `expanded`** - Show more detail when user requests
271
+ 5. **Use theme colors** - For consistent appearance
272
+ 6. **Keep it compact** - Show summary by default, details when expanded
273
+
274
+ ### Theme Colors
275
+
276
+ ```typescript
277
+ // Foreground
278
+ theme.fg("toolTitle", text) // Tool names
279
+ theme.fg("accent", text) // Highlights
280
+ theme.fg("success", text) // Success
281
+ theme.fg("error", text) // Errors
282
+ theme.fg("warning", text) // Warnings
283
+ theme.fg("muted", text) // Secondary text
284
+ theme.fg("dim", text) // Tertiary text
285
+ theme.fg("toolOutput", text) // Output content
286
+
287
+ // Styles
288
+ theme.bold(text)
289
+ theme.italic(text)
290
+ ```
291
+
292
+ ### Fallback Behavior
293
+
294
+ If `renderCall` or `renderResult` is not defined or throws an error:
295
+ - `renderCall`: Shows tool name
296
+ - `renderResult`: Shows raw text output from `content`
297
+
298
+ ## Execute Function
299
+
300
+ ```typescript
301
+ async execute(toolCallId, args, signal, onUpdate) {
302
+ // Type assertion for params (TypeBox schema doesn't flow through)
303
+ const params = args as { action: "list" | "add"; text?: string };
304
+
305
+ // Check for abort
306
+ if (signal?.aborted) {
307
+ return { content: [...], details: { status: "aborted" } };
308
+ }
309
+
310
+ // Stream progress
311
+ onUpdate?.({
312
+ content: [{ type: "text", text: "Working..." }],
313
+ details: { progress: 50 },
314
+ });
315
+
316
+ // Return final result
317
+ return {
318
+ content: [{ type: "text", text: "Done" }], // Sent to LLM
319
+ details: { data: result }, // For rendering only
320
+ };
321
+ }
322
+ ```
323
+
324
+ ## Multiple Tools from One File
325
+
326
+ Return an array to share state between related tools:
327
+
328
+ ```typescript
329
+ const factory: CustomToolFactory = (pi) => {
330
+ // Shared state
331
+ let connection = null;
332
+
333
+ return [
334
+ { name: "db_connect", ... },
335
+ { name: "db_query", ... },
336
+ {
337
+ name: "db_close",
338
+ dispose() { connection?.close(); }
339
+ },
340
+ ];
341
+ };
342
+ ```
343
+
344
+ ## Examples
345
+
346
+ See [`examples/custom-tools/todo.ts`](../examples/custom-tools/todo.ts) for a complete example with:
347
+ - `onSession` for state reconstruction
348
+ - Custom `renderCall` and `renderResult`
349
+ - Proper branching support via details storage
350
+
351
+ Test with:
352
+ ```bash
353
+ pi --tool packages/coding-agent/examples/custom-tools/todo.ts
354
+ ```
package/docs/hooks.md CHANGED
@@ -43,8 +43,8 @@ A hook is a TypeScript file that exports a default function. The function receiv
43
43
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
44
44
 
45
45
  export default function (pi: HookAPI) {
46
- pi.on("session_start", async (event, ctx) => {
47
- ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info");
46
+ pi.on("session", async (event, ctx) => {
47
+ ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionFile ?? "ephemeral"}`, "info");
48
48
  });
49
49
  }
50
50
  ```
@@ -76,7 +76,7 @@ Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works
76
76
  ```
77
77
  pi starts
78
78
 
79
- ├─► session_start
79
+ ├─► session (reason: "start")
80
80
 
81
81
 
82
82
  user sends prompt ─────────────────────────────────────────┐
@@ -98,36 +98,54 @@ user sends prompt ────────────────────
98
98
 
99
99
  user sends another prompt ◄────────────────────────────────┘
100
100
 
101
- user branches or switches session
101
+ user branches (/branch)
102
102
 
103
- └─► session_switch
103
+ ├─► branch (BEFORE branch, can control)
104
+ └─► session (reason: "switch", AFTER branch)
105
+
106
+ user switches session (/session)
107
+
108
+ └─► session (reason: "switch")
109
+
110
+ user clears session (/clear)
111
+
112
+ └─► session (reason: "clear")
104
113
  ```
105
114
 
106
115
  A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
107
116
 
108
- ### session_start
117
+ ### session
109
118
 
110
- Fired once when pi starts.
119
+ Fired on startup and when session changes.
111
120
 
112
121
  ```typescript
113
- pi.on("session_start", async (event, ctx) => {
114
- // ctx.sessionFile: string | null
115
- // ctx.hasUI: boolean
122
+ pi.on("session", async (event, ctx) => {
123
+ // event.entries: SessionEntry[] - all session entries
124
+ // event.sessionFile: string | null - current session file
125
+ // event.previousSessionFile: string | null - previous session file
126
+ // event.reason: "start" | "switch" | "clear"
116
127
  });
117
128
  ```
118
129
 
119
- ### session_switch
130
+ **Reasons:**
131
+ - `start`: Initial session load on startup
132
+ - `switch`: User switched sessions (`/session`) or branched (`/branch`)
133
+ - `clear`: User cleared the session (`/clear`)
120
134
 
121
- Fired when session changes (`/branch` or session switch).
135
+ ### branch
136
+
137
+ Fired BEFORE a branch happens. Can control branch behavior.
122
138
 
123
139
  ```typescript
124
- pi.on("session_switch", async (event, ctx) => {
125
- // event.newSessionFile: string | null (null in --no-session mode)
126
- // event.previousSessionFile: string | null (null in --no-session mode)
127
- // event.reason: "branch" | "switch"
140
+ pi.on("branch", async (event, ctx) => {
141
+ // event.targetTurnIndex: number
142
+ // event.entries: SessionEntry[]
143
+ return { skipConversationRestore: true }; // or undefined
128
144
  });
129
145
  ```
130
146
 
147
+ Note: After branch completes, a `session` event fires with `reason: "switch"`.
148
+
131
149
  ### agent_start / agent_end
132
150
 
133
151
  Fired once per user prompt.
@@ -192,18 +210,6 @@ pi.on("tool_result", async (event, ctx) => {
192
210
  });
193
211
  ```
194
212
 
195
- ### branch
196
-
197
- Fired when user branches via `/branch`.
198
-
199
- ```typescript
200
- pi.on("branch", async (event, ctx) => {
201
- // event.targetTurnIndex: number
202
- // event.entries: SessionEntry[]
203
- return { skipConversationRestore: true }; // or undefined
204
- });
205
- ```
206
-
207
213
  ## Context API
208
214
 
209
215
  Every event handler receives a context object with these methods:
@@ -309,7 +315,9 @@ import * as fs from "node:fs";
309
315
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
310
316
 
311
317
  export default function (pi: HookAPI) {
312
- pi.on("session_start", async (event, ctx) => {
318
+ pi.on("session", async (event, ctx) => {
319
+ if (event.reason !== "start") return;
320
+
313
321
  // Watch a trigger file
314
322
  const triggerFile = "/tmp/agent-trigger.txt";
315
323
 
@@ -339,7 +347,9 @@ import * as http from "node:http";
339
347
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
340
348
 
341
349
  export default function (pi: HookAPI) {
342
- pi.on("session_start", async (event, ctx) => {
350
+ pi.on("session", async (event, ctx) => {
351
+ if (event.reason !== "start") return;
352
+
343
353
  const server = http.createServer((req, res) => {
344
354
  let body = "";
345
355
  req.on("data", chunk => body += chunk);
@@ -563,7 +573,7 @@ Key behaviors:
563
573
  Mode initialization:
564
574
  -> hookRunner.setUIContext(ctx, hasUI)
565
575
  -> hookRunner.setSessionFile(path)
566
- -> hookRunner.emit({ type: "session_start" })
576
+ -> hookRunner.emit({ type: "session", reason: "start", ... })
567
577
 
568
578
  User sends prompt:
569
579
  -> AgentSession.prompt()
@@ -581,9 +591,19 @@ User sends prompt:
581
591
  -> [repeat if more tool calls]
582
592
  -> hookRunner.emit({ type: "agent_end", messages })
583
593
 
584
- Branch or session switch:
585
- -> AgentSession.branch() or AgentSession.switchSession()
586
- -> hookRunner.emit({ type: "session_switch", ... })
594
+ Branch:
595
+ -> AgentSession.branch()
596
+ -> hookRunner.emit({ type: "branch", ... }) # BEFORE branch
597
+ -> [branch happens]
598
+ -> hookRunner.emit({ type: "session", reason: "switch", ... }) # AFTER
599
+
600
+ Session switch:
601
+ -> AgentSession.switchSession()
602
+ -> hookRunner.emit({ type: "session", reason: "switch", ... })
603
+
604
+ Clear:
605
+ -> AgentSession.reset()
606
+ -> hookRunner.emit({ type: "session", reason: "clear", ... })
587
607
  ```
588
608
 
589
609
  ## UI Context by Mode
@@ -0,0 +1,101 @@
1
+ # Custom Tools Examples
2
+
3
+ Example custom tools for pi-coding-agent.
4
+
5
+ ## Examples
6
+
7
+ ### hello.ts
8
+ Minimal example showing the basic structure of a custom tool.
9
+
10
+ ### question.ts
11
+ Demonstrates `pi.ui.select()` for asking the user questions with options.
12
+
13
+ ### todo.ts
14
+ Full-featured example demonstrating:
15
+ - `onSession` for state reconstruction from session history
16
+ - Custom `renderCall` and `renderResult`
17
+ - Proper branching support via details storage
18
+ - State management without external files
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # Test directly
24
+ pi --tool examples/custom-tools/todo.ts
25
+
26
+ # Or copy to tools directory for persistent use
27
+ cp todo.ts ~/.pi/agent/tools/
28
+ ```
29
+
30
+ Then in pi:
31
+ ```
32
+ > add a todo "test custom tools"
33
+ > list todos
34
+ > toggle todo #1
35
+ > clear todos
36
+ ```
37
+
38
+ ## Writing Custom Tools
39
+
40
+ See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation.
41
+
42
+ ### Key Points
43
+
44
+ **Factory pattern:**
45
+ ```typescript
46
+ import { Type } from "@sinclair/typebox";
47
+ import { StringEnum } from "@mariozechner/pi-ai";
48
+ import { Text } from "@mariozechner/pi-tui";
49
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
50
+
51
+ const factory: CustomToolFactory = (pi) => ({
52
+ name: "my_tool",
53
+ label: "My Tool",
54
+ description: "Tool description for LLM",
55
+ parameters: Type.Object({
56
+ action: StringEnum(["list", "add"] as const),
57
+ }),
58
+
59
+ // Called on session start/switch/branch/clear
60
+ onSession(event) {
61
+ // Reconstruct state from event.entries
62
+ },
63
+
64
+ async execute(toolCallId, params) {
65
+ return {
66
+ content: [{ type: "text", text: "Result" }],
67
+ details: { /* for rendering and state reconstruction */ },
68
+ };
69
+ },
70
+ });
71
+
72
+ export default factory;
73
+ ```
74
+
75
+ **Custom rendering:**
76
+ ```typescript
77
+ renderCall(args, theme) {
78
+ return new Text(
79
+ theme.fg("toolTitle", theme.bold("my_tool ")) + args.action,
80
+ 0, 0 // No padding - Box handles it
81
+ );
82
+ },
83
+
84
+ renderResult(result, { expanded, isPartial }, theme) {
85
+ if (isPartial) {
86
+ return new Text(theme.fg("warning", "Working..."), 0, 0);
87
+ }
88
+ return new Text(theme.fg("success", "✓ Done"), 0, 0);
89
+ },
90
+ ```
91
+
92
+ **Use StringEnum for string parameters** (required for Google API compatibility):
93
+ ```typescript
94
+ import { StringEnum } from "@mariozechner/pi-ai";
95
+
96
+ // Good
97
+ action: StringEnum(["list", "add"] as const)
98
+
99
+ // Bad - doesn't work with Google
100
+ action: Type.Union([Type.Literal("list"), Type.Literal("add")])
101
+ ```
@@ -0,0 +1,20 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
3
+
4
+ const factory: CustomToolFactory = (pi) => ({
5
+ name: "hello",
6
+ label: "Hello",
7
+ description: "A simple greeting tool",
8
+ parameters: Type.Object({
9
+ name: Type.String({ description: "Name to greet" }),
10
+ }),
11
+
12
+ async execute(toolCallId, params) {
13
+ return {
14
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
15
+ details: { greeted: params.name },
16
+ };
17
+ },
18
+ });
19
+
20
+ export default factory;