mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
Merge hooks and custom-tools into unified extensions system (#454)
Breaking changes: - Settings: 'hooks' and 'customTools' arrays replaced with 'extensions' - CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e' - API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom' - API: FileSlashCommand renamed to PromptTemplate - API: discoverSlashCommands() renamed to discoverPromptTemplates() - Directories: commands/ renamed to prompts/ for prompt templates Migration: - Session version bumped to 3 (auto-migrates v2 sessions) - Old 'hookMessage' role entries converted to 'custom' Structural changes: - src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/ - src/core/slash-commands.ts renamed to src/core/prompt-templates.ts - examples/hooks/ and examples/custom-tools/ merged into examples/extensions/ - docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md New test coverage: - test/extensions-runner.test.ts (10 tests) - test/extensions-discovery.test.ts (26 tests) - test/prompt-templates.test.ts
This commit is contained in:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
|
|
@ -1,550 +0,0 @@
|
|||
> pi can create custom tools. Ask it to build one for your use case.
|
||||
|
||||
# Custom Tools
|
||||
|
||||
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
|
||||
|
||||
**Key capabilities:**
|
||||
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
|
||||
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
|
||||
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
|
||||
- **State management** - Persist state in tool result `details` for proper branching support
|
||||
- **Streaming results** - Send partial updates via `onUpdate` callback
|
||||
|
||||
**Example use cases:**
|
||||
- Interactive dialogs (questions with selectable options)
|
||||
- Stateful tools (todo lists, connection pools)
|
||||
- Rich output rendering (progress indicators, structured views)
|
||||
- External service integrations with confirmation flows
|
||||
|
||||
**When to use custom tools vs. alternatives:**
|
||||
|
||||
| Need | Solution |
|
||||
|------|----------|
|
||||
| Always-needed context (conventions, commands) | AGENTS.md |
|
||||
| User triggers a specific prompt template | Slash command |
|
||||
| On-demand capability package (workflows, scripts, setup) | Skill |
|
||||
| Additional tool directly callable by the LLM | **Custom tool** |
|
||||
|
||||
See [examples/custom-tools/](../examples/custom-tools/) for working examples.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a file `~/.pi/agent/tools/hello/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "hello",
|
||||
label: "Hello",
|
||||
description: "A simple greeting tool",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default factory;
|
||||
```
|
||||
|
||||
The tool is automatically discovered and available in your next pi session.
|
||||
|
||||
## Tool Locations
|
||||
|
||||
Tools must be in a subdirectory with an `index.ts` entry point:
|
||||
|
||||
| Location | Scope | Auto-discovered |
|
||||
|----------|-------|-----------------|
|
||||
| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes |
|
||||
| `.pi/tools/*/index.ts` | Project-local | Yes |
|
||||
| `settings.json` `customTools` array | Configured paths | Yes |
|
||||
| `--tool <path>` CLI flag | One-off/debugging | No |
|
||||
|
||||
**Example structure:**
|
||||
```
|
||||
~/.pi/agent/tools/
|
||||
├── hello/
|
||||
│ └── index.ts # Entry point (auto-discovered)
|
||||
└── complex-tool/
|
||||
├── index.ts # Entry point (auto-discovered)
|
||||
├── helpers.ts # Helper module (not loaded directly)
|
||||
└── types.ts # Type definitions (not loaded directly)
|
||||
```
|
||||
|
||||
**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
|
||||
|
||||
**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
|
||||
|
||||
## Available Imports
|
||||
|
||||
Custom tools can import from these packages (automatically resolved by pi):
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) |
|
||||
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
|
||||
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
|
||||
|
||||
Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
|
||||
|
||||
## Tool Definition
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "What this tool does (be specific for LLM)",
|
||||
parameters: Type.Object({
|
||||
// Use StringEnum for string enums (Google API compatible)
|
||||
action: StringEnum(["list", "add", "remove"] as const),
|
||||
text: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// signal - AbortSignal for cancellation
|
||||
// onUpdate - Callback for streaming partial results
|
||||
// ctx - CustomToolContext with sessionManager, modelRegistry, model
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: { /* structured data for rendering */ },
|
||||
};
|
||||
},
|
||||
|
||||
// Optional: Session lifecycle callback
|
||||
onSession(event, ctx) {
|
||||
if (event.reason === "shutdown") {
|
||||
// Cleanup resources (close connections, save state, etc.)
|
||||
return;
|
||||
}
|
||||
// Reconstruct state from ctx.sessionManager.getBranch()
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { /* return Component */ },
|
||||
renderResult(result, options, theme) { /* return Component */ },
|
||||
});
|
||||
|
||||
export default factory;
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
## CustomToolAPI Object
|
||||
|
||||
The factory receives a `CustomToolAPI` object (named `pi` by convention):
|
||||
|
||||
```typescript
|
||||
interface CustomToolAPI {
|
||||
cwd: string; // Current working directory
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
ui: ToolUIContext;
|
||||
hasUI: boolean; // false in --print or --mode rpc
|
||||
events: EventBus; // Shared event bus for tool/hook communication
|
||||
sendMessage(message, options?): void; // Send messages to the agent session
|
||||
}
|
||||
|
||||
interface ToolUIContext {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
signal?: AbortSignal; // Cancel the process
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
killed?: boolean; // True if process was killed by signal/timeout
|
||||
}
|
||||
```
|
||||
|
||||
Always check `pi.hasUI` before using UI methods.
|
||||
|
||||
### Event Bus
|
||||
|
||||
Tools can emit events that hooks (or other tools) listen for via `pi.events`:
|
||||
|
||||
```typescript
|
||||
// Emit an event
|
||||
pi.events.emit("mytool:completed", { result: "success", itemCount: 42 });
|
||||
|
||||
// Listen for events (tools can also subscribe)
|
||||
const unsubscribe = pi.events.on("other:event", (data) => {
|
||||
console.log("Received:", data);
|
||||
});
|
||||
```
|
||||
|
||||
Event handlers persist across session switches (registered once at tool load time). Use namespaced channel names like `"toolname:event"` to avoid collisions. Handler errors (sync and async) are caught and logged.
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Tools can send messages to the agent session via `pi.sendMessage()`:
|
||||
|
||||
```typescript
|
||||
pi.sendMessage({
|
||||
customType: "mytool-notify",
|
||||
content: "Configuration was updated",
|
||||
display: true,
|
||||
}, {
|
||||
deliverAs: "nextTurn",
|
||||
});
|
||||
```
|
||||
|
||||
**Delivery modes:** `"steer"` (default) interrupts streaming, `"followUp"` waits for completion, `"nextTurn"` queues for next user message. Use `triggerTurn: true` to wake an idle agent immediately.
|
||||
|
||||
See [hooks documentation](hooks.md#pisendmessagemessage-options) for full details.
|
||||
|
||||
### Cancellation Example
|
||||
|
||||
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const result = await pi.exec("long-running-command", ["arg"], { signal });
|
||||
if (result.killed) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.stdout }] };
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Throw an error** when the tool fails. Do not return an error message as content.
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { path } = params as { path: string };
|
||||
|
||||
// Throw on error - pi will catch it and report to the LLM
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
// Return content only on success
|
||||
return { content: [{ type: "text", text: "Success" }] };
|
||||
}
|
||||
```
|
||||
|
||||
Thrown errors are:
|
||||
- Reported to the LLM as tool errors (with `isError: true`)
|
||||
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
|
||||
- Displayed in the TUI with error styling
|
||||
|
||||
## CustomToolContext
|
||||
|
||||
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
||||
|
||||
```typescript
|
||||
interface CustomToolContext {
|
||||
sessionManager: ReadonlySessionManager; // Read-only access to session
|
||||
modelRegistry: ModelRegistry; // For API key resolution
|
||||
model: Model | undefined; // Current model (may be undefined)
|
||||
isIdle(): boolean; // Whether agent is streaming
|
||||
hasQueuedMessages(): boolean; // Whether user has queued messages
|
||||
abort(): void; // Abort current operation (fire-and-forget)
|
||||
}
|
||||
```
|
||||
|
||||
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
|
||||
|
||||
### Checking Queue State
|
||||
|
||||
Interactive tools can skip prompts when the user has already queued a message:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// If user already queued a message, skip the interactive prompt
|
||||
if (ctx.hasQueuedMessages()) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Skipped - user has queued input" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, prompt for input
|
||||
const answer = await pi.ui.input("What would you like to do?");
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-line Editor
|
||||
|
||||
For longer text editing, use `pi.ui.editor()` which supports Ctrl+G for external editor:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const text = await pi.ui.editor("Edit your response:", "prefilled text");
|
||||
// Returns edited text or undefined if cancelled (Escape)
|
||||
// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
|
||||
|
||||
if (!text) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
Tools can implement `onSession` to react to session changes:
|
||||
|
||||
```typescript
|
||||
interface CustomToolSessionEvent {
|
||||
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
**Reasons:**
|
||||
- `start`: Initial session load on startup
|
||||
- `switch`: User started a new session (`/new`) or switched to a different session (`/resume`)
|
||||
- `branch`: User branched from a previous message (`/branch`)
|
||||
- `tree`: User navigated to a different point in the session tree (`/tree`)
|
||||
- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
|
||||
|
||||
To check if a session is fresh (no messages), use `ctx.sessionManager.getEntries().length === 0`.
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
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.
|
||||
|
||||
```typescript
|
||||
interface MyToolDetails {
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
// In-memory state
|
||||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session entries
|
||||
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") return;
|
||||
|
||||
items = [];
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (msg.role !== "toolResult") continue;
|
||||
if (msg.toolName !== "my_tool") continue;
|
||||
|
||||
const details = msg.details as MyToolDetails | undefined;
|
||||
if (details) {
|
||||
items = details.items;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "...",
|
||||
parameters: Type.Object({ ... }),
|
||||
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Modify items...
|
||||
items.push("new item");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Added item" }],
|
||||
// Store current state in details for reconstruction
|
||||
details: { items: [...items] },
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
This pattern ensures:
|
||||
- When user branches, state is correct for that point in history
|
||||
- When user switches sessions, state matches that session
|
||||
- When user starts a new session, state resets
|
||||
|
||||
## Custom Rendering
|
||||
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API.
|
||||
|
||||
### How It Works
|
||||
|
||||
Tool output is wrapped in a `Box` component that handles:
|
||||
- Padding (1 character horizontal, 1 line vertical)
|
||||
- Background color based on state (pending/success/error)
|
||||
|
||||
Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding.
|
||||
|
||||
### renderCall
|
||||
|
||||
Renders the tool call (before/during execution):
|
||||
|
||||
```typescript
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("my_tool "));
|
||||
text += theme.fg("muted", args.action);
|
||||
if (args.text) {
|
||||
text += " " + theme.fg("dim", `"${args.text}"`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
Called when:
|
||||
- Tool call starts (may have partial args during streaming)
|
||||
- Args are updated during streaming
|
||||
|
||||
### renderResult
|
||||
|
||||
Renders the tool result:
|
||||
|
||||
```typescript
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
const { details } = result;
|
||||
|
||||
// Handle streaming/partial results
|
||||
if (isPartial) {
|
||||
return new Text(theme.fg("warning", "Processing..."), 0, 0);
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (details?.error) {
|
||||
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
||||
}
|
||||
|
||||
// Normal result
|
||||
let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");
|
||||
|
||||
// Support expanded view (Ctrl+O)
|
||||
if (expanded && details?.items) {
|
||||
for (const item of details.items) {
|
||||
text += "\n" + theme.fg("dim", ` ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `expanded`: User pressed Ctrl+O to expand
|
||||
- `isPartial`: Result is from `onUpdate` (streaming), not final
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use `Text` with padding `(0, 0)`** - The Box handles padding
|
||||
2. **Use `\n` for multi-line content** - Not multiple Text components
|
||||
3. **Handle `isPartial`** - Show progress during streaming
|
||||
4. **Support `expanded`** - Show more detail when user requests
|
||||
5. **Use theme colors** - For consistent appearance
|
||||
6. **Keep it compact** - Show summary by default, details when expanded
|
||||
|
||||
### Theme Colors
|
||||
|
||||
```typescript
|
||||
// Foreground
|
||||
theme.fg("toolTitle", text) // Tool names
|
||||
theme.fg("accent", text) // Highlights
|
||||
theme.fg("success", text) // Success
|
||||
theme.fg("error", text) // Errors
|
||||
theme.fg("warning", text) // Warnings
|
||||
theme.fg("muted", text) // Secondary text
|
||||
theme.fg("dim", text) // Tertiary text
|
||||
theme.fg("toolOutput", text) // Output content
|
||||
|
||||
// Styles
|
||||
theme.bold(text)
|
||||
theme.italic(text)
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If `renderCall` or `renderResult` is not defined or throws an error:
|
||||
- `renderCall`: Shows tool name
|
||||
- `renderResult`: Shows raw text output from `content`
|
||||
|
||||
## Execute Function
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, args, onUpdate, ctx, signal) {
|
||||
// Type assertion for params (TypeBox schema doesn't flow through)
|
||||
const params = args as { action: "list" | "add"; text?: string };
|
||||
|
||||
// Check for abort
|
||||
if (signal?.aborted) {
|
||||
return { content: [...], details: { status: "aborted" } };
|
||||
}
|
||||
|
||||
// Stream progress
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: "Working..." }],
|
||||
details: { progress: 50 },
|
||||
});
|
||||
|
||||
// Return final result
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }], // Sent to LLM
|
||||
details: { data: result }, // For rendering only
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Tools from One File
|
||||
|
||||
Return an array to share state between related tools:
|
||||
|
||||
```typescript
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
// Shared state
|
||||
let connection = null;
|
||||
|
||||
const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") {
|
||||
connection?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "db_connect", onSession: handleSession, ... },
|
||||
{ name: "db_query", onSession: handleSession, ... },
|
||||
{ name: "db_close", onSession: handleSession, ... },
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See [`examples/custom-tools/todo/index.ts`](../examples/custom-tools/todo/index.ts) for a complete example with:
|
||||
- `onSession` for state reconstruction
|
||||
- Custom `renderCall` and `renderResult`
|
||||
- Proper branching support via details storage
|
||||
|
||||
Test with:
|
||||
```bash
|
||||
pi --tool packages/coding-agent/examples/custom-tools/todo/index.ts
|
||||
```
|
||||
698
packages/coding-agent/docs/extensions.md
Normal file
698
packages/coding-agent/docs/extensions.md
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
> pi can create extensions. Ask it to build one for your use case.
|
||||
|
||||
# Extensions
|
||||
|
||||
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.
|
||||
|
||||
**Key capabilities:**
|
||||
- **Custom tools** - Register tools the LLM can call via `pi.registerTool()`
|
||||
- **Event interception** - Block or modify tool calls, inject context, customize compaction
|
||||
- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
|
||||
- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()`
|
||||
- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()`
|
||||
- **Session persistence** - Store state that survives restarts via `pi.appendEntry()`
|
||||
- **Custom rendering** - Control how tool calls/results and messages appear in TUI
|
||||
|
||||
**Example use cases:**
|
||||
- Permission gates (confirm before `rm -rf`, `sudo`, etc.)
|
||||
- Git checkpointing (stash at each turn, restore on `/branch`)
|
||||
- Path protection (block writes to `.env`, `node_modules/`)
|
||||
- Interactive tools (questions, wizards, custom dialogs)
|
||||
- Stateful tools (todo lists, connection pools)
|
||||
- External integrations (file watchers, webhooks, CI triggers)
|
||||
|
||||
See [examples/extensions/](../examples/extensions/) and [examples/hooks/](../examples/hooks/) for working implementations.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create `~/.pi/agent/extensions/my-extension.ts`:
|
||||
|
||||
```typescript
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// React to events
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
ctx.ui.notify("Extension loaded!", "info");
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
});
|
||||
|
||||
// Register a custom tool
|
||||
pi.registerTool({
|
||||
name: "greet",
|
||||
label: "Greet",
|
||||
description: "Greet someone by name",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register a command
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify(`Hello ${args || "world"}!`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Test with `--extension` (or `-e`) flag:
|
||||
|
||||
```bash
|
||||
pi -e ./my-extension.ts
|
||||
```
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-discovered from:
|
||||
|
||||
| Location | Scope |
|
||||
|----------|-------|
|
||||
| `~/.pi/agent/extensions/*.ts` | Global (all projects) |
|
||||
| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
|
||||
| `.pi/extensions/*.ts` | Project-local |
|
||||
| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
|
||||
|
||||
Additional paths via `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extensions": ["/path/to/extension.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
**Subdirectory structure with package.json:**
|
||||
|
||||
```
|
||||
~/.pi/agent/extensions/
|
||||
├── simple.ts # Direct file (auto-discovered)
|
||||
└── complex-extension/
|
||||
├── package.json # Optional: { "pi": { "extensions": ["./src/main.ts"] } }
|
||||
├── index.ts # Entry point (if no package.json)
|
||||
└── src/
|
||||
└── main.ts # Custom entry (via package.json)
|
||||
```
|
||||
|
||||
## Available Imports
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
|
||||
| `@sinclair/typebox` | Schema definitions for tool parameters |
|
||||
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
|
||||
| `@mariozechner/pi-tui` | TUI components for custom rendering |
|
||||
|
||||
Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
|
||||
|
||||
## Writing an Extension
|
||||
|
||||
An extension exports a default function that receives `ExtensionAPI`:
|
||||
|
||||
```typescript
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Subscribe to events
|
||||
pi.on("event_name", async (event, ctx) => {
|
||||
// Handle event
|
||||
});
|
||||
|
||||
// Register tools, commands, shortcuts, flags
|
||||
pi.registerTool({ ... });
|
||||
pi.registerCommand("name", { ... });
|
||||
pi.registerShortcut("ctrl+x", { ... });
|
||||
pi.registerFlag("--my-flag", { ... });
|
||||
}
|
||||
```
|
||||
|
||||
Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
|
||||
|
||||
## Events
|
||||
|
||||
### Lifecycle Overview
|
||||
|
||||
```
|
||||
pi starts
|
||||
│
|
||||
└─► session_start
|
||||
│
|
||||
▼
|
||||
user sends prompt ─────────────────────────────────────────┐
|
||||
│ │
|
||||
├─► before_agent_start (can inject message, append to system prompt)
|
||||
├─► agent_start │
|
||||
│ │
|
||||
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
|
||||
│ │ │ │
|
||||
│ ├─► turn_start │ │
|
||||
│ ├─► context (can modify messages) │ │
|
||||
│ │ │ │
|
||||
│ │ LLM responds, may call tools: │ │
|
||||
│ │ ├─► tool_call (can block) │ │
|
||||
│ │ │ tool executes │ │
|
||||
│ │ └─► tool_result (can modify) │ │
|
||||
│ │ │ │
|
||||
│ └─► turn_end │ │
|
||||
│ │
|
||||
└─► agent_end │
|
||||
│
|
||||
user sends another prompt ◄────────────────────────────────┘
|
||||
|
||||
/new (new session) or /resume (switch session)
|
||||
├─► session_before_switch (can cancel)
|
||||
└─► session_switch
|
||||
|
||||
/branch
|
||||
├─► session_before_branch (can cancel)
|
||||
└─► session_branch
|
||||
|
||||
/compact or auto-compaction
|
||||
├─► session_before_compact (can cancel or customize)
|
||||
└─► session_compact
|
||||
|
||||
/tree navigation
|
||||
├─► session_before_tree (can cancel or customize)
|
||||
└─► session_tree
|
||||
|
||||
exit (Ctrl+C, Ctrl+D)
|
||||
└─► session_shutdown
|
||||
```
|
||||
|
||||
### Session Events
|
||||
|
||||
#### session_start
|
||||
|
||||
Fired on initial session load.
|
||||
|
||||
```typescript
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
|
||||
});
|
||||
```
|
||||
|
||||
#### session_before_switch / session_switch
|
||||
|
||||
Fired when starting a new session (`/new`) or switching sessions (`/resume`).
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_switch", async (event, ctx) => {
|
||||
// event.reason - "new" or "resume"
|
||||
// event.targetSessionFile - session we're switching to (only for "resume")
|
||||
|
||||
if (event.reason === "new") {
|
||||
const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
|
||||
if (!ok) return { cancel: true };
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (event, ctx) => {
|
||||
// event.reason - "new" or "resume"
|
||||
// event.previousSessionFile - session we came from
|
||||
});
|
||||
```
|
||||
|
||||
#### session_before_branch / session_branch
|
||||
|
||||
Fired when branching via `/branch`.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
// event.entryId - ID of the entry being branched from
|
||||
return { cancel: true }; // Cancel branch
|
||||
// OR
|
||||
return { skipConversationRestore: true }; // Branch but don't rewind messages
|
||||
});
|
||||
|
||||
pi.on("session_branch", async (event, ctx) => {
|
||||
// event.previousSessionFile - previous session file
|
||||
});
|
||||
```
|
||||
|
||||
#### session_before_compact / session_compact
|
||||
|
||||
Fired on compaction. See [compaction.md](compaction.md) for details.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, branchEntries, customInstructions, signal } = event;
|
||||
|
||||
// Cancel:
|
||||
return { cancel: true };
|
||||
|
||||
// Custom summary:
|
||||
return {
|
||||
compaction: {
|
||||
summary: "...",
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("session_compact", async (event, ctx) => {
|
||||
// event.compactionEntry - the saved compaction
|
||||
// event.fromExtension - whether extension provided it
|
||||
});
|
||||
```
|
||||
|
||||
#### session_before_tree / session_tree
|
||||
|
||||
Fired on `/tree` navigation.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
const { preparation, signal } = event;
|
||||
return { cancel: true };
|
||||
// OR provide custom summary:
|
||||
return { summary: { summary: "...", details: {} } };
|
||||
});
|
||||
|
||||
pi.on("session_tree", async (event, ctx) => {
|
||||
// event.newLeafId, oldLeafId, summaryEntry, fromExtension
|
||||
});
|
||||
```
|
||||
|
||||
#### session_shutdown
|
||||
|
||||
Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
|
||||
|
||||
```typescript
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
// Cleanup, save state, etc.
|
||||
});
|
||||
```
|
||||
|
||||
### Agent Events
|
||||
|
||||
#### before_agent_start
|
||||
|
||||
Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.
|
||||
|
||||
```typescript
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
// event.prompt - user's prompt text
|
||||
// event.images - attached images (if any)
|
||||
|
||||
return {
|
||||
// Inject a persistent message (stored in session, sent to LLM)
|
||||
message: {
|
||||
customType: "my-extension",
|
||||
content: "Additional context for the LLM",
|
||||
display: true,
|
||||
},
|
||||
// Append to system prompt for this turn only
|
||||
systemPromptAppend: "Extra instructions for this turn...",
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### agent_start / agent_end
|
||||
|
||||
Fired once per user prompt.
|
||||
|
||||
```typescript
|
||||
pi.on("agent_start", async (_event, ctx) => {});
|
||||
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
// event.messages - messages from this prompt
|
||||
});
|
||||
```
|
||||
|
||||
#### turn_start / turn_end
|
||||
|
||||
Fired for each turn (one LLM response + tool calls).
|
||||
|
||||
```typescript
|
||||
pi.on("turn_start", async (event, ctx) => {
|
||||
// event.turnIndex, event.timestamp
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
// event.turnIndex, event.message, event.toolResults
|
||||
});
|
||||
```
|
||||
|
||||
#### context
|
||||
|
||||
Fired before each LLM call. Modify messages non-destructively.
|
||||
|
||||
```typescript
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// event.messages - deep copy, safe to modify
|
||||
const filtered = event.messages.filter(m => !shouldPrune(m));
|
||||
return { messages: filtered };
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
#### tool_call
|
||||
|
||||
Fired before tool executes. **Can block.**
|
||||
|
||||
```typescript
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
// event.toolName - "bash", "read", "write", "edit", etc.
|
||||
// event.toolCallId
|
||||
// event.input - tool parameters
|
||||
|
||||
if (shouldBlock(event)) {
|
||||
return { block: true, reason: "Not allowed" };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### tool_result
|
||||
|
||||
Fired after tool executes. **Can modify result.**
|
||||
|
||||
```typescript
|
||||
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// event.toolName, event.toolCallId, event.input
|
||||
// event.content, event.details, event.isError
|
||||
|
||||
if (isBashToolResult(event)) {
|
||||
// event.details is typed as BashToolDetails
|
||||
}
|
||||
|
||||
// Modify result:
|
||||
return { content: [...], details: {...}, isError: false };
|
||||
});
|
||||
```
|
||||
|
||||
## ExtensionContext
|
||||
|
||||
Every handler receives `ctx: ExtensionContext`:
|
||||
|
||||
### ctx.ui
|
||||
|
||||
UI methods for user interaction:
|
||||
|
||||
```typescript
|
||||
// Select from options
|
||||
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
|
||||
|
||||
// Confirm dialog
|
||||
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
|
||||
|
||||
// Text input
|
||||
const name = await ctx.ui.input("Name:", "placeholder");
|
||||
|
||||
// Multi-line editor
|
||||
const text = await ctx.ui.editor("Edit:", "prefilled text");
|
||||
|
||||
// Notification
|
||||
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
||||
|
||||
// Status in footer
|
||||
ctx.ui.setStatus("my-ext", "Processing...");
|
||||
ctx.ui.setStatus("my-ext", undefined); // Clear
|
||||
|
||||
// Widget above editor
|
||||
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
|
||||
ctx.ui.setWidget("my-widget", undefined); // Clear
|
||||
|
||||
// Terminal title
|
||||
ctx.ui.setTitle("pi - my-project");
|
||||
|
||||
// Editor text
|
||||
ctx.ui.setEditorText("Prefill text");
|
||||
const current = ctx.ui.getEditorText();
|
||||
```
|
||||
|
||||
**Custom components:**
|
||||
|
||||
```typescript
|
||||
const result = await ctx.ui.custom((tui, theme, done) => {
|
||||
const component = new MyComponent();
|
||||
component.onComplete = (value) => done(value);
|
||||
return component;
|
||||
});
|
||||
```
|
||||
|
||||
### ctx.hasUI
|
||||
|
||||
`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`.
|
||||
|
||||
### ctx.cwd
|
||||
|
||||
Current working directory.
|
||||
|
||||
### ctx.sessionManager
|
||||
|
||||
Read-only access to session state:
|
||||
|
||||
```typescript
|
||||
ctx.sessionManager.getEntries() // All entries
|
||||
ctx.sessionManager.getBranch() // Current branch
|
||||
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||
```
|
||||
|
||||
### ctx.modelRegistry / ctx.model
|
||||
|
||||
Access to models and API keys.
|
||||
|
||||
### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
|
||||
|
||||
Control flow helpers.
|
||||
|
||||
## ExtensionCommandContext
|
||||
|
||||
Slash command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with:
|
||||
|
||||
```typescript
|
||||
await ctx.waitForIdle(); // Wait for agent to finish
|
||||
await ctx.newSession({ ... }); // Create new session
|
||||
await ctx.branch(entryId); // Branch from entry
|
||||
await ctx.navigateTree(targetId); // Navigate tree
|
||||
```
|
||||
|
||||
## ExtensionAPI Methods
|
||||
|
||||
### pi.on(event, handler)
|
||||
|
||||
Subscribe to events. See [Events](#events).
|
||||
|
||||
### pi.registerTool(definition)
|
||||
|
||||
Register a custom tool callable by the LLM:
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "What this tool does",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add"] as const),
|
||||
text: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Stream progress
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: "Working..." }],
|
||||
details: { progress: 50 },
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
details: { result: "..." },
|
||||
};
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) {
|
||||
return new Text(theme.fg("toolTitle", "my_tool ") + args.action, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text("Working...", 0, 0);
|
||||
return new Text(theme.fg("success", "✓ Done"), 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums (Google API compatible).
|
||||
|
||||
### pi.sendMessage(message, options?)
|
||||
|
||||
Inject a message into the session:
|
||||
|
||||
```typescript
|
||||
pi.sendMessage({
|
||||
customType: "my-extension",
|
||||
content: "Message text",
|
||||
display: true,
|
||||
details: { ... },
|
||||
}, {
|
||||
triggerTurn: true, // Trigger LLM response if idle
|
||||
deliverAs: "steer", // "steer", "followUp", or "nextTurn"
|
||||
});
|
||||
```
|
||||
|
||||
### pi.appendEntry(customType, data?)
|
||||
|
||||
Persist extension state (does NOT participate in LLM context):
|
||||
|
||||
```typescript
|
||||
pi.appendEntry("my-state", { count: 42 });
|
||||
|
||||
// Restore on reload
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type === "custom" && entry.customType === "my-state") {
|
||||
// Reconstruct from entry.data
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### pi.registerCommand(name, options)
|
||||
|
||||
Register a command:
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("stats", {
|
||||
description: "Show session statistics",
|
||||
handler: async (args, ctx) => {
|
||||
const count = ctx.sessionManager.getEntries().length;
|
||||
ctx.ui.notify(`${count} entries`, "info");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### pi.registerMessageRenderer(customType, renderer)
|
||||
|
||||
Register a custom TUI renderer for messages with your `customType`:
|
||||
|
||||
```typescript
|
||||
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
|
||||
return new Text(theme.fg("accent", `[INFO] `) + message.content, 0, 0);
|
||||
});
|
||||
```
|
||||
|
||||
### pi.registerShortcut(shortcut, options)
|
||||
|
||||
Register a keyboard shortcut:
|
||||
|
||||
```typescript
|
||||
pi.registerShortcut("ctrl+shift+p", {
|
||||
description: "Toggle plan mode",
|
||||
handler: async (ctx) => {
|
||||
ctx.ui.notify("Toggled!");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### pi.registerFlag(name, options)
|
||||
|
||||
Register a CLI flag:
|
||||
|
||||
```typescript
|
||||
pi.registerFlag("--plan", {
|
||||
description: "Start in plan mode",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
// Check value
|
||||
if (pi.getFlag("--plan")) {
|
||||
// Plan mode enabled
|
||||
}
|
||||
```
|
||||
|
||||
### pi.exec(command, args, options?)
|
||||
|
||||
Execute a shell command:
|
||||
|
||||
```typescript
|
||||
const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
|
||||
// result.stdout, result.stderr, result.code, result.killed
|
||||
```
|
||||
|
||||
### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
|
||||
|
||||
Manage active tools:
|
||||
|
||||
```typescript
|
||||
const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
|
||||
pi.setActiveTools(["read", "bash"]); // Switch to read-only
|
||||
```
|
||||
|
||||
### pi.events
|
||||
|
||||
Shared event bus for communication between extensions:
|
||||
|
||||
```typescript
|
||||
pi.events.on("my:event", (data) => { ... });
|
||||
pi.events.emit("my:event", { ... });
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Extensions with state should store it in tool result `details` for proper branching support:
|
||||
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
items = [];
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "toolResult") {
|
||||
if (entry.message.toolName === "my_tool") {
|
||||
items = entry.message.details?.items ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
// ...
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
items.push("new item");
|
||||
return {
|
||||
content: [{ type: "text", text: "Added" }],
|
||||
details: { items: [...items] }, // Store for reconstruction
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Extension errors are logged, agent continues
|
||||
- `tool_call` errors block the tool (fail-safe)
|
||||
- Tool `execute` errors are reported to the LLM with `isError: true`
|
||||
|
||||
## Mode Behavior
|
||||
|
||||
| Mode | UI Methods | Notes |
|
||||
|------|-----------|-------|
|
||||
| Interactive | Full TUI | Normal operation |
|
||||
| RPC | JSON protocol | Host handles UI |
|
||||
| Print (`-p`) | No-op | Extensions run but can't prompt |
|
||||
|
||||
In print mode, check `ctx.hasUI` before using UI methods.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -52,9 +52,9 @@ With images:
|
|||
|
||||
If the agent is streaming and no `streamingBehavior` is specified, the command returns an error.
|
||||
|
||||
**Hook commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Hook commands manage their own LLM interaction via `pi.sendMessage()`.
|
||||
**Extension commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`.
|
||||
|
||||
**Slash commands**: File-based slash commands (from `.md` files) are expanded before sending/queueing.
|
||||
**Prompt templates**: File-based prompt templates (from `.md` files) are expanded before sending/queueing.
|
||||
|
||||
Response:
|
||||
```json
|
||||
|
|
@ -65,7 +65,7 @@ The `images` field is optional. Each image uses `ImageContent` format with base6
|
|||
|
||||
#### steer
|
||||
|
||||
Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead).
|
||||
Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
|
||||
|
||||
```json
|
||||
{"type": "steer", "message": "Stop and do this instead"}
|
||||
|
|
@ -80,7 +80,7 @@ See [set_steering_mode](#set_steering_mode) for controlling how steering message
|
|||
|
||||
#### follow_up
|
||||
|
||||
Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead).
|
||||
Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
|
||||
|
||||
```json
|
||||
{"type": "follow_up", "message": "After you're done, also do this"}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ interface AgentSession {
|
|||
|
||||
### Prompting and Message Queueing
|
||||
|
||||
The `prompt()` method handles slash commands, hook commands, and message sending:
|
||||
The `prompt()` method handles prompt templates, extension commands, and message sending:
|
||||
|
||||
```typescript
|
||||
// Basic prompt (when not streaming)
|
||||
|
|
@ -146,8 +146,8 @@ await session.prompt("After you're done, also check X", { streamingBehavior: "fo
|
|||
```
|
||||
|
||||
**Behavior:**
|
||||
- **Hook commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`.
|
||||
- **File-based slash commands** (from `.md` files): Expanded to their content before sending/queueing.
|
||||
- **Extension commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`.
|
||||
- **File-based prompt templates** (from `.md` files): Expanded to their content before sending/queueing.
|
||||
- **During streaming without `streamingBehavior`**: Throws an error. Use `steer()` or `followUp()` directly, or specify the option.
|
||||
|
||||
For explicit queueing during streaming:
|
||||
|
|
@ -160,7 +160,7 @@ await session.steer("New instruction");
|
|||
await session.followUp("After you're done, also do this");
|
||||
```
|
||||
|
||||
Both `steer()` and `followUp()` expand file-based slash commands but error on hook commands (hook commands cannot be queued).
|
||||
Both `steer()` and `followUp()` expand file-based prompt templates but error on extension commands (extension commands cannot be queued).
|
||||
|
||||
### Agent and AgentState
|
||||
|
||||
|
|
@ -260,18 +260,16 @@ const { session } = await createAgentSession({
|
|||
```
|
||||
|
||||
`cwd` is used for:
|
||||
- Project hooks (`.pi/hooks/`)
|
||||
- Project tools (`.pi/tools/`)
|
||||
- Project extensions (`.pi/extensions/`)
|
||||
- Project skills (`.pi/skills/`)
|
||||
- Project commands (`.pi/commands/`)
|
||||
- Project prompts (`.pi/prompts/`)
|
||||
- Context files (`AGENTS.md` walking up from cwd)
|
||||
- Session directory naming
|
||||
|
||||
`agentDir` is used for:
|
||||
- Global hooks (`hooks/`)
|
||||
- Global tools (`tools/`)
|
||||
- Global extensions (`extensions/`)
|
||||
- Global skills (`skills/`)
|
||||
- Global commands (`commands/`)
|
||||
- Global prompts (`prompts/`)
|
||||
- Global context file (`AGENTS.md`)
|
||||
- Settings (`settings.json`)
|
||||
- Custom models (`models.json`)
|
||||
|
|
@ -502,7 +500,7 @@ const loggingHook: HookFactory = (api) => {
|
|||
return undefined;
|
||||
});
|
||||
|
||||
// Register custom slash command
|
||||
// Register custom prompt template
|
||||
api.registerCommand("stats", {
|
||||
description: "Show session stats",
|
||||
handler: async (ctx) => {
|
||||
|
|
@ -556,7 +554,7 @@ Hook API methods:
|
|||
- `api.events.on(channel, handler)` - Listen on shared event bus
|
||||
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
|
||||
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
|
||||
- `api.registerCommand(name, options)` - Register custom slash command
|
||||
- `api.registerCommand(name, options)` - Register custom command
|
||||
- `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
|
||||
- `api.exec(command, args, options?)` - Execute shell commands
|
||||
|
||||
|
|
@ -628,11 +626,11 @@ const { session } = await createAgentSession({
|
|||
### Slash Commands
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const discovered = discoverSlashCommands();
|
||||
const discovered = discoverPromptTemplates();
|
||||
|
||||
const customCommand: FileSlashCommand = {
|
||||
const customCommand: PromptTemplate = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
|
|
@ -640,11 +638,11 @@ const customCommand: FileSlashCommand = {
|
|||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
slashCommands: [...discovered, customCommand],
|
||||
promptTemplates: [...discovered, customCommand],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts)
|
||||
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
|
||||
|
||||
### Session Management
|
||||
|
||||
|
|
@ -773,7 +771,7 @@ import {
|
|||
discoverHooks,
|
||||
discoverCustomTools,
|
||||
discoverContextFiles,
|
||||
discoverSlashCommands,
|
||||
discoverPromptTemplates,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
|
@ -800,8 +798,8 @@ const tools = await discoverCustomTools(eventBus, cwd, agentDir);
|
|||
// Context files
|
||||
const contextFiles = discoverContextFiles(cwd, agentDir);
|
||||
|
||||
// Slash commands
|
||||
const commands = discoverSlashCommands(cwd, agentDir);
|
||||
// Prompt templates
|
||||
const commands = discoverPromptTemplates(cwd, agentDir);
|
||||
|
||||
// Settings (global + project merged)
|
||||
const settings = loadSettings(cwd, agentDir);
|
||||
|
|
@ -908,7 +906,7 @@ const { session } = await createAgentSession({
|
|||
hooks: [{ factory: auditHook }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
promptTemplates: [],
|
||||
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
|
|
@ -963,7 +961,7 @@ discoverSkills
|
|||
discoverHooks
|
||||
discoverCustomTools
|
||||
discoverContextFiles
|
||||
discoverSlashCommands
|
||||
discoverPromptTemplates
|
||||
|
||||
// Event Bus (for shared hook/tool communication)
|
||||
createEventBus
|
||||
|
|
@ -994,7 +992,7 @@ type CreateAgentSessionResult
|
|||
type CustomTool
|
||||
type HookFactory
|
||||
type Skill
|
||||
type FileSlashCommand
|
||||
type PromptTemplate
|
||||
type Settings
|
||||
type SkillsSettings
|
||||
type Tool
|
||||
|
|
|
|||
|
|
@ -1,441 +0,0 @@
|
|||
# Session Tree Implementation Plan
|
||||
|
||||
Reference: [session-tree.md](./session-tree.md)
|
||||
|
||||
## Phase 1: SessionManager Core ✅
|
||||
|
||||
- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase)
|
||||
- [x] Add `version` field to `SessionHeader`
|
||||
- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId`
|
||||
- [x] Add `BranchSummaryEntry` type
|
||||
- [x] Add `CustomEntry` type for hooks
|
||||
- [x] Add `byId: Map<string, SessionEntry>` index
|
||||
- [x] Add `leafId: string` tracking
|
||||
- [x] Implement `getPath(fromId?)` tree traversal
|
||||
- [x] Implement `getTree()` returning `SessionTreeNode[]`
|
||||
- [x] Implement `getEntry(id)` lookup
|
||||
- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers
|
||||
- [x] Update `_buildIndex()` to populate `byId` map
|
||||
- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf)
|
||||
- [x] Add `appendCustomEntry(customType, data)` for hooks
|
||||
- [x] Update `buildSessionContext()` to use `getPath()` traversal
|
||||
|
||||
## Phase 2: Migration ✅
|
||||
|
||||
- [x] Add `CURRENT_SESSION_VERSION = 2` constant
|
||||
- [x] Implement `migrateV1ToV2()` with extensible migration chain
|
||||
- [x] Update `setSessionFile()` to detect version and migrate
|
||||
- [x] Implement `_rewriteFile()` for post-migration persistence
|
||||
- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration
|
||||
|
||||
## Phase 3: Branching ✅
|
||||
|
||||
- [x] Implement `branch(id)` - switch leaf pointer
|
||||
- [x] Implement `branchWithSummary(id, summary)` - create summary entry
|
||||
- [x] Implement `createBranchedSession(leafId)` - extract path to new file
|
||||
- [x] Update `AgentSession.branch()` to use new API
|
||||
|
||||
## Phase 4: Compaction Integration ✅
|
||||
|
||||
- [x] Update `compaction.ts` to work with IDs
|
||||
- [x] Update `prepareCompaction()` to return `firstKeptEntryId`
|
||||
- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId`
|
||||
- [x] Update `AgentSession` compaction methods
|
||||
- [x] Add `firstKeptEntryId` to `before_compact` hook event
|
||||
|
||||
## Phase 5: Testing ✅
|
||||
|
||||
- [x] `migration.test.ts` - v1 to v2 migration, idempotency
|
||||
- [x] `build-context.test.ts` - context building with tree structure, compaction, branches
|
||||
- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching
|
||||
- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession
|
||||
- [x] `save-entry.test.ts` - custom entry integration
|
||||
- [x] Update existing compaction tests for new types
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Compaction Refactor
|
||||
|
||||
- [x] Use `CompactionResult` type for hook return value
|
||||
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [x] Make `CompactionResult<T>` generic to match
|
||||
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [x] Update `before_compact` event:
|
||||
- Pass `preparation: CompactionPreparation` instead of individual fields
|
||||
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
|
||||
- Keep: `customInstructions`, `model`, `signal`
|
||||
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
|
||||
- [x] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
|
||||
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
|
||||
|
||||
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
|
||||
|
||||
### Branch Summary Design ✅
|
||||
|
||||
Current type:
|
||||
```typescript
|
||||
export interface BranchSummaryEntry extends SessionEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string;
|
||||
fromId: string; // References the abandoned leaf
|
||||
fromHook?: boolean; // Whether summary was generated by a hook
|
||||
details?: unknown; // File tracking: { readFiles, modifiedFiles }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] `fromId` field references the abandoned leaf
|
||||
- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries
|
||||
- [x] `details` field for file tracking
|
||||
- [x] Branch summarizer implemented with structured output format
|
||||
- [x] Uses serialization approach (same as compaction) to prevent model confusion
|
||||
- [x] Tests for `branchWithSummary()` flow
|
||||
|
||||
### Entry Labels ✅
|
||||
|
||||
- [x] Add `LabelEntry` type with `targetId` and `label` fields
|
||||
- [x] Add `labelsById: Map<string, string>` private field
|
||||
- [x] Build labels map in `_buildIndex()` via linear scan
|
||||
- [x] Add `getLabel(id)` method
|
||||
- [x] Add `appendLabelChange(targetId, label)` method (undefined clears)
|
||||
- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map
|
||||
- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types)
|
||||
- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()`
|
||||
- [x] Display labels in UI (tree-selector shows labels)
|
||||
- [x] `/label` command (implemented in tree-selector)
|
||||
|
||||
### CustomMessageEntry<T>
|
||||
|
||||
Hook-injected messages that participate in LLM context. Unlike `CustomEntry<T>` (for hook state only), these are sent to the model.
|
||||
|
||||
```typescript
|
||||
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string; // Hook identifier
|
||||
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
|
||||
details?: T; // Hook-specific data for state reconstruction on reload
|
||||
display: boolean; // Whether to display in TUI
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- [x] Type definition matching plan
|
||||
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
|
||||
- [x] `buildSessionContext()` includes custom_message entries as user messages
|
||||
- [x] Exported from main index
|
||||
- [x] TUI rendering:
|
||||
- `display: false` - hidden entirely
|
||||
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
|
||||
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
|
||||
- [x] Renderer returns inner Component, TUI wraps in styled Box
|
||||
|
||||
### Hook API Changes ✅
|
||||
|
||||
**Renamed:**
|
||||
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
||||
|
||||
**New: `sendMessage()` ✅**
|
||||
|
||||
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
||||
|
||||
```typescript
|
||||
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
|
||||
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
|
||||
- `message_end` handler routes based on marker presence
|
||||
- `AgentSession.sendHookMessage()` handles three cases:
|
||||
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
|
||||
- Not streaming + triggerTurn: direct append + `agent.continue()`
|
||||
- Not streaming + no trigger: direct append only
|
||||
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
|
||||
|
||||
**New: `appendEntry()` ✅**
|
||||
|
||||
For hook state persistence (NOT in LLM context):
|
||||
|
||||
```typescript
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
```
|
||||
|
||||
Calls `sessionManager.appendCustomEntry()` directly.
|
||||
|
||||
**New: `registerCommand()` (types ✅, wiring TODO)**
|
||||
|
||||
```typescript
|
||||
// HookAPI (the `pi` object) - utilities available to all hooks:
|
||||
interface HookAPI {
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
registerCommand(name: string, options: RegisteredCommand): void;
|
||||
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void;
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
// HookEventContext - passed to event handlers, has stable context:
|
||||
interface HookEventContext {
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase
|
||||
|
||||
// HookCommandContext - passed to command handlers:
|
||||
interface HookCommandContext {
|
||||
args: string; // Everything after /commandname
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec and sendMessage accessed via `pi` closure
|
||||
|
||||
registerCommand(name: string, options: {
|
||||
description?: string;
|
||||
handler: (ctx: HookCommandContext) => Promise<void>;
|
||||
}): void;
|
||||
```
|
||||
|
||||
Handler return:
|
||||
- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM)
|
||||
|
||||
Wiring (all in AgentSession.prompt()):
|
||||
- [x] Add hook commands to autocomplete in interactive-mode
|
||||
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
|
||||
- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc.
|
||||
- [x] If handler returns string, use as prompt text
|
||||
- [x] If handler returns undefined, return early (no LLM call)
|
||||
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
|
||||
|
||||
**New: `ui.custom()` ✅**
|
||||
|
||||
For arbitrary hook UI with keyboard focus:
|
||||
|
||||
```typescript
|
||||
interface HookUIContext {
|
||||
// ... existing: select, confirm, input, notify
|
||||
|
||||
/** Show custom component with keyboard focus. Call done() when finished. */
|
||||
custom(component: Component, done: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
||||
|
||||
**New: `context` event ✅**
|
||||
|
||||
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages that will be sent to the LLM */
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface ContextEventResult {
|
||||
/** Modified messages to send instead */
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
// In HookAPI:
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
|
||||
```
|
||||
|
||||
Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330))
|
||||
|
||||
Non-destructive pruning of tool results to reduce context size:
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
// Register /prune command
|
||||
pi.registerCommand("prune", {
|
||||
description: "Mark tool results for pruning",
|
||||
handler: async (ctx) => {
|
||||
// Show UI to select which tool results to prune
|
||||
// Append custom entry recording pruning decisions:
|
||||
// { toolResultId, strategy: "summary" | "truncate" | "remove" }
|
||||
pi.appendEntry("tool-result-pruning", { ... });
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept context before LLM call
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// Find all pruning entries in session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const pruningRules = entries
|
||||
.filter(e => e.type === "custom" && e.customType === "tool-result-pruning")
|
||||
.map(e => e.data);
|
||||
|
||||
// Apply pruning rules to messages
|
||||
const prunedMessages = applyPruning(event.messages, pruningRules);
|
||||
return { messages: prunedMessages };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Original tool results stay intact in session
|
||||
- Pruning is stored as custom entries, survives session reload
|
||||
- Works with branching (pruning entries are part of the tree)
|
||||
- Trade-off: cache busting on first submission after pruning
|
||||
|
||||
### Investigate: `context` event vs `before_agent_start` ✅
|
||||
|
||||
References:
|
||||
- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal
|
||||
- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added)
|
||||
|
||||
**Current `context` event:**
|
||||
- Fires before each LLM call within the agent loop
|
||||
- Receives `AgentMessage[]` (deep copy, safe to modify)
|
||||
- Returns `Message[]` (inconsistent with input type)
|
||||
- Modifications are transient (not persisted to session)
|
||||
- No TUI visibility of what was changed
|
||||
- Use case: non-destructive pruning, dynamic context manipulation
|
||||
|
||||
**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`:
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
messages: AgentMessage[]; // Input
|
||||
}
|
||||
interface ContextEventResult {
|
||||
messages?: Message[]; // Output - different type!
|
||||
}
|
||||
```
|
||||
|
||||
Questions:
|
||||
- [ ] Should input/output both be `Message[]` (LLM format)?
|
||||
- [ ] Or both be `AgentMessage[]` with conversion happening after?
|
||||
- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen?
|
||||
|
||||
**Proposed `before_agent_start` event:**
|
||||
- Fires once when user submits a prompt, before `agent_start`
|
||||
- Allows hooks to inject additional content that gets **persisted** to session
|
||||
- Injected content is visible in TUI (observability)
|
||||
- Does not bust prompt cache (appended after user message, not modifying system prompt)
|
||||
|
||||
**Key difference:**
|
||||
| Aspect | `context` | `before_agent_start` |
|
||||
|--------|-----------|---------------------|
|
||||
| When | Before each LLM call | Once per user prompt |
|
||||
| Persisted | No | Yes (as SystemMessage) |
|
||||
| TUI visible | No | Yes (collapsible) |
|
||||
| Cache impact | Can bust cache | Append-only, cache-safe |
|
||||
| Use case | Transient manipulation | Persistent context injection |
|
||||
|
||||
**Implementation (completed):**
|
||||
- Reuses `HookMessage` type (no new message type needed)
|
||||
- Handler returns `{ message: Pick<HookMessage, "customType" | "content" | "display" | "details"> }`
|
||||
- Message is appended to agent state AND persisted to session before `agent.prompt()` is called
|
||||
- Renders using existing `HookMessageComponent` (or custom renderer if registered)
|
||||
- [ ] How does it interact with compaction? (treated like user messages?)
|
||||
- [ ] Can hook return multiple messages or just one?
|
||||
|
||||
**Implementation sketch:**
|
||||
```typescript
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
userMessage: UserMessage; // The prompt user just submitted
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
/** Additional context to inject (persisted as SystemMessage) */
|
||||
inject?: {
|
||||
label: string; // Shown in collapsed TUI state
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Export
|
||||
|
||||
- [ ] Add collapsible sidebar showing full tree structure
|
||||
- [ ] Allow selecting any node in tree to view that path
|
||||
- [ ] Add "reset to session leaf" button
|
||||
- [ ] Render full path (no compaction resolution needed)
|
||||
- [ ] Responsive: collapse sidebar on mobile
|
||||
|
||||
### UI Commands ✅
|
||||
|
||||
- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`)
|
||||
- [x] `/tree` - In-session tree navigation via tree-selector component
|
||||
- Shows full tree structure with labels
|
||||
- Navigate between branches (moves leaf pointer)
|
||||
- Shows current position
|
||||
- Generates branch summaries when switching branches
|
||||
|
||||
### Tree Selector Improvements ✅
|
||||
|
||||
- [x] Active line highlight using `selectedBg` theme color
|
||||
- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward):
|
||||
- `default`: hides label/custom entries
|
||||
- `no-tools`: default minus tool results
|
||||
- `user-only`: just user messages
|
||||
- `labeled-only`: just labeled entries
|
||||
- `all`: everything
|
||||
|
||||
### Documentation
|
||||
|
||||
Review and update all docs:
|
||||
|
||||
- [ ] `docs/hooks.md` - Major update for hook API:
|
||||
- `pi.send()` → `pi.sendMessage()` with new signature
|
||||
- New `pi.appendEntry()` for state persistence
|
||||
- New `pi.registerCommand()` for custom slash commands
|
||||
- New `pi.registerCustomMessageRenderer()` for custom TUI rendering
|
||||
- `HookCommandContext` interface and handler patterns
|
||||
- `HookMessage<T>` type
|
||||
- Updated event signatures (`SessionEventBase`, `before_compact`, etc.)
|
||||
- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete
|
||||
- [ ] `docs/sdk.md` - Update for:
|
||||
- `HookMessage` and `isHookMessage()`
|
||||
- `Agent.prompt(AppMessage)` overload
|
||||
- Session v2 tree structure
|
||||
- SessionManager API changes
|
||||
- [ ] `docs/session.md` - Update for v2 tree structure, new entry types
|
||||
- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools
|
||||
- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode
|
||||
- [ ] `docs/skills.md` - Review for any hook-related updates
|
||||
- [ ] `docs/extension-loading.md` - Review
|
||||
- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total)
|
||||
- [ ] `README.md` - Update hook examples if any
|
||||
|
||||
### Examples
|
||||
|
||||
Review and update examples:
|
||||
|
||||
- [ ] `examples/hooks/` - Update existing, add new examples:
|
||||
- [ ] Review `custom-compaction.ts` for new API
|
||||
- [ ] Add `registerCommand()` example
|
||||
- [ ] Add `sendMessage()` example
|
||||
- [ ] Add `registerCustomMessageRenderer()` example
|
||||
- [ ] `examples/sdk/` - Update for new session/hook APIs
|
||||
- [ ] `examples/custom-tools/` - Review for compatibility
|
||||
|
||||
---
|
||||
|
||||
## Before Release
|
||||
|
||||
- [ ] Run full automated test suite: `npm test`
|
||||
- [ ] Manual testing of tree navigation and branch summarization
|
||||
- [ ] Verify compaction with file tracking works correctly
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All append methods return the new entry's ID
|
||||
- Migration rewrites file on first load if version < CURRENT_VERSION
|
||||
- Existing sessions become linear chains after migration (parentId = previous entry)
|
||||
- Tree features available immediately after migration
|
||||
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
|
||||
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer
|
||||
|
|
@ -37,7 +37,7 @@ Skills are loaded when:
|
|||
|
||||
**Not a good fit for skills:**
|
||||
- "Always use TypeScript strict mode" → put in AGENTS.md
|
||||
- "Review my code" → make a slash command
|
||||
- "Review my code" → make a prompt template
|
||||
- Need user confirmation dialogs or custom TUI rendering → make a custom tool
|
||||
|
||||
## Skill Structure
|
||||
|
|
|
|||
|
|
@ -343,4 +343,4 @@ Call `invalidate()` when state changes, then `handle.requestRender()` to trigger
|
|||
## Examples
|
||||
|
||||
- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
|
||||
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`
|
||||
- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - Custom `renderCall` and `renderResult`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue