@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/find.ts +7 -1
  62. package/src/core/tools/gemini-image.ts +361 -0
  63. package/src/core/tools/git.ts +216 -0
  64. package/src/core/tools/index.ts +28 -15
  65. package/src/core/tools/ls.ts +9 -2
  66. package/src/core/tools/lsp/config.ts +5 -4
  67. package/src/core/tools/lsp/index.ts +17 -12
  68. package/src/core/tools/lsp/render.ts +39 -47
  69. package/src/core/tools/read.ts +66 -29
  70. package/src/core/tools/render-utils.ts +268 -0
  71. package/src/core/tools/renderers.ts +243 -225
  72. package/src/core/tools/task/discovery.ts +2 -2
  73. package/src/core/tools/task/executor.ts +66 -58
  74. package/src/core/tools/task/index.ts +29 -10
  75. package/src/core/tools/task/model-resolver.ts +8 -13
  76. package/src/core/tools/task/omp-command.ts +24 -0
  77. package/src/core/tools/task/render.ts +37 -62
  78. package/src/core/tools/task/types.ts +3 -0
  79. package/src/core/tools/web-fetch.ts +29 -28
  80. package/src/core/tools/web-search/index.ts +6 -5
  81. package/src/core/tools/web-search/providers/exa.ts +6 -5
  82. package/src/core/tools/web-search/render.ts +66 -111
  83. package/src/core/voice-controller.ts +135 -0
  84. package/src/core/voice-supervisor.ts +1003 -0
  85. package/src/core/voice.ts +308 -0
  86. package/src/discovery/builtin.ts +75 -1
  87. package/src/discovery/claude.ts +47 -1
  88. package/src/discovery/codex.ts +54 -2
  89. package/src/discovery/gemini.ts +55 -2
  90. package/src/discovery/helpers.ts +100 -1
  91. package/src/discovery/index.ts +2 -0
  92. package/src/index.ts +14 -9
  93. package/src/lib/worktree/collapse.ts +179 -0
  94. package/src/lib/worktree/constants.ts +14 -0
  95. package/src/lib/worktree/errors.ts +23 -0
  96. package/src/lib/worktree/git.ts +110 -0
  97. package/src/lib/worktree/index.ts +23 -0
  98. package/src/lib/worktree/operations.ts +216 -0
  99. package/src/lib/worktree/session.ts +114 -0
  100. package/src/lib/worktree/stats.ts +67 -0
  101. package/src/main.ts +61 -37
  102. package/src/migrations.ts +37 -7
  103. package/src/modes/interactive/components/bash-execution.ts +6 -4
  104. package/src/modes/interactive/components/custom-editor.ts +55 -0
  105. package/src/modes/interactive/components/custom-message.ts +95 -0
  106. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  107. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  108. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  109. package/src/modes/interactive/components/extensions/types.ts +1 -0
  110. package/src/modes/interactive/components/footer.ts +324 -0
  111. package/src/modes/interactive/components/hook-selector.ts +3 -3
  112. package/src/modes/interactive/components/model-selector.ts +7 -6
  113. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  114. package/src/modes/interactive/components/settings-defs.ts +55 -6
  115. package/src/modes/interactive/components/status-line.ts +45 -37
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +643 -113
  118. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  119. package/src/modes/print-mode.ts +14 -72
  120. package/src/modes/rpc/rpc-client.ts +23 -9
  121. package/src/modes/rpc/rpc-mode.ts +137 -125
  122. package/src/modes/rpc/rpc-types.ts +46 -24
  123. package/src/prompts/task.md +1 -0
  124. package/src/prompts/tools/gemini-image.md +4 -0
  125. package/src/prompts/tools/git.md +9 -0
  126. package/src/prompts/voice-summary.md +12 -0
  127. package/src/utils/image-convert.ts +26 -0
  128. package/src/utils/image-resize.ts +215 -0
  129. package/src/utils/shell-snapshot.ts +22 -20
@@ -0,0 +1,1055 @@
1
+ > pi can create extensions. Ask it to build one for your use case.
2
+
3
+ # Extensions
4
+
5
+ Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
6
+
7
+ **Key capabilities:**
8
+ - **Custom tools** - Register tools the LLM can call via `pi.registerTool()`
9
+ - **Event interception** - Block or modify tool calls, inject context, customize compaction
10
+ - **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
11
+ - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions
12
+ - **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()`
13
+ - **Session persistence** - Store state that survives restarts via `pi.appendEntry()`
14
+ - **Custom rendering** - Control how tool calls/results and messages appear in TUI
15
+
16
+ **Example use cases:**
17
+ - Permission gates (confirm before `rm -rf`, `sudo`, etc.)
18
+ - Git checkpointing (stash at each turn, restore on branch)
19
+ - Path protection (block writes to `.env`, `node_modules/`)
20
+ - Custom compaction (summarize conversation your way)
21
+ - Interactive tools (questions, wizards, custom dialogs)
22
+ - Stateful tools (todo lists, connection pools)
23
+ - External integrations (file watchers, webhooks, CI triggers)
24
+ - Games while you wait (see `snake.ts` example)
25
+
26
+ See [examples/extensions/](../examples/extensions/) for working implementations.
27
+
28
+ ## Table of Contents
29
+
30
+ - [Quick Start](#quick-start)
31
+ - [Extension Locations](#extension-locations)
32
+ - [Available Imports](#available-imports)
33
+ - [Writing an Extension](#writing-an-extension)
34
+ - [Extension Styles](#extension-styles)
35
+ - [Events](#events)
36
+ - [Lifecycle Overview](#lifecycle-overview)
37
+ - [Session Events](#session-events)
38
+ - [Agent Events](#agent-events)
39
+ - [Tool Events](#tool-events)
40
+ - [ExtensionContext](#extensioncontext)
41
+ - [ExtensionCommandContext](#extensioncommandcontext)
42
+ - [ExtensionAPI Methods](#extensionapi-methods)
43
+ - [State Management](#state-management)
44
+ - [Custom Tools](#custom-tools)
45
+ - [Custom UI](#custom-ui)
46
+ - [Error Handling](#error-handling)
47
+ - [Mode Behavior](#mode-behavior)
48
+
49
+ ## Quick Start
50
+
51
+ Create `~/.omp/agent/extensions/my-extension.ts` (legacy alias: `~/.pi/agent/extensions/`):
52
+
53
+ ```typescript
54
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
55
+ import { Type } from "@sinclair/typebox";
56
+
57
+ export default function (pi: ExtensionAPI) {
58
+ // React to events
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ ctx.ui.notify("Extension loaded!", "info");
61
+ });
62
+
63
+ pi.on("tool_call", async (event, ctx) => {
64
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
65
+ const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
66
+ if (!ok) return { block: true, reason: "Blocked by user" };
67
+ }
68
+ });
69
+
70
+ // Register a custom tool
71
+ pi.registerTool({
72
+ name: "greet",
73
+ label: "Greet",
74
+ description: "Greet someone by name",
75
+ parameters: Type.Object({
76
+ name: Type.String({ description: "Name to greet" }),
77
+ }),
78
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
79
+ return {
80
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
81
+ details: {},
82
+ };
83
+ },
84
+ });
85
+
86
+ // Register a command
87
+ pi.registerCommand("hello", {
88
+ description: "Say hello",
89
+ handler: async (args, ctx) => {
90
+ ctx.ui.notify(`Hello ${args || "world"}!`, "info");
91
+ },
92
+ });
93
+ }
94
+ ```
95
+
96
+ Test with `--extension` (or `-e`) flag:
97
+
98
+ ```bash
99
+ pi -e ./my-extension.ts
100
+ ```
101
+
102
+ ## Extension Locations
103
+
104
+ Extensions are auto-discovered from:
105
+
106
+ | Location | Scope |
107
+ |----------|-------|
108
+ | `~/.omp/agent/extensions/*.ts` | Global (all projects) |
109
+ | `~/.omp/agent/extensions/*/index.ts` | Global (subdirectory) |
110
+ | `.omp/extensions/*.ts` | Project-local |
111
+ | `.omp/extensions/*/index.ts` | Project-local (subdirectory) |
112
+
113
+ Legacy `.pi` directories are supported as aliases for the `.omp` paths above.
114
+
115
+ Additional paths via `settings.json`:
116
+
117
+ ```json
118
+ {
119
+ "extensions": ["/path/to/extension.ts", "/path/to/extension/dir"]
120
+ }
121
+ ```
122
+
123
+ **Discovery rules:**
124
+
125
+ 1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
126
+ 2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension
127
+ 3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"omp"` field (legacy `"pi"` supported) → loads declared paths
128
+
129
+ ```
130
+ ~/.omp/agent/extensions/
131
+ ├── simple.ts # Direct file (auto-discovered)
132
+ ├── my-tool/
133
+ │ └── index.ts # Subdirectory with index (auto-discovered)
134
+ └── my-extension-pack/
135
+ ├── package.json # Declares multiple extensions
136
+ ├── node_modules/ # Dependencies installed here
137
+ └── src/
138
+ ├── safety-gates.ts # First extension
139
+ └── custom-tools.ts # Second extension
140
+ ```
141
+
142
+ ```json
143
+ // my-extension-pack/package.json
144
+ {
145
+ "name": "my-extension-pack",
146
+ "dependencies": {
147
+ "zod": "^3.0.0"
148
+ },
149
+ "omp": {
150
+ "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"]
151
+ }
152
+ }
153
+ ```
154
+
155
+ The `package.json` approach enables:
156
+ - Multiple extensions from one package
157
+ - Third-party npm dependencies (resolved via jiti)
158
+ - Nested source structure (no depth limit within the package)
159
+ - Deployment to and installation from npm
160
+
161
+ ## Available Imports
162
+
163
+ | Package | Purpose |
164
+ |---------|---------|
165
+ | `@oh-my-pi/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
166
+ | `@sinclair/typebox` | Schema definitions for tool parameters |
167
+ | `@oh-my-pi/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
168
+ | `@oh-my-pi/pi-tui` | TUI components for custom rendering |
169
+
170
+ npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically.
171
+
172
+ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
173
+
174
+ ## Writing an Extension
175
+
176
+ An extension exports a default function that receives `ExtensionAPI`:
177
+
178
+ ```typescript
179
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
180
+
181
+ export default function (pi: ExtensionAPI) {
182
+ // Subscribe to events
183
+ pi.on("event_name", async (event, ctx) => {
184
+ // ctx.ui for user interaction
185
+ const ok = await ctx.ui.confirm("Title", "Are you sure?");
186
+ ctx.ui.notify("Done!", "success");
187
+ ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
188
+ ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor
189
+ });
190
+
191
+ // Register tools, commands, shortcuts, flags
192
+ pi.registerTool({ ... });
193
+ pi.registerCommand("name", { ... });
194
+ pi.registerShortcut("ctrl+x", { ... });
195
+ pi.registerFlag("--my-flag", { ... });
196
+ }
197
+ ```
198
+
199
+ Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
200
+
201
+ ### Extension Styles
202
+
203
+ **Single file** - simplest, for small extensions:
204
+
205
+ ```
206
+ ~/.omp/agent/extensions/
207
+ └── my-extension.ts
208
+ ```
209
+
210
+ **Directory with index.ts** - for multi-file extensions:
211
+
212
+ ```
213
+ ~/.omp/agent/extensions/
214
+ └── my-extension/
215
+ ├── index.ts # Entry point (exports default function)
216
+ ├── tools.ts # Helper module
217
+ └── utils.ts # Helper module
218
+ ```
219
+
220
+ **Package with dependencies** - for extensions that need npm packages:
221
+
222
+ ```
223
+ ~/.omp/agent/extensions/
224
+ └── my-extension/
225
+ ├── package.json # Declares dependencies and entry points
226
+ ├── package-lock.json
227
+ ├── node_modules/ # After npm install
228
+ └── src/
229
+ └── index.ts
230
+ ```
231
+
232
+ ```json
233
+ // package.json
234
+ {
235
+ "name": "my-extension",
236
+ "dependencies": {
237
+ "zod": "^3.0.0",
238
+ "chalk": "^5.0.0"
239
+ },
240
+ "pi": {
241
+ "extensions": ["./src/index.ts"]
242
+ }
243
+ }
244
+ ```
245
+
246
+ Run `npm install` in the extension directory, then imports from `node_modules/` work automatically.
247
+
248
+ ## Events
249
+
250
+ ### Lifecycle Overview
251
+
252
+ ```
253
+ pi starts
254
+
255
+ └─► session_start
256
+
257
+
258
+ user sends prompt ─────────────────────────────────────────┐
259
+ │ │
260
+ ├─► before_agent_start (can inject message, append to system prompt)
261
+ ├─► agent_start │
262
+ │ │
263
+ │ ┌─── turn (repeats while LLM calls tools) ───┐ │
264
+ │ │ │ │
265
+ │ ├─► turn_start │ │
266
+ │ ├─► context (can modify messages) │ │
267
+ │ │ │ │
268
+ │ │ LLM responds, may call tools: │ │
269
+ │ │ ├─► tool_call (can block) │ │
270
+ │ │ │ tool executes │ │
271
+ │ │ └─► tool_result (can modify) │ │
272
+ │ │ │ │
273
+ │ └─► turn_end │ │
274
+ │ │
275
+ └─► agent_end │
276
+
277
+ user sends another prompt ◄────────────────────────────────┘
278
+
279
+ /new (new session) or /resume (switch session)
280
+ ├─► session_before_switch (can cancel)
281
+ └─► session_switch
282
+
283
+ /branch
284
+ ├─► session_before_branch (can cancel)
285
+ └─► session_branch
286
+
287
+ /compact or auto-compaction
288
+ ├─► session_before_compact (can cancel or customize)
289
+ └─► session_compact
290
+
291
+ /tree navigation
292
+ ├─► session_before_tree (can cancel or customize)
293
+ └─► session_tree
294
+
295
+ exit (Ctrl+C, Ctrl+D)
296
+ └─► session_shutdown
297
+ ```
298
+
299
+ ### Session Events
300
+
301
+ #### session_start
302
+
303
+ Fired on initial session load.
304
+
305
+ ```typescript
306
+ pi.on("session_start", async (_event, ctx) => {
307
+ ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
308
+ });
309
+ ```
310
+
311
+ #### session_before_switch / session_switch
312
+
313
+ Fired when starting a new session (`/new`) or switching sessions (`/resume`).
314
+
315
+ ```typescript
316
+ pi.on("session_before_switch", async (event, ctx) => {
317
+ // event.reason - "new" or "resume"
318
+ // event.targetSessionFile - session we're switching to (only for "resume")
319
+
320
+ if (event.reason === "new") {
321
+ const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
322
+ if (!ok) return { cancel: true };
323
+ }
324
+ });
325
+
326
+ pi.on("session_switch", async (event, ctx) => {
327
+ // event.reason - "new" or "resume"
328
+ // event.previousSessionFile - session we came from
329
+ });
330
+ ```
331
+
332
+ #### session_before_branch / session_branch
333
+
334
+ Fired when branching via `/branch`.
335
+
336
+ ```typescript
337
+ pi.on("session_before_branch", async (event, ctx) => {
338
+ // event.entryId - ID of the entry being branched from
339
+ return { cancel: true }; // Cancel branch
340
+ // OR
341
+ return { skipConversationRestore: true }; // Branch but don't rewind messages
342
+ });
343
+
344
+ pi.on("session_branch", async (event, ctx) => {
345
+ // event.previousSessionFile - previous session file
346
+ });
347
+ ```
348
+
349
+ #### session_before_compact / session_compact
350
+
351
+ Fired on compaction. See [compaction.md](compaction.md) for details.
352
+
353
+ ```typescript
354
+ pi.on("session_before_compact", async (event, ctx) => {
355
+ const { preparation, branchEntries, customInstructions, signal } = event;
356
+
357
+ // Cancel:
358
+ return { cancel: true };
359
+
360
+ // Custom summary:
361
+ return {
362
+ compaction: {
363
+ summary: "...",
364
+ firstKeptEntryId: preparation.firstKeptEntryId,
365
+ tokensBefore: preparation.tokensBefore,
366
+ }
367
+ };
368
+ });
369
+
370
+ pi.on("session_compact", async (event, ctx) => {
371
+ // event.compactionEntry - the saved compaction
372
+ // event.fromExtension - whether extension provided it
373
+ });
374
+ ```
375
+
376
+ #### session_before_tree / session_tree
377
+
378
+ Fired on `/tree` navigation.
379
+
380
+ ```typescript
381
+ pi.on("session_before_tree", async (event, ctx) => {
382
+ const { preparation, signal } = event;
383
+ return { cancel: true };
384
+ // OR provide custom summary:
385
+ return { summary: { summary: "...", details: {} } };
386
+ });
387
+
388
+ pi.on("session_tree", async (event, ctx) => {
389
+ // event.newLeafId, oldLeafId, summaryEntry, fromExtension
390
+ });
391
+ ```
392
+
393
+ #### session_shutdown
394
+
395
+ Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
396
+
397
+ ```typescript
398
+ pi.on("session_shutdown", async (_event, ctx) => {
399
+ // Cleanup, save state, etc.
400
+ });
401
+ ```
402
+
403
+ ### Agent Events
404
+
405
+ #### before_agent_start
406
+
407
+ Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.
408
+
409
+ ```typescript
410
+ pi.on("before_agent_start", async (event, ctx) => {
411
+ // event.prompt - user's prompt text
412
+ // event.images - attached images (if any)
413
+
414
+ return {
415
+ // Inject a persistent message (stored in session, sent to LLM)
416
+ message: {
417
+ customType: "my-extension",
418
+ content: "Additional context for the LLM",
419
+ display: true,
420
+ },
421
+ // Append to system prompt for this turn only
422
+ systemPromptAppend: "Extra instructions for this turn...",
423
+ };
424
+ });
425
+ ```
426
+
427
+ #### agent_start / agent_end
428
+
429
+ Fired once per user prompt.
430
+
431
+ ```typescript
432
+ pi.on("agent_start", async (_event, ctx) => {});
433
+
434
+ pi.on("agent_end", async (event, ctx) => {
435
+ // event.messages - messages from this prompt
436
+ });
437
+ ```
438
+
439
+ #### turn_start / turn_end
440
+
441
+ Fired for each turn (one LLM response + tool calls).
442
+
443
+ ```typescript
444
+ pi.on("turn_start", async (event, ctx) => {
445
+ // event.turnIndex, event.timestamp
446
+ });
447
+
448
+ pi.on("turn_end", async (event, ctx) => {
449
+ // event.turnIndex, event.message, event.toolResults
450
+ });
451
+ ```
452
+
453
+ #### context
454
+
455
+ Fired before each LLM call. Modify messages non-destructively.
456
+
457
+ ```typescript
458
+ pi.on("context", async (event, ctx) => {
459
+ // event.messages - deep copy, safe to modify
460
+ const filtered = event.messages.filter(m => !shouldPrune(m));
461
+ return { messages: filtered };
462
+ });
463
+ ```
464
+
465
+ ### Tool Events
466
+
467
+ #### tool_call
468
+
469
+ Fired before tool executes. **Can block.**
470
+
471
+ ```typescript
472
+ pi.on("tool_call", async (event, ctx) => {
473
+ // event.toolName - "bash", "read", "write", "edit", etc.
474
+ // event.toolCallId
475
+ // event.input - tool parameters
476
+
477
+ if (shouldBlock(event)) {
478
+ return { block: true, reason: "Not allowed" };
479
+ }
480
+ });
481
+ ```
482
+
483
+ #### tool_result
484
+
485
+ Fired after tool executes. **Can modify result.**
486
+
487
+ ```typescript
488
+ import { isBashToolResult } from "@oh-my-pi/pi-coding-agent";
489
+
490
+ pi.on("tool_result", async (event, ctx) => {
491
+ // event.toolName, event.toolCallId, event.input
492
+ // event.content, event.details, event.isError
493
+
494
+ if (isBashToolResult(event)) {
495
+ // event.details is typed as BashToolDetails
496
+ }
497
+
498
+ // Modify result:
499
+ return { content: [...], details: {...}, isError: false };
500
+ });
501
+ ```
502
+
503
+ ## ExtensionContext
504
+
505
+ Every handler receives `ctx: ExtensionContext`:
506
+
507
+ ### ctx.ui
508
+
509
+ UI methods for user interaction. See [Custom UI](#custom-ui) for full details.
510
+
511
+ ### ctx.hasUI
512
+
513
+ `false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`.
514
+
515
+ ### ctx.cwd
516
+
517
+ Current working directory.
518
+
519
+ ### ctx.sessionManager
520
+
521
+ Read-only access to session state:
522
+
523
+ ```typescript
524
+ ctx.sessionManager.getEntries() // All entries
525
+ ctx.sessionManager.getBranch() // Current branch
526
+ ctx.sessionManager.getLeafId() // Current leaf entry ID
527
+ ```
528
+
529
+ ### ctx.modelRegistry / ctx.model
530
+
531
+ Access to models and API keys.
532
+
533
+ ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
534
+
535
+ Control flow helpers.
536
+
537
+ ## ExtensionCommandContext
538
+
539
+ Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.
540
+
541
+ ### ctx.waitForIdle()
542
+
543
+ Wait for the agent to finish streaming:
544
+
545
+ ```typescript
546
+ pi.registerCommand("my-cmd", {
547
+ handler: async (args, ctx) => {
548
+ await ctx.waitForIdle();
549
+ // Agent is now idle, safe to modify session
550
+ },
551
+ });
552
+ ```
553
+
554
+ ### ctx.newSession(options?)
555
+
556
+ Create a new session:
557
+
558
+ ```typescript
559
+ const result = await ctx.newSession({
560
+ parentSession: ctx.sessionManager.getSessionFile(),
561
+ setup: async (sm) => {
562
+ sm.appendMessage({
563
+ role: "user",
564
+ content: [{ type: "text", text: "Context from previous session..." }],
565
+ timestamp: Date.now(),
566
+ });
567
+ },
568
+ });
569
+
570
+ if (result.cancelled) {
571
+ // An extension cancelled the new session
572
+ }
573
+ ```
574
+
575
+ ### ctx.branch(entryId)
576
+
577
+ Branch from a specific entry:
578
+
579
+ ```typescript
580
+ const result = await ctx.branch("entry-id-123");
581
+ if (!result.cancelled) {
582
+ // Now in the branched session
583
+ }
584
+ ```
585
+
586
+ ### ctx.navigateTree(targetId, options?)
587
+
588
+ Navigate to a different point in the session tree:
589
+
590
+ ```typescript
591
+ const result = await ctx.navigateTree("entry-id-456", {
592
+ summarize: true,
593
+ });
594
+ ```
595
+
596
+ ## ExtensionAPI Methods
597
+
598
+ ### pi.on(event, handler)
599
+
600
+ Subscribe to events. See [Events](#events).
601
+
602
+ ### pi.registerTool(definition)
603
+
604
+ Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.
605
+
606
+ ```typescript
607
+ import { Type } from "@sinclair/typebox";
608
+ import { StringEnum } from "@oh-my-pi/pi-ai";
609
+
610
+ pi.registerTool({
611
+ name: "my_tool",
612
+ label: "My Tool",
613
+ description: "What this tool does",
614
+ parameters: Type.Object({
615
+ action: StringEnum(["list", "add"] as const),
616
+ text: Type.Optional(Type.String()),
617
+ }),
618
+
619
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
620
+ // Stream progress
621
+ onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
622
+
623
+ return {
624
+ content: [{ type: "text", text: "Done" }],
625
+ details: { result: "..." },
626
+ };
627
+ },
628
+
629
+ // Optional: Custom rendering
630
+ renderCall(args, theme) { ... },
631
+ renderResult(result, options, theme) { ... },
632
+ });
633
+ ```
634
+
635
+ ### pi.sendMessage(message, options?)
636
+
637
+ Inject a message into the session:
638
+
639
+ ```typescript
640
+ pi.sendMessage({
641
+ customType: "my-extension",
642
+ content: "Message text",
643
+ display: true,
644
+ details: { ... },
645
+ }, {
646
+ triggerTurn: true,
647
+ deliverAs: "steer",
648
+ });
649
+ ```
650
+
651
+ **Options:**
652
+ - `deliverAs` - Delivery mode:
653
+ - `"steer"` (default) - Interrupts streaming. Delivered after current tool finishes, remaining tools skipped.
654
+ - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls.
655
+ - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
656
+ - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
657
+
658
+ ### pi.appendEntry(customType, data?)
659
+
660
+ Persist extension state (does NOT participate in LLM context):
661
+
662
+ ```typescript
663
+ pi.appendEntry("my-state", { count: 42 });
664
+
665
+ // Restore on reload
666
+ pi.on("session_start", async (_event, ctx) => {
667
+ for (const entry of ctx.sessionManager.getEntries()) {
668
+ if (entry.type === "custom" && entry.customType === "my-state") {
669
+ // Reconstruct from entry.data
670
+ }
671
+ }
672
+ });
673
+ ```
674
+
675
+ ### pi.registerCommand(name, options)
676
+
677
+ Register a command:
678
+
679
+ ```typescript
680
+ pi.registerCommand("stats", {
681
+ description: "Show session statistics",
682
+ handler: async (args, ctx) => {
683
+ const count = ctx.sessionManager.getEntries().length;
684
+ ctx.ui.notify(`${count} entries`, "info");
685
+ }
686
+ });
687
+ ```
688
+
689
+ ### pi.registerMessageRenderer(customType, renderer)
690
+
691
+ Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
692
+
693
+ ### pi.registerShortcut(shortcut, options)
694
+
695
+ Register a keyboard shortcut:
696
+
697
+ ```typescript
698
+ pi.registerShortcut("ctrl+shift+p", {
699
+ description: "Toggle plan mode",
700
+ handler: async (ctx) => {
701
+ ctx.ui.notify("Toggled!");
702
+ },
703
+ });
704
+ ```
705
+
706
+ ### pi.registerFlag(name, options)
707
+
708
+ Register a CLI flag:
709
+
710
+ ```typescript
711
+ pi.registerFlag("--plan", {
712
+ description: "Start in plan mode",
713
+ type: "boolean",
714
+ default: false,
715
+ });
716
+
717
+ // Check value
718
+ if (pi.getFlag("--plan")) {
719
+ // Plan mode enabled
720
+ }
721
+ ```
722
+
723
+ ### pi.exec(command, args, options?)
724
+
725
+ Execute a shell command:
726
+
727
+ ```typescript
728
+ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
729
+ // result.stdout, result.stderr, result.code, result.killed
730
+ ```
731
+
732
+ ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
733
+
734
+ Manage active tools:
735
+
736
+ ```typescript
737
+ const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
738
+ pi.setActiveTools(["read", "bash"]); // Switch to read-only
739
+ ```
740
+
741
+ ### pi.events
742
+
743
+ Shared event bus for communication between extensions:
744
+
745
+ ```typescript
746
+ pi.events.on("my:event", (data) => { ... });
747
+ pi.events.emit("my:event", { ... });
748
+ ```
749
+
750
+ ## State Management
751
+
752
+ Extensions with state should store it in tool result `details` for proper branching support:
753
+
754
+ ```typescript
755
+ export default function (pi: ExtensionAPI) {
756
+ let items: string[] = [];
757
+
758
+ // Reconstruct state from session
759
+ pi.on("session_start", async (_event, ctx) => {
760
+ items = [];
761
+ for (const entry of ctx.sessionManager.getBranch()) {
762
+ if (entry.type === "message" && entry.message.role === "toolResult") {
763
+ if (entry.message.toolName === "my_tool") {
764
+ items = entry.message.details?.items ?? [];
765
+ }
766
+ }
767
+ }
768
+ });
769
+
770
+ pi.registerTool({
771
+ name: "my_tool",
772
+ // ...
773
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
774
+ items.push("new item");
775
+ return {
776
+ content: [{ type: "text", text: "Added" }],
777
+ details: { items: [...items] }, // Store for reconstruction
778
+ };
779
+ },
780
+ });
781
+ }
782
+ ```
783
+
784
+ ## Custom Tools
785
+
786
+ Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering.
787
+
788
+ ### Tool Definition
789
+
790
+ ```typescript
791
+ import { Type } from "@sinclair/typebox";
792
+ import { StringEnum } from "@oh-my-pi/pi-ai";
793
+ import { Text } from "@oh-my-pi/pi-tui";
794
+
795
+ pi.registerTool({
796
+ name: "my_tool",
797
+ label: "My Tool",
798
+ description: "What this tool does (shown to LLM)",
799
+ parameters: Type.Object({
800
+ action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
801
+ text: Type.Optional(Type.String()),
802
+ }),
803
+
804
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
805
+ // Check for cancellation
806
+ if (signal?.aborted) {
807
+ return { content: [{ type: "text", text: "Cancelled" }] };
808
+ }
809
+
810
+ // Stream progress updates
811
+ onUpdate?.({
812
+ content: [{ type: "text", text: "Working..." }],
813
+ details: { progress: 50 },
814
+ });
815
+
816
+ // Run commands via pi.exec (captured from extension closure)
817
+ const result = await pi.exec("some-command", [], { signal });
818
+
819
+ // Return result
820
+ return {
821
+ content: [{ type: "text", text: "Done" }], // Sent to LLM
822
+ details: { data: result }, // For rendering & state
823
+ };
824
+ },
825
+
826
+ // Optional: Custom rendering
827
+ renderCall(args, theme) { ... },
828
+ renderResult(result, options, theme) { ... },
829
+ });
830
+ ```
831
+
832
+ **Important:** Use `StringEnum` from `@oh-my-pi/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.
833
+
834
+ ### Multiple Tools
835
+
836
+ One extension can register multiple tools with shared state:
837
+
838
+ ```typescript
839
+ export default function (pi: ExtensionAPI) {
840
+ let connection = null;
841
+
842
+ pi.registerTool({ name: "db_connect", ... });
843
+ pi.registerTool({ name: "db_query", ... });
844
+ pi.registerTool({ name: "db_close", ... });
845
+
846
+ pi.on("session_shutdown", async () => {
847
+ connection?.close();
848
+ });
849
+ }
850
+ ```
851
+
852
+ ### Custom Rendering
853
+
854
+ Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API.
855
+
856
+ Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).
857
+
858
+ #### renderCall
859
+
860
+ Renders the tool call (before/during execution):
861
+
862
+ ```typescript
863
+ import { Text } from "@oh-my-pi/pi-tui";
864
+
865
+ renderCall(args, theme) {
866
+ let text = theme.fg("toolTitle", theme.bold("my_tool "));
867
+ text += theme.fg("muted", args.action);
868
+ if (args.text) {
869
+ text += " " + theme.fg("dim", `"${args.text}"`);
870
+ }
871
+ return new Text(text, 0, 0); // 0,0 padding - Box handles it
872
+ }
873
+ ```
874
+
875
+ #### renderResult
876
+
877
+ Renders the tool result:
878
+
879
+ ```typescript
880
+ renderResult(result, { expanded, isPartial }, theme) {
881
+ // Handle streaming
882
+ if (isPartial) {
883
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
884
+ }
885
+
886
+ // Handle errors
887
+ if (result.details?.error) {
888
+ return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
889
+ }
890
+
891
+ // Normal result - support expanded view (Ctrl+O)
892
+ let text = theme.fg("success", "✓ Done");
893
+ if (expanded && result.details?.items) {
894
+ for (const item of result.details.items) {
895
+ text += "\n " + theme.fg("dim", item);
896
+ }
897
+ }
898
+ return new Text(text, 0, 0);
899
+ }
900
+ ```
901
+
902
+ #### Best Practices
903
+
904
+ - Use `Text` with padding `(0, 0)` - the Box handles padding
905
+ - Use `\n` for multi-line content
906
+ - Handle `isPartial` for streaming progress
907
+ - Support `expanded` for detail on demand
908
+ - Keep default view compact
909
+
910
+ #### Fallback
911
+
912
+ If `renderCall`/`renderResult` is not defined or throws:
913
+ - `renderCall`: Shows tool name
914
+ - `renderResult`: Shows raw text from `content`
915
+
916
+ ## Custom UI
917
+
918
+ Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
919
+
920
+ ### Dialogs
921
+
922
+ ```typescript
923
+ // Select from options
924
+ const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
925
+
926
+ // Confirm dialog
927
+ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
928
+
929
+ // Text input
930
+ const name = await ctx.ui.input("Name:", "placeholder");
931
+
932
+ // Multi-line editor
933
+ const text = await ctx.ui.editor("Edit:", "prefilled text");
934
+
935
+ // Notification (non-blocking)
936
+ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
937
+ ```
938
+
939
+ ### Widgets and Status
940
+
941
+ ```typescript
942
+ // Status in footer (persistent until cleared)
943
+ ctx.ui.setStatus("my-ext", "Processing...");
944
+ ctx.ui.setStatus("my-ext", undefined); // Clear
945
+
946
+ // Widget above editor (string array or factory function)
947
+ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
948
+ ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
949
+ ctx.ui.setWidget("my-widget", undefined); // Clear
950
+
951
+ // Terminal title
952
+ ctx.ui.setTitle("pi - my-project");
953
+
954
+ // Editor text
955
+ ctx.ui.setEditorText("Prefill text");
956
+ const current = ctx.ui.getEditorText();
957
+ ```
958
+
959
+ ### Custom Components
960
+
961
+ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
962
+
963
+ ```typescript
964
+ import { Text, Component } from "@oh-my-pi/pi-tui";
965
+
966
+ const result = await ctx.ui.custom<boolean>((tui, theme, done) => {
967
+ const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
968
+
969
+ text.onKey = (key) => {
970
+ if (key === "return") done(true);
971
+ if (key === "escape") done(false);
972
+ return true;
973
+ };
974
+
975
+ return text;
976
+ });
977
+
978
+ if (result) {
979
+ // User pressed Enter
980
+ }
981
+ ```
982
+
983
+ The callback receives:
984
+ - `tui` - TUI instance (for screen dimensions, focus management)
985
+ - `theme` - Current theme for styling
986
+ - `done(value)` - Call to close component and return value
987
+
988
+ See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts).
989
+
990
+ ### Message Rendering
991
+
992
+ Register a custom renderer for messages with your `customType`:
993
+
994
+ ```typescript
995
+ import { Text } from "@oh-my-pi/pi-tui";
996
+
997
+ pi.registerMessageRenderer("my-extension", (message, options, theme) => {
998
+ const { expanded } = options;
999
+ let text = theme.fg("accent", `[${message.customType}] `);
1000
+ text += message.content;
1001
+
1002
+ if (expanded && message.details) {
1003
+ text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
1004
+ }
1005
+
1006
+ return new Text(text, 0, 0);
1007
+ });
1008
+ ```
1009
+
1010
+ Messages are sent via `pi.sendMessage()`:
1011
+
1012
+ ```typescript
1013
+ pi.sendMessage({
1014
+ customType: "my-extension", // Matches registerMessageRenderer
1015
+ content: "Status update",
1016
+ display: true, // Show in TUI
1017
+ details: { ... }, // Available in renderer
1018
+ });
1019
+ ```
1020
+
1021
+ ### Theme Colors
1022
+
1023
+ All render functions receive a `theme` object:
1024
+
1025
+ ```typescript
1026
+ // Foreground colors
1027
+ theme.fg("toolTitle", text) // Tool names
1028
+ theme.fg("accent", text) // Highlights
1029
+ theme.fg("success", text) // Success (green)
1030
+ theme.fg("error", text) // Errors (red)
1031
+ theme.fg("warning", text) // Warnings (yellow)
1032
+ theme.fg("muted", text) // Secondary text
1033
+ theme.fg("dim", text) // Tertiary text
1034
+
1035
+ // Text styles
1036
+ theme.bold(text)
1037
+ theme.italic(text)
1038
+ theme.strikethrough(text)
1039
+ ```
1040
+
1041
+ ## Error Handling
1042
+
1043
+ - Extension errors are logged, agent continues
1044
+ - `tool_call` errors block the tool (fail-safe)
1045
+ - Tool `execute` errors are reported to the LLM with `isError: true`
1046
+
1047
+ ## Mode Behavior
1048
+
1049
+ | Mode | UI Methods | Notes |
1050
+ |------|-----------|-------|
1051
+ | Interactive | Full TUI | Normal operation |
1052
+ | RPC | JSON protocol | Host handles UI |
1053
+ | Print (`-p`) | No-op | Extensions run but can't prompt |
1054
+
1055
+ In print mode, check `ctx.hasUI` before using UI methods.