@oh-my-pi/pi-coding-agent 12.7.6 → 12.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +37 -37
  2. package/README.md +9 -1052
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +1 -0
  5. package/src/cli/update-cli.ts +49 -35
  6. package/src/cli/web-search-cli.ts +3 -2
  7. package/src/commands/web-search.ts +1 -0
  8. package/src/config/model-registry.ts +6 -0
  9. package/src/config/settings-schema.ts +25 -3
  10. package/src/config/settings.ts +1 -0
  11. package/src/extensibility/extensions/wrapper.ts +20 -13
  12. package/src/extensibility/slash-commands.ts +12 -91
  13. package/src/lsp/client.ts +24 -27
  14. package/src/lsp/index.ts +92 -42
  15. package/src/mcp/config-writer.ts +33 -0
  16. package/src/mcp/config.ts +6 -1
  17. package/src/mcp/types.ts +1 -0
  18. package/src/modes/components/custom-editor.ts +8 -5
  19. package/src/modes/components/settings-defs.ts +2 -1
  20. package/src/modes/controllers/command-controller.ts +12 -6
  21. package/src/modes/controllers/input-controller.ts +21 -186
  22. package/src/modes/controllers/mcp-command-controller.ts +60 -3
  23. package/src/modes/interactive-mode.ts +2 -2
  24. package/src/modes/types.ts +1 -1
  25. package/src/sdk.ts +23 -1
  26. package/src/secrets/index.ts +116 -0
  27. package/src/secrets/obfuscator.ts +269 -0
  28. package/src/secrets/regex.ts +21 -0
  29. package/src/session/agent-session.ts +143 -21
  30. package/src/session/compaction/branch-summarization.ts +2 -2
  31. package/src/session/compaction/compaction.ts +10 -3
  32. package/src/session/compaction/utils.ts +25 -1
  33. package/src/slash-commands/builtin-registry.ts +419 -0
  34. package/src/web/scrapers/github.ts +50 -12
  35. package/src/web/search/index.ts +5 -5
  36. package/src/web/search/provider.ts +13 -2
  37. package/src/web/search/providers/brave.ts +165 -0
  38. package/src/web/search/types.ts +1 -1
  39. package/docs/compaction.md +0 -436
  40. package/docs/config-usage.md +0 -176
  41. package/docs/custom-tools.md +0 -585
  42. package/docs/environment-variables.md +0 -257
  43. package/docs/extension-loading.md +0 -106
  44. package/docs/extensions.md +0 -1342
  45. package/docs/fs-scan-cache-architecture.md +0 -50
  46. package/docs/hooks.md +0 -906
  47. package/docs/models.md +0 -234
  48. package/docs/python-repl.md +0 -110
  49. package/docs/rpc.md +0 -1173
  50. package/docs/sdk.md +0 -1039
  51. package/docs/session-tree-plan.md +0 -84
  52. package/docs/session.md +0 -368
  53. package/docs/skills.md +0 -254
  54. package/docs/theme.md +0 -696
  55. package/docs/tree.md +0 -206
  56. package/docs/tui.md +0 -487
package/docs/hooks.md DELETED
@@ -1,906 +0,0 @@
1
- > omp can create hooks. Ask it to build one for your use case.
2
-
3
- # Hooks
4
-
5
- Hooks are TypeScript modules that extend omp'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
-
9
- - **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify)
10
- - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()`
11
- - **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()`
12
- - **Event interception** - Block or modify tool calls, inject context, customize compaction
13
- - **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()`
14
-
15
- **Example use cases:**
16
-
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
- - External integrations (file watchers, webhooks, CI triggers)
21
- - Interactive tools (games, wizards, custom dialogs)
22
-
23
- See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI.
24
-
25
- ## Quick Start
26
-
27
- Create `~/.omp/agent/hooks/pre/my-hook.ts` (or project-local `.omp/hooks/pre/`):
28
-
29
- ```typescript
30
- import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
31
-
32
- export default function (pi: HookAPI) {
33
- pi.on("session_start", async (_event, ctx) => {
34
- ctx.ui.notify("Hook loaded!", "info");
35
- });
36
-
37
- pi.on("tool_call", async (event, ctx) => {
38
- if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
39
- const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
40
- if (!ok) return { block: true, reason: "Blocked by user" };
41
- }
42
- });
43
- }
44
- ```
45
-
46
- Test with `--hook` flag:
47
-
48
- ```bash
49
- omp --hook ./my-hook.ts
50
- ```
51
-
52
- ## Hook Locations
53
-
54
- Hooks are auto-discovered from config directories under `hooks/`:
55
-
56
- Native (`.omp`, `.pi`) and Claude (`.claude`) use subdirectory structure:
57
-
58
- - User-level:
59
- - Native: `~/.omp/agent/hooks/{pre,post}/*.ts` (or `~/.pi/agent`)
60
- - Claude: `~/.claude/hooks/{pre,post}/*.ts`
61
- - Project-level: `.omp/hooks/{pre,post}/*.ts` (or `.pi`, `.claude`)
62
-
63
- Codex (`.codex`) uses flat structure with filename prefixes (`pre-*.ts`, `post-*.ts`):
64
-
65
- - User-level: `~/.codex/hooks/*.ts`
66
- - Project-level: `.codex/hooks/*.ts`
67
-
68
- Hooks can also be loaded from plugin manifests or explicitly via `--hook`.
69
-
70
- ## Available Imports
71
-
72
- | Package | Purpose |
73
- | --------------------------------- | ---------------------------------------------------- |
74
- | `@oh-my-pi/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
75
- | `@oh-my-pi/pi-coding-agent` | Components (`BorderedLoader`), utilities, type re-exports |
76
- | `@oh-my-pi/pi-ai` | AI utilities (`complete`, message types) |
77
- | `@oh-my-pi/pi-tui` | TUI components (`CancellableLoader`, etc.) |
78
-
79
- Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
80
-
81
- ## Writing a Hook
82
-
83
- A hook exports a default function that receives `HookAPI`:
84
-
85
- ```typescript
86
- import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
87
-
88
- export default function (pi: HookAPI) {
89
- // Subscribe to events
90
- pi.on("event_name", async (event, ctx) => {
91
- // Handle event
92
- });
93
- }
94
- ```
95
-
96
- Hooks are loaded via native Bun import, so TypeScript works without compilation.
97
-
98
- ## Events
99
-
100
- ### Lifecycle Overview
101
-
102
- ```
103
- omp starts
104
-
105
- └─► session_start
106
-
107
-
108
- user sends prompt ─────────────────────────────────────────┐
109
- │ │
110
- ├─► before_agent_start (can inject message) │
111
- ├─► agent_start │
112
- │ │
113
- │ ┌─── turn (repeats while LLM calls tools) ───┐ │
114
- │ │ │ │
115
- │ ├─► turn_start │ │
116
- │ ├─► context (can modify messages) │ │
117
- │ │ │ │
118
- │ │ LLM responds, may call tools: │ │
119
- │ │ ├─► tool_call (can block) │ │
120
- │ │ │ tool executes │ │
121
- │ │ └─► tool_result (can modify) │ │
122
- │ │ │ │
123
- │ └─► turn_end │ │
124
- │ │
125
- └─► agent_end │
126
-
127
- user sends another prompt ◄────────────────────────────────┘
128
-
129
- /new, /resume, or /fork
130
- ├─► session_before_switch (can cancel, has reason: "new" | "resume" | "fork")
131
- └─► session_switch (has reason: "new" | "resume" | "fork")
132
-
133
- /branch
134
- ├─► session_before_branch (can cancel)
135
- └─► session_branch
136
-
137
- /compact or auto-compaction
138
- ├─► session_before_compact (can cancel or customize)
139
- ├─► session.compacting (customize prompt/context)
140
- └─► session_compact
141
-
142
- /tree navigation
143
- ├─► session_before_tree (can cancel or customize)
144
- └─► session_tree
145
-
146
- exit (Ctrl+C, Ctrl+D)
147
- └─► session_shutdown
148
- ```
149
-
150
- ### Session Events
151
-
152
- #### session_start
153
-
154
- Fired on initial session load.
155
-
156
- ```typescript
157
- pi.on("session_start", async (_event, ctx) => {
158
- ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
159
- });
160
- ```
161
-
162
- #### session_before_switch / session_switch
163
-
164
- Fired when starting a new session (`/new`), resuming (`/resume`), or forking (`/fork`).
165
-
166
- ```typescript
167
- pi.on("session_before_switch", async (event, ctx) => {
168
- // event.reason - "new" (starting fresh), "resume" (switching to existing), or "fork" (branch switch)
169
- // event.targetSessionFile - session we're switching to (only for "resume")
170
-
171
- if (event.reason === "new") {
172
- const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
173
- if (!ok) return { cancel: true };
174
- }
175
-
176
- return { cancel: true }; // Cancel the switch/new
177
- });
178
-
179
- pi.on("session_switch", async (event, ctx) => {
180
- // event.reason - "new", "resume", or "fork"
181
- // event.previousSessionFile - session we came from
182
- });
183
- ```
184
-
185
- #### session_before_branch / session_branch
186
-
187
- Fired when branching via `/branch`.
188
-
189
- ```typescript
190
- pi.on("session_before_branch", async (event, ctx) => {
191
- // event.entryId - ID of the entry being branched from
192
-
193
- return { cancel: true }; // Cancel branch
194
- // OR
195
- return { skipConversationRestore: true }; // Branch but don't rewind messages
196
- });
197
-
198
- pi.on("session_branch", async (event, ctx) => {
199
- // event.previousSessionFile - previous session file
200
- });
201
- ```
202
-
203
- The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately.
204
-
205
- #### session_before_compact / session.compacting / session_compact
206
-
207
- Fired on compaction. See [compaction.md](compaction.md) for details.
208
-
209
- ```typescript
210
- pi.on("session_before_compact", async (event, ctx) => {
211
- const { preparation, branchEntries, customInstructions, signal } = event;
212
-
213
- // Cancel:
214
- return { cancel: true };
215
-
216
- // Custom summary:
217
- return {
218
- compaction: {
219
- summary: "...",
220
- firstKeptEntryId: preparation.firstKeptEntryId,
221
- tokensBefore: preparation.tokensBefore,
222
- },
223
- };
224
- });
225
-
226
- ```
227
-
228
- #### session.compacting
229
-
230
- Fired after preparation but before the default summarizer runs. Use it to customize the prompt or add context
231
- when you are not returning a full compaction result from `session_before_compact`.
232
-
233
- ```typescript
234
- pi.on("session.compacting", async (event, ctx) => {
235
- // event.sessionId
236
- // event.messages - messages about to be summarized
237
-
238
- return {
239
- context: ["Additional context line"],
240
- prompt: "Custom compaction prompt...",
241
- preserveData: { source: "my-hook" },
242
- };
243
- });
244
- ```
245
-
246
- ```typescript
247
- pi.on("session_compact", async (event, ctx) => {
248
- // event.compactionEntry - the saved compaction
249
- // event.fromExtension - whether hook provided it
250
- });
251
- ```
252
-
253
- #### session_before_tree / session_tree
254
-
255
- Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details.
256
-
257
- ```typescript
258
- pi.on("session_before_tree", async (event, ctx) => {
259
- const { preparation, signal } = event;
260
- // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize
261
- // preparation.userWantsSummary - whether user chose to summarize
262
-
263
- return { cancel: true };
264
- // OR provide custom summary (only used if userWantsSummary is true):
265
- return { summary: { summary: "...", details: {} } };
266
- });
267
-
268
- pi.on("session_tree", async (event, ctx) => {
269
- // event.newLeafId, oldLeafId, summaryEntry, fromExtension
270
- });
271
- ```
272
-
273
- #### session_shutdown
274
-
275
- Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
276
-
277
- ```typescript
278
- pi.on("session_shutdown", async (_event, ctx) => {
279
- // Cleanup, save state, etc.
280
- });
281
- ```
282
-
283
- ### Agent Events
284
-
285
- #### before_agent_start
286
-
287
- Fired after user submits prompt, before agent loop. Can inject a persistent message.
288
-
289
- ```typescript
290
- pi.on("before_agent_start", async (event, ctx) => {
291
- // event.prompt - user's prompt text
292
- // event.images - attached images (if any)
293
-
294
- return {
295
- message: {
296
- customType: "my-hook",
297
- content: "Additional context for the LLM",
298
- display: true, // Show in TUI
299
- },
300
- };
301
- });
302
- ```
303
-
304
- The injected message is persisted as `CustomMessageEntry` and sent to the LLM.
305
-
306
- #### agent_start / agent_end
307
-
308
- Fired once per user prompt.
309
-
310
- ```typescript
311
- pi.on("agent_start", async (_event, ctx) => {});
312
-
313
- pi.on("agent_end", async (event, ctx) => {
314
- // event.messages - messages from this prompt
315
- });
316
- ```
317
-
318
- #### turn_start / turn_end
319
-
320
- Fired for each turn (one LLM response + tool calls).
321
-
322
- ```typescript
323
- pi.on("turn_start", async (event, ctx) => {
324
- // event.turnIndex, event.timestamp
325
- });
326
-
327
- pi.on("turn_end", async (event, ctx) => {
328
- // event.turnIndex
329
- // event.message - assistant's response
330
- // event.toolResults - tool results from this turn
331
- });
332
- ```
333
-
334
- #### context
335
-
336
- Fired before each LLM call. Modify messages non-destructively (session unchanged).
337
-
338
- ```typescript
339
- pi.on("context", async (event, ctx) => {
340
- // event.messages - deep copy, safe to modify
341
-
342
- // Filter or transform messages
343
- const filtered = event.messages.filter((m) => !shouldPrune(m));
344
- return { messages: filtered };
345
- });
346
- ```
347
-
348
- ### Tool Events
349
-
350
- #### tool_call
351
-
352
- Fired before tool executes. **Can block.**
353
-
354
- ```typescript
355
- pi.on("tool_call", async (event, ctx) => {
356
- // event.toolName - "bash", "read", "write", "edit", etc.
357
- // event.toolCallId
358
- // event.input - tool parameters
359
-
360
- if (shouldBlock(event)) {
361
- return { block: true, reason: "Not allowed" };
362
- }
363
- });
364
- ```
365
-
366
- Tool inputs (common built-ins):
367
-
368
- - `bash`: `{ command, timeout?, cwd?, head?, tail? }`
369
- - `read`: `{ path, offset?, limit?, lines? }`
370
- - `write`: `{ path, content }`
371
- - `edit` (replace mode): `{ path, old_text, new_text, all? }`
372
- - `edit` (patch mode): `{ path, op?, rename?, diff? }`
373
- - `find`: `{ pattern, hidden?, limit? }`
374
- - `grep`: `{ pattern, path?, glob?, type?, i?, pre?, post?, multiline?, limit?, offset? }`
375
-
376
- The edit input shape depends on the current edit variant (replace vs patch). Inspect `event.input` to
377
- see which schema is active.
378
-
379
- Other tools (ask, browser, task, todo_write, fetch, web_search, python, notebook, lsp, ssh, calc) use
380
- their own schemas; inspect the tool prompt or `src/tools/*.ts` for details.
381
-
382
- #### tool_result
383
-
384
- Fired after tool executes (including errors). **Can modify result.**
385
-
386
- Check `event.isError` to distinguish successful executions from failures.
387
-
388
- ```typescript
389
- pi.on("tool_result", async (event, ctx) => {
390
- // event.toolName, event.toolCallId, event.input
391
- // event.content - array of TextContent | ImageContent
392
- // event.details - tool-specific (see below)
393
- // event.isError - true if the tool threw an error
394
-
395
- if (event.isError) {
396
- // Handle error case
397
- }
398
-
399
- // Modify result:
400
- return { content: [...], details: {...}, isError: false };
401
- });
402
- ```
403
-
404
- Use `event.toolName` to narrow tool-specific details:
405
-
406
- ```typescript
407
- pi.on("tool_result", async (event, ctx) => {
408
- if (event.toolName === "bash") {
409
- // event.details is BashToolDetails | undefined
410
- const artifactId = event.details?.meta?.truncation?.artifactId;
411
- if (artifactId) {
412
- // Full output is stored under the artifact ID
413
- }
414
- }
415
- });
416
- ```
417
-
418
- ## HookContext
419
-
420
- Every handler receives `ctx: HookContext`:
421
-
422
- ### ctx.ui
423
-
424
- UI methods for user interaction. Hooks can prompt users and even render custom TUI components.
425
-
426
- **Built-in dialogs:**
427
-
428
- ```typescript
429
- // Select from options
430
- const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
431
- // Returns selected string or undefined if cancelled
432
-
433
- // Confirm dialog
434
- const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
435
- // Returns true or false
436
-
437
- // Text input (single line)
438
- const name = await ctx.ui.input("Name:", "placeholder");
439
- // Returns string or undefined if cancelled
440
-
441
- // Multi-line editor (with Ctrl+G for external editor)
442
- const text = await ctx.ui.editor("Edit prompt:", "prefilled text");
443
- // Returns edited text or undefined if cancelled (Escape)
444
- // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
445
-
446
- // Notification (non-blocking)
447
- ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
448
-
449
- // Set status text in footer (persistent until cleared)
450
- ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
451
- ctx.ui.setStatus("my-hook", undefined); // Clear status
452
-
453
- // Set the core input editor text (pre-fill prompts, generated content)
454
- ctx.ui.setEditorText("Generated prompt text here...");
455
-
456
- // Get current editor text
457
- const currentText = ctx.ui.getEditorText();
458
- ```
459
-
460
- **Status text notes:**
461
-
462
- - Multiple hooks can set their own status using unique keys
463
- - Statuses are displayed on a single line in the footer, sorted alphabetically by key
464
- - Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
465
- - Use `ctx.ui.theme` to style status text with theme colors (see below)
466
-
467
- **Styling with theme colors:**
468
-
469
- Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
470
-
471
- ```typescript
472
- const theme = ctx.ui.theme;
473
-
474
- // Foreground colors
475
- ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + theme.fg("dim", " Ready"));
476
- ctx.ui.setStatus("my-hook", theme.fg("error", "✗") + theme.fg("dim", " Failed"));
477
- ctx.ui.setStatus("my-hook", theme.fg("accent", "●") + theme.fg("dim", " Working..."));
478
-
479
- // Available fg colors: accent, success, error, warning, muted, dim, text, and more
480
- // See docs/theme.md for the full list of theme colors
481
- ```
482
-
483
- See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a complete example.
484
-
485
- **Custom components:**
486
-
487
- Show a custom TUI component with keyboard focus:
488
-
489
- ```typescript
490
- import { BorderedLoader } from "@oh-my-pi/pi-coding-agent";
491
-
492
- const result = await ctx.ui.custom((tui, theme, done) => {
493
- const loader = new BorderedLoader(tui, theme, "Working...");
494
- loader.onAbort = () => done(null);
495
-
496
- doWork(loader.signal)
497
- .then(done)
498
- .catch(() => done(null));
499
-
500
- return loader;
501
- });
502
- ```
503
-
504
- Your component can:
505
-
506
- - Implement `handleInput(data: string)` to receive keyboard input
507
- - Implement `render(width: number): string[]` to render lines
508
- - Implement `invalidate()` to clear cached render
509
- - Implement `dispose()` for cleanup when closed
510
- - Call `tui.requestRender()` to trigger re-render
511
- - Call `done(result)` when done to restore normal UI
512
-
513
- 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.
514
-
515
- ### ctx.hasUI
516
-
517
- `false` in print mode (`-p`) and JSON print mode. RPC mode provides UI via the host, so `ctx.hasUI` is true.
518
- Always check before using `ctx.ui`:
519
-
520
- ```typescript
521
- if (ctx.hasUI) {
522
- const choice = await ctx.ui.select(...);
523
- } else {
524
- // Default behavior
525
- }
526
- ```
527
-
528
- ### ctx.cwd
529
-
530
- Current working directory.
531
-
532
- ### ctx.sessionManager
533
-
534
- Read-only access to session state. See `ReadonlySessionManager` in [`src/session/session-manager.ts`](../src/session/session-manager.ts).
535
-
536
- ```typescript
537
- // Session info
538
- ctx.sessionManager.getCwd(); // Working directory
539
- ctx.sessionManager.getSessionDir(); // Session directory (~/.omp/agent/sessions)
540
- ctx.sessionManager.getSessionId(); // Current session ID
541
- ctx.sessionManager.getSessionFile(); // Session file path (undefined with --no-session)
542
-
543
- // Entries
544
- ctx.sessionManager.getEntries(); // All entries (excludes header)
545
- ctx.sessionManager.getHeader(); // Session header entry
546
- ctx.sessionManager.getEntry(id); // Specific entry by ID
547
- ctx.sessionManager.getLabel(id); // Entry label (if any)
548
-
549
- // Tree navigation
550
- ctx.sessionManager.getBranch(); // Current branch (root to leaf)
551
- ctx.sessionManager.getBranch(leafId); // Specific branch
552
- ctx.sessionManager.getTree(); // Full tree structure
553
- ctx.sessionManager.getLeafId(); // Current leaf entry ID
554
- ctx.sessionManager.getLeafEntry(); // Current leaf entry
555
- ```
556
-
557
- Use `pi.sendMessage()` or `pi.appendEntry()` for writes.
558
-
559
- ### ctx.modelRegistry
560
-
561
- Access to models and API keys:
562
-
563
- ```typescript
564
- // Get API key for a model
565
- const apiKey = await ctx.modelRegistry.getApiKey(model);
566
-
567
- // Get available models
568
- const models = ctx.modelRegistry.getAvailable();
569
- ```
570
-
571
- ### ctx.model
572
-
573
- Current model, or `undefined` if none selected yet. Use for LLM calls in hooks:
574
-
575
- ```typescript
576
- if (ctx.model) {
577
- const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
578
- // Use with @oh-my-pi/pi-ai complete()
579
- }
580
- ```
581
-
582
- ### ctx.isIdle()
583
-
584
- Returns `true` if the agent is not currently streaming:
585
-
586
- ```typescript
587
- if (ctx.isIdle()) {
588
- // Agent is not processing
589
- }
590
- ```
591
-
592
- ### ctx.abort()
593
-
594
- Abort the current agent operation (fire-and-forget, does not wait):
595
-
596
- ```typescript
597
- ctx.abort();
598
- ```
599
-
600
- ### ctx.hasQueuedMessages()
601
-
602
- Check if there are messages queued (user typed while agent was streaming):
603
-
604
- ```typescript
605
- if (ctx.hasQueuedMessages()) {
606
- // Skip interactive prompt, let queued message take over
607
- return;
608
- }
609
- ```
610
-
611
- ## HookCommandContext (Slash Commands Only)
612
-
613
- 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).
614
-
615
- ### ctx.waitForIdle()
616
-
617
- Wait for the agent to finish streaming:
618
-
619
- ```typescript
620
- await ctx.waitForIdle();
621
- // Agent is now idle
622
- ```
623
-
624
- ### ctx.newSession(options?)
625
-
626
- Create a new session, optionally with initialization:
627
-
628
- ```typescript
629
- const result = await ctx.newSession({
630
- parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
631
- setup: async (sm) => {
632
- // Initialize the new session
633
- sm.appendMessage({
634
- role: "user",
635
- content: [{ type: "text", text: "Context from previous session..." }],
636
- timestamp: Date.now(),
637
- });
638
- },
639
- });
640
-
641
- if (result.cancelled) {
642
- // A hook cancelled the new session
643
- }
644
- ```
645
-
646
- ### ctx.branch(entryId)
647
-
648
- Branch from a specific entry, creating a new session file:
649
-
650
- ```typescript
651
- const result = await ctx.branch("entry-id-123");
652
- if (!result.cancelled) {
653
- // Now in the branched session
654
- }
655
- ```
656
-
657
- ### ctx.navigateTree(targetId, options?)
658
-
659
- Navigate to a different point in the session tree:
660
-
661
- ```typescript
662
- const result = await ctx.navigateTree("entry-id-456", {
663
- summarize: true, // Summarize the abandoned branch
664
- });
665
- ```
666
-
667
- ## HookAPI Methods
668
-
669
- ### pi.on(event, handler)
670
-
671
- Subscribe to events. See [Events](#events) for all event types.
672
-
673
- ### pi.sendMessage(message, options?)
674
-
675
- Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context.
676
-
677
- ```typescript
678
- pi.sendMessage(
679
- {
680
- customType: "my-hook", // Your hook's identifier
681
- content: "Message text", // string or (TextContent | ImageContent)[]
682
- display: true, // Show in TUI
683
- details: { ... }, // Optional metadata (not sent to LLM)
684
- },
685
- { triggerTurn: true }, // Trigger a new LLM response if idle
686
- );
687
- ```
688
-
689
- **Storage and timing:**
690
-
691
- - The message is appended to the session file immediately as a `CustomMessageEntry`
692
- - If the agent is currently streaming, the message is queued and appended after the current turn
693
- - If `options.triggerTurn` is true and the agent is idle, a new agent loop starts
694
- - `options.deliverAs` chooses how to enqueue the message (`"steer"` or `"followUp"`)
695
-
696
- **LLM context:**
697
-
698
- - `CustomMessageEntry` is converted to a user message when building context for the LLM
699
- - Only `content` is sent to the LLM; `details` is for rendering/state only
700
-
701
- **TUI display:**
702
-
703
- - If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
704
- - If `display: false`, the message is hidden from the TUI but still sent to the LLM
705
- - Use `pi.registerMessageRenderer()` to customize how your messages render (see below)
706
-
707
- ### pi.appendEntry(customType, data?)
708
-
709
- Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context).
710
-
711
- ```typescript
712
- // Save state
713
- pi.appendEntry("my-hook-state", { count: 42 });
714
-
715
- // Restore on reload
716
- pi.on("session_start", async (_event, ctx) => {
717
- for (const entry of ctx.sessionManager.getEntries()) {
718
- if (entry.type === "custom" && entry.customType === "my-hook-state") {
719
- // Reconstruct from entry.data
720
- }
721
- }
722
- });
723
- ```
724
-
725
- ### pi.registerCommand(name, options)
726
-
727
- Register a custom slash command:
728
-
729
- ```typescript
730
- pi.registerCommand("stats", {
731
- description: "Show session statistics",
732
- handler: async (args, ctx) => {
733
- // args = everything after /stats
734
- const count = ctx.sessionManager.getEntries().length;
735
- ctx.ui.notify(`${count} entries`, "info");
736
- },
737
- });
738
- ```
739
-
740
- For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts).
741
-
742
- To trigger the LLM after a command, call `pi.sendMessage(..., { triggerTurn: true })`.
743
-
744
- ### pi.registerMessageRenderer(customType, renderer)
745
-
746
- 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.
747
-
748
- ```typescript
749
- import { Text } from "@oh-my-pi/pi-tui";
750
-
751
- pi.registerMessageRenderer("my-hook", (message, options, theme) => {
752
- // message.content - the message content (string or content array)
753
- // message.details - your custom metadata
754
- // options.expanded - true if user pressed Ctrl+O
755
-
756
- const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `);
757
- const text =
758
- typeof message.content === "string"
759
- ? message.content
760
- : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join("");
761
-
762
- return new Text(prefix + theme.fg("text", text), 0, 0);
763
- });
764
- ```
765
-
766
- **Renderer signature:**
767
-
768
- ```typescript
769
- type HookMessageRenderer = (
770
- message: HookMessage,
771
- options: { expanded: boolean },
772
- theme: Theme
773
- ) => Component | undefined;
774
- ```
775
-
776
- Return `undefined` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details.
777
-
778
- ### pi.exec(command, args, options?)
779
-
780
- Execute a shell command:
781
-
782
- ```typescript
783
- const result = await pi.exec("git", ["status"], {
784
- signal, // AbortSignal
785
- timeout, // Milliseconds
786
- });
787
-
788
- // result.stdout, result.stderr, result.code, result.killed
789
- ```
790
-
791
- ### pi.logger / pi.typebox / pi.pi
792
-
793
- - `pi.logger` is the shared logger (avoid `console.*` to keep the TUI clean)
794
- - `pi.typebox` exposes `@sinclair/typebox` for schema definitions
795
- - `pi.pi` exposes `@oh-my-pi/pi-coding-agent` exports (components, helpers)
796
-
797
- ## Examples
798
-
799
- ### Permission Gate
800
-
801
- ```typescript
802
- import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
803
-
804
- export default function (pi: HookAPI) {
805
- const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
806
-
807
- pi.on("tool_call", async (event, ctx) => {
808
- if (event.toolName !== "bash") return;
809
-
810
- const cmd = event.input.command as string;
811
- if (dangerous.some((p) => p.test(cmd))) {
812
- if (!ctx.hasUI) {
813
- return { block: true, reason: "Dangerous (no UI)" };
814
- }
815
- const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
816
- if (!ok) return { block: true, reason: "Blocked by user" };
817
- }
818
- });
819
- }
820
- ```
821
-
822
- ### Protected Paths
823
-
824
- ```typescript
825
- import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
826
-
827
- export default function (pi: HookAPI) {
828
- const protectedPaths = [".env", ".git/", "node_modules/"];
829
-
830
- pi.on("tool_call", async (event, ctx) => {
831
- if (event.toolName !== "write" && event.toolName !== "edit") return;
832
-
833
- const path = event.input.path as string;
834
- if (protectedPaths.some((p) => path.includes(p))) {
835
- ctx.ui.notify(`Blocked: ${path}`, "warning");
836
- return { block: true, reason: `Protected: ${path}` };
837
- }
838
- });
839
- }
840
- ```
841
-
842
- ### Git Checkpoint
843
-
844
- ```typescript
845
- import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
846
-
847
- export default function (pi: HookAPI) {
848
- const checkpoints = new Map<string, string>();
849
- let currentEntryId: string | undefined;
850
-
851
- pi.on("tool_result", async (_event, ctx) => {
852
- const leaf = ctx.sessionManager.getLeafEntry();
853
- if (leaf) currentEntryId = leaf.id;
854
- });
855
-
856
- pi.on("turn_start", async () => {
857
- const { stdout } = await pi.exec("git", ["stash", "create"]);
858
- if (stdout.trim() && currentEntryId) {
859
- checkpoints.set(currentEntryId, stdout.trim());
860
- }
861
- });
862
-
863
- pi.on("session_before_branch", async (event, ctx) => {
864
- const ref = checkpoints.get(event.entryId);
865
- if (!ref || !ctx.hasUI) return;
866
-
867
- const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?");
868
- if (ok) {
869
- await pi.exec("git", ["stash", "apply", ref]);
870
- ctx.ui.notify("Code restored", "info");
871
- }
872
- });
873
-
874
- pi.on("agent_end", () => checkpoints.clear());
875
- }
876
- ```
877
-
878
- ### Custom Command
879
-
880
- See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence.
881
-
882
- ## Mode Behavior
883
-
884
- | Mode | UI Methods | Notes |
885
- | --------------- | -------------------------- | ------------------------------------------ |
886
- | Interactive | Full TUI | Normal operation |
887
- | RPC | UI via RPC | Host handles UI, `ctx.hasUI` is true |
888
- | Print (`-p`) | No-op (returns undefined/false) | Hooks run but can't prompt (`ctx.hasUI`=false) |
889
-
890
- In print mode (including JSON output), `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns
891
- `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this
892
- by checking `ctx.hasUI`.
893
-
894
- ## Error Handling
895
-
896
- - Hook errors are logged, agent continues
897
- - `tool_call` errors block the tool (fail-safe)
898
- - Errors display in UI with hook path and message
899
- - If a hook hangs, use Ctrl+C to abort
900
-
901
- ## Debugging
902
-
903
- 1. Open VS Code in hooks directory
904
- 2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal")
905
- 3. Set breakpoints
906
- 4. Run `omp --hook ./my-hook.ts`