Merge branch 'main' into pb/tui-status-coalesce

This commit is contained in:
Mario Zechner 2026-01-01 00:27:54 +01:00
commit ac6f5006a9
216 changed files with 14479 additions and 8725 deletions

View file

@ -2,13 +2,213 @@
## [Unreleased]
This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking.
### Session Tree
Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
**Existing sessions are automatically migrated** (v1 → v2) on first load. No manual action required.
New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks).
See [docs/session.md](docs/session.md) for the file format and `SessionManager` API.
### Hooks Migration
The hooks API has been restructured with more granular events and better session access.
**Type renames:**
- `HookEventContext``HookContext`
- `HookCommandContext` removed (use `HookContext` for command handlers)
**Event changes:**
- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown`
- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary)
- New `before_agent_start` event: inject messages before the agent loop starts
- New `context` event: modify messages non-destructively before each LLM call
- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead
**API changes:**
- `pi.send(text, attachments?)``pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`)
- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context)
- New `pi.registerCommand(name, options)` for custom slash commands
- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering
- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus
- `ctx.exec()` moved to `pi.exec()`
- `ctx.sessionFile``ctx.sessionManager.getSessionFile()`
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
**Removed:**
- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort)
- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`)
See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API.
### Custom Tools Migration
The custom tools API has been restructured to mirror the hooks pattern with a context object.
**Type renames:**
- `CustomAgentTool``CustomTool`
- `ToolAPI``CustomToolAPI`
- `ToolContext``CustomToolContext`
- `ToolSessionEvent``CustomToolSessionEvent`
**Execute signature changed:**
```typescript
// Before (v0.30.2)
execute(toolCallId, params, signal, onUpdate)
// After
execute(toolCallId, params, onUpdate, ctx, signal?)
```
The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and `model`.
**Session event changes:**
- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile`
- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state
- New reasons: `"tree"` (for `/tree` navigation) and `"shutdown"` (for cleanup on exit)
- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup
See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API.
### SDK Migration
**Type changes:**
- `CustomAgentTool``CustomTool`
- `AppMessage``AgentMessage`
- `sessionFile` returns `string | undefined` (was `string | null`)
- `model` returns `Model | undefined` (was `Model | null`)
- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays.
**AgentSession branching API:**
- `branch(entryIndex: number)``branch(entryId: string)`
- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`
- `reset()` and `switchSession()` now return `Promise<boolean>` (false if cancelled by hook)
- New `navigateTree(targetId, options?)` for in-place tree navigation
**Hook integration:**
- New `sendHookMessage(message, triggerTurn?)` for hook message injection
**SessionManager API:**
- Method renames: `saveXXX()``appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
- `branchInPlace()``branch()`
- `reset()``newSession()`
- `createBranchedSessionFromEntries(entries, index)``createBranchedSession(leafId)`
- `saveCompaction(entry)``appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)`
- `getEntries()` now excludes the session header (use `getHeader()` separately)
- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions)
- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()`
- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()`
- New branch methods: `branch(entryId)`, `branchWithSummary()`
**ModelRegistry (new):**
`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`.
```typescript
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
// Get all models (built-in + custom)
const allModels = modelRegistry.getAll();
// Get only models with valid API keys
const available = await modelRegistry.getAvailable();
// Find specific model
const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514");
// Get API key for a model
const apiKey = await modelRegistry.getApiKey(model);
```
This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`.
**Renamed exports:**
- `messageTransformer``convertToLlm`
- `SessionContext` alias `LoadedSession` removed
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API.
### RPC Migration
**Branching commands:**
- `branch` command: `entryIndex``entryId`
- `get_branch_messages` response: `entryIndex``entryId`
**Type changes:**
- Messages are now `AgentMessage` (was `AppMessage`)
- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format
**Compaction events:**
- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`)
- `auto_compaction_end` now includes `willRetry` field
- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`)
See [docs/rpc.md](docs/rpc.md) for the current protocol.
### Structured Compaction
Compaction and branch summarization now use a structured output format:
- Clear sections: Goal, Progress, Key Information, File Operations
- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions
- Conversations are serialized to text before summarization to prevent the model from "continuing" them
The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md).
### Interactive Mode
**`/tree` command:**
- Navigate the full session tree in-place
- Search by typing, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
- Press `l` to label entries as bookmarks
- Selecting a branch switches context and optionally injects a summary of the abandoned branch
**Entry labels:**
- Bookmark any entry via `/tree` → select → `l`
- Labels appear in tree view and persist as `LabelEntry`
**Theme changes (breaking for custom themes):**
Custom themes must add these new color tokens or they will fail to load:
- `selectedBg`: background for selected/highlighted items in tree selector and other components
- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`)
- `customMessageText`: text color for hook messages
- `customMessageLabel`: label color for hook messages (the `[customType]` prefix)
Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes.
**Settings:**
- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI)
### Added
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts).
- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner))
### Changed
- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs
- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY`
### Fixed
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355))
- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed.
- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou))
- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon))
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey))
- **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri))
- **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez))
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings
- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()`
## [0.30.2] - 2025-12-26

View file

@ -25,12 +25,13 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
- [Project Context Files](#project-context-files)
- [Custom System Prompt](#custom-system-prompt)
- [Custom Models and Providers](#custom-models-and-providers)
- [Settings File](#settings-file)
- [Extensions](#extensions)
- [Themes](#themes)
- [Custom Slash Commands](#custom-slash-commands)
- [Skills](#skills)
- [Hooks](#hooks)
- [Custom Tools](#custom-tools)
- [Settings File](#settings-file)
- [CLI Reference](#cli-reference)
- [Tools](#tools)
- [Programmatic Usage](#programmatic-usage)
@ -193,6 +194,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/session` | Show session info: path, message counts, token usage, cost |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display full version history |
| `/tree` | Navigate session tree in-place (search, filter, label entries) |
| `/branch` | Create new conversation branch from a previous message |
| `/resume` | Switch to a different session (interactive selector) |
| `/login` | OAuth login for subscription-based models |
@ -291,6 +293,10 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett
## Sessions
Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
See [docs/session.md](docs/session.md) for the file format and programmatic API.
### Session Management
Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.
@ -319,14 +325,6 @@ Long sessions can exhaust context windows. Compaction summarizes older messages
When disabled, neither case triggers automatic compaction (use `/compact` manually if needed).
**How it works:**
1. Cut point calculated to keep ~20k tokens of recent messages
2. Messages before cut point are summarized
3. Summary replaces old messages as "context handoff"
4. Previous compaction summaries chain into new ones
Compaction does not create a new session, but continues the existing one, with a marker in the `.jsonl` file that encodes the compaction point.
**Configuration** (`~/.pi/agent/settings.json`):
```json
@ -339,11 +337,20 @@ Compaction does not create a new session, but continues the existing one, with a
}
```
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point.
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
### Branching
Use `/branch` to explore alternative conversation paths:
**In-place navigation (`/tree`):** Navigate the session tree without creating new files. Select any previous point, continue from there, and switch between branches while preserving all history.
- Search by typing, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
- Press `l` to label entries as bookmarks
- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor)
**Create new session (`/branch`):** Branch to a new session file:
1. Opens selector showing all your user messages
2. Select a message to branch from
@ -473,6 +480,75 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
> pi can help you create custom provider and model configurations.
### Settings File
Settings are loaded from two locations and merged:
1. **Global:** `~/.pi/agent/settings.json` - user preferences
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
Global `~/.pi/agent/settings.json` stores persistent preferences:
```json
{
"theme": "dark",
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"defaultThinkingLevel": "medium",
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
"queueMode": "one-at-a-time",
"shellPath": "C:\\path\\to\\bash.exe",
"hideThinkingBlock": false,
"collapseChangelog": false,
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
},
"skills": {
"enabled": true
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"terminal": {
"showImages": true
},
"hooks": ["/path/to/hook.ts"],
"customTools": ["/path/to/tool.ts"]
}
```
| Setting | Description | Default |
|---------|-------------|---------|
| `theme` | Color theme name | auto-detected |
| `defaultProvider` | Default model provider | - |
| `defaultModel` | Default model ID | - |
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
| `shellPath` | Custom bash path (Windows) | auto-detected |
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
| `collapseChangelog` | Show condensed changelog after update | `false` |
| `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
| `hooks` | Additional hook file paths | `[]` |
| `customTools` | Additional custom tool file paths | `[]` |
---
## Extensions
### Themes
Built-in themes: `dark` (default), `light`. Auto-detected on first run.
@ -612,18 +688,23 @@ export default function (pi: HookAPI) {
**Sending messages from hooks:**
Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.
Use `pi.sendMessage(message, triggerTurn?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM. If the agent is streaming, the message is queued; otherwise a new agent loop starts if `triggerTurn` is true.
```typescript
import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event) => {
if (event.reason !== "start") return;
pi.on("session_start", async () => {
fs.watch("/tmp/trigger.txt", () => {
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
if (content) pi.send(content);
if (content) {
pi.sendMessage({
customType: "file-trigger",
content,
display: true,
}, true); // triggerTurn: start agent loop
}
});
});
}
@ -659,10 +740,11 @@ const factory: CustomToolFactory = (pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});
@ -682,73 +764,6 @@ export default factory;
> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.
### Settings File
Settings are loaded from two locations and merged:
1. **Global:** `~/.pi/agent/settings.json` - user preferences
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
Global `~/.pi/agent/settings.json` stores persistent preferences:
```json
{
"theme": "dark",
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"defaultThinkingLevel": "medium",
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
"queueMode": "one-at-a-time",
"shellPath": "C:\\path\\to\\bash.exe",
"hideThinkingBlock": false,
"collapseChangelog": false,
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
},
"skills": {
"enabled": true
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"terminal": {
"showImages": true
},
"hooks": ["/path/to/hook.ts"],
"hookTimeout": 30000,
"customTools": ["/path/to/tool.ts"]
}
```
| Setting | Description | Default |
|---------|-------------|---------|
| `theme` | Color theme name | auto-detected |
| `defaultProvider` | Default model provider | - |
| `defaultModel` | Default model ID | - |
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
| `shellPath` | Custom bash path (Windows) | auto-detected |
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
| `collapseChangelog` | Show condensed changelog after update | `false` |
| `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
| `hooks` | Additional hook file paths | `[]` |
| `hookTimeout` | Timeout for hook operations (ms) | `30000` |
| `customTools` | Additional custom tool file paths | `[]` |
---
## CLI Reference

View file

@ -0,0 +1,388 @@
# Compaction & Branch Summarization
LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
**Source files:**
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types
## Overview
Pi has two summarization mechanisms:
| Mechanism | Trigger | Purpose |
|-----------|---------|---------|
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively.
## Compaction
### When It Triggers
Auto-compaction triggers when:
```
contextTokens > contextWindow - reserveTokens
```
By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.
You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
### How It Works
1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached
2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
3. **Generate summary**: Call LLM to summarize with structured format
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
```
Before compaction:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
└────────┬───────┘ └──────────────┬──────────────┘
messagesToSummarize kept messages
firstKeptEntryId (entry 4)
After compaction (new entry appended):
entry: 0 1 2 3 4 5 6 7 8 9 10
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
└──────────┬──────┘ └──────────────────────┬───────────────────┘
not sent to LLM sent to LLM
starts from firstKeptEntryId
What the LLM sees:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └─────────────────┬────────────────┘
prompt from cmp messages from firstKeptEntryId
```
### Split Turns
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
```
Split turn (one huge turn exceeds budget):
entry: 0 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑
turnStartIndex = 1 firstKeptEntryId = 7
│ │
└──── turnPrefixMessages (1-6) ───────┘
└── kept (7-8)
isSplitTurn = true
messagesToSummarize = [] (no complete turns before)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
```
For split turns, pi generates two summaries and merges them:
1. **History summary**: Previous context (if any)
2. **Turn prefix summary**: The early part of the split turn
### Cut Point Rules
Valid cut points are:
- User messages
- Assistant messages
- BashExecution messages
- Hook messages (custom_message, branch_summary)
Never cut at tool results (they must stay with their tool call).
### CompactionEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
fromHook?: boolean; // true if hook provided the compaction
details?: T; // hook-specific data
}
// Default compaction uses this for details (from compaction.ts):
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure.
See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation.
## Branch Summarization
### When It Triggers
When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.
### How It Works
1. **Find common ancestor**: Deepest node shared by old and new positions
2. **Collect entries**: Walk from old leaf back to common ancestor
3. **Prepare with budget**: Include messages up to token budget (newest first)
4. **Generate summary**: Call LLM with structured format
5. **Append entry**: Save `BranchSummaryEntry` at navigation point
```
Tree before navigation:
┌─ B ─ C ─ D (old leaf, being abandoned)
A ───┤
└─ E ─ F (target)
Common ancestor: A
Entries to summarize: B, C, D
After navigation with summary:
┌─ B ─ C ─ D ─ [summary of B,C,D]
A ───┤
└─ E ─ F (new leaf)
```
### Cumulative File Tracking
Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:
- Tool calls in the messages being summarized
- Previous compaction or branch summary `details` (if any)
This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.
### BranchSummaryEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string; // Entry we navigated from
fromHook?: boolean; // true if hook provided the summary
details?: T; // hook-specific data
}
// Default branch summarization uses this for details (from branch-summarization.ts):
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Same as compaction, hooks can store custom data in `details`.
See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
## Summary Format
Both compaction and branch summarization use the same structured format:
```markdown
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
- [Requirements mentioned by user]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues, if any]
## Key Decisions
- **[Decision]**: [Rationale]
## Next Steps
1. [What should happen next]
## Critical Context
- [Data needed to continue]
<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>
<modified-files>
path/to/changed.ts
</modified-files>
```
### Message Serialization
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
```
[User]: What they said
[Assistant thinking]: Internal reasoning
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool
```
This prevents the model from treating it as a conversation to continue.
## Custom Summarization via Hooks
Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions.
### session_before_compact
Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
// preparation.messagesToSummarize - messages to summarize
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
// preparation.previousSummary - previous compaction summary
// preparation.fileOps - extracted file operations
// preparation.tokensBefore - context tokens before compaction
// preparation.firstKeptEntryId - where kept messages start
// preparation.settings - compaction settings
// branchEntries - all entries on current branch (for custom state)
// signal - AbortSignal (pass to LLM calls)
// Cancel:
return { cancel: true };
// Custom summary:
return {
compaction: {
summary: "Your summary...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { /* custom data */ },
}
};
});
```
#### Converting Messages to Text
To generate a summary with your own model, convert messages to text using `serializeConversation`:
```typescript
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
// Convert AgentMessage[] to Message[], then serialize to text
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
// Returns:
// [User]: message text
// [Assistant thinking]: thinking content
// [Assistant]: response text
// [Assistant tool calls]: read(path="..."); bash(command="...")
// [Tool result]: output text
// Now send to your model for summarization
const summary = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
}
};
});
```
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
### session_before_tree
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.
```typescript
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
// preparation.targetId - where we're navigating to
// preparation.oldLeafId - current position (being abandoned)
// preparation.commonAncestorId - shared ancestor
// preparation.entriesToSummarize - entries that would be summarized
// preparation.userWantsSummary - whether user chose to summarize
// Cancel navigation entirely:
return { cancel: true };
// Provide custom summary (only used if userWantsSummary is true):
if (preparation.userWantsSummary) {
return {
summary: {
summary: "Your summary...",
details: { /* custom data */ },
}
};
}
});
```
See `SessionBeforeTreeEvent` and `TreePreparation` in the types file.
## Settings
Configure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`:
```json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `enabled` | `true` | Enable auto-compaction |
| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.

View file

@ -1,13 +1,21 @@
> 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:**
- Ask the user questions with selectable options
- Maintain state across calls (todo lists, connection pools)
- Custom TUI rendering (progress indicators, structured output)
- Integrate external services with proper error handling
- Tools that need user confirmation before proceeding
- 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:**
@ -36,10 +44,11 @@ const factory: CustomToolFactory = (pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});
@ -82,7 +91,7 @@ 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`, `ToolSessionEvent`, 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) |
@ -94,7 +103,12 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
import type {
CustomTool,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
} from "@mariozechner/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "my_tool",
@ -106,9 +120,10 @@ const factory: CustomToolFactory = (pi) => ({
text: Type.Optional(Type.String()),
}),
async execute(toolCallId, params, signal, onUpdate) {
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 */ },
@ -116,14 +131,17 @@ const factory: CustomToolFactory = (pi) => ({
},
// Optional: Session lifecycle callback
onSession(event) { /* reconstruct state from entries */ },
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 */ },
// Optional: Cleanup on session end
dispose() { /* save state, close connections */ },
});
export default factory;
@ -131,23 +149,26 @@ 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.
## ToolAPI Object
## CustomToolAPI Object
The factory receives a `ToolAPI` object (named `pi` by convention):
The factory receives a `CustomToolAPI` object (named `pi` by convention):
```typescript
interface ToolAPI {
interface CustomToolAPI {
cwd: string; // Current working directory
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
ui: {
select(title: string, options: string[]): Promise<string | null>;
confirm(title: string, message: string): Promise<boolean>;
input(title: string, placeholder?: string): Promise<string | null>;
notify(message: string, type?: "info" | "warning" | "error"): void;
};
ui: ToolUIContext;
hasUI: boolean; // false in --print or --mode rpc
}
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
@ -168,7 +189,7 @@ Always check `pi.hasUI` before using UI methods.
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
```typescript
async execute(toolCallId, params, signal) {
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" }] };
@ -177,16 +198,51 @@ async execute(toolCallId, params, signal) {
}
```
### 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)
}
```
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
## Session Lifecycle
Tools can implement `onSession` to react to session changes:
```typescript
interface ToolSessionEvent {
entries: SessionEntry[]; // All session entries
sessionFile: string | null; // Current session file
previousSessionFile: string | null; // Previous session file
reason: "start" | "switch" | "branch" | "new";
interface CustomToolSessionEvent {
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
previousSessionFile: string | undefined;
}
```
@ -195,6 +251,8 @@ interface ToolSessionEvent {
- `switch`: User switched to a different session (`/resume`)
- `branch`: User branched from a previous message (`/branch`)
- `new`: User started a new session (`/new`)
- `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
### State Management Pattern
@ -210,9 +268,11 @@ const factory: CustomToolFactory = (pi) => {
let items: string[] = [];
// Reconstruct state from session entries
const reconstructState = (event: ToolSessionEvent) => {
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
if (event.reason === "shutdown") return;
items = [];
for (const entry of event.entries) {
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult") continue;
@ -233,7 +293,7 @@ const factory: CustomToolFactory = (pi) => {
onSession: reconstructState,
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
// Modify items...
items.push("new item");
@ -254,7 +314,7 @@ This pattern ensures:
## Custom Rendering
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
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
@ -355,7 +415,7 @@ If `renderCall` or `renderResult` is not defined or throws an error:
## Execute Function
```typescript
async execute(toolCallId, args, signal, onUpdate) {
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 };
@ -387,13 +447,16 @@ 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", ... },
{ name: "db_query", ... },
{
name: "db_close",
dispose() { connection?.close(); }
},
{ name: "db_connect", onSession: handleSession, ... },
{ name: "db_query", onSession: handleSession, ... },
{ name: "db_close", onSession: handleSession, ... },
];
};
```

View file

@ -1,385 +0,0 @@
# Hooks v2: Context Control + Commands
Issue: #289
## Motivation
Enable features like session stacking (`/pop`) as hooks, not core code. Core provides primitives, hooks implement features.
## Primitives
| Primitive | Purpose |
|-----------|---------|
| `ctx.saveEntry({type, ...})` | Persist custom entry to session |
| `pi.on("context", handler)` | Transform messages before LLM |
| `ctx.rebuildContext()` | Trigger context rebuild |
| `pi.command(name, opts)` | Register slash command |
## Extended HookEventContext
```typescript
interface HookEventContext {
// Existing
exec, ui, hasUI, cwd, sessionFile
// State (read-only)
model: Model<any> | null;
thinkingLevel: ThinkingLevel;
entries: readonly SessionEntry[];
// Utilities
findModel(provider: string, id: string): Model<any> | null;
availableModels(): Promise<Model<any>[]>;
resolveApiKey(model: Model<any>): Promise<string | undefined>;
// Mutation
saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
rebuildContext(): Promise<void>;
}
interface ContextMessage {
message: AppMessage;
entryIndex: number | null; // null = synthetic
}
interface ContextEvent {
type: "context";
entries: readonly SessionEntry[];
messages: ContextMessage[];
}
```
Commands also get: `args`, `argsRaw`, `signal`, `setModel()`, `setThinkingLevel()`.
## Stacking: Design
### Entry Format
```typescript
interface StackPopEntry {
type: "stack_pop";
backToIndex: number;
summary: string;
prePopSummary?: string; // when crossing compaction
timestamp: number;
}
```
### Crossing Compaction
Entries are never deleted. Raw data always available.
When `backToIndex < compaction.firstKeptEntryIndex`:
1. Read raw entries `[0, backToIndex)` → summarize → `prePopSummary`
2. Read raw entries `[backToIndex, now)` → summarize → `summary`
### Context Algorithm: Later Wins
Assign sequential IDs to ranges. On overlap, highest ID wins.
```
Compaction at 40: range [0, 30) id=0
StackPop at 50, backTo=20, prePopSummary: ranges [0, 20) id=1, [20, 50) id=2
Index 0-19: id=0 and id=1 cover → id=1 wins (prePopSummary)
Index 20-29: id=0 and id=2 cover → id=2 wins (popSummary)
Index 30-49: id=2 covers → id=2 (already emitted at 20)
Index 50+: no coverage → include as messages
```
## Complex Scenario Trace
```
Initial: [msg1, msg2, msg3, msg4, msg5]
idx: 1, 2, 3, 4, 5
Compaction triggers:
[msg1-5, compaction{firstKept:4, summary:C1}]
idx: 1-5, 6
Context: [C1, msg4, msg5]
User continues:
[..., compaction, msg4, msg5, msg6, msg7]
idx: 6, 4*, 5*, 7, 8 (* kept from before)
User does /pop to msg2 (index 2):
- backTo=2 < firstKept=4 crossing!
- prePopSummary: summarize raw [0,2) → P1
- summary: summarize raw [2,8) → S1
- save: stack_pop{backTo:2, summary:S1, prePopSummary:P1} at index 9
Ranges:
compaction [0,4) id=0
prePopSummary [0,2) id=1
popSummary [2,9) id=2
Context build:
idx 0: covered by id=0,1 → id=1 wins, emit P1
idx 1: covered by id=0,1 → id=1 (already emitted)
idx 2: covered by id=0,2 → id=2 wins, emit S1
idx 3-8: covered by id=0 or id=2 → id=2 (already emitted)
idx 9: stack_pop entry, skip
idx 10+: not covered, include as messages
Result: [P1, S1, msg10+]
User continues, another compaction:
[..., stack_pop, msg10, msg11, msg12, compaction{firstKept:11, summary:C2}]
idx: 9, 10, 11, 12, 13
Ranges:
compaction@6 [0,4) id=0
prePopSummary [0,2) id=1
popSummary [2,9) id=2
compaction@13 [0,11) id=3 ← this now covers previous ranges!
Context build:
idx 0-10: covered by multiple, id=3 wins → emit C2 at idx 0
idx 11+: include as messages
Result: [C2, msg11, msg12]
C2's summary text includes info from P1 and S1 (they were in context when C2 was generated).
```
The "later wins" rule naturally handles all cases.
## Core Changes
| File | Change |
|------|--------|
| `session-manager.ts` | `saveEntry()`, `buildSessionContext()` returns `ContextMessage[]` |
| `hooks/types.ts` | `ContextEvent`, `ContextMessage`, extended context, command types |
| `hooks/loader.ts` | Track commands |
| `hooks/runner.ts` | `setStateCallbacks()`, `emitContext()`, command methods |
| `agent-session.ts` | `saveEntry()`, `rebuildContext()`, state callbacks |
| `interactive-mode.ts` | Command handling, autocomplete |
## Stacking Hook: Complete Implementation
```typescript
import { complete } from "@mariozechner/pi-ai";
import type { HookAPI, AppMessage, SessionEntry, ContextMessage } from "@mariozechner/pi-coding-agent/hooks";
export default function(pi: HookAPI) {
pi.command("pop", {
description: "Pop to previous turn, summarizing work",
handler: async (ctx) => {
const entries = ctx.entries as SessionEntry[];
// Get user turns
const turns = entries
.map((e, i) => ({ e, i }))
.filter(({ e }) => e.type === "message" && (e as any).message.role === "user")
.map(({ e, i }) => ({ idx: i, text: preview((e as any).message) }));
if (turns.length < 2) return { status: "Need at least 2 turns" };
// Select target (skip last turn - that's current)
const options = turns.slice(0, -1).map(t => `[${t.idx}] ${t.text}`);
const selected = ctx.args[0]
? options.find(o => o.startsWith(`[${ctx.args[0]}]`))
: await ctx.ui.select("Pop to:", options);
if (!selected) return;
const backTo = parseInt(selected.match(/\[(\d+)\]/)![1]);
// Check compaction crossing
const compactions = entries.filter(e => e.type === "compaction") as any[];
const latestCompaction = compactions[compactions.length - 1];
const crossing = latestCompaction && backTo < latestCompaction.firstKeptEntryIndex;
// Generate summaries
let prePopSummary: string | undefined;
if (crossing) {
ctx.ui.notify("Crossing compaction, generating pre-pop summary...", "info");
const preMsgs = getMessages(entries.slice(0, backTo));
prePopSummary = await summarize(preMsgs, ctx, "context before this work");
}
const popMsgs = getMessages(entries.slice(backTo));
const summary = await summarize(popMsgs, ctx, "completed work");
// Save and rebuild
await ctx.saveEntry({
type: "stack_pop",
backToIndex: backTo,
summary,
prePopSummary,
});
await ctx.rebuildContext();
return { status: `Popped to turn ${backTo}` };
}
});
pi.on("context", (event, ctx) => {
const hasPops = event.entries.some(e => e.type === "stack_pop");
if (!hasPops) return;
// Collect ranges with IDs
let rangeId = 0;
const ranges: Array<{from: number; to: number; summary: string; id: number}> = [];
for (let i = 0; i < event.entries.length; i++) {
const e = event.entries[i] as any;
if (e.type === "compaction") {
ranges.push({ from: 0, to: e.firstKeptEntryIndex, summary: e.summary, id: rangeId++ });
}
if (e.type === "stack_pop") {
if (e.prePopSummary) {
ranges.push({ from: 0, to: e.backToIndex, summary: e.prePopSummary, id: rangeId++ });
}
ranges.push({ from: e.backToIndex, to: i, summary: e.summary, id: rangeId++ });
}
}
// Build messages
const messages: ContextMessage[] = [];
const emitted = new Set<number>();
for (let i = 0; i < event.entries.length; i++) {
const covering = ranges.filter(r => r.from <= i && i < r.to);
if (covering.length) {
const winner = covering.reduce((a, b) => a.id > b.id ? a : b);
if (i === winner.from && !emitted.has(winner.id)) {
messages.push({
message: { role: "user", content: `[Summary]\n\n${winner.summary}`, timestamp: Date.now() } as AppMessage,
entryIndex: null
});
emitted.add(winner.id);
}
continue;
}
const e = event.entries[i];
if (e.type === "message") {
messages.push({ message: (e as any).message, entryIndex: i });
}
}
return { messages };
});
}
function getMessages(entries: SessionEntry[]): AppMessage[] {
return entries.filter(e => e.type === "message").map(e => (e as any).message);
}
function preview(msg: AppMessage): string {
const text = typeof msg.content === "string" ? msg.content
: (msg.content as any[]).filter(c => c.type === "text").map(c => c.text).join(" ");
return text.slice(0, 40) + (text.length > 40 ? "..." : "");
}
async function summarize(msgs: AppMessage[], ctx: any, purpose: string): Promise<string> {
const apiKey = await ctx.resolveApiKey(ctx.model);
const resp = await complete(ctx.model, {
messages: [...msgs, { role: "user", content: `Summarize as "${purpose}". Be concise.`, timestamp: Date.now() }]
}, { apiKey, maxTokens: 2000, signal: ctx.signal });
return resp.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
}
```
## Edge Cases
### Session Resumed Without Hook
User has stacking hook, does `/pop`, saves `stack_pop` entry. Later removes hook and resumes session.
**What happens:**
1. Core loads all entries (including `stack_pop`)
2. Core's `buildSessionContext()` ignores unknown types, returns compaction + message entries
3. `context` event fires, but no handler processes `stack_pop`
4. Core's messages pass through unchanged
**Result:** Messages that were "popped" return to context. The pop is effectively undone.
**Why this is OK:**
- Session file is intact, no data lost
- If compaction happened after pop, the compaction summary captured the popped state
- User removed the hook, so hook's behavior (hiding messages) is gone
- User can re-add hook to restore stacking behavior
**Mitigation:** Could warn on session load if unknown entry types found:
```typescript
// In session load
const unknownTypes = entries
.map(e => e.type)
.filter(t => !knownTypes.has(t));
if (unknownTypes.length) {
console.warn(`Session has entries of unknown types: ${unknownTypes.join(", ")}`);
}
```
### Hook Added to Existing Session
User has old session without stacking. Adds stacking hook, does `/pop`.
**What happens:**
1. Hook saves `stack_pop` entry
2. `context` event fires, hook processes it
3. Works normally
No issue. Hook processes entries it recognizes, ignores others.
### Multiple Hooks with Different Entry Types
Hook A handles `type_a` entries, Hook B handles `type_b` entries.
**What happens:**
1. `context` event chains through both hooks
2. Each hook checks for its entry types, passes through if none found
3. Each hook's transforms are applied in order
**Best practice:** Hooks should:
- Only process their own entry types
- Return `undefined` (pass through) if no relevant entries
- Use prefixed type names: `myhook_pop`, `myhook_prune`
### Conflicting Hooks
Two hooks both try to handle the same entry type (e.g., both handle `compaction`).
**What happens:**
- Later hook (project > global) wins in the chain
- Earlier hook's transform is overwritten
**Mitigation:**
- Core entry types (`compaction`, `message`, etc.) should not be overridden by hooks
- Hooks should use unique prefixed type names
- Document which types are "reserved"
### Session with Future Entry Types
User downgrades pi version, session has entry types from newer version.
**What happens:**
- Same as "hook removed" - unknown types ignored
- Core handles what it knows, hooks handle what they know
**Session file is forward-compatible:** Unknown entries are preserved in file, just not processed.
## Implementation Phases
| Phase | Scope | LOC |
|-------|-------|-----|
| v2.0 | `saveEntry`, `context` event, `rebuildContext`, extended context | ~150 |
| v2.1 | `pi.command()`, TUI integration, autocomplete | ~200 |
| v2.2 | Example hooks, documentation | ~300 |
## Implementation Order
1. `ContextMessage` type, update `buildSessionContext()` return type
2. `saveEntry()` in session-manager
3. `context` event in runner with chaining
4. State callbacks interface and wiring
5. `rebuildContext()` in agent-session
6. Manual test with simple hook
7. Command registration in loader
8. Command invocation in runner
9. TUI command handling + autocomplete
10. Stacking example hook
11. Pruning example hook
12. Update hooks.md

File diff suppressed because it is too large Load diff

View file

@ -36,9 +36,9 @@ Send a user prompt to the agent. Returns immediately; events stream asynchronous
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
```
With attachments:
With images:
```json
{"type": "prompt", "message": "What's in this image?", "attachments": [...]}
{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "source": {"type": "base64", "mediaType": "image/png", "data": "..."}}]}
```
Response:
@ -46,7 +46,7 @@ Response:
{"id": "req-1", "type": "response", "command": "prompt", "success": true}
```
The `attachments` field is optional. See [Attachments](#attachments) for the schema.
The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source.
#### queue_message
@ -145,7 +145,7 @@ Response:
}
```
Messages are `AppMessage` objects (see [Message Types](#message-types)).
Messages are `AgentMessage` objects (see [Message Types](#message-types)).
### Model
@ -289,8 +289,10 @@ Response:
"command": "compact",
"success": true,
"data": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"summary": "Summary of conversation..."
"details": {}
}
}
```
@ -491,7 +493,7 @@ If a hook cancelled the switch:
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
```json
{"type": "branch", "entryIndex": 2}
{"type": "branch", "entryId": "abc123"}
```
Response:
@ -530,8 +532,8 @@ Response:
"success": true,
"data": {
"messages": [
{"entryIndex": 0, "text": "First prompt..."},
{"entryIndex": 2, "text": "Second prompt..."}
{"entryId": "abc123", "text": "First prompt..."},
{"entryId": "def456", "text": "Second prompt..."}
]
}
}
@ -618,7 +620,7 @@ A turn consists of one assistant response plus any resulting tool calls and resu
### message_start / message_end
Emitted when a message begins and completes. The `message` field contains an `AppMessage`.
Emitted when a message begins and completes. The `message` field contains an `AgentMessage`.
```json
{"type": "message_start", "message": {...}}
@ -717,20 +719,27 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd
Emitted when automatic compaction runs (when context is nearly full).
```json
{"type": "auto_compaction_start"}
{"type": "auto_compaction_start", "reason": "threshold"}
```
The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit).
```json
{
"type": "auto_compaction_end",
"result": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"summary": "Summary of conversation..."
"details": {}
},
"aborted": false
"aborted": false,
"willRetry": false
}
```
If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.
If compaction was aborted, `result` is `null` and `aborted` is `true`.
### auto_retry_start / auto_retry_end
@ -806,7 +815,7 @@ Parse errors:
Source files:
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types

View file

@ -1,3 +1,5 @@
> pi can help you use the SDK. Ask it to build an integration for your use case.
# SDK
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
@ -81,26 +83,32 @@ interface AgentSession {
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
// Session info
sessionFile: string | null;
sessionFile: string | undefined; // undefined for in-memory
sessionId: string;
// Model control
setModel(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | null>;
cycleThinkingLevel(): ThinkingLevel | null;
cycleModel(): Promise<ModelCycleResult | undefined>;
cycleThinkingLevel(): ThinkingLevel | undefined;
// State access
agent: Agent;
model: Model | null;
model: Model | undefined;
thinkingLevel: ThinkingLevel;
messages: AppMessage[];
messages: AgentMessage[];
isStreaming: boolean;
// Session management
reset(): Promise<void>;
branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>;
switchSession(sessionPath: string): Promise<void>;
reset(): Promise<boolean>; // Returns false if cancelled by hook
switchSession(sessionPath: string): Promise<boolean>;
// Branching
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
// Hook message injection
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
// Compaction
compact(customInstructions?: string): Promise<CompactionResult>;
@ -122,7 +130,7 @@ The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM inte
// Access current state
const state = session.agent.state;
// state.messages: AppMessage[] - conversation history
// state.messages: AgentMessage[] - conversation history
// state.model: Model - current model
// state.thinkingLevel: ThinkingLevel - current thinking level
// state.systemPrompt: string - system prompt
@ -394,10 +402,10 @@ const { session } = await createAgentSession({
```typescript
import { Type } from "@sinclair/typebox";
import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent";
import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
// Inline custom tool
const myTool: CustomAgentTool = {
const myTool: CustomTool = {
name: "my_tool",
label: "My Tool",
description: "Does something useful",
@ -436,18 +444,38 @@ import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechn
// Inline hook
const loggingHook: HookFactory = (api) => {
// Log tool calls
api.on("tool_call", async (event) => {
console.log(`Tool: ${event.toolName}`);
return undefined; // Don't block
});
// Block dangerous commands
api.on("tool_call", async (event) => {
// Block dangerous commands
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
return { block: true, reason: "Dangerous command" };
}
return undefined;
});
// Register custom slash command
api.registerCommand("stats", {
description: "Show session stats",
handler: async (ctx) => {
const entries = ctx.sessionManager.getEntries();
ctx.ui.notify(`${entries.length} entries`, "info");
},
});
// Inject messages
api.sendMessage({
customType: "my-hook",
content: "Hook initialized",
display: false, // Hidden from TUI
}, false); // Don't trigger agent turn
// Persist hook state
api.appendEntry("my-hook", { initialized: true });
};
// Replace discovery
@ -472,7 +500,15 @@ const { session } = await createAgentSession({
});
```
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts)
Hook API methods:
- `api.on(event, handler)` - Subscribe to events
- `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.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
- `api.exec(command, args, options?)` - Execute shell commands
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) and [docs/hooks.md](hooks.md)
### Skills
@ -560,6 +596,8 @@ const { session } = await createAgentSession({
### Session Management
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
```typescript
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
@ -597,12 +635,32 @@ const customDir = "/path/to/my-sessions";
const { session } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd(), customDir),
});
// Also works with list and continueRecent:
// SessionManager.list(process.cwd(), customDir);
// SessionManager.continueRecent(process.cwd(), customDir);
```
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts)
**SessionManager tree API:**
```typescript
const sm = SessionManager.open("/path/to/session.jsonl");
// Tree traversal
const entries = sm.getEntries(); // All entries (excludes header)
const tree = sm.getTree(); // Full tree structure
const path = sm.getPath(); // Path from root to current leaf
const leaf = sm.getLeafEntry(); // Current leaf entry
const entry = sm.getEntry(id); // Get entry by ID
const children = sm.getChildren(id); // Direct children of entry
// Labels
const label = sm.getLabel(id); // Get label for entry
sm.appendLabelChange(id, "checkpoint"); // Set label
// Branching
sm.branch(entryId); // Move leaf to earlier entry
sm.branchWithSummary(id, "Summary..."); // Branch with context summary
sm.createBranchedSession(leafId); // Extract path to new file
```
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) and [docs/session.md](session.md)
### Settings Management
@ -737,7 +795,7 @@ import {
readTool,
bashTool,
type HookFactory,
type CustomAgentTool,
type CustomTool,
} from "@mariozechner/pi-coding-agent";
// Set up auth storage (custom location)
@ -760,7 +818,7 @@ const auditHook: HookFactory = (api) => {
};
// Inline tool
const statusTool: CustomAgentTool = {
const statusTool: CustomTool = {
name: "status",
label: "Status",
description: "Get system status",
@ -876,7 +934,7 @@ createGrepTool, createFindTool, createLsTool
// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type CustomAgentTool
type CustomTool
type HookFactory
type Skill
type FileSlashCommand
@ -888,7 +946,21 @@ type Tool
For hook types, import from the hooks subpath:
```typescript
import type { HookAPI, HookEvent, ToolCallEvent } from "@mariozechner/pi-coding-agent/hooks";
import type {
HookAPI,
HookMessage,
HookFactory,
HookEventContext,
HookCommandContext,
ToolCallEvent,
ToolResultEvent,
} from "@mariozechner/pi-coding-agent/hooks";
```
For message utilities:
```typescript
import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
```
For config utilities:

View file

@ -0,0 +1,441 @@
# 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

View file

@ -1,452 +0,0 @@
# Session Tree Format
Analysis of switching from linear JSONL to tree-based session storage.
## Current Format (Linear)
```jsonl
{"type":"session","id":"...","timestamp":"...","cwd":"..."}
{"type":"message","timestamp":"...","message":{"role":"user",...}}
{"type":"message","timestamp":"...","message":{"role":"assistant",...}}
{"type":"compaction","timestamp":"...","summary":"...","firstKeptEntryIndex":2,"tokensBefore":50000}
{"type":"message","timestamp":"...","message":{"role":"user",...}}
```
Context is built by scanning linearly, applying compaction ranges.
## Proposed Format (Tree)
Each entry has a `uuid` and `parentUuid` field (null for root). Session header includes `version` for future migrations:
```jsonl
{"type":"session","version":2,"uuid":"a1b2c3","parentUuid":null,"id":"...","cwd":"..."}
{"type":"message","uuid":"d4e5f6","parentUuid":"a1b2c3","message":{"role":"user",...}}
{"type":"message","uuid":"g7h8i9","parentUuid":"d4e5f6","message":{"role":"assistant",...}}
{"type":"message","uuid":"j0k1l2","parentUuid":"g7h8i9","message":{"role":"user",...}}
{"type":"message","uuid":"m3n4o5","parentUuid":"j0k1l2","message":{"role":"assistant",...}}
```
Version history:
- **v1** (implicit): Linear format, no uuid/parentUuid
- **v2**: Tree format with uuid/parentUuid
The **last entry** is always the current leaf. Context = walk from leaf to root via `parentUuid`.
Using UUIDs (like Claude Code does) instead of indices because:
- No remapping needed when branching to new file
- Robust to entry deletion/reordering
- Orphan references are detectable
- ~30 extra bytes per entry is negligible for text-heavy sessions
### Branching
Branch from entry `g7h8i9` (after first assistant response):
```jsonl
... entries unchanged ...
{"type":"message","uuid":"p6q7r8","parentUuid":"g7h8i9","message":{"role":"user",...}}
{"type":"message","uuid":"s9t0u1","parentUuid":"p6q7r8","message":{"role":"assistant",...}}
```
Walking s9t0u1→p6q7r8→g7h8i9→d4e5f6→a1b2c3 gives the branched context.
The old path (j0k1l2, m3n4o5) remains in the file but is not in the current context.
### Visual
```
[a1b2:session]
[d4e5:user "hello"]
[g7h8:assistant "hi"]
┌────┴────┐
│ │
[j0k1:user A] [p6q7:user B] ← branch point
│ │
[m3n4:asst A] [s9t0:asst B] ← current leaf
(old path)
```
## Context Building
```typescript
function buildContext(entries: SessionEntry[]): AppMessage[] {
// Build UUID -> entry map
const byUuid = new Map(entries.map(e => [e.uuid, e]));
// Start from last entry (current leaf)
let current: SessionEntry | undefined = entries[entries.length - 1];
// Walk to root, collecting messages
const path: SessionEntry[] = [];
while (current) {
path.unshift(current);
current = current.parentUuid ? byUuid.get(current.parentUuid) : undefined;
}
// Extract messages, apply compaction summaries
return pathToMessages(path);
}
```
Complexity: O(n) to build map, O(depth) to walk. Total O(n), but walk is fast.
## Consequences for Stacking
### Current Approach (hooks-v2.md)
Stacking uses `stack_pop` entries with complex range overlap rules:
```typescript
interface StackPopEntry {
type: "stack_pop";
backToIndex: number;
summary: string;
prePopSummary?: string;
}
```
Context building requires tracking ranges, IDs, "later wins" logic.
### Tree Approach
Stacking becomes trivial branching:
```jsonl
... conversation entries ...
{"type":"stack_summary","uuid":"x1y2z3","parentUuid":"g7h8i9","summary":"Work done after this point"}
```
To "pop" to entry `g7h8i9`:
1. Generate summary of entries after `g7h8i9`
2. Append summary entry with `parentUuid: "g7h8i9"`
Context walk follows parentUuid chain. Abandoned entries are not traversed.
**No range tracking. No overlap rules. No "later wins" logic.**
### Multiple Pops
```
[a]─[b]─[c]─[d]─[e]─[f]─[g]─[h]
└─[i:summary]─[j]─[k]─[l]
└─[m:summary]─[n:current]
```
Each pop just creates a new branch. Context: n→m→k→j→i→c→b→a.
## Consequences for Compaction
### Current Approach
Compaction stores `firstKeptEntryIndex` (an index) and requires careful handling when stacking crosses compaction boundaries.
### Tree Approach
Compaction is just another entry in the linear chain, not a branch. Only change: `firstKeptEntryIndex``firstKeptEntryUuid`.
```
root → m1 → m2 → m3 → m4 → m5 → m6 → m7 → m8 → m9 → m10 → compaction
```
```jsonl
{"type":"compaction","uuid":"c1","parentUuid":"m10","summary":"...","firstKeptEntryUuid":"m6","tokensBefore":50000}
```
Context building:
1. Walk from leaf (compaction) to root
2. See compaction entry → note `firstKeptEntryUuid: "m6"`
3. Continue walking: m10, m9, m8, m7, m6 ← stop here
4. Everything before m6 is replaced by summary
5. Result: `[summary, m6, m7, m8, m9, m10]`
**Tree is for branching (stacking, alternative paths). Compaction is just a marker in the linear chain.**
### Compaction + Stacking
Stacking creates a branch, compaction is inline on each branch:
```
[root]─[m1]─[m2]─[m3]─[m4]─[m5]─[compaction1]─[m6]─[m7]─[m8]
└─[stack_summary]─[m9]─[m10]─[compaction2]─[m11:current]
```
Each branch has its own compaction history. Context walks the current branch only.
## Consequences for API
### SessionManager Changes
```typescript
interface SessionEntry {
type: string;
uuid: string; // NEW: unique identifier
parentUuid: string | null; // NEW: null for root
timestamp?: string;
// ... type-specific fields
}
class SessionManager {
// NEW: Get current leaf entry
getCurrentLeaf(): SessionEntry;
// NEW: Walk from entry to root
getPath(fromUuid?: string): SessionEntry[];
// NEW: Get entry by UUID
getEntry(uuid: string): SessionEntry | undefined;
// CHANGED: Uses tree walk instead of linear scan
buildSessionContext(): SessionContext;
// NEW: Create branch point
branch(parentUuid: string): string; // returns new entry's uuid
// NEW: Create branch with summary of abandoned subtree
branchWithSummary(parentUuid: string, summary: string): string;
// CHANGED: Simpler, just creates summary node
saveCompaction(entry: CompactionEntry): void;
// CHANGED: Now requires parentUuid (uses current leaf if omitted)
saveMessage(message: AppMessage, parentUuid?: string): void;
saveEntry(entry: SessionEntry): void;
}
```
### AgentSession Changes
```typescript
class AgentSession {
// CHANGED: Uses tree-based branching
async branch(entryUuid: string): Promise<BranchResult>;
// NEW: Branch in current session (no new file)
async branchInPlace(entryUuid: string, options?: {
summarize?: boolean; // Generate summary of abandoned subtree
}): Promise<void>;
// NEW: Get tree structure for visualization
getSessionTree(): SessionTree;
// CHANGED: Simpler implementation
async compact(): Promise<CompactionResult>;
}
interface BranchResult {
selectedText: string;
cancelled: boolean;
newSessionFile?: string; // If branching to new file
inPlace: boolean; // If branched in current file
}
```
### Hook API Changes
```typescript
interface HookEventContext {
// NEW: Tree-aware entry access
entries: readonly SessionEntry[];
currentPath: readonly SessionEntry[]; // Entries from root to current leaf
// NEW: Branch without creating new file
branchInPlace(parentUuid: string, summary?: string): Promise<void>;
// Existing
saveEntry(entry: SessionEntry): Promise<void>;
rebuildContext(): Promise<void>;
}
```
## New Features Enabled
### 1. In-Place Branching
Currently, `/branch` always creates a new session file. With tree format:
```
/branch → Create new session file (current behavior)
/branch-here → Branch in current file, optionally with summary
```
Use case: Quick "let me try something else" without file proliferation.
### 2. Branch History Navigation
```
/branches → List all branches in current session
/switch <uuid> → Switch to branch at entry
```
The session file contains full history. UI can visualize the tree.
### 3. Simpler Stacking
No hooks needed for basic stacking:
```
/pop → Branch to previous user message with auto-summary
/pop <uuid> → Branch to specific entry with auto-summary
```
Core functionality, not hook-dependent.
### 4. Subtree Export
```
/export-branch <uuid> → Export just the subtree from entry
```
Useful for sharing specific conversation paths. No index remapping needed since UUIDs are stable.
### 5. Merge/Cherry-pick (Future)
With tree structure, could support:
```
/cherry-pick <uuid> → Copy entry's message to current branch
/merge <uuid> → Merge branch into current
```
## Migration
### Strategy: Migrate on Load + Rewrite
When loading a session, check if migration is needed. If so, migrate in memory and rewrite the file. This is transparent to users and only happens once per session file.
```typescript
const CURRENT_VERSION = 2;
function loadSession(path: string): SessionEntry[] {
const content = readFileSync(path, 'utf8');
const entries = parseEntries(content);
const header = entries.find(e => e.type === 'session');
const version = header?.version ?? 1;
if (version < CURRENT_VERSION) {
migrateEntries(entries, version);
writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
}
return entries;
}
function migrateEntries(entries: SessionEntry[], fromVersion: number): void {
if (fromVersion < 2) {
// v1 → v2: Add uuid/parentUuid, convert firstKeptEntryIndex
const uuids: string[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const uuid = generateUuid();
uuids.push(uuid);
entry.uuid = uuid;
entry.parentUuid = i === 0 ? null : uuids[i - 1];
// Update session header version
if (entry.type === 'session') {
entry.version = CURRENT_VERSION;
}
// Convert compaction index to UUID
if (entry.type === 'compaction' && 'firstKeptEntryIndex' in entry) {
entry.firstKeptEntryUuid = uuids[entry.firstKeptEntryIndex];
delete entry.firstKeptEntryIndex;
}
}
}
// Future migrations: if (fromVersion < 3) { ... }
}
```
### What Gets Migrated
| v1 Field | v2 Field |
|----------|----------|
| (none) | `uuid` (generated) |
| (none) | `parentUuid` (previous entry's uuid, null for root) |
| (none on session) | `version: 2` |
| `firstKeptEntryIndex` | `firstKeptEntryUuid` |
Migrated sessions work exactly as before (linear path). Tree features become available.
### API Compatibility
- `buildSessionContext()` returns same structure
- `branch()` still works, just uses UUIDs
- Existing hooks continue to work
- Old sessions auto-migrate on first load
## Complexity Analysis
| Operation | Linear | Tree |
|-----------|--------|------|
| Append message | O(1) | O(1) |
| Build context | O(n) | O(n) map + O(depth) walk |
| Branch to new file | O(n) copy | O(path) copy, no remapping |
| Find entry by UUID | O(n) | O(1) with map |
| Compaction | O(n) | O(depth) |
Tree with UUIDs is comparable or better. The UUID map can be cached.
## File Size
Tree format adds ~50 bytes per entry (`"uuid":"...","parentUuid":"..."`, 36 chars each). For 1000-entry session: ~50KB overhead. Negligible for text-heavy sessions.
Abandoned branches remain in file but don't affect context building performance.
## Example: Full Session with Branching
```jsonl
{"type":"session","version":2,"uuid":"ses1","parentUuid":null,"id":"abc","cwd":"/project"}
{"type":"message","uuid":"m1","parentUuid":"ses1","message":{"role":"user","content":"Build a CLI"}}
{"type":"message","uuid":"m2","parentUuid":"m1","message":{"role":"assistant","content":"I'll create..."}}
{"type":"message","uuid":"m3","parentUuid":"m2","message":{"role":"user","content":"Add --verbose flag"}}
{"type":"message","uuid":"m4","parentUuid":"m3","message":{"role":"assistant","content":"Here's the flag..."}}
{"type":"message","uuid":"m5","parentUuid":"m4","message":{"role":"user","content":"Actually use Python"}}
{"type":"message","uuid":"m6","parentUuid":"m5","message":{"role":"assistant","content":"Converting to Python..."}}
{"type":"branch_summary","uuid":"bs1","parentUuid":"m2","summary":"Attempted Node.js CLI with --verbose flag"}
{"type":"message","uuid":"m7","parentUuid":"bs1","message":{"role":"user","content":"Use Rust instead"}}
{"type":"message","uuid":"m8","parentUuid":"m7","message":{"role":"assistant","content":"Creating Rust CLI..."}}
```
Context path: m8→m7→bs1→m2→m1→ses1
Result:
1. User: "Build a CLI"
2. Assistant: "I'll create..."
3. Summary: "Attempted Node.js CLI with --verbose flag"
4. User: "Use Rust instead"
5. Assistant: "Creating Rust CLI..."
Entries m3-m6 (the Node.js/Python path) are preserved but not in context.
## Prior Art
Claude Code uses the same approach:
- `uuid` field on each entry
- `parentUuid` links to parent (null for root)
- `leafUuid` in summary entries to track conversation endpoints
- Separate files for sidechains (`isSidechain: true`)
## Recommendation
The tree format with UUIDs:
- Simplifies stacking (no range overlap logic)
- Simplifies compaction (no boundary crossing)
- Enables in-place branching
- Enables branch visualization/navigation
- No index remapping on branch-to-file
- Maintains backward compatibility
- Validated by Claude Code's implementation
**Recommend implementing for v2 of hooks/session system.**

View file

@ -1,6 +1,6 @@
# Session File Format
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field.
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.
## File Location
@ -10,47 +10,66 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
Where `<path>` is the working directory with `/` replaced by `-`.
## Session Version
Sessions have a version field in the header:
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
- **Version 2**: Tree structure with `id`/`parentId` linking
Existing v1 sessions are automatically migrated to v2 when loaded.
## Type Definitions
- [`src/session-manager.ts`](../src/session-manager.ts) - Session entry types (`SessionHeader`, `SessionMessageEntry`, etc.)
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `ThinkingLevel`
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
## Entry Base
All entries (except `SessionHeader`) extend `SessionEntryBase`:
```typescript
interface SessionEntryBase {
type: string;
id: string; // 8-char hex ID
parentId: string | null; // Parent entry ID (null for first entry)
timestamp: string; // ISO timestamp
}
```
## Entry Types
### SessionHeader
First line of the file. Defines session metadata.
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
```json
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off"}
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
```
For branched sessions, includes the source session path:
For branched sessions (created via `/branch` command):
```json
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off","branchedFrom":"/path/to/original/session.jsonl"}
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","branchedFrom":"/path/to/original/session.jsonl"}
```
### SessionMessageEntry
A message in the conversation. The `message` field contains an `AppMessage` (see [rpc.md](./rpc.md#message-types)).
A message in the conversation. The `message` field contains an `AgentMessage`.
```json
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}}
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false}}
```
The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema.
### ModelChangeEntry
Emitted when the user switches models mid-session.
```json
{"type":"model_change","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
```
### ThinkingLevelChangeEntry
@ -58,9 +77,92 @@ Emitted when the user switches models mid-session.
Emitted when the user changes the thinking/reasoning level.
```json
{"type":"thinking_level_change","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
```
### CompactionEntry
Created when context is compacted. Stores a summary of earlier messages.
```json
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
```
Optional fields:
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations)
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated
### BranchSummaryEntry
Created when switching branches via `/tree` with an LLM generated summary of the left branch up to the common ancestor. Captures context from the abandoned path.
```json
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}
```
Optional fields:
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation
- `fromHook`: `true` if generated by a hook
### CustomEntry
Hook state persistence. Does NOT participate in LLM context.
```json
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}}
```
Use `customType` to identify your hook's entries on reload.
### CustomMessageEntry
Hook-injected messages that DO participate in LLM context.
```json
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true}
```
Fields:
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
- `display`: `true` = show in TUI with purple styling, `false` = hidden
- `details`: Optional hook-specific metadata (not sent to LLM)
### LabelEntry
User-defined bookmark/marker on an entry.
```json
{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}
```
Set `label` to `undefined` to clear a label.
## Tree Structure
Entries form a tree:
- First entry has `parentId: null`
- Each subsequent entry points to its parent via `parentId`
- Branching creates new children from an earlier entry
- The "leaf" is the current position in the tree
```
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
└─ [branch_summary] ─── [user msg] ← alternate branch
```
## Context Building
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
1. Collects all entries on the path
2. Extracts current model and thinking level settings
3. If a `CompactionEntry` is on the path:
- Emits the summary first
- Then messages from `firstKeptEntryId` to compaction
- Then messages after compaction
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
## Parsing Example
```typescript
@ -70,20 +172,69 @@ const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
for (const line of lines) {
const entry = JSON.parse(line);
switch (entry.type) {
case "session":
console.log(`Session: ${entry.id}, Model: ${entry.provider}/${entry.modelId}`);
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
break;
case "message":
console.log(`${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
break;
case "compaction":
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
break;
case "branch_summary":
console.log(`[${entry.id}] Branch from ${entry.fromId}`);
break;
case "custom":
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
break;
case "custom_message":
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
break;
case "label":
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
break;
case "model_change":
console.log(`Switched to: ${entry.provider}/${entry.modelId}`);
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
break;
case "thinking_level_change":
console.log(`Thinking: ${entry.thinkingLevel}`);
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
break;
}
}
```
## SessionManager API
Key methods for working with sessions programmatically:
### Creation
- `SessionManager.create(cwd, sessionDir?)` - New session
- `SessionManager.open(path, sessionDir?)` - Open existing
- `SessionManager.continueRecent(cwd, sessionDir?)` - Continue most recent or create new
- `SessionManager.inMemory(cwd?)` - No file persistence
### Appending (all return entry ID)
- `appendMessage(message)` - Add message
- `appendThinkingLevelChange(level)` - Record thinking change
- `appendModelChange(provider, modelId)` - Record model change
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
- `appendLabelChange(targetId, label)` - Set/clear label
### Tree Navigation
- `getLeafId()` - Current position
- `getEntry(id)` - Get entry by ID
- `getPath(fromId?)` - Walk from entry to root
- `getTree()` - Get full tree structure
- `getChildren(parentId)` - Get direct children
- `getLabel(id)` - Get label for entry
- `branch(entryId)` - Move leaf to earlier entry
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
### Context
- `buildSessionContext()` - Get messages for LLM
- `getEntries()` - All entries (excluding header)
- `getHeader()` - Session metadata

View file

@ -1,3 +1,5 @@
> pi can create skills. Ask it to build one for your use case.
# Skills
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.

View file

@ -1,3 +1,5 @@
> pi can create themes. Ask it to build one for your use case.
# Pi Coding Agent Themes
Themes allow you to customize the colors used throughout the coding agent TUI.
@ -20,13 +22,18 @@ Every theme must define all color tokens. There are no optional colors.
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
| `dim` | Very dimmed text | Less important info, placeholders |
| `text` | Default text color | Main content (usually `""`) |
| `thinkingText` | Thinking block text | Assistant reasoning traces |
### Backgrounds & Content Text (7 colors)
### Backgrounds & Content Text (11 colors)
| Token | Purpose |
|-------|---------|
| `selectedBg` | Selected/active line background (e.g., tree selector) |
| `userMessageBg` | User message background |
| `userMessageText` | User message text color |
| `customMessageBg` | Hook custom message background |
| `customMessageText` | Hook custom message text color |
| `customMessageLabel` | Hook custom message label/type text |
| `toolPendingBg` | Tool execution box (pending state) |
| `toolSuccessBg` | Tool execution box (success state) |
| `toolErrorBg` | Tool execution box (error state) |
@ -95,7 +102,7 @@ These create a visual hierarchy: off → minimal → low → medium → high →
|-------|---------|
| `bashMode` | Editor border color when in bash mode (! prefix) |
**Total: 46 color tokens** (all required)
**Total: 50 color tokens** (all required)
## Theme Format
@ -113,6 +120,7 @@ Themes are defined in JSON files with the following structure:
"colors": {
"accent": "blue",
"muted": "gray",
"thinkingText": "gray",
"text": "",
...
}

View file

@ -0,0 +1,197 @@
# Session Tree Navigation
The `/tree` command provides tree-based navigation of the session history.
## Overview
Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving.
### Comparison with `/branch`
| Feature | `/branch` | `/tree` |
|---------|-----------|---------|
| View | Flat list of user messages | Full tree structure |
| Action | Extracts path to **new session file** | Changes leaf in **same session** |
| Summary | Never | Optional (user prompted) |
| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` |
## Tree UI
```
├─ user: "Hello, can you help..."
│ └─ assistant: "Of course! I can..."
│ ├─ user: "Let's try approach A..."
│ │ └─ assistant: "For approach A..."
│ │ └─ [compaction: 12k tokens]
│ │ └─ user: "That worked..." ← active
│ └─ user: "Actually, approach B..."
│ └─ assistant: "For approach B..."
```
### Controls
| Key | Action |
|-----|--------|
| ↑/↓ | Navigate (depth-first order) |
| Enter | Select node |
| Escape/Ctrl+C | Cancel |
| Ctrl+U | Toggle: user messages only |
| Ctrl+O | Toggle: show all (including custom/label entries) |
### Display
- Height: half terminal height
- Current leaf marked with `← active`
- Labels shown inline: `[label-name]`
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
- Children sorted by timestamp (oldest first)
## Selection Behavior
### User Message or Custom Message
1. Leaf set to **parent** of selected node (or `null` if root)
2. Message text placed in **editor** for re-submission
3. User edits and submits, creating a new branch
### Non-User Message (assistant, compaction, etc.)
1. Leaf set to **selected node**
2. Editor stays empty
3. User continues from that point
### Selecting Root User Message
If user selects the very first message (has no parent):
1. Leaf reset to `null` (empty conversation)
2. Message text placed in editor
3. User effectively restarts from scratch
## Branch Summarization
When switching, user is prompted: "Summarize the branch you're leaving?"
### What Gets Summarized
Path from old leaf back to common ancestor with target:
```
A → B → C → D → E → F ← old leaf
↘ G → H ← target
```
Abandoned path: D → E → F (summarized)
Summarization stops at:
1. Common ancestor (always)
2. Compaction node (if encountered first)
### Summary Storage
Stored as `BranchSummaryEntry`:
```typescript
interface BranchSummaryEntry {
type: "branch_summary";
id: string;
parentId: string; // New leaf position
timestamp: string;
fromId: string; // Old leaf we abandoned
summary: string; // LLM-generated summary
details?: unknown; // Optional hook data
}
```
## Implementation
### AgentSession.navigateTree()
```typescript
async navigateTree(
targetId: string,
options?: { summarize?: boolean; customInstructions?: string }
): Promise<{ editorText?: string; cancelled: boolean }>
```
Flow:
1. Validate target, check no-op (target === current leaf)
2. Find common ancestor between old leaf and target
3. Collect entries to summarize (if requested)
4. Fire `session_before_tree` event (hook can cancel or provide summary)
5. Run default summarizer if needed
6. Switch leaf via `branch()` or `branchWithSummary()`
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
8. Fire `session_tree` event
9. Notify custom tools via session event
10. Return result with `editorText` if user message was selected
### SessionManager
- `getLeafUuid(): string | null` - Current leaf (null if empty)
- `resetLeaf(): void` - Set leaf to null (for root user message navigation)
- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp
- `branch(id)` - Change leaf pointer
- `branchWithSummary(id, summary)` - Change leaf and create summary entry
### InteractiveMode
`/tree` command shows `TreeSelectorComponent`, then:
1. Prompt for summarization
2. Call `session.navigateTree()`
3. Clear and re-render chat
4. Set editor text if applicable
## Hook Events
### `session_before_tree`
```typescript
interface TreePreparation {
targetId: string;
oldLeafId: string | null;
commonAncestorId: string | null;
entriesToSummarize: SessionEntry[];
userWantsSummary: boolean;
}
interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
model: Model;
signal: AbortSignal;
}
interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: { summary: string; details?: unknown };
}
```
### `session_tree`
```typescript
interface SessionTreeEvent {
type: "session_tree";
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromHook?: boolean;
}
```
### Example: Custom Summarizer
```typescript
export default function(pi: HookAPI) {
pi.on("session_before_tree", async (event, ctx) => {
if (!event.preparation.userWantsSummary) return;
if (event.preparation.entriesToSummarize.length === 0) return;
const summary = await myCustomSummarizer(event.preparation.entriesToSummarize);
return { summary: { summary, details: { custom: true } } };
});
}
```
## Error Handling
- Summarization failure: cancels navigation, shows error
- User abort (Escape): cancels navigation
- Hook returns `cancel: true`: cancels navigation silently

View file

@ -0,0 +1,343 @@
> pi can create TUI components. Ask it to build one for your use case.
# TUI Components
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
## Component Interface
All components implement:
```typescript
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
```
| Method | Description |
|--------|-------------|
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `invalidate?()` | Clear cached render state. |
## Using Components
**In hooks** via `ctx.ui.custom()`:
```typescript
pi.on("session_start", async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent);
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
});
```
**In custom tools** via `pi.ui.custom()`:
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
}
```
## Built-in Components
Import from `@mariozechner/pi-tui`:
```typescript
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
```
### Text
Multi-line text with word wrapping.
```typescript
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");
```
### Box
Container with padding and background color.
```typescript
const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s) // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));
```
### Container
Groups child components vertically.
```typescript
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);
```
### Spacer
Empty vertical space.
```typescript
const spacer = new Spacer(2); // 2 empty lines
```
### Markdown
Renders markdown with syntax highlighting.
```typescript
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");
```
### Image
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
```typescript
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);
```
## Keyboard Input
Use key detection helpers:
```typescript
import {
isEnter, isEscape, isTab,
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
isCtrlC, isCtrlO, isBackspace, isDelete,
// ... and more
} from "@mariozechner/pi-tui";
handleInput(data: string) {
if (isArrowUp(data)) {
this.selectedIndex--;
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data)) {
this.onCancel?.();
}
}
```
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter.
```typescript
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}
```
Utilities:
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
## Creating Custom Components
Example: Interactive selector
```typescript
import {
isEnter, isEscape, isArrowUp, isArrowDown,
truncateToWidth, visibleWidth
} from "@mariozechner/pi-tui";
class MySelector {
private items: string[];
private selected = 0;
private cachedWidth?: number;
private cachedLines?: string[];
public onSelect?: (item: string) => void;
public onCancel?: () => void;
constructor(items: string[]) {
this.items = items;
}
handleInput(data: string): void {
if (isArrowUp(data) && this.selected > 0) {
this.selected--;
this.invalidate();
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
this.selected++;
this.invalidate();
} else if (isEnter(data)) {
this.onSelect?.(this.items[this.selected]);
} else if (isEscape(data)) {
this.onCancel?.();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
this.cachedLines = this.items.map((item, i) => {
const prefix = i === this.selected ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
this.cachedWidth = width;
return this.cachedLines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
```
Usage in a hook:
```typescript
pi.registerCommand("pick", {
description: "Pick an item",
handler: async (args, ctx) => {
const items = ["Option A", "Option B", "Option C"];
const selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, "info");
handle.close();
resolve();
};
selector.onCancel = () => {
handle.close();
resolve();
};
handle = ctx.ui.custom(selector);
});
}
});
```
## Theming
Components accept theme objects for styling.
**In `renderCall`/`renderResult`**, use the `theme` parameter:
```typescript
renderResult(result, options, theme) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}
```
**Foreground colors** (`theme.fg(color, text)`):
| Category | Colors |
|----------|--------|
| General | `text`, `accent`, `muted`, `dim` |
| Status | `success`, `error`, `warning` |
| Borders | `border`, `borderAccent`, `borderMuted` |
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
| Tools | `toolTitle`, `toolOutput` |
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
| Modes | `bashMode` |
**Background colors** (`theme.bg(color, text)`):
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
**For Markdown**, use `getMarkdownTheme()`:
```typescript
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/pi-tui";
renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}
```
**For custom components**, define your own theme interface:
```typescript
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}
```
## Performance
Cache rendered output when possible:
```typescript
class CachedComponent {
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
// ... compute lines ...
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
```
Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
## 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`

View file

@ -9,10 +9,11 @@ const factory: CustomToolFactory = (_pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});

View file

@ -2,7 +2,7 @@
* Question Tool - Let the LLM ask the user a question with options
*/
import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -18,13 +18,13 @@ const QuestionParams = Type.Object({
});
const factory: CustomToolFactory = (pi) => {
const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
const tool: CustomTool<typeof QuestionParams, QuestionDetails> = {
name: "question",
label: "Question",
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
parameters: QuestionParams,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
if (!pi.hasUI) {
return {
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
@ -41,7 +41,7 @@ const factory: CustomToolFactory = (pi) => {
const answer = await pi.ui.select(params.question, params.options);
if (answer === null) {
if (answer === undefined) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: params.options, answer: null },

View file

@ -16,13 +16,14 @@ import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult, Message } from "@mariozechner/pi-ai";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
import { StringEnum } from "@mariozechner/pi-ai";
import {
type CustomAgentTool,
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
getMarkdownTheme,
type ToolAPI,
} from "@mariozechner/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -223,7 +224,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
async function runSingleAgent(
pi: ToolAPI,
pi: CustomToolAPI,
agents: AgentConfig[],
agentName: string,
task: string,
@ -410,7 +411,7 @@ const SubagentParams = Type.Object({
});
const factory: CustomToolFactory = (pi) => {
const tool: CustomAgentTool<typeof SubagentParams, SubagentDetails> = {
const tool: CustomTool<typeof SubagentParams, SubagentDetails> = {
name: "subagent",
label: "Subagent",
get description() {
@ -432,7 +433,7 @@ const factory: CustomToolFactory = (pi) => {
},
parameters: SubagentParams,
async execute(_toolCallId, params, signal, onUpdate) {
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(pi.cwd, agentScope);
const agents = discovery.agents;

View file

@ -9,7 +9,12 @@
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
import type {
CustomTool,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
} from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -43,11 +48,12 @@ const factory: CustomToolFactory = (_pi) => {
* Reconstruct state from session entries.
* Scans tool results for this tool and applies them in order.
*/
const reconstructState = (event: ToolSessionEvent) => {
const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
todos = [];
nextId = 1;
for (const entry of event.entries) {
// Use getBranch() to get entries on the current branch
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
@ -63,7 +69,7 @@ const factory: CustomToolFactory = (_pi) => {
}
};
const tool: CustomAgentTool<typeof TodoParams, TodoDetails> = {
const tool: CustomTool<typeof TodoParams, TodoDetails> = {
name: "todo",
label: "Todo",
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
@ -72,7 +78,7 @@ const factory: CustomToolFactory = (_pi) => {
// Called on session start/switch/branch/clear
onSession: reconstructState,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
switch (params.action) {
case "list":
return {

View file

@ -2,97 +2,53 @@
Example hooks for pi-coding-agent.
## Examples
### permission-gate.ts
Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).
### git-checkpoint.ts
Creates git stash checkpoints at each turn, allowing code restoration when branching.
### protected-paths.ts
Blocks writes to protected paths (.env, .git/, node_modules/).
### file-trigger.ts
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
### confirm-destructive.ts
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.
### dirty-repo-guard.ts
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.
### auto-commit-on-exit.ts
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
### custom-compaction.ts
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.
## Usage
```bash
# Test directly
# Load a hook with --hook flag
pi --hook examples/hooks/permission-gate.ts
# Or copy to hooks directory for persistent use
# Or copy to hooks directory for auto-discovery
cp permission-gate.ts ~/.pi/agent/hooks/
```
## Examples
| Hook | Description |
|------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
## Writing Hooks
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
### Key Points
**Hook structure:**
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
// "before_branch" | "branch" | "shutdown"
// event.targetTurnIndex: number (only for before_branch/branch)
// ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
// Cancel before_* actions:
if (event.reason === "before_clear") {
return { cancel: true };
}
return undefined;
});
// Subscribe to events
pi.on("tool_call", async (event, ctx) => {
// Can block tool execution
if (dangerous) {
return { block: true, reason: "Blocked" };
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" };
}
return undefined;
});
pi.on("tool_result", async (event, ctx) => {
// Can modify result
return { result: "modified result" };
// Register custom commands
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello!", "info");
},
});
}
```
**Available events:**
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
- `agent_start` / `agent_end` - per user prompt
- `turn_start` / `turn_end` - per LLM turn
- `tool_call` - before tool execution (can block)
- `tool_result` - after tool execution (can modify)
**UI methods:**
```typescript
const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
const input = await ctx.ui.input("Title", "placeholder");
ctx.ui.notify("Message", "info"); // or "warning", "error"
```
**Sending messages:**
```typescript
pi.send("Message to inject into conversation");
```

View file

@ -5,14 +5,12 @@
* Uses the last assistant message to generate a commit message.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "shutdown") return;
pi.on("session_shutdown", async (_event, ctx) => {
// Check for uncommitted changes
const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]);
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
if (code !== 0 || status.trim().length === 0) {
// Not a git repo or no changes
@ -20,9 +18,10 @@ export default function (pi: HookAPI) {
}
// Find the last assistant message for commit context
const entries = ctx.sessionManager.getEntries();
let lastAssistantText = "";
for (let i = event.entries.length - 1; i >= 0; i--) {
const entry = event.entries[i];
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message" && entry.message.role === "assistant") {
const content = entry.message.content;
if (Array.isArray(content)) {
@ -40,8 +39,8 @@ export default function (pi: HookAPI) {
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
// Stage and commit
await ctx.exec("git", ["add", "-A"]);
const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]);
await pi.exec("git", ["add", "-A"]);
const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
if (commitCode === 0 && ctx.hasUI) {
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");

View file

@ -2,59 +2,56 @@
* Confirm Destructive Actions Hook
*
* Prompts for confirmation before destructive session actions (clear, switch, branch).
* Demonstrates how to cancel session events using the before_* variants.
* Demonstrates how to cancel session events using the before_* events.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// Only handle before_* events (the ones that can be cancelled)
if (event.reason === "before_new") {
if (!ctx.hasUI) return;
pi.on("session_before_new", async (_event, ctx) => {
if (!ctx.hasUI) return;
const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session.");
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
});
pi.on("session_before_switch", async (_event, ctx) => {
if (!ctx.hasUI) return;
// Check if there are unsaved changes (messages since last assistant response)
const entries = ctx.sessionManager.getEntries();
const hasUnsavedWork = entries.some(
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
);
if (hasUnsavedWork) {
const confirmed = await ctx.ui.confirm(
"Clear session?",
"This will delete all messages in the current session.",
"Switch session?",
"You have messages in the current session. Switch anyway?",
);
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
}
if (event.reason === "before_switch") {
if (!ctx.hasUI) return;
// Check if there are unsaved changes (messages since last assistant response)
const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user");
if (hasUnsavedWork) {
const confirmed = await ctx.ui.confirm(
"Switch session?",
"You have messages in the current session. Switch anyway?",
);
if (!confirmed) {
ctx.ui.notify("Switch cancelled", "info");
return { cancel: true };
}
}
}
if (event.reason === "before_branch") {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from turn ${event.targetTurnIndex}?`, [
"Yes, create branch",
"No, stay in current session",
]);
if (choice !== "Yes, create branch") {
ctx.ui.notify("Branch cancelled", "info");
ctx.ui.notify("Switch cancelled", "info");
return { cancel: true };
}
}
});
pi.on("session_before_branch", async (event, ctx) => {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [
"Yes, create branch",
"No, stay in current session",
]);
if (choice !== "Yes, create branch") {
ctx.ui.notify("Branch cancelled", "info");
return { cancel: true };
}
});
}

View file

@ -3,7 +3,7 @@
*
* Replaces the default compaction behavior with a full summary of the entire context.
* Instead of keeping the last 20k tokens of conversation turns, this hook:
* 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary)
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
* 2. Discards all old turns completely, keeping only the summary
*
* This example also demonstrates using a different model (Gemini Flash) for summarization,
@ -14,17 +14,15 @@
*/
import { complete, getModel } from "@mariozechner/pi-ai";
import { messageTransformer } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "before_compact") return;
pi.on("session_before_compact", async (event, ctx) => {
ctx.ui.notify("Custom compaction hook triggered", "info");
const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, resolveApiKey, entries, signal } =
event;
const { preparation, branchEntries: _, signal } = event;
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
const model = getModel("google", "gemini-2.5-flash");
@ -34,35 +32,34 @@ export default function (pi: HookAPI) {
}
// Resolve API key for the summarization model
const apiKey = await resolveApiKey(model);
const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
return;
}
// Combine all messages for full summary
const allMessages = [...messagesToSummarize, ...messagesToKeep];
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
ctx.ui.notify(
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
"info",
);
// Transform app messages to pi-ai package format
const transformedMessages = messageTransformer(allMessages);
// Convert messages to readable text format
const conversationText = serializeConversation(convertToLlm(allMessages));
// Include previous summary context if available
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
// Build messages that ask for a comprehensive summary
const summaryMessages = [
...transformedMessages,
{
role: "user" as const,
content: [
{
type: "text" as const,
text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
1. The main goals and objectives discussed
2. Key decisions made and their rationale
@ -73,7 +70,11 @@ export default function (pi: HookAPI) {
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
Format the summary as structured markdown with clear sections.`,
Format the summary as structured markdown with clear sections.
<conversation>
${conversationText}
</conversation>`,
},
],
timestamp: Date.now(),
@ -94,14 +95,12 @@ Format the summary as structured markdown with clear sections.`,
return;
}
// Return a compaction entry that discards ALL messages
// firstKeptEntryIndex points past all current entries
// Return compaction content - SessionManager adds id/parentId
// Use firstKeptEntryId from preparation to keep recent messages
return {
compactionEntry: {
type: "compaction" as const,
timestamp: new Date().toISOString(),
compaction: {
summary,
firstKeptEntryIndex: entries.length,
firstKeptEntryId,
tokensBefore,
},
};

View file

@ -5,47 +5,51 @@
* Useful to ensure work is committed before switching context.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
// Check for uncommitted changes
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
if (code !== 0) {
// Not a git repo, allow the action
return;
}
const hasChanges = stdout.trim().length > 0;
if (!hasChanges) {
return;
}
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { cancel: true };
}
// Count changed files
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
"Yes, proceed anyway",
"No, let me commit first",
]);
if (choice !== "Yes, proceed anyway") {
ctx.ui.notify("Commit your changes first", "warning");
return { cancel: true };
}
}
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// Only guard destructive actions
if (event.reason !== "before_new" && event.reason !== "before_switch" && event.reason !== "before_branch") {
return;
}
pi.on("session_before_new", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "new session");
});
// Check for uncommitted changes
const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]);
pi.on("session_before_switch", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "switch session");
});
if (code !== 0) {
// Not a git repo, allow the action
return;
}
const hasChanges = stdout.trim().length > 0;
if (!hasChanges) {
return;
}
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { cancel: true };
}
// Count changed files
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
const action =
event.reason === "before_new" ? "new session" : event.reason === "before_switch" ? "switch session" : "branch";
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
"Yes, proceed anyway",
"No, let me commit first",
]);
if (choice !== "Yes, proceed anyway") {
ctx.ui.notify("Commit your changes first", "warning");
return { cancel: true };
}
pi.on("session_before_branch", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "branch");
});
}

View file

@ -9,19 +9,24 @@
*/
import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "start") return;
pi.on("session_start", async (_event, ctx) => {
const triggerFile = "/tmp/agent-trigger.txt";
fs.watch(triggerFile, () => {
try {
const content = fs.readFileSync(triggerFile, "utf-8").trim();
if (content) {
pi.send(`External trigger: ${content}`);
pi.sendMessage(
{
customType: "file-trigger",
content: `External trigger: ${content}`,
display: true,
},
true, // triggerTurn - get LLM to respond
);
fs.writeFileSync(triggerFile, ""); // Clear after reading
}
} catch {

View file

@ -5,25 +5,29 @@
* When branching, offers to restore code to that point in history.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const checkpoints = new Map<number, string>();
const checkpoints = new Map<string, string>();
let currentEntryId: string | undefined;
pi.on("turn_start", async (event, ctx) => {
// Track the current entry ID when user messages are saved
pi.on("tool_result", async (_event, ctx) => {
const leaf = ctx.sessionManager.getLeafEntry();
if (leaf) currentEntryId = leaf.id;
});
pi.on("turn_start", async () => {
// Create a git stash entry before LLM makes changes
const { stdout } = await ctx.exec("git", ["stash", "create"]);
const { stdout } = await pi.exec("git", ["stash", "create"]);
const ref = stdout.trim();
if (ref) {
checkpoints.set(event.turnIndex, ref);
if (ref && currentEntryId) {
checkpoints.set(currentEntryId, ref);
}
});
pi.on("session", async (event, ctx) => {
// Only handle before_branch events
if (event.reason !== "before_branch") return;
const ref = checkpoints.get(event.targetTurnIndex);
pi.on("session_before_branch", async (event, ctx) => {
const ref = checkpoints.get(event.entryId);
if (!ref) return;
if (!ctx.hasUI) {
@ -37,7 +41,7 @@ export default function (pi: HookAPI) {
]);
if (choice?.startsWith("Yes")) {
await ctx.exec("git", ["stash", "apply", ref]);
await pi.exec("git", ["stash", "apply", ref]);
ctx.ui.notify("Code restored to checkpoint", "info");
}
});

View file

@ -5,7 +5,7 @@
* Patterns checked: rm -rf, sudo, chmod/chown 777
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];

View file

@ -5,7 +5,7 @@
* Useful for preventing accidental modifications to sensitive files.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const protectedPaths = [".env", ".git/", "node_modules/"];

View file

@ -0,0 +1,119 @@
/**
* Q&A extraction hook - extracts questions from assistant responses
*
* Demonstrates the "prompt generator" pattern:
* 1. /qna command gets the last assistant message
* 2. Shows a spinner while extracting (hides editor)
* 3. Loads the result into the editor for user to fill in answers
*/
import { complete, type UserMessage } from "@mariozechner/pi-ai";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
Output format:
- List each question on its own line, prefixed with "Q: "
- After each question, add a blank line for the answer prefixed with "A: "
- If no questions are found, output "No questions found in the last message."
Example output:
Q: What is your preferred database?
A:
Q: Should we use TypeScript or JavaScript?
A:
Keep questions in the order they appeared. Be concise.`;
export default function (pi: HookAPI) {
pi.registerCommand("qna", {
description: "Extract questions from last assistant message into editor",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("qna requires interactive mode", "error");
return;
}
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
// Find the last assistant message on the current branch
const branch = ctx.sessionManager.getBranch();
let lastAssistantText: string | undefined;
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
if (entry.type === "message") {
const msg = entry.message;
if ("role" in msg && msg.role === "assistant") {
if (msg.stopReason !== "stop") {
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
return;
}
const textParts = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
if (textParts.length > 0) {
lastAssistantText = textParts.join("\n");
break;
}
}
}
}
if (!lastAssistantText) {
ctx.ui.notify("No assistant messages found", "error");
return;
}
// Run extraction with loader UI
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
loader.onAbort = () => done(null);
// Do the work
const doExtract = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
const userMessage: UserMessage = {
role: "user",
content: [{ type: "text", text: lastAssistantText! }],
timestamp: Date.now(),
};
const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal },
);
if (response.stopReason === "aborted") {
return null;
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
};
doExtract()
.then(done)
.catch(() => done(null));
return loader;
});
if (result === null) {
ctx.ui.notify("Cancelled", "info");
return;
}
ctx.ui.setEditorText(result);
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
},
});
}

View file

@ -0,0 +1,343 @@
/**
* Snake game hook - play snake with /snake command
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui";
const GAME_WIDTH = 40;
const GAME_HEIGHT = 15;
const TICK_MS = 100;
type Direction = "up" | "down" | "left" | "right";
type Point = { x: number; y: number };
interface GameState {
snake: Point[];
food: Point;
direction: Direction;
nextDirection: Direction;
score: number;
gameOver: boolean;
highScore: number;
}
function createInitialState(): GameState {
const startX = Math.floor(GAME_WIDTH / 2);
const startY = Math.floor(GAME_HEIGHT / 2);
return {
snake: [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
],
food: spawnFood([{ x: startX, y: startY }]),
direction: "right",
nextDirection: "right",
score: 0,
gameOver: false,
highScore: 0,
};
}
function spawnFood(snake: Point[]): Point {
let food: Point;
do {
food = {
x: Math.floor(Math.random() * GAME_WIDTH),
y: Math.floor(Math.random() * GAME_HEIGHT),
};
} while (snake.some((s) => s.x === food.x && s.y === food.y));
return food;
}
class SnakeComponent {
private state: GameState;
private interval: ReturnType<typeof setInterval> | null = null;
private onClose: () => void;
private onSave: (state: GameState | null) => void;
private tui: { requestRender: () => void };
private cachedLines: string[] = [];
private cachedWidth = 0;
private version = 0;
private cachedVersion = -1;
private paused: boolean;
constructor(
tui: { requestRender: () => void },
onClose: () => void,
onSave: (state: GameState | null) => void,
savedState?: GameState,
) {
this.tui = tui;
if (savedState && !savedState.gameOver) {
// Resume from saved state, start paused
this.state = savedState;
this.paused = true;
} else {
// New game or saved game was over
this.state = createInitialState();
if (savedState) {
this.state.highScore = savedState.highScore;
}
this.paused = false;
this.startGame();
}
this.onClose = onClose;
this.onSave = onSave;
}
private startGame(): void {
this.interval = setInterval(() => {
if (!this.state.gameOver) {
this.tick();
this.version++;
this.tui.requestRender();
}
}, TICK_MS);
}
private tick(): void {
// Apply queued direction change
this.state.direction = this.state.nextDirection;
// Calculate new head position
const head = this.state.snake[0];
let newHead: Point;
switch (this.state.direction) {
case "up":
newHead = { x: head.x, y: head.y - 1 };
break;
case "down":
newHead = { x: head.x, y: head.y + 1 };
break;
case "left":
newHead = { x: head.x - 1, y: head.y };
break;
case "right":
newHead = { x: head.x + 1, y: head.y };
break;
}
// Check wall collision
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
this.state.gameOver = true;
return;
}
// Check self collision
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
this.state.gameOver = true;
return;
}
// Move snake
this.state.snake.unshift(newHead);
// Check food collision
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
this.state.score += 10;
if (this.state.score > this.state.highScore) {
this.state.highScore = this.state.score;
}
this.state.food = spawnFood(this.state.snake);
} else {
this.state.snake.pop();
}
}
handleInput(data: string): void {
// If paused (resuming), wait for any key
if (this.paused) {
if (isEscape(data) || data === "q" || data === "Q") {
// Quit without clearing save
this.dispose();
this.onClose();
return;
}
// Any other key resumes
this.paused = false;
this.startGame();
return;
}
// ESC to pause and save
if (isEscape(data)) {
this.dispose();
this.onSave(this.state);
this.onClose();
return;
}
// Q to quit without saving (clears saved state)
if (data === "q" || data === "Q") {
this.dispose();
this.onSave(null); // Clear saved state
this.onClose();
return;
}
// Arrow keys or WASD
if (isArrowUp(data) || data === "w" || data === "W") {
if (this.state.direction !== "down") this.state.nextDirection = "up";
} else if (isArrowDown(data) || data === "s" || data === "S") {
if (this.state.direction !== "up") this.state.nextDirection = "down";
} else if (isArrowRight(data) || data === "d" || data === "D") {
if (this.state.direction !== "left") this.state.nextDirection = "right";
} else if (isArrowLeft(data) || data === "a" || data === "A") {
if (this.state.direction !== "right") this.state.nextDirection = "left";
}
// Restart on game over
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
const highScore = this.state.highScore;
this.state = createInitialState();
this.state.highScore = highScore;
this.onSave(null); // Clear saved state on restart
this.version++;
this.tui.requestRender();
}
}
invalidate(): void {
this.cachedWidth = 0;
}
render(width: number): string[] {
if (width === this.cachedWidth && this.cachedVersion === this.version) {
return this.cachedLines;
}
const lines: string[] = [];
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
const cellWidth = 2;
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
const effectiveHeight = GAME_HEIGHT;
// Colors
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
const boxWidth = effectiveWidth * cellWidth;
// Helper to pad content inside box
const boxLine = (content: string) => {
const contentLen = visibleWidth(content);
const padding = Math.max(0, boxWidth - contentLen);
return dim(" │") + content + " ".repeat(padding) + dim("│");
};
// Top border
lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));
// Header with score
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
const title = `${bold(green("SNAKE"))}${scoreText}${highText}`;
lines.push(this.padLine(boxLine(title), width));
// Separator
lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));
// Game grid
for (let y = 0; y < effectiveHeight; y++) {
let row = "";
for (let x = 0; x < effectiveWidth; x++) {
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
const isFood = this.state.food.x === x && this.state.food.y === y;
if (isHead) {
row += green("██"); // Snake head (2 chars)
} else if (isBody) {
row += green("▓▓"); // Snake body (2 chars)
} else if (isFood) {
row += red("◆ "); // Food (2 chars)
} else {
row += " "; // Empty cell (2 spaces)
}
}
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
}
// Separator
lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));
// Footer
let footer: string;
if (this.paused) {
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
} else if (this.state.gameOver) {
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
} else {
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
}
lines.push(this.padLine(boxLine(footer), width));
// Bottom border
lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));
this.cachedLines = lines;
this.cachedWidth = width;
this.cachedVersion = this.version;
return lines;
}
private padLine(line: string, width: number): string {
// Calculate visible length (strip ANSI codes)
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
const padding = Math.max(0, width - visibleLen);
return line + " ".repeat(padding);
}
dispose(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
const SNAKE_SAVE_TYPE = "snake-save";
export default function (pi: HookAPI) {
pi.registerCommand("snake", {
description: "Play Snake!",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Snake requires interactive mode", "error");
return;
}
// Load saved state from session
const entries = ctx.sessionManager.getEntries();
let savedState: GameState | undefined;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
savedState = entry.data as GameState;
break;
}
}
await ctx.ui.custom((tui, _theme, done) => {
return new SnakeComponent(
tui,
() => done(undefined),
(state) => {
// Save or clear state
pi.appendEntry(SNAKE_SAVE_TYPE, state);
},
savedState,
);
});
},
});
}

View file

@ -5,7 +5,7 @@
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
*/
import { createAgentSession } from "../../src/index.js";
import { createAgentSession } from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession();

View file

@ -5,7 +5,7 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = discoverAuthStorage();

View file

@ -4,7 +4,7 @@
* Shows how to replace or modify the default system prompt.
*/
import { createAgentSession, SessionManager } from "../../src/index.js";
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
// Option 1: Replace prompt entirely
const { session: session1 } = await createAgentSession({

View file

@ -5,7 +5,7 @@
* Discover, filter, merge, or replace them.
*/
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js";
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const allSkills = discoverSkills();

View file

@ -8,10 +8,9 @@
* tools resolve paths relative to your cwd, not process.cwd().
*/
import { Type } from "@sinclair/typebox";
import {
bashTool, // read, bash, edit, write - uses process.cwd()
type CustomAgentTool,
type CustomTool,
createAgentSession,
createBashTool,
createCodingTools, // Factory: creates tools for specific cwd
@ -21,7 +20,8 @@ import {
readOnlyTools, // read, grep, find, ls - uses process.cwd()
readTool,
SessionManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Read-only mode (no edit/write) - uses process.cwd()
await createAgentSession({
@ -55,7 +55,7 @@ await createAgentSession({
console.log("Specific tools with custom cwd session created");
// Inline custom tool (needs TypeBox schema)
const weatherTool: CustomAgentTool = {
const weatherTool: CustomTool = {
name: "get_weather",
label: "Get Weather",
description: "Get current weather for a city",

View file

@ -4,7 +4,7 @@
* Hooks intercept agent events for logging, blocking, or modification.
*/
import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js";
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
// Logging hook
const loggingHook: HookFactory = (api) => {

View file

@ -4,7 +4,7 @@
* Context files provide project-specific instructions loaded into the system prompt.
*/
import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js";
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files walking up from cwd
const discovered = discoverContextFiles();

View file

@ -4,7 +4,12 @@
* File-based commands that inject content when invoked with /commandname.
*/
import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js";
import {
createAgentSession,
discoverSlashCommands,
type FileSlashCommand,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
const discovered = discoverSlashCommands();

View file

@ -11,7 +11,7 @@ import {
discoverModels,
ModelRegistry,
SessionManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json

View file

@ -4,7 +4,7 @@
* Override settings using SettingsManager.
*/
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
// Load current settings (merged global + project)
const settings = loadSettings();

View file

@ -4,7 +4,7 @@
* Control session persistence: in-memory, new file, continue, or open specific.
*/
import { createAgentSession, SessionManager } from "../../src/index.js";
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
// In-memory (no persistence)
const { session: inMemory } = await createAgentSession({

View file

@ -9,10 +9,9 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
AuthStorage,
type CustomAgentTool,
type CustomTool,
createAgentSession,
createBashTool,
createReadTool,
@ -20,7 +19,8 @@ import {
ModelRegistry,
SessionManager,
SettingsManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Custom auth storage location
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
@ -42,7 +42,7 @@ const auditHook: HookFactory = (api) => {
};
// Inline custom tool
const statusTool: CustomAgentTool = {
const statusTool: CustomTool = {
name: "status",
label: "Status",
description: "Get system status",
@ -68,15 +68,12 @@ const cwd = process.cwd();
const { session } = await createAgentSession({
cwd,
agentDir: "/tmp/my-agent",
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
systemPrompt: `You are a minimal assistant.
Available: read, bash, status. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
customTools: [{ tool: statusTool }],

View file

@ -3,21 +3,21 @@
*/
import { access, readFile, stat } from "node:fs/promises";
import type { Attachment } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
export interface ProcessedFiles {
textContent: string;
imageAttachments: Attachment[];
text: string;
images: ImageContent[];
}
/** Process @file arguments into text content and image attachments */
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
let textContent = "";
const imageAttachments: Attachment[] = [];
let text = "";
const images: ImageContent[] = [];
for (const fileArg of fileArgs) {
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
@ -45,24 +45,21 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
const content = await readFile(absolutePath);
const base64Content = content.toString("base64");
const attachment: Attachment = {
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
const attachment: ImageContent = {
type: "image",
fileName: absolutePath.split("/").pop() || absolutePath,
mimeType,
size: stats.size,
content: base64Content,
data: base64Content,
};
imageAttachments.push(attachment);
images.push(attachment);
// Add text reference to image
textContent += `<file name="${absolutePath}"></file>\n`;
text += `<file name="${absolutePath}"></file>\n`;
} else {
// Handle text file
try {
const content = await readFile(absolutePath, "utf-8");
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
@ -71,5 +68,5 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
}
}
return { textContent, imageAttachments };
return { text, images };
}

View file

@ -75,6 +75,11 @@ export function getDocsPath(): string {
return resolve(join(getPackageDir(), "docs"));
}
/** Get path to examples directory */
export function getExamplesPath(): string {
return resolve(join(getPackageDir(), "examples"));
}
/** Get path to CHANGELOG.md */
export function getChangelogPath(): string {
return resolve(join(getPackageDir(), "CHANGELOG.md"));

File diff suppressed because it is too large Load diff

View file

@ -94,8 +94,8 @@ export class AuthStorage {
/**
* Get credential for a provider.
*/
get(provider: string): AuthCredential | null {
return this.data[provider] ?? null;
get(provider: string): AuthCredential | undefined {
return this.data[provider] ?? undefined;
}
/**
@ -191,7 +191,7 @@ export class AuthStorage {
* 4. Environment variable
* 5. Fallback resolver (models.json custom providers)
*/
async getApiKey(provider: string): Promise<string | null> {
async getApiKey(provider: string): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(provider);
if (runtimeKey) {
@ -230,6 +230,6 @@ export class AuthStorage {
if (envKey) return envKey;
// Fall back to custom resolver (e.g., models.json custom providers)
return this.fallbackResolver?.(provider) ?? null;
return this.fallbackResolver?.(provider) ?? undefined;
}
}

View file

@ -29,8 +29,8 @@ export interface BashExecutorOptions {
export interface BashResult {
/** Combined stdout + stderr output (sanitized, possibly truncated) */
output: string;
/** Process exit code (null if killed/cancelled) */
exitCode: number | null;
/** Process exit code (undefined if killed/cancelled) */
exitCode: number | undefined;
/** Whether the command was cancelled via signal */
cancelled: boolean;
/** Whether the output was truncated */
@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
child.kill();
resolve({
output: "",
exitCode: null,
exitCode: undefined,
cancelled: true,
truncated: false,
});
@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
resolve({
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: code,
exitCode: cancelled ? undefined : code,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,

View file

@ -1,530 +0,0 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } from "./messages.js";
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
// ============================================================================
// Types
// ============================================================================
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AppMessage): Usage | null {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
return null;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) return usage;
}
}
return null;
}
/**
* Check if compaction should trigger based on context usage.
*/
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
export function estimateTokens(message: AppMessage): number {
let chars = 0;
// Handle bashExecution messages
if (message.role === "bashExecution") {
const bash = message as unknown as { command: string; output: string };
chars = bash.command.length + bash.output.length;
return Math.ceil(chars / 4);
}
// Handle user messages
if (message.role === "user") {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
return Math.ceil(chars / 4);
}
// Handle assistant messages
if (message.role === "assistant") {
const assistant = message as AssistantMessage;
for (const block of assistant.content) {
if (block.type === "text") {
chars += block.text.length;
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
// Handle tool results
if (message.role === "toolResult") {
const toolResult = message as { content: Array<{ type: string; text?: string }> };
for (const block of toolResult.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
return Math.ceil(chars / 4);
}
return 0;
}
/**
* Find valid cut points: indices of user, assistant, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
if (entry.type === "message") {
const role = entry.message.role;
// user, assistant, and bashExecution are valid cut points
// toolResult must stay with its preceding tool call
if (role === "user" || role === "assistant" || role === "bashExecution") {
cutPoints.push(i);
}
}
}
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type === "message") {
const role = entry.message.role;
if (role === "user" || role === "bashExecution") {
return i;
}
}
}
return -1;
}
export interface CutPointResult {
/** Index of first entry to keep */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
export function findCutPoint(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = startIndex; // Default: keep everything in range
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}
// ============================================================================
// Summarization
// ============================================================================
const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- Absolute file paths of any relevant files that were read or modified
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
/**
* Generate a summary of the conversation using the LLM.
*/
export async function generateSummary(
currentMessages: AppMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
const prompt = customInstructions
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
: SUMMARIZATION_PROMPT;
// Transform custom messages (like bashExecution) to LLM-compatible messages
const transformedMessages = messageTransformer(currentMessages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: prompt }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
const textContent = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return textContent;
}
// ============================================================================
// Compaction Preparation (for hooks)
// ============================================================================
export interface CompactionPreparation {
cutPoint: CutPointResult;
/** Messages that will be summarized and discarded */
messagesToSummarize: AppMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AppMessage[];
tokensBefore: number;
boundaryStart: number;
}
export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
return null;
}
let prevCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = entries.length;
const lastUsage = getLastAssistantUsage(entries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AppMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
messagesToSummarize.push(entry.message);
}
}
// Messages to keep (recent turns, kept after summary)
const messagesToKeep: AppMessage[] = [];
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
messagesToKeep.push(entry.message);
}
}
return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.
Create a handoff summary that captures:
- What the user originally asked for in this turn
- Key decisions and progress made early in this turn
- Important context needed to understand the kept suffix
Be concise. Focus on information needed to understand the retained recent work.`;
/**
* Calculate compaction and generate summary.
* Returns the CompactionEntry to append to the session file.
*
* @param entries - All session entries
* @param model - Model to use for summarization
* @param settings - Compaction settings
* @param apiKey - API key for LLM
* @param signal - Optional abort signal
* @param customInstructions - Optional custom focus for the summary
*/
export async function compact(
entries: SessionEntry[],
model: Model<any>,
settings: CompactionSettings,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
): Promise<CompactionEntry> {
// Don't compact if the last entry is already a compaction
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
throw new Error("Already compacted");
}
// Find previous compaction boundary
let prevCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = entries.length;
// Get token count before compaction
const lastUsage = getLastAssistantUsage(entries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
// Find cut point (entry index) within the valid range
const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Extract messages for history summary (before the turn that contains the cut point)
const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex;
const historyMessages: AppMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
historyMessages.push(entry.message);
}
}
// Include previous summary if there was a compaction
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
historyMessages.unshift({
role: "user",
content: `Previous session summary:\n${prevCompaction.summary}`,
timestamp: Date.now(),
});
}
// Extract messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AppMessage[] = [];
if (cutResult.isSplitTurn) {
for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
const entry = entries[i];
if (entry.type === "message") {
turnPrefixMessages.push(entry.message);
}
}
}
// Generate summaries (can be parallel if both needed) and merge into one
let summary: string;
if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
historyMessages.length > 0
? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
// Just generate history summary
summary = await generateSummary(
historyMessages,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
);
}
return {
type: "compaction",
timestamp: new Date().toISOString(),
summary,
firstKeptEntryIndex: cutResult.firstKeptEntryIndex,
tokensBefore,
};
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AppMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const transformedMessages = messageTransformer(messages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
}

View file

@ -0,0 +1,343 @@
/**
* Branch summarization for tree navigation.
*
* When navigating to a different point in the session tree, this generates
* a summary of the branch being left so context isn't lost.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { completeSimple } from "@mariozechner/pi-ai";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
} from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================
// Types
// ============================================================================
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
}
/** Details stored in BranchSummaryEntry.details for file tracking */
export interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */
messages: AgentMessage[];
/** File operations extracted from tool calls */
fileOps: FileOperations;
/** Total estimated tokens in messages */
totalTokens: number;
}
export interface CollectEntriesResult {
/** Entries to summarize, in chronological order */
entries: SessionEntry[];
/** Common ancestor between old and new position, if any */
commonAncestorId: string | null;
}
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
model: Model<any>;
/** API key for the model */
apiKey: string;
/** Abort signal for cancellation */
signal: AbortSignal;
/** Optional custom instructions for summarization */
customInstructions?: string;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
}
// ============================================================================
// Entry Collection
// ============================================================================
/**
* Collect entries that should be summarized when navigating from one position to another.
*
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
* along the way. Does NOT stop at compaction boundaries - those are included and their
* summaries become context.
*
* @param session - Session manager (read-only access)
* @param oldLeafId - Current position (where we're navigating from)
* @param targetId - Target position (where we're navigating to)
* @returns Entries to summarize and the common ancestor
*/
export function collectEntriesForBranchSummary(
session: ReadonlySessionManager,
oldLeafId: string | null,
targetId: string,
): CollectEntriesResult {
// If no old position, nothing to summarize
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
const targetPath = session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
commonAncestorId = targetPath[i].id;
break;
}
}
// Collect entries from old leaf back to common ancestor
const entries: SessionEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = session.getEntry(current);
if (!entry) break;
entries.push(entry);
current = entry.parentId;
}
// Reverse to get chronological order
entries.reverse();
return { entries, commonAncestorId };
}
// ============================================================================
// Entry to Message Conversion
// ============================================================================
/**
* Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message;
case "custom_message":
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
// These don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
return undefined;
}
}
/**
* Prepare entries for summarization with token budget.
*
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long.
*
* Also collects file operations from:
* - Tool calls in assistant messages
* - Existing branch_summary entries' details (for cumulative tracking)
*
* @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit)
*/
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
// Modified files go into both edited and written for proper deduplication
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
messages.unshift(message);
totalTokens += tokens;
}
return { messages, fileOps, totalTokens };
}
// ============================================================================
// Summary Generation
// ============================================================================
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
`;
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
Use this EXACT format:
## Goal
[What was the user trying to accomplish in this branch?]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Work that was started but not finished]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [What should happen next to continue this work]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of abandoned branch entries.
*
* @param entries - Session entries to summarize (chronological order)
* @param options - Generation options
*/
export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return { summary: "No content to summarize" };
}
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, signal, maxTokens: 2048 },
);
// Check if aborted or errored
if (response.stopReason === "aborted") {
return { aborted: true };
}
if (response.stopReason === "error") {
return { error: response.errorMessage || "Summarization failed" };
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
// Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return {
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
};
}

View file

@ -0,0 +1,742 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete, completeSimple } from "@mariozechner/pi-ai";
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================
// File Operation Tracking
// ============================================================================
/** Details stored in CompactionEntry.details for file tracking */
export interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
/**
* Extract file operations from messages and previous compaction entries.
*/
function extractFileOperations(
messages: AgentMessage[],
entries: SessionEntry[],
prevCompactionIndex: number,
): FileOperations {
const fileOps = createFileOps();
// Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
for (const f of details.modifiedFiles) fileOps.edited.add(f);
}
}
}
// Extract from tool calls in messages
for (const msg of messages) {
extractFileOpsFromMessage(msg, fileOps);
}
return fileOps;
}
// ============================================================================
// Message Extraction
// ============================================================================
/**
* Extract AgentMessage from an entry if it produces one.
* Returns undefined for entries that don't contribute to LLM context.
*/
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message;
}
if (entry.type === "custom_message") {
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
}
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
}
return undefined;
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
}
// ============================================================================
// Types
// ============================================================================
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) return usage;
}
}
return undefined;
}
/**
* Check if compaction should trigger based on context usage.
*/
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user": {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
return Math.ceil(chars / 4);
}
case "assistant": {
const assistant = message as AssistantMessage;
for (const block of assistant.content) {
if (block.type === "text") {
chars += block.text.length;
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
case "hookMessage":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
} else {
for (const block of message.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
}
}
}
return Math.ceil(chars / 4);
}
case "bashExecution": {
chars = message.command.length + message.output.length;
return Math.ceil(chars / 4);
}
case "branchSummary":
case "compactionSummary": {
chars = message.summary.length;
return Math.ceil(chars / 4);
}
}
return 0;
}
/**
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
switch (entry.type) {
case "message": {
const role = entry.message.role;
switch (role) {
case "bashExecution":
case "hookMessage":
case "branchSummary":
case "compactionSummary":
case "user":
case "assistant":
cutPoints.push(i);
break;
case "toolResult":
break;
}
break;
}
case "thinking_level_change":
case "model_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
}
// branch_summary and custom_message are user-role messages, valid cut points
if (entry.type === "branch_summary" || entry.type === "custom_message") {
cutPoints.push(i);
}
}
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
// branch_summary and custom_message are user-role messages, can start a turn
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
if (entry.type === "message") {
const role = entry.message.role;
if (role === "user" || role === "bashExecution") {
return i;
}
}
}
return -1;
}
export interface CutPointResult {
/** Index of first entry to keep */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
export function findCutPoint(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at session header or compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}
// ============================================================================
// Summarization
// ============================================================================
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
*/
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
},
];
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: "high" },
);
if (response.stopReason === "error") {
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
}
const textContent = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return textContent;
}
// ============================================================================
// Compaction Preparation (for hooks)
// ============================================================================
export interface CompactionPreparation {
/** UUID of first entry to keep */
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
turnPrefixMessages: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
isSplitTurn: boolean;
tokensBefore: number;
/** Summary from previous compaction, for iterative update */
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
settings: CompactionSettings;
}
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
}
let prevCompactionIndex = -1;
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = pathEntries.length;
const lastUsage = getLastAssistantUsage(pathEntries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Get UUID of first kept entry
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return undefined; // Session needs migration
}
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) messagesToSummarize.push(msg);
}
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) turnPrefixMessages.push(msg);
}
}
// Get previous summary for iterative update
let previousSummary: string | undefined;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
// Also extract file ops from turn prefix if splitting
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
};
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
apiKey: string,
customInstructions?: string,
signal?: AbortSignal,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
} = preparation;
// Generate summaries (can be parallel if both needed) and merge into one
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
previousSummary,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
// Just generate history summary
summary = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
previousSummary,
);
}
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
}
return {
summary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles } as CompactionDetails,
};
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const transformedMessages = convertToLlm(messages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
if (response.stopReason === "error") {
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
}

View file

@ -0,0 +1,7 @@
/**
* Compaction and summarization utilities.
*/
export * from "./branch-summarization.js";
export * from "./compaction.js";
export * from "./utils.js";

View file

@ -0,0 +1,154 @@
/**
* Shared utilities for compaction and branch summarization.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
// ============================================================================
// File Operation Tracking
// ============================================================================
export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
export function createFileOps(): FileOperations {
return {
read: new Set(),
written: new Set(),
edited: new Set(),
};
}
/**
* Extract file operations from tool calls in an assistant message.
*/
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || block.type !== "toolCall") continue;
if (!("arguments" in block) || !("name" in block)) continue;
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/**
* Compute final file lists from file operations.
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
// ============================================================================
// Message Serialization
// ============================================================================
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*/
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) parts.push(`[User]: ${content}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
const toolCalls: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
}
if (thinkingParts.length > 0) {
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
const content = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) {
parts.push(`[Tool result]: ${content}`);
}
}
}
return parts.join("\n\n");
}
// ============================================================================
// Summarization System Prompt
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

View file

@ -4,14 +4,18 @@
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
export type {
AgentToolResult,
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolResult,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent,
ToolAPI,
ToolUIContext,
} from "./types.js";
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";

View file

@ -7,7 +7,6 @@
* for custom tools that depend on pi packages.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
@ -15,15 +14,10 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir, isBunBinary } from "../../config.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type {
CustomToolFactory,
CustomToolsLoadResult,
ExecOptions,
ExecResult,
LoadedCustomTool,
ToolAPI,
} from "./types.js";
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -87,97 +81,18 @@ function resolveToolPath(toolPath: string, cwd: string): string {
return path.resolve(cwd, expanded);
}
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr,
code: code ?? 0,
killed,
});
});
proc.on("error", (err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr: stderr || err.message,
code: 1,
killed,
});
});
});
}
/**
* Create a no-op UI context for headless modes.
*/
function createNoOpUIContext(): HookUIContext {
return {
select: async () => null,
select: async () => undefined,
confirm: async () => false,
input: async () => null,
input: async () => undefined,
notify: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
}
@ -191,7 +106,7 @@ function createNoOpUIContext(): HookUIContext {
*/
async function loadToolWithBun(
resolvedPath: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
try {
// Try to import directly - will work for tools without @mariozechner/* imports
@ -236,7 +151,7 @@ async function loadToolWithBun(
async function loadTool(
toolPath: string,
cwd: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
const resolvedPath = resolveToolPath(toolPath, cwd);
@ -296,9 +211,10 @@ export async function loadCustomTools(
const seenNames = new Set<string>(builtInToolNames);
// Shared API object - all tools get the same instance
const sharedApi: ToolAPI = {
const sharedApi: CustomToolAPI = {
cwd,
exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, cwd, options),
exec: (command: string, args: string[], options?: ExecOptions) =>
execCommand(command, args, options?.cwd ?? cwd, options),
ui: createNoOpUIContext(),
hasUI: false,
};

View file

@ -5,56 +5,56 @@
* They can provide custom rendering for tool calls and results in the TUI.
*/
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { SessionEntry } from "../session-manager.js";
import type { ModelRegistry } from "../model-registry.js";
import type { ReadonlySessionManager } from "../session-manager.js";
/** Alias for clarity */
export type ToolUIContext = HookUIContext;
export type CustomToolUIContext = HookUIContext;
/** Re-export for custom tools to use in execute signature */
export type { AgentToolUpdateCallback };
export type { AgentToolResult, AgentToolUpdateCallback };
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
}
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/** API passed to custom tool factory (stable across session changes) */
export interface ToolAPI {
export interface CustomToolAPI {
/** Current working directory */
cwd: string;
/** Execute a command */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** UI methods for user interaction (select, confirm, input, notify) */
ui: ToolUIContext;
/** UI methods for user interaction (select, confirm, input, notify, custom) */
ui: CustomToolUIContext;
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
}
/**
* Context passed to tool execute and onSession callbacks.
* Provides access to session state and model information.
*/
export interface CustomToolContext {
/** Session manager (read-only) */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
}
/** Session event passed to onSession callback */
export interface SessionEvent {
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or null in --no-session mode */
sessionFile: string | null;
/** Previous session file path, or null for "start" and "new" */
previousSessionFile: string | null;
export interface CustomToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new";
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
previousSessionFile: string | undefined;
}
/** Rendering options passed to renderResult */
@ -65,60 +65,89 @@ export interface RenderResultOptions {
isPartial: boolean;
}
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
/**
* Custom tool with optional lifecycle and rendering methods.
* Custom tool definition.
*
* The execute signature inherited from AgentTool includes an optional onUpdate callback
* for streaming progress updates during long-running operations:
* - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM.
* - Partial updates should use the same TDetails type as the final result (use a union if needed).
* Custom tools are standalone - they don't extend AgentTool directly.
* When loaded, they are wrapped in an AgentTool for the agent to use.
*
* The execute callback receives a ToolContext with access to session state,
* model registry, and current model.
*
* @example
* ```typescript
* type Details =
* | { status: "running"; step: number; total: number }
* | { status: "done"; count: number };
* const factory: CustomToolFactory = (pi) => ({
* name: "my_tool",
* label: "My Tool",
* description: "Does something useful",
* parameters: Type.Object({ input: Type.String() }),
*
* async execute(toolCallId, params, signal, onUpdate) {
* const items = params.items || [];
* for (let i = 0; i < items.length; i++) {
* onUpdate?.({
* content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }],
* details: { status: "running", step: i + 1, total: items.length },
* });
* await processItem(items[i], signal);
* async execute(toolCallId, params, onUpdate, ctx, signal) {
* // Access session state via ctx.sessionManager
* // Access model registry via ctx.modelRegistry
* // Current model via ctx.model
* return { content: [{ type: "text", text: "Done" }] };
* },
*
* onSession(event, ctx) {
* if (event.reason === "shutdown") {
* // Cleanup
* }
* // Reconstruct state from ctx.sessionManager.getEntries()
* }
* return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } };
* }
* });
* ```
*
* Progress updates are rendered via renderResult with isPartial: true.
*/
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
extends AgentTool<TParams, TDetails> {
/** Called on session start/switch/branch/clear - use to reconstruct state from entries */
onSession?: (event: SessionEvent) => void | Promise<void>;
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
label: string;
/** Description for LLM */
description: string;
/** Parameter schema (TypeBox) */
parameters: TParams;
/**
* Execute the tool.
* @param toolCallId - Unique ID for this tool call
* @param params - Parsed parameters matching the schema
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
* @param ctx - Context with session manager, model registry, and current model
* @param signal - Optional abort signal for cancellation
*/
execute(
toolCallId: string,
params: Static<TParams>,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
/** Custom rendering for tool call display - return a Component */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
/** Custom rendering for tool result display - return a Component */
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
/** Called when session ends - cleanup resources */
dispose?: () => Promise<void> | void;
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
}
/** Factory function that creates a custom tool or array of tools */
export type CustomToolFactory = (
pi: ToolAPI,
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;
pi: CustomToolAPI,
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
/** Loaded custom tool with metadata */
/** Loaded custom tool with metadata and wrapped AgentTool */
export interface LoadedCustomTool {
/** Original path (as specified) */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** The tool instance */
tool: CustomAgentTool;
/** The original custom tool instance */
tool: CustomTool;
}
/** Result from loading custom tools */
@ -126,5 +155,5 @@ export interface CustomToolsLoadResult {
tools: LoadedCustomTool[];
errors: Array<{ path: string; error: string }>;
/** Update the UI context for all loaded tools. Call when mode initializes. */
setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
}

View file

@ -0,0 +1,28 @@
/**
* Wraps CustomTool instances into AgentTool for use with the agent.
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
/**
* Wrap a CustomTool into an AgentTool.
* The wrapper injects the ToolContext into execute calls.
*/
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
};
}
/**
* Wrap all loaded custom tools into AgentTools.
*/
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
}

View file

@ -0,0 +1,104 @@
/**
* Shared command execution utilities for hooks and custom tools.
*/
import { spawn } from "node:child_process";
/**
* Options for executing shell commands.
*/
export interface ExecOptions {
/** AbortSignal to cancel the command */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
/**
* Result of executing a shell command.
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
export async function execCommand(
command: string,
args: string[],
cwd: string,
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}

View file

@ -1,4 +1,4 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, writeFileSync } from "fs";
import hljs from "highlight.js";
@ -7,7 +7,6 @@ import { homedir } from "os";
import * as path from "path";
import { basename } from "path";
import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
import type { SessionManager } from "./session-manager.js";
// ============================================================================
@ -122,7 +121,7 @@ function resolveColorValue(
}
/** Load theme JSON from built-in or custom themes directory. */
function loadThemeJson(name: string): ThemeJson | null {
function loadThemeJson(name: string): ThemeJson | undefined {
// Try built-in themes first
const themesDir = getThemesDir();
const builtinPath = path.join(themesDir, `${name}.json`);
@ -130,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson;
} catch {
return null;
return undefined;
}
}
@ -141,11 +140,11 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson;
} catch {
return null;
return undefined;
}
}
return null;
return undefined;
}
/** Build complete theme colors object, resolving theme JSON values against defaults. */
@ -821,110 +820,138 @@ function formatToolExecution(
return { html, bgColor };
}
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>, colors: ThemeColors): string {
function formatMessage(
message: AgentMessage,
toolResultsMap: Map<string, ToolResultMessage>,
colors: ThemeColors,
): string {
let html = "";
const timestamp = (message as { timestamp?: number }).timestamp;
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
// Handle bash execution messages (user-executed via ! command)
if (isBashExecutionMessage(message)) {
const bashMsg = message as unknown as BashExecutionMessage;
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
switch (message.role) {
case "bashExecution": {
const isError =
message.cancelled ||
(message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined);
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
html += timestampHtml;
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
html += timestampHtml;
html += `<div class="tool-command">$ ${escapeHtml(message.command)}</div>`;
if (bashMsg.output) {
const lines = bashMsg.output.split("\n");
html += formatExpandableOutput(lines, 10);
if (message.output) {
const lines = message.output.split("\n");
html += formatExpandableOutput(lines, 10);
}
if (message.cancelled) {
html += `<div class="bash-status warning">(cancelled)</div>`;
} else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) {
html += `<div class="bash-status error">(exit ${message.exitCode})</div>`;
}
if (message.truncated && message.fullOutputPath) {
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}</div>`;
}
html += `</div>`;
break;
}
case "user": {
const userMsg = message as UserMessage;
let textContent = "";
const images: ImageContent[] = [];
if (bashMsg.cancelled) {
html += `<div class="bash-status warning">(cancelled)</div>`;
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
html += `<div class="bash-status error">(exit ${bashMsg.exitCode})</div>`;
}
if (bashMsg.truncated && bashMsg.fullOutputPath) {
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
}
html += `</div>`;
return html;
}
if (message.role === "user") {
const userMsg = message as UserMessage;
let textContent = "";
const images: ImageContent[] = [];
if (typeof userMsg.content === "string") {
textContent = userMsg.content;
} else {
for (const block of userMsg.content) {
if (block.type === "text") {
textContent += block.text;
} else if (block.type === "image") {
images.push(block as ImageContent);
if (typeof userMsg.content === "string") {
textContent = userMsg.content;
} else {
for (const block of userMsg.content) {
if (block.type === "text") {
textContent += block.text;
} else if (block.type === "image") {
images.push(block as ImageContent);
}
}
}
}
html += `<div class="user-message">${timestampHtml}`;
html += `<div class="user-message">${timestampHtml}`;
// Render images first
if (images.length > 0) {
html += `<div class="message-images">`;
for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
// Render images first
if (images.length > 0) {
html += `<div class="message-images">`;
for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
}
html += `</div>`;
}
// Render text as markdown (server-side)
if (textContent.trim()) {
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
}
html += `</div>`;
break;
}
case "assistant": {
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
// Render text as markdown (server-side)
if (textContent.trim()) {
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
}
html += `</div>`;
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
for (const content of assistantMsg.content) {
if (content.type === "text" && content.text.trim()) {
// Render markdown server-side
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
} else if (content.type === "thinking" && content.thinking.trim()) {
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
for (const content of message.content) {
if (content.type === "text" && content.text.trim()) {
// Render markdown server-side
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
} else if (content.type === "thinking" && content.thinking.trim()) {
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
}
}
}
for (const content of assistantMsg.content) {
if (content.type === "toolCall") {
const toolResult = toolResultsMap.get(content.id);
const { html: toolHtml, bgColor } = formatToolExecution(
content.name,
content.arguments as Record<string, unknown>,
toolResult,
colors,
);
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
for (const content of message.content) {
if (content.type === "toolCall") {
const toolResult = toolResultsMap.get(content.id);
const { html: toolHtml, bgColor } = formatToolExecution(
content.name,
content.arguments as Record<string, unknown>,
toolResult,
colors,
);
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
}
}
}
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (assistantMsg.stopReason === "aborted") {
html += '<div class="error-text">Aborted</div>';
} else if (assistantMsg.stopReason === "error") {
html += `<div class="error-text">Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}</div>`;
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (message.stopReason === "aborted") {
html += '<div class="error-text">Aborted</div>';
} else if (message.stopReason === "error") {
html += `<div class="error-text">Error: ${escapeHtml(message.errorMessage || "Unknown error")}</div>`;
}
}
}
if (timestampHtml) {
html += "</div>";
if (timestampHtml) {
html += "</div>";
}
break;
}
case "toolResult":
// Tool results are rendered inline with tool calls
break;
case "hookMessage":
// Hook messages with display:true shown as info boxes
if (message.display) {
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
html += `<div class="hook-message">${timestampHtml}<div class="hook-type">[${escapeHtml(message.customType)}]</div><div class="markdown-content">${renderMarkdown(content)}</div></div>`;
}
break;
case "compactionSummary":
// Rendered separately via formatCompaction
break;
case "branchSummary":
// Rendered as compaction-like summary
html += `<div class="compaction-container expanded"><div class="compaction-content"><div class="compaction-summary"><div class="compaction-summary-header">Branch Summary</div><div class="compaction-summary-content">${escapeHtml(message.summary).replace(/\n/g, "<br>")}</div></div></div></div>`;
break;
default: {
// Exhaustive check
const _exhaustive: never = message;
}
}
@ -995,7 +1022,7 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
const contextWindow = data.contextWindow || 0;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined;
let messagesHtml = "";
for (const event of data.sessionEvents) {
@ -1343,6 +1370,9 @@ export function exportSessionToHtml(
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) {
throw new Error("Cannot export in-memory session to HTML");
}
const content = readFileSync(sessionFile, "utf8");
const data = parseSessionFile(content);

View file

@ -1,39 +1,13 @@
export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js";
export { type HookErrorListener, HookRunner } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
CustomToolResultEvent,
EditToolResultEvent,
ExecResult,
FindToolResultEvent,
GrepToolResultEvent,
HookAPI,
HookError,
HookEvent,
HookEventContext,
HookFactory,
HookUIContext,
LsToolResultEvent,
ReadToolResultEvent,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./types.js";
// biome-ignore assist/source/organizeImports: biome is not smart
export {
isBashToolResult,
isEditToolResult,
isFindToolResult,
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isWriteToolResult,
} from "./types.js";
discoverAndLoadHooks,
loadHooks,
type AppendEntryHandler,
type LoadedHook,
type LoadHooksResult,
type SendMessageHandler,
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type * from "./types.js";
export type { ReadonlySessionManager } from "../session-manager.js";

View file

@ -7,10 +7,11 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Attachment } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookAPI, HookFactory } from "./types.js";
import type { HookMessage } from "../messages.js";
import { execCommand } from "./runner.js";
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -47,9 +48,17 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Send handler type for pi.send().
* Send message handler type for pi.sendMessage().
*/
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
) => void;
/**
* Append entry handler type for pi.appendEntry().
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* Registered handlers for a loaded hook.
@ -61,8 +70,14 @@ export interface LoadedHook {
resolvedPath: string;
/** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>;
/** Set the send handler for this hook's pi.send() */
setSendHandler: (handler: SendHandler) => void;
/** Map of customType to hook message renderer */
messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** Set the send message handler for this hook's pi.sendMessage() */
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
}
/**
@ -110,32 +125,62 @@ function resolveHookPath(hookPath: string, cwd: string): string {
}
/**
* Create a HookAPI instance that collects handlers.
* Returns the API and a function to set the send handler later.
* Create a HookAPI instance that collects handlers, renderers, and commands.
* Returns the API, maps, and a function to set the send message handler later.
*/
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
function createHookAPI(
handlers: Map<string, HandlerFn[]>,
cwd: string,
): {
api: HookAPI;
setSendHandler: (handler: SendHandler) => void;
messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
} {
let sendHandler: SendHandler = () => {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
};
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const api: HookAPI = {
// Cast to HookAPI - the implementation is more general (string event names)
// but the interface has specific overloads for type safety in hooks
const api = {
on(event: string, handler: HandlerFn): void {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
send(text: string, attachments?: Attachment[]): void {
sendHandler(text, attachments);
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
sendMessageHandler(message, triggerTurn);
},
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);
},
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
messageRenderers.set(customType, renderer as HookMessageRenderer);
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
},
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
} as HookAPI;
return {
api,
setSendHandler: (handler: SendHandler) => {
sendHandler = handler;
messageRenderers,
commands,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
};
}
@ -164,13 +209,24 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const { api, setSendHandler } = createHookAPI(handlers);
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
handlers,
cwd,
);
// Call factory to register handlers
factory(api);
return {
hook: { path: hookPath, resolvedPath, handlers, setSendHandler },
hook: {
path: hookPath,
resolvedPath,
handlers,
messageRenderers,
commands,
setSendMessageHandler,
setAppendEntryHandler,
},
error: null,
};
} catch (err) {

View file

@ -2,120 +2,46 @@
* Hook runner - executes hooks and manages their lifecycle.
*/
import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type {
ExecOptions,
ExecResult,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
HookContext,
HookError,
HookEvent,
HookEventContext,
HookMessageRenderer,
HookUIContext,
SessionEvent,
SessionEventResult,
RegisteredCommand,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
} from "./types.js";
/**
* Default timeout for hook execution (30 seconds).
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Listener for hook errors.
*/
export type HookErrorListener = (error: HookError) => void;
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}
/**
* Create a promise that rejects after a timeout.
*/
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let timeoutId: NodeJS.Timeout;
const promise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
});
return {
promise,
clear: () => clearTimeout(timeoutId),
};
}
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
select: async () => null,
select: async () => undefined,
confirm: async () => false,
input: async () => null,
input: async () => undefined,
notify: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
/**
@ -126,26 +52,57 @@ export class HookRunner {
private uiContext: HookUIContext;
private hasUI: boolean;
private cwd: string;
private sessionFile: string | null;
private timeout: number;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
this.hooks = hooks;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionFile = null;
this.timeout = timeout;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
}
/**
* Set the UI context for hooks.
* Call this when the mode initializes and UI is available.
* Initialize HookRunner with all required context.
* Modes call this once the agent session is fully set up.
*/
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
this.uiContext = uiContext;
this.hasUI = hasUI;
initialize(options: {
/** Function to get the current model */
getModel: () => Model<any> | undefined;
/** Handler for hooks to send messages */
sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler;
/** UI context for interactive prompts */
uiContext?: HookUIContext;
/** Whether UI is available */
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
for (const hook of this.hooks) {
hook.setSendMessageHandler(options.sendMessageHandler);
hook.setAppendEntryHandler(options.appendEntryHandler);
}
this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false;
}
/**
* Get the UI context (set by mode).
*/
getUIContext(): HookUIContext | null {
return this.uiContext;
}
/**
* Get whether UI is available.
*/
getHasUI(): boolean {
return this.hasUI;
}
/**
@ -155,23 +112,6 @@ export class HookRunner {
return this.hooks.map((h) => h.path);
}
/**
* Set the session file path.
*/
setSessionFile(sessionFile: string | null): void {
this.sessionFile = sessionFile;
}
/**
* Set the send handler for all hooks' pi.send().
* Call this when the mode initializes.
*/
setSendHandler(handler: SendHandler): void {
for (const hook of this.hooks) {
hook.setSendHandler(handler);
}
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function
@ -184,7 +124,10 @@ export class HookRunner {
/**
* Emit an error to all listeners.
*/
private emitError(error: HookError): void {
/**
* Emit an error to all error listeners.
*/
emitError(error: HookError): void {
for (const listener of this.errorListeners) {
listener(error);
}
@ -203,26 +146,90 @@ export class HookRunner {
return false;
}
/**
* Get a message renderer for the given customType.
* Returns the first renderer found across all hooks, or undefined if none.
*/
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
for (const hook of this.hooks) {
const renderer = hook.messageRenderers.get(customType);
if (renderer) {
return renderer;
}
}
return undefined;
}
/**
* Get all registered commands from all hooks.
*/
getRegisteredCommands(): RegisteredCommand[] {
const commands: RegisteredCommand[] = [];
for (const hook of this.hooks) {
for (const command of hook.commands.values()) {
commands.push(command);
}
}
return commands;
}
/**
* Get a registered command by name.
* Returns the first command found across all hooks, or undefined if none.
*/
getCommand(name: string): RegisteredCommand | undefined {
for (const hook of this.hooks) {
const command = hook.commands.get(name);
if (command) {
return command;
}
}
return undefined;
}
/**
* Create the event context for handlers.
*/
private createContext(): HookEventContext {
private createContext(): HookContext {
return {
exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options),
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,
sessionFile: this.sessionFile,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
};
}
/**
* Emit an event to all hooks.
* Returns the result from session/tool_result events (if any handler returns one).
* Check if event type is a session "before_*" event that can be cancelled.
*/
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
private isSessionBeforeEvent(
type: string,
): type is
| "session_before_switch"
| "session_before_new"
| "session_before_branch"
| "session_before_compact"
| "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_new" ||
type === "session_before_branch" ||
type === "session_before_compact" ||
type === "session_before_tree"
);
}
/**
* Emit an event to all hooks.
* Returns the result from session before_* / tool_result events (if any handler returns one).
*/
async emit(
event: HookEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionEventResult | ToolResultEventResult | undefined;
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
@ -230,21 +237,11 @@ export class HookRunner {
for (const handler of handlers) {
try {
// No timeout for before_compact events (like tool_call, they may take a while)
const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact";
let handlerResult: unknown;
const handlerResult = await handler(event, ctx);
if (isBeforeCompact) {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
}
// For session events, capture the result (for before_* cancellation)
if (event.type === "session" && handlerResult) {
result = handlerResult as SessionEventResult;
// For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;
@ -298,4 +295,79 @@ export class HookRunner {
return result;
}
/**
* Emit a context event to all hooks.
* Handlers are chained - each gets the previous handler's output (if any).
* Returns the final modified messages, or the original if no modifications.
*
* Note: Messages are already deep-copied by the caller (pi-ai preprocessor).
*/
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = messages;
for (const hook of this.hooks) {
const handlers = hook.handlers.get("context");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitError({
hookPath: hook.path,
event: "context",
error: message,
});
}
}
}
return currentMessages;
}
/**
* Emit before_agent_start event to all hooks.
* Returns the first message to inject (if any handler returns one).
*/
async emitBeforeAgentStart(
prompt: string,
images?: import("@mariozechner/pi-ai").ImageContent[],
): Promise<BeforeAgentStartEventResult | undefined> {
const ctx = this.createContext();
let result: BeforeAgentStartEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get("before_agent_start");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const handlerResult = await handler(event, ctx);
// Take the first message returned
if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) {
result = handlerResult as BeforeAgentStartEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitError({
hookPath: hook.path,
event: "before_agent_start",
error: message,
});
}
}
}
return result;
}
}

View file

@ -2,7 +2,7 @@
* Tool wrapper - wraps tools with hook callbacks for interception.
*/
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { HookRunner } from "./runner.js";
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
}
// Execute the actual tool, forwarding onUpdate for progress streaming
const result = await tool.execute(toolCallId, params, signal, onUpdate);
try {
const result = await tool.execute(toolCallId, params, signal, onUpdate);
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
}
}
}
return result;
return result;
} catch (err) {
// Emit tool_result event for errors so hooks can observe failures
if (hookRunner.hasHandlers("tool_result")) {
await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
});
}
throw err; // Re-throw original error for agent-loop
}
},
};
}

View file

@ -5,10 +5,17 @@
* and interact with the user via UI primitives.
*/
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { CutPointResult } from "../compaction.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
BashToolDetails,
FindToolDetails,
@ -17,27 +24,8 @@ import type {
ReadToolDetails,
} from "../tools/index.js";
// ============================================================================
// Execution Context
// ============================================================================
/**
* Result of executing a command via ctx.exec()
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
}
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/**
* UI context for hooks to request interactive UI from the harness.
@ -50,7 +38,7 @@ export interface HookUIContext {
* @param options - Array of string options
* @returns Selected option string, or null if cancelled
*/
select(title: string, options: string[]): Promise<string | null>;
select(title: string, options: string[]): Promise<string | undefined>;
/**
* Show a confirmation dialog.
@ -60,97 +48,225 @@ export interface HookUIContext {
/**
* Show a text input dialog.
* @returns User input, or null if cancelled
* @returns User input, or undefined if cancelled
*/
input(title: string, placeholder?: string): Promise<string | null>;
input(title: string, placeholder?: string): Promise<string | undefined>;
/**
* Show a notification to the user.
*/
notify(message: string, type?: "info" | "warning" | "error"): void;
/**
* Show a custom component with keyboard focus.
* The factory receives TUI, theme, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
* return loader;
* });
*/
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/**
* Set the text in the core input editor.
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
* @param text - Text to set in the editor
*/
setEditorText(text: string): void;
/**
* Get the current text from the core input editor.
* @returns Current editor text
*/
getEditorText(): string;
}
/**
* Context passed to hook event handlers.
* Context passed to hook event and command handlers.
*/
export interface HookEventContext {
/** Execute a command and return stdout/stderr/code */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
export interface HookContext {
/** UI methods for user interaction */
ui: HookUIContext;
/** Whether UI is available (false in print mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Path to session file, or null if --no-session */
sessionFile: string | null;
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
}
// ============================================================================
// Events
// Session Events
// ============================================================================
/**
* Base fields shared by all session events.
*/
interface SessionEventBase {
type: "session";
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or null in --no-session mode */
sessionFile: string | null;
/** Previous session file path, or null for "start" and "new" */
previousSessionFile: string | null;
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/**
* Event data for session events.
* Discriminated union based on reason.
*
* Lifecycle:
* - start: Initial session load
* - before_switch / switch: Session switch (e.g., /resume command)
* - before_new / new: New session (e.g., /new command)
* - before_branch / branch: Session branch (e.g., /branch command)
* - before_compact / compact: Before/after context compaction
* - shutdown: Process exit (SIGINT/SIGTERM)
*
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
* Other events fire after the action completes.
*/
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Session file we're switching to */
targetSessionFile: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before creating a new session (can be cancelled) */
export interface SessionBeforeNewEvent {
type: "session_before_new";
}
/** Fired after creating a new session */
export interface SessionNewEvent {
type: "session_new";
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromHook?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| (SessionEventBase & {
reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown";
})
| (SessionEventBase & {
reason: "branch" | "before_branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
})
| (SessionEventBase & {
reason: "before_compact";
cutPoint: CutPointResult;
/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
previousSummary?: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AppMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AppMessage[];
tokensBefore: number;
customInstructions?: string;
model: Model<any>;
/** Resolve API key for any model (checks settings, OAuth, env vars) */
resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
})
| (SessionEventBase & {
reason: "compact";
compactionEntry: CompactionEntry;
tokensBefore: number;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
});
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeNewEvent
| SessionNewEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/**
* Event data for context event.
* Fired before each LLM call, allowing hooks to modify context non-destructively.
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/**
* Event data for before_agent_start event.
* Fired after user submits a prompt but before the agent loop starts.
* Allows hooks to inject context that will be persisted and visible in TUI.
*/
export interface BeforeAgentStartEvent {
type: "before_agent_start";
/** The user's prompt text */
prompt: string;
/** Any images attached to the prompt */
images?: ImageContent[];
}
/**
* Event data for agent_start event.
@ -165,7 +281,7 @@ export interface AgentStartEvent {
*/
export interface AgentEndEvent {
type: "agent_end";
messages: AppMessage[];
messages: AgentMessage[];
}
/**
@ -183,7 +299,7 @@ export interface TurnStartEvent {
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AppMessage;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
@ -231,7 +347,7 @@ export interface ReadToolResultEvent extends ToolResultEventBase {
/** Tool result event for edit tool */
export interface EditToolResultEvent extends ToolResultEventBase {
toolName: "edit";
details: undefined;
details: EditToolDetails | undefined;
}
/** Tool result event for write tool */
@ -307,6 +423,8 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
*/
export type HookEvent =
| SessionEvent
| ContextEvent
| BeforeAgentStartEvent
| AgentStartEvent
| AgentEndEvent
| TurnStartEvent
@ -318,6 +436,15 @@ export type HookEvent =
// Event Results
// ============================================================================
/**
* Return type for context event handlers.
* Allows hooks to modify messages before they're sent to the LLM.
*/
export interface ContextEventResult {
/** Modified messages to send instead of the original */
messages?: Message[];
}
/**
* Return type for tool_call event handlers.
* Allows hooks to block tool execution.
@ -343,16 +470,68 @@ export interface ToolResultEventResult {
}
/**
* Return type for session event handlers.
* Allows hooks to cancel "before_*" actions.
* Return type for before_agent_start event handlers.
* Allows hooks to inject context before the agent runs.
*/
export interface SessionEventResult {
/** If true, cancel the pending action (switch, clear, or branch) */
export interface BeforeAgentStartEventResult {
/** Message to inject into context (persisted to session, visible in TUI) */
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
}
/** Return type for session_before_switch handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
/** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */
}
/** Return type for session_before_new handlers */
export interface SessionBeforeNewResult {
/** If true, cancel the new session */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint hook that restores code state separately.
* The hook handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` nothing happens, user stays in current session
* - `skipConversationRestore: true` branch happens, but messages stay as-is
* - neither branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
/** Custom compaction entry (for before_compact event) */
compactionEntry?: CompactionEntry;
}
/** Return type for session_before_compact handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for session_before_tree handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================
@ -361,29 +540,134 @@ export interface SessionEventResult {
/**
* Handler function type for each event.
* Handlers can return R, undefined, or void (bare return statements).
*/
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
expanded: boolean;
}
/**
* Renderer for hook messages.
* Hooks register these to provide custom TUI rendering for their message types.
*/
export type HookMessageRenderer<T = unknown> = (
message: HookMessage<T>,
options: HookMessageRenderOptions,
theme: Theme,
) => Component | undefined;
/**
* Command registration options.
*/
export interface RegisteredCommand {
name: string;
description?: string;
handler: (args: string, ctx: HookContext) => Promise<void>;
}
/**
* HookAPI passed to hook factory functions.
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
*/
export interface HookAPI {
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
// Session events
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "session_before_new", handler: HookHandler<SessionBeforeNewEvent, SessionBeforeNewResult>): void;
on(event: "session_new", handler: HookHandler<SessionNewEvent>): void;
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
on(
event: "session_before_compact",
handler: HookHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
// Context and agent events
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
on(event: "before_agent_start", handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
/**
* Send a message to the agent.
* If the agent is streaming, the message is queued.
* If the agent is idle, a new agent loop is started.
* Send a custom message to the session. Creates a CustomMessageEntry that
* participates in LLM context and can be displayed in the TUI.
*
* Use this when you want the LLM to see the message content.
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
*
* @param message - The message to send
* @param message.customType - Identifier for your hook (used for filtering on reload)
* @param message.content - Message content (string or TextContent/ImageContent array)
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
* @param message.details - Optional hook-specific metadata (not sent to LLM)
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
* If agent is streaming, message is queued and triggerTurn is ignored.
*/
send(text: string, attachments?: Attachment[]): void;
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
): void;
/**
* Append a custom entry to the session for hook state persistence.
* Creates a CustomEntry that does NOT participate in LLM context.
*
* Use this to store hook-specific data that should persist across session reloads
* but should NOT be sent to the LLM. On reload, scan session entries for your
* customType to reconstruct hook state.
*
* For messages that SHOULD be sent to the LLM, use sendMessage() instead.
*
* @param customType - Identifier for your hook (used for filtering on reload)
* @param data - Hook-specific data to persist (must be JSON-serializable)
*
* @example
* // Store permission state
* pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() });
*
* // On reload, reconstruct state from entries
* pi.on("session", async (event, ctx) => {
* if (event.reason === "start") {
* const entries = event.sessionManager.getEntries();
* const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions");
* // Reconstruct state from myEntries...
* }
* });
*/
appendEntry<T = unknown>(customType: string, data?: T): void;
/**
* Register a custom renderer for CustomMessageEntry with a specific customType.
* The renderer is called when rendering the entry in the TUI.
* Return nothing to use the default renderer.
*/
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
/**
* Register a custom slash command.
* Handler receives HookCommandContext.
*/
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}
/**

View file

@ -7,29 +7,29 @@ export {
type AgentSessionConfig,
type AgentSessionEvent,
type AgentSessionEventListener,
type CompactionResult,
type ModelCycleResult,
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export {
type CustomAgentTool,
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
type CustomToolsLoadResult,
type CustomToolUIContext,
discoverAndLoadCustomTools,
type ExecResult,
type LoadedCustomTool,
loadCustomTools,
type RenderResultOptions,
type ToolAPI,
type ToolUIContext,
} from "./custom-tools/index.js";
export {
type HookAPI,
type HookContext,
type HookError,
type HookEvent,
type HookEventContext,
type HookFactory,
HookRunner,
type HookUIContext,

View file

@ -1,16 +1,27 @@
/**
* Custom message types and transformers for the coding agent.
*
* Extends the base AppMessage type with coding-agent specific message types,
* Extends the base AgentMessage type with coding-agent specific message types,
* and provides a transformer to convert them to LLM-compatible messages.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
// ============================================================================
// Custom Message Types
// ============================================================================
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
<summary>
`;
export const COMPACTION_SUMMARY_SUFFIX = `
</summary>`;
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
<summary>
`;
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
/**
* Message type for bash executions via the ! command.
@ -19,35 +30,50 @@ export interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
exitCode: number | null;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
timestamp: number;
}
// Extend CustomMessages via declaration merging
/**
* Message type for hook-injected messages via sendMessage().
* These are custom messages that hooks can inject into the conversation.
*/
export interface HookMessage<T = unknown> {
role: "hookMessage";
customType: string;
content: string | (TextContent | ImageContent)[];
display: boolean;
details?: T;
timestamp: number;
}
export interface BranchSummaryMessage {
role: "branchSummary";
summary: string;
fromId: string;
timestamp: number;
}
export interface CompactionSummaryMessage {
role: "compactionSummary";
summary: string;
tokensBefore: number;
timestamp: number;
}
// Extend CustomAgentMessages via declaration merging
declare module "@mariozechner/pi-agent-core" {
interface CustomMessages {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
hookMessage: HookMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
}
// ============================================================================
// Type Guards
// ============================================================================
/**
* Type guard for BashExecutionMessage.
*/
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
return (msg as BashExecutionMessage).role === "bashExecution";
}
// ============================================================================
// Message Formatting
// ============================================================================
/**
* Convert a BashExecutionMessage to user message text for LLM context.
*/
@ -60,7 +86,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
}
if (msg.cancelled) {
text += "\n\n(command cancelled)";
} else if (msg.exitCode !== null && msg.exitCode !== 0) {
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
text += `\n\nCommand exited with code ${msg.exitCode}`;
}
if (msg.truncated && msg.fullOutputPath) {
@ -69,34 +95,95 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
return text;
}
// ============================================================================
// Message Transformer
// ============================================================================
export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
return {
role: "branchSummary",
summary,
fromId,
timestamp: new Date(timestamp).getTime(),
};
}
export function createCompactionSummaryMessage(
summary: string,
tokensBefore: number,
timestamp: string,
): CompactionSummaryMessage {
return {
role: "compactionSummary",
summary: summary,
tokensBefore,
timestamp: new Date(timestamp).getTime(),
};
}
/** Convert CustomMessageEntry to AgentMessage format */
export function createHookMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details: unknown | undefined,
timestamp: string,
): HookMessage {
return {
role: "hookMessage",
customType,
content,
display,
details,
timestamp: new Date(timestamp).getTime(),
};
}
/**
* Transform AppMessages (including custom types) to LLM-compatible Messages.
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
*
* This is used by:
* - Agent's messageTransformer option (for prompt calls)
* - Agent's transormToLlm option (for prompt calls and queued messages)
* - Compaction's generateSummary (for summarization)
* - Custom hooks and tools
*/
export function messageTransformer(messages: AppMessage[]): Message[] {
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | null => {
if (isBashExecutionMessage(m)) {
// Convert bash execution to user message
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
case "hookMessage": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return {
role: "user",
content,
timestamp: m.timestamp,
};
}
case "branchSummary":
return {
role: "user",
content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],
timestamp: m.timestamp,
};
case "compactionSummary":
return {
role: "user",
content: [
{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX },
],
timestamp: m.timestamp,
};
case "user":
case "assistant":
case "toolResult":
return m;
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
return undefined;
}
// Pass through standard LLM roles
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
return m as Message;
}
// Filter out unknown message types
return null;
})
.filter((m): m is Message => m !== null);
.filter((m) => m !== undefined);
}

View file

@ -90,11 +90,11 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
export class ModelRegistry {
private models: Model<Api>[] = [];
private customProviderApiKeys: Map<string, string> = new Map();
private loadError: string | null = null;
private loadError: string | undefined = undefined;
constructor(
readonly authStorage: AuthStorage,
private modelsJsonPath: string | null = null,
private modelsJsonPath: string | undefined = undefined,
) {
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver((provider) => {
@ -114,14 +114,14 @@ export class ModelRegistry {
*/
refresh(): void {
this.customProviderApiKeys.clear();
this.loadError = null;
this.loadError = undefined;
this.loadModels();
}
/**
* Get any error from loading models.json (null if no error).
* Get any error from loading models.json (undefined if no error).
*/
getError(): string | null {
getError(): string | undefined {
return this.loadError;
}
@ -160,9 +160,9 @@ export class ModelRegistry {
}
}
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | null } {
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
if (!existsSync(modelsJsonPath)) {
return { models: [], error: null };
return { models: [], error: undefined };
}
try {
@ -186,7 +186,7 @@ export class ModelRegistry {
this.validateConfig(config);
// Parse models
return { models: this.parseModels(config), error: null };
return { models: this.parseModels(config), error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return {
@ -294,14 +294,14 @@ export class ModelRegistry {
/**
* Find a model by provider and ID.
*/
find(provider: string, modelId: string): Model<Api> | null {
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? null;
find(provider: string, modelId: string): Model<Api> | undefined {
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
}
/**
* Get API key for a model.
*/
async getApiKey(model: Model<Api>): Promise<string | null> {
async getApiKey(model: Model<Api>): Promise<string | undefined> {
return this.authStorage.getApiKey(model.provider);
}

View file

@ -44,9 +44,9 @@ function isAlias(id: string): boolean {
/**
* Try to match a pattern to a model from the available models list.
* Returns the matched model or null if no match found.
* Returns the matched model or undefined if no match found.
*/
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | null {
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
// Check for provider/modelId format (provider is everything before the first /)
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
@ -75,7 +75,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
);
if (matches.length === 0) {
return null;
return undefined;
}
// Separate into aliases and dated versions
@ -94,9 +94,9 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
}
export interface ParsedModelResult {
model: Model<Api> | null;
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
warning: string | null;
warning: string | undefined;
}
/**
@ -116,14 +116,14 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: "off", warning: null };
return { model: exactMatch, thinkingLevel: "off", warning: undefined };
}
// No match - try splitting on last colon if present
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
// No colons, pattern simply doesn't match any model
return { model: null, thinkingLevel: "off", warning: null };
return { model: undefined, thinkingLevel: "off", warning: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
@ -193,9 +193,9 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
}
export interface InitialModelResult {
model: Model<Api> | null;
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
fallbackMessage: string | null;
fallbackMessage: string | undefined;
}
/**
@ -227,7 +227,7 @@ export async function findInitialModel(options: {
modelRegistry,
} = options;
let model: Model<Api> | null = null;
let model: Model<Api> | undefined;
let thinkingLevel: ThinkingLevel = "off";
// 1. CLI args take priority
@ -237,7 +237,7 @@ export async function findInitialModel(options: {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
process.exit(1);
}
return { model: found, thinkingLevel: "off", fallbackMessage: null };
return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
}
// 2. Use first model from scoped models (skip if continuing/resuming)
@ -245,7 +245,7 @@ export async function findInitialModel(options: {
return {
model: scopedModels[0].model,
thinkingLevel: scopedModels[0].thinkingLevel,
fallbackMessage: null,
fallbackMessage: undefined,
};
}
@ -257,7 +257,7 @@ export async function findInitialModel(options: {
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: null };
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
@ -270,16 +270,16 @@ export async function findInitialModel(options: {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: "off", fallbackMessage: null };
return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
}
}
// If no default found, use first available
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null };
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
}
// 5. No model found
return { model: null, thinkingLevel: "off", fallbackMessage: null };
return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
}
/**
@ -288,10 +288,10 @@ export async function findInitialModel(options: {
export async function restoreModelFromSession(
savedProvider: string,
savedModelId: string,
currentModel: Model<Api> | null,
currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean,
modelRegistry: ModelRegistry,
): Promise<{ model: Model<Api> | null; fallbackMessage: string | null }> {
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
// Check if restored model exists and has a valid API key
@ -301,7 +301,7 @@ export async function restoreModelFromSession(
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
}
return { model: restoredModel, fallbackMessage: null };
return { model: restoredModel, fallbackMessage: undefined };
}
// Model not found or no API key - fall back
@ -327,7 +327,7 @@ export async function restoreModelFromSession(
if (availableModels.length > 0) {
// Try to find a default model from known providers
let fallbackModel: Model<Api> | null = null;
let fallbackModel: Model<Api> | undefined;
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
@ -353,5 +353,5 @@ export async function restoreModelFromSession(
}
// No models available
return { model: null, fallbackMessage: null };
return { model: undefined, fallbackMessage: undefined };
}

View file

@ -29,17 +29,22 @@
* ```
*/
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js";
import type { CustomAgentTool } from "./custom-tools/types.js";
import {
type CustomToolsLoadResult,
discoverAndLoadCustomTools,
type LoadedCustomTool,
wrapCustomTools,
} from "./custom-tools/index.js";
import type { CustomTool } from "./custom-tools/types.js";
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
import type { HookFactory } from "./hooks/types.js";
import { messageTransformer } from "./messages.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { SessionManager } from "./session-manager.js";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions {
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
customTools?: Array<{ path?: string; tool: CustomTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
@ -127,18 +132,15 @@ export interface CreateAgentSessionResult {
/** The created session */
session: AgentSession;
/** Custom tools result (for UI context setup in interactive mode) */
customToolsResult: {
tools: LoadedCustomTool[];
setUIContext: (uiContext: any, hasUI: boolean) => void;
};
customToolsResult: CustomToolsLoadResult;
/** Warning if session was restored with a different model than saved */
modelFallbackMessage?: string;
}
// Re-exports
export type { CustomAgentTool } from "./custom-tools/types.js";
export type { HookAPI, HookFactory } from "./hooks/types.js";
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { FileSlashCommand } from "./slash-commands.js";
@ -219,7 +221,7 @@ export async function discoverHooks(
export async function discoverCustomTools(
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; tool: CustomAgentTool }>> {
): Promise<Array<{ path: string; tool: CustomTool }>> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
@ -311,7 +313,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
hookTimeout: manager.getHookTimeout(),
customTools: manager.getCustomToolPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
@ -340,7 +341,10 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
return definitions.map((def) => {
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
@ -348,8 +352,17 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
list.push(handler);
handlers.set(event, list);
},
send: (text: string, attachments?: any[]) => {
sendHandler(text, attachments);
sendMessage: (message: any, triggerTurn?: boolean) => {
sendMessageHandler(message, triggerTurn);
},
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
},
registerMessageRenderer: (customType: string, renderer: any) => {
messageRenderers.set(customType, renderer);
},
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
};
@ -359,8 +372,13 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
path: def.path ?? "<inline>",
resolvedPath: def.path ?? "<inline>",
handlers,
setSendHandler: (handler: (text: string, attachments?: any[]) => void) => {
sendHandler = handler;
messageRenderers,
commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
};
});
@ -490,7 +508,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const builtInTools = options.tools ?? createCodingTools(cwd);
time("createCodingTools");
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
let customToolsResult: CustomToolsLoadResult;
if (options.customTools !== undefined) {
// Use provided custom tools
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
@ -500,24 +518,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}));
customToolsResult = {
tools: loadedTools,
errors: [],
setUIContext: () => {},
};
} else {
// Discover custom tools, merging with additional paths
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
time("discoverAndLoadCustomTools");
for (const { path, error } of result.errors) {
for (const { path, error } of customToolsResult.errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
customToolsResult = result;
}
let hookRunner: HookRunner | null = null;
let hookRunner: HookRunner | undefined;
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout());
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
}
} else {
// Discover hooks, merging with additional paths
@ -528,11 +546,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
console.error(`Failed to load hook "${path}": ${error}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout());
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
}
}
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
// Wrap custom tools with context getter (agent is assigned below, accessed at execute time)
let agent: Agent;
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
sessionManager,
modelRegistry,
model: agent.state.model,
}));
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
time("combineTools");
if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
@ -564,34 +590,43 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
time("discoverSlashCommands");
const agent = new Agent({
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel,
tools: allToolsArray,
},
messageTransformer,
convertToLlm,
transformContext: hookRunner
? async (messages) => {
return hookRunner.emitContext(messages);
}
: undefined,
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
}
return key;
},
}),
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
}
return key;
},
});
time("createAgent");
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
} else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
sessionManager.appendModelChange(model.provider, model.id);
}
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
const session = new AgentSession({

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,10 @@ export interface CompactionSettings {
keepRecentTokens?: number; // default: 20000
}
export interface BranchSummarySettings {
reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
}
export interface RetrySettings {
enabled?: boolean; // default: true
maxRetries?: number; // default: 3
@ -38,12 +42,12 @@ export interface Settings {
queueMode?: "all" | "one-at-a-time";
theme?: string;
compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
retry?: RetrySettings;
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
customTools?: string[]; // Array of custom tool file paths
skills?: SkillsSettings;
terminal?: TerminalSettings;
@ -255,6 +259,12 @@ export class SettingsManager {
};
}
getBranchSummarySettings(): { reserveTokens: number } {
return {
reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
};
}
getRetryEnabled(): boolean {
return this.settings.retry?.enabled ?? true;
}
@ -303,7 +313,7 @@ export class SettingsManager {
}
getHookPaths(): string[] {
return this.settings.hooks ?? [];
return [...(this.settings.hooks ?? [])];
}
setHookPaths(paths: string[]): void {
@ -311,17 +321,8 @@ export class SettingsManager {
this.save();
}
getHookTimeout(): number {
return this.settings.hookTimeout ?? 30000;
}
setHookTimeout(timeout: number): void {
this.globalSettings.hookTimeout = timeout;
this.save();
}
getCustomToolPaths(): string[] {
return this.settings.customTools ?? [];
return [...(this.settings.customTools ?? [])];
}
setCustomToolPaths(paths: string[]): void {
@ -349,9 +350,9 @@ export class SettingsManager {
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
enablePiUser: this.settings.skills?.enablePiUser ?? true,
enablePiProject: this.settings.skills?.enablePiProject ?? true,
customDirectories: this.settings.skills?.customDirectories ?? [],
ignoredSkills: this.settings.skills?.ignoredSkills ?? [],
includeSkills: this.settings.skills?.includeSkills ?? [],
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
};
}

View file

@ -5,7 +5,7 @@
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
import type { ToolName } from "./tools/index.js";
@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
return prompt;
}
// Get absolute paths to documentation
// Get absolute paths to documentation and examples
const readmePath = getReadmePath();
const docsPath = getDocsPath();
const examplesPath = getExamplesPath();
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
@ -279,7 +280,9 @@ ${guidelines}
Documentation:
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;
- Examples: ${examplesPath} (hooks, custom tools, SDK)
- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md)
- Always read the doc, examples, AND follow .md cross-references before implementing`;
if (appendSection) {
prompt += appendSection;

View file

@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig, killProcessTree } from "../../utils/shell.js";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import { constants } from "fs";
@ -23,8 +23,13 @@ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
/**
* Generate a unified diff string with line numbers and context
* Returns both the diff string and the first changed line number (in the new file)
*/
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
@ -36,6 +41,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
@ -45,6 +51,11 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
@ -113,7 +124,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
}
return output.join("\n");
return { diff: output.join("\n"), firstChangedLine };
}
const editSchema = Type.Object({
@ -122,6 +133,13 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export interface EditToolDetails {
/** Unified diff of the changes made */
diff: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return {
name: "edit",
@ -138,7 +156,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: { diff: string } | undefined;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
@ -257,6 +275,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
signal.removeEventListener("abort", onAbort);
}
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
resolve({
content: [
{
@ -264,7 +283,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
text: `Successfully replaced text in ${path}.`,
},
],
details: { diff: generateDiffString(normalizedContent, normalizedNewContent) },
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: any) {
// Clean up abort handler

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";

View file

@ -1,5 +1,5 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, type Stats, statSync } from "fs";

View file

@ -1,5 +1,3 @@
import type { AgentTool } from "@mariozechner/pi-ai";
export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
export { createEditTool, editTool } from "./edit.js";
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
@ -9,6 +7,7 @@ export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
export type { TruncationResult } from "./truncate.js";
export { createWriteTool, writeTool } from "./write.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";

View file

@ -1,4 +1,5 @@
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname } from "path";

View file

@ -4,7 +4,6 @@ export {
type AgentSessionConfig,
type AgentSessionEvent,
type AgentSessionEventListener,
type CompactionResult,
type ModelCycleResult,
type PromptOptions,
type SessionStats,
@ -13,56 +12,43 @@ export {
export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js";
// Compaction
export {
type BranchPreparation,
type BranchSummaryResult,
type CollectEntriesResult,
type CompactionResult,
type CutPointResult,
calculateContextTokens,
collectEntriesForBranchSummary,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateTokens,
type FileOperations,
findCutPoint,
findTurnStartIndex,
type GenerateBranchSummaryOptions,
generateBranchSummary,
generateSummary,
getLastAssistantUsage,
prepareBranchEntries,
serializeConversation,
shouldCompact,
} from "./core/compaction.js";
} from "./core/compaction/index.js";
// Custom tools
export type {
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent as ToolSessionEvent,
ToolAPI,
ToolUIContext,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
CustomToolResultEvent,
EditToolResultEvent,
FindToolResultEvent,
GrepToolResultEvent,
HookAPI,
HookEvent,
HookEventContext,
HookFactory,
HookUIContext,
LsToolResultEvent,
ReadToolResultEvent,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./core/hooks/index.js";
export type * from "./core/hooks/index.js";
// Hook system types and type guards
export {
isBashToolResult,
@ -73,7 +59,7 @@ export {
isReadToolResult,
isWriteToolResult,
} from "./core/hooks/index.js";
export { messageTransformer } from "./core/messages.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
// SDK for programmatic usage
export {
@ -102,25 +88,33 @@ export {
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js";
export {
type BranchSummaryEntry,
buildSessionContext,
type CompactionEntry,
createSummaryMessage,
CURRENT_SESSION_VERSION,
type CustomEntry,
type CustomMessageEntry,
type FileEntry,
getLatestCompactionEntry,
type ModelChangeEntry,
migrateSessionEntries,
parseSessionEntries,
type SessionContext as LoadedSession,
type SessionContext,
type SessionEntry,
type SessionEntryBase,
type SessionHeader,
type SessionInfo,
SessionManager,
type SessionMessageEntry,
SUMMARY_PREFIX,
SUMMARY_SUFFIX,
type ThinkingLevelChangeEntry,
} from "./core/session-manager.js";
export {
@ -160,5 +154,7 @@ export {
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";
// UI components for hooks
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
// Theme utilities for custom tools
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";

View file

@ -5,8 +5,7 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import type { Attachment } from "@mariozechner/pi-agent-core";
import { supportsXhigh } from "@mariozechner/pi-ai";
import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
@ -34,10 +33,10 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
import { ensureTool } from "./utils/tools-manager.js";
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest");
if (!response.ok) return null;
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
@ -46,26 +45,26 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
return latestVersion;
}
return null;
return undefined;
} catch {
return null;
return undefined;
}
}
async function runInteractiveMode(
session: AgentSession,
version: string,
changelogMarkdown: string | null,
changelogMarkdown: string | undefined,
modelFallbackMessage: string | undefined,
modelsJsonError: string | null,
modelsJsonError: string | undefined,
migratedProviders: string[],
versionCheckPromise: Promise<string | null>,
versionCheckPromise: Promise<string | undefined>,
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialAttachments?: Attachment[],
fdPath: string | null = null,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
@ -77,7 +76,7 @@ async function runInteractiveMode(
}
});
mode.renderInitialMessages(session.state);
mode.renderInitialMessages();
if (migratedProviders.length > 0) {
mode.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
@ -93,7 +92,7 @@ async function runInteractiveMode(
if (initialMessage) {
try {
await session.prompt(initialMessage, { attachments: initialAttachments });
await session.prompt(initialMessage, { images: initialImages });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
mode.showError(errorMessage);
@ -122,31 +121,31 @@ async function runInteractiveMode(
async function prepareInitialMessage(parsed: Args): Promise<{
initialMessage?: string;
initialAttachments?: Attachment[];
initialImages?: ImageContent[];
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
const { text, images } = await processFileArguments(parsed.fileArgs);
let initialMessage: string;
if (parsed.messages.length > 0) {
initialMessage = textContent + parsed.messages[0];
initialMessage = text + parsed.messages[0];
parsed.messages.shift();
} else {
initialMessage = textContent;
initialMessage = text;
}
return {
initialMessage,
initialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined,
initialImages: images.length > 0 ? images : undefined,
};
}
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
if (parsed.continue || parsed.resume) {
return null;
return undefined;
}
const lastVersion = settingsManager.getLastChangelogVersion();
@ -166,10 +165,10 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
}
}
return null;
return undefined;
}
function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
if (parsed.noSession) {
return SessionManager.inMemory();
}
@ -184,8 +183,8 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null
if (parsed.sessionDir) {
return SessionManager.create(cwd, parsed.sessionDir);
}
// Default case (new session) returns null, SDK will create one
return null;
// Default case (new session) returns undefined, SDK will create one
return undefined;
}
/** Discover SYSTEM.md file if no CLI system prompt was provided */
@ -208,7 +207,7 @@ function discoverSystemPromptFile(): string | undefined {
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | null,
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@ -330,7 +329,7 @@ export async function main(args: string[]) {
}
const cwd = process.cwd();
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
const { initialMessage, initialImages } = await prepareInitialMessage(parsed);
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
@ -409,7 +408,7 @@ export async function main(args: string[]) {
if (mode === "rpc") {
await runRpcMode(session);
} else if (isInteractive) {
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
if (scopedModels.length > 0) {
@ -438,11 +437,11 @@ export async function main(args: string[]) {
customToolsResult.tools,
customToolsResult.setUIContext,
initialMessage,
initialAttachments,
initialImages,
fdPath,
);
} else {
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages);
stopThemeWatcher();
if (process.stdout.writableLength > 0) {
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));

View file

@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container {
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
if (hasTextAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
// Thinking traces in thinkingText color, italic
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
);

View file

@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
private command: string;
private outputLines: string[] = [];
private status: "running" | "complete" | "cancelled" | "error" = "running";
private exitCode: number | null = null;
private exitCode: number | undefined = undefined;
private loader: Loader;
private truncationResult?: TruncationResult;
private fullOutputPath?: string;
@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
}
setComplete(
exitCode: number | null,
exitCode: number | undefined,
cancelled: boolean,
truncationResult?: TruncationResult,
fullOutputPath?: string,
): void {
this.exitCode = exitCode;
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
this.status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.truncationResult = truncationResult;
this.fullOutputPath = fullOutputPath;

View file

@ -0,0 +1,41 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Loader wrapped with borders for hook UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
constructor(tui: TUI, theme: Theme, message: string) {
super();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
get signal(): AbortSignal {
return this.loader.signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
}
handleInput(data: string): void {
this.loader.handleInput(data);
}
dispose(): void {
this.loader.dispose();
}
}

View file

@ -0,0 +1,42 @@
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a branch summary message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
*/
export class BranchSummaryMessageComponent extends Box {
private expanded = false;
private message: BranchSummaryMessage;
constructor(message: BranchSummaryMessage) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = "**Branch Summary**\n\n";
this.addChild(
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0));
}
}
}

View file

@ -0,0 +1,45 @@
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
*/
export class CompactionSummaryMessageComponent extends Box {
private expanded = false;
private message: CompactionSummaryMessage;
constructor(message: CompactionSummaryMessage) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const tokenStr = this.message.tokensBefore.toLocaleString();
const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
this.addChild(
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(
new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
);
}
}
}

View file

@ -1,52 +0,0 @@
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction indicator with collapsed/expanded state.
* Collapsed: shows "Context compacted from X tokens"
* Expanded: shows the full summary rendered as markdown (like a user message)
*/
export class CompactionComponent extends Container {
private expanded = false;
private tokensBefore: number;
private summary: string;
constructor(tokensBefore: number, summary: string) {
super();
this.tokensBefore = tokensBefore;
this.summary = summary;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
if (this.expanded) {
// Show header + summary as markdown (like user message)
this.addChild(new Spacer(1));
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
this.addChild(
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),
color: (text: string) => theme.fg("userMessageText", text),
}),
);
this.addChild(new Spacer(1));
} else {
// Collapsed: simple text in warning color with token count
const tokenStr = this.tokensBefore.toLocaleString();
this.addChild(
new Text(
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
1,
1,
),
);
}
}
}

View file

@ -0,0 +1,96 @@
import type { TextContent } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
import type { HookMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a custom message entry from hooks.
* Uses distinct styling to differentiate from user messages.
*/
export class HookMessageComponent extends Container {
private message: HookMessage<unknown>;
private customRenderer?: HookMessageRenderer;
private box: Box;
private customComponent?: Component;
private _expanded = false;
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.addChild(new Spacer(1));
// Create box with purple background (used for default rendering)
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
this.rebuild();
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
private rebuild(): void {
// Remove previous content component
if (this.customComponent) {
this.removeChild(this.customComponent);
this.customComponent = undefined;
}
this.removeChild(this.box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
if (component) {
// Custom renderer provides its own styled component
this.customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
// Default rendering uses our box
this.addChild(this.box);
this.box.clear();
// Default rendering: label + content
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
this.box.addChild(new Text(label, 0, 0));
this.box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
} else {
text = this.message.content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
}
// Limit lines when collapsed
if (!this._expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n...`;
}
}
this.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
}
}

View file

@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
private allModels: ModelItem[] = [];
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
private currentModel: Model<any> | null;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage: string | null = null;
private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray<ScopedModelItem>;
constructor(
tui: TUI,
currentModel: Model<any> | null,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,

View file

@ -11,7 +11,7 @@ import {
type TUI,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
import type { CustomTool } from "../../../core/custom-tools/types.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
import { renderDiff } from "./diff.js";
@ -55,7 +55,7 @@ export class ToolExecutionComponent extends Container {
private expanded = false;
private showImages: boolean;
private isPartial = true;
private customTool?: CustomAgentTool;
private customTool?: CustomTool;
private ui: TUI;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
@ -67,7 +67,7 @@ export class ToolExecutionComponent extends Container {
toolName: string,
args: any,
options: ToolExecutionOptions = {},
customTool: CustomAgentTool | undefined,
customTool: CustomTool | undefined,
ui: TUI,
) {
super();
@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container {
} else if (this.toolName === "edit") {
const rawPath = this.args?.file_path || this.args?.path || "";
const path = shortenPath(rawPath);
text =
theme.fg("toolTitle", theme.bold("edit")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
// Build path display, appending :line if we have a successful result with line info
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
}
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
if (this.result) {
if (this.result.isError) {

View file

@ -0,0 +1,866 @@
import {
type Component,
Container,
Input,
isArrowDown,
isArrowLeft,
isArrowRight,
isArrowUp,
isBackspace,
isCtrlC,
isCtrlO,
isEnter,
isEscape,
isShiftCtrlO,
Spacer,
Text,
TruncatedText,
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
position: number; // displayIndent level where the connector was shown
show: boolean; // true = show │, false = show spaces
}
/** Flattened tree node for navigation */
interface FlatNode {
node: SessionTreeNode;
/** Indentation level (each level = 3 chars) */
indent: number;
/** Whether to show connector (├─ or └─) - true if parent has multiple children */
showConnector: boolean;
/** If showConnector, true = last sibling (└─), false = not last (├─) */
isLast: boolean;
/** Gutter info for each ancestor branch point */
gutters: GutterInfo[];
/** True if this node is a root under a virtual branching root (multiple roots) */
isVirtualRootChild: boolean;
}
/** Filter mode for tree display */
type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
/**
* Tree list component with selection and ASCII art visualization
*/
/** Tool call info for lookup */
interface ToolCallInfo {
name: string;
arguments: Record<string, unknown>;
}
class TreeList implements Component {
private flatNodes: FlatNode[] = [];
private filteredNodes: FlatNode[] = [];
private selectedIndex = 0;
private currentLeafId: string | null;
private maxVisibleLines: number;
private filterMode: FilterMode = "default";
private searchQuery = "";
private toolCallMap: Map<string, ToolCallInfo> = new Map();
private multipleRoots = false;
private activePathIds: Set<string> = new Set();
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) {
this.currentLeafId = currentLeafId;
this.maxVisibleLines = maxVisibleLines;
this.multipleRoots = tree.length > 1;
this.flatNodes = this.flattenTree(tree);
this.buildActivePath();
this.applyFilter();
// Start with current leaf selected
const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId);
if (leafIndex !== -1) {
this.selectedIndex = leafIndex;
} else {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
}
/** Build the set of entry IDs on the path from root to current leaf */
private buildActivePath(): void {
this.activePathIds.clear();
if (!this.currentLeafId) return;
// Build a map of id -> entry for parent lookup
const entryMap = new Map<string, FlatNode>();
for (const flatNode of this.flatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Walk from leaf to root
let currentId: string | null = this.currentLeafId;
while (currentId) {
this.activePathIds.add(currentId);
const node = entryMap.get(currentId);
if (!node) break;
currentId = node.node.entry.parentId ?? null;
}
}
private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
const result: FlatNode[] = [];
this.toolCallMap.clear();
// Indentation rules:
// - At indent 0: stay at 0 unless parent has >1 children (then +1)
// - At indent 1: children always go to indent 2 (visual grouping of subtree)
// - At indent 2+: stay flat for single-child chains, +1 only if parent branches
// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];
const stack: StackItem[] = [];
// Determine which subtrees contain the active leaf (to sort current branch first)
// Use iterative post-order traversal to avoid stack overflow
const containsActive = new Map<SessionTreeNode, boolean>();
const leafId = this.currentLeafId;
{
// Build list in pre-order, then process in reverse for post-order effect
const allNodes: SessionTreeNode[] = [];
const preOrderStack: SessionTreeNode[] = [...roots];
while (preOrderStack.length > 0) {
const node = preOrderStack.pop()!;
allNodes.push(node);
// Push children in reverse so they're processed left-to-right
for (let i = node.children.length - 1; i >= 0; i--) {
preOrderStack.push(node.children[i]);
}
}
// Process in reverse (post-order): children before parents
for (let i = allNodes.length - 1; i >= 0; i--) {
const node = allNodes[i];
let has = leafId !== null && node.entry.id === leafId;
for (const child of node.children) {
if (containsActive.get(child)) {
has = true;
}
}
containsActive.set(node, has);
}
}
// Add roots in reverse order, prioritizing the one containing the active leaf
// If multiple roots, treat them as children of a virtual root that branches
const multipleRoots = roots.length > 1;
const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));
for (let i = orderedRoots.length - 1; i >= 0; i--) {
const isLast = i === orderedRoots.length - 1;
stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);
}
while (stack.length > 0) {
const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
// Extract tool calls from assistant messages for later lookup
const entry = node.entry;
if (entry.type === "message" && entry.message.role === "assistant") {
const content = (entry.message as { content?: unknown }).content;
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") {
const tc = block as { id: string; name: string; arguments: Record<string, unknown> };
this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });
}
}
}
}
result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });
const children = node.children;
const multipleChildren = children.length > 1;
// Order children so the branch containing the active leaf comes first
const orderedChildren = (() => {
const prioritized: SessionTreeNode[] = [];
const rest: SessionTreeNode[] = [];
for (const child of children) {
if (containsActive.get(child)) {
prioritized.push(child);
} else {
rest.push(child);
}
}
return [...prioritized, ...rest];
})();
// Calculate child indent
let childIndent: number;
if (multipleChildren) {
// Parent branches: children get +1
childIndent = indent + 1;
} else if (justBranched && indent > 0) {
// First generation after a branch: +1 for visual grouping
childIndent = indent + 1;
} else {
// Single-child chain: stay flat
childIndent = indent;
}
// Build gutters for children
// If this node showed a connector, add a gutter entry for descendants
// Only add gutter if connector is actually displayed (not suppressed for virtual root children)
const connectorDisplayed = showConnector && !isVirtualRootChild;
// When connector is displayed, add a gutter entry at the connector's position
// Connector is at position (displayIndent - 1), so gutter should be there too
const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
const childGutters: GutterInfo[] = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order
for (let i = orderedChildren.length - 1; i >= 0; i--) {
const childIsLast = i === orderedChildren.length - 1;
stack.push([
orderedChildren[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false,
]);
}
}
return result;
}
private applyFilter(): void {
// Remember currently selected node to preserve cursor position
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
this.filteredNodes = this.flatNodes.filter((flatNode) => {
const entry = flatNode.node.entry;
const isCurrentLeaf = entry.id === this.currentLeafId;
// Skip assistant messages with only tool calls (no text) unless error/aborted
// Always show current leaf so active position is visible
if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) {
const msg = entry.message as { stopReason?: string; content?: unknown };
const hasText = this.hasTextContent(msg.content);
const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse";
// Only hide if no text AND not an error/aborted message
if (!hasText && !isErrorOrAborted) {
return false;
}
}
// Apply filter mode
let passesFilter = true;
// Entry types hidden in default view (settings/bookkeeping)
const isSettingsEntry =
entry.type === "label" ||
entry.type === "custom" ||
entry.type === "model_change" ||
entry.type === "thinking_level_change";
switch (this.filterMode) {
case "user-only":
// Just user messages
passesFilter = entry.type === "message" && entry.message.role === "user";
break;
case "no-tools":
// Default minus tool results
passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult");
break;
case "labeled-only":
// Just labeled entries
passesFilter = flatNode.node.label !== undefined;
break;
case "all":
// Show everything
passesFilter = true;
break;
default:
// Default mode: hide settings/bookkeeping entries
passesFilter = !isSettingsEntry;
break;
}
if (!passesFilter) return false;
// Apply search filter
if (searchTokens.length > 0) {
const nodeText = this.getSearchableText(flatNode.node).toLowerCase();
return searchTokens.every((token) => nodeText.includes(token));
}
return true;
});
// Try to preserve cursor on the same node after filtering
if (previouslySelectedId) {
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
if (newIndex !== -1) {
this.selectedIndex = newIndex;
return;
}
}
// Fall back: clamp index if out of bounds
if (this.selectedIndex >= this.filteredNodes.length) {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
}
/** Get searchable text content from a node */
private getSearchableText(node: SessionTreeNode): string {
const entry = node.entry;
const parts: string[] = [];
if (node.label) {
parts.push(node.label);
}
switch (entry.type) {
case "message": {
const msg = entry.message;
parts.push(msg.role);
if ("content" in msg && msg.content) {
parts.push(this.extractContent(msg.content));
}
if (msg.role === "bashExecution") {
const bashMsg = msg as { command?: string };
if (bashMsg.command) parts.push(bashMsg.command);
}
break;
}
case "custom_message": {
parts.push(entry.customType);
if (typeof entry.content === "string") {
parts.push(entry.content);
} else {
parts.push(this.extractContent(entry.content));
}
break;
}
case "compaction":
parts.push("compaction");
break;
case "branch_summary":
parts.push("branch summary", entry.summary);
break;
case "model_change":
parts.push("model", entry.modelId);
break;
case "thinking_level_change":
parts.push("thinking", entry.thinkingLevel);
break;
case "custom":
parts.push("custom", entry.customType);
break;
case "label":
parts.push("label", entry.label ?? "");
break;
}
return parts.join(" ");
}
invalidate(): void {}
getSearchQuery(): string {
return this.searchQuery;
}
getSelectedNode(): SessionTreeNode | undefined {
return this.filteredNodes[this.selectedIndex]?.node;
}
updateNodeLabel(entryId: string, label: string | undefined): void {
for (const flatNode of this.flatNodes) {
if (flatNode.node.entry.id === entryId) {
flatNode.node.label = label;
break;
}
}
}
private getFilterLabel(): string {
switch (this.filterMode) {
case "no-tools":
return " [no-tools]";
case "user-only":
return " [user]";
case "labeled-only":
return " [labeled]";
case "all":
return " [all]";
default:
return "";
}
}
render(width: number): string[] {
const lines: string[] = [];
if (this.filteredNodes.length === 0) {
lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width));
return lines;
}
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisibleLines / 2),
this.filteredNodes.length - this.maxVisibleLines,
),
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
const entry = flatNode.node.entry;
const isSelected = i === this.selectedIndex;
// Build line: cursor + prefix + path marker + label + content
const cursor = isSelected ? theme.fg("accent", " ") : " ";
// If multiple roots, shift display (roots at 0, not 1)
const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
// Build prefix with gutters at their correct positions
// Each gutter has a position (displayIndent where its connector was shown)
const connector =
flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : "";
const connectorPosition = connector ? displayIndent - 1 : -1;
// Build prefix char by char, placing gutters and connector at their positions
const totalChars = displayIndent * 3;
const prefixChars: string[] = [];
for (let i = 0; i < totalChars; i++) {
const level = Math.floor(i / 3);
const posInLevel = i % 3;
// Check if there's a gutter at this level
const gutter = flatNode.gutters.find((g) => g.position === level);
if (gutter) {
if (posInLevel === 0) {
prefixChars.push(gutter.show ? "│" : " ");
} else {
prefixChars.push(" ");
}
} else if (connector && level === connectorPosition) {
// Connector at this level
if (posInLevel === 0) {
prefixChars.push(flatNode.isLast ? "└" : "├");
} else if (posInLevel === 1) {
prefixChars.push("─");
} else {
prefixChars.push(" ");
}
} else {
prefixChars.push(" ");
}
}
const prefix = prefixChars.join("");
// Active path marker - shown right before the entry text
const isOnActivePath = this.activePathIds.has(entry.id);
const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
}
lines.push(truncateToWidth(line, width));
}
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),
width,
),
);
return lines;
}
private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {
const entry = node.entry;
let result: string;
const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim();
switch (entry.type) {
case "message": {
const msg = entry.message;
const role = msg.role;
if (role === "user") {
const msgWithContent = msg as { content?: unknown };
const content = normalize(this.extractContent(msgWithContent.content));
result = theme.fg("accent", "user: ") + content;
} else if (role === "assistant") {
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
const textContent = normalize(this.extractContent(msgWithContent.content));
if (textContent) {
result = theme.fg("success", "assistant: ") + textContent;
} else if (msgWithContent.stopReason === "aborted") {
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
} else if (msgWithContent.errorMessage) {
const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
} else {
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
}
} else if (role === "toolResult") {
const toolMsg = msg as { toolCallId?: string; toolName?: string };
const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;
if (toolCall) {
result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments));
} else {
result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`);
}
} else if (role === "bashExecution") {
const bashMsg = msg as { command?: string };
result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`);
} else {
result = theme.fg("dim", `[${role}]`);
}
break;
}
case "custom_message": {
const content =
typeof entry.content === "string"
? entry.content
: entry.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content);
break;
}
case "compaction": {
const tokens = Math.round(entry.tokensBefore / 1000);
result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`);
break;
}
case "branch_summary":
result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
break;
case "model_change":
result = theme.fg("dim", `[model: ${entry.modelId}]`);
break;
case "thinking_level_change":
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
break;
case "custom":
result = theme.fg("dim", `[custom: ${entry.customType}]`);
break;
case "label":
result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
break;
default:
result = "";
}
return isSelected ? theme.bold(result) : result;
}
private extractContent(content: unknown): string {
const maxLen = 200;
if (typeof content === "string") return content.slice(0, maxLen);
if (Array.isArray(content)) {
let result = "";
for (const c of content) {
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
result += (c as { text: string }).text;
if (result.length >= maxLen) return result.slice(0, maxLen);
}
}
return result;
}
return "";
}
private hasTextContent(content: unknown): boolean {
if (typeof content === "string") return content.trim().length > 0;
if (Array.isArray(content)) {
for (const c of content) {
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
const text = (c as { text?: string }).text;
if (text && text.trim().length > 0) return true;
}
}
}
return false;
}
private formatToolCall(name: string, args: Record<string, unknown>): string {
const shortenPath = (p: string): string => {
const home = process.env.HOME || process.env.USERPROFILE || "";
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
return p;
};
switch (name) {
case "read": {
const path = shortenPath(String(args.path || args.file_path || ""));
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
let display = path;
if (offset !== undefined || limit !== undefined) {
const start = offset ?? 1;
const end = limit !== undefined ? start + limit - 1 : "";
display += `:${start}${end ? `-${end}` : ""}`;
}
return `[read: ${display}]`;
}
case "write": {
const path = shortenPath(String(args.path || args.file_path || ""));
return `[write: ${path}]`;
}
case "edit": {
const path = shortenPath(String(args.path || args.file_path || ""));
return `[edit: ${path}]`;
}
case "bash": {
const rawCmd = String(args.command || "");
const cmd = rawCmd
.replace(/[\n\t]/g, " ")
.trim()
.slice(0, 50);
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
}
case "grep": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[grep: /${pattern}/ in ${path}]`;
}
case "find": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[find: ${pattern} in ${path}]`;
}
case "ls": {
const path = shortenPath(String(args.path || "."));
return `[ls: ${path}]`;
}
default: {
// Custom tool - show name and truncated JSON args
const argsStr = JSON.stringify(args).slice(0, 40);
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
}
}
}
handleInput(keyData: string): void {
if (isArrowUp(keyData)) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;
} else if (isArrowDown(keyData)) {
this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
} else if (isArrowLeft(keyData)) {
// Page up
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
} else if (isArrowRight(keyData)) {
// Page down
this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
} else if (isEnter(keyData)) {
const selected = this.filteredNodes[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.node.entry.id);
}
} else if (isEscape(keyData)) {
if (this.searchQuery) {
this.searchQuery = "";
this.applyFilter();
} else {
this.onCancel?.();
}
} else if (isCtrlC(keyData)) {
this.onCancel?.();
} else if (isShiftCtrlO(keyData)) {
// Cycle filter backwards
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
const currentIndex = modes.indexOf(this.filterMode);
this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
this.applyFilter();
} else if (isCtrlO(keyData)) {
// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
const currentIndex = modes.indexOf(this.filterMode);
this.filterMode = modes[(currentIndex + 1) % modes.length];
this.applyFilter();
} else if (isBackspace(keyData)) {
if (this.searchQuery.length > 0) {
this.searchQuery = this.searchQuery.slice(0, -1);
this.applyFilter();
}
} else if (keyData === "l" && !this.searchQuery) {
const selected = this.filteredNodes[this.selectedIndex];
if (selected && this.onLabelEdit) {
this.onLabelEdit(selected.node.entry.id, selected.node.label);
}
} else {
const hasControlChars = [...keyData].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars && keyData.length > 0) {
this.searchQuery += keyData;
this.applyFilter();
}
}
}
}
/** Component that displays the current search query */
class SearchLine implements Component {
constructor(private treeList: TreeList) {}
invalidate(): void {}
render(width: number): string[] {
const query = this.treeList.getSearchQuery();
if (query) {
return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
}
return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)];
}
handleInput(_keyData: string): void {}
}
/** Label input component shown when editing a label */
class LabelInput implements Component {
private input: Input;
private entryId: string;
public onSubmit?: (entryId: string, label: string | undefined) => void;
public onCancel?: () => void;
constructor(entryId: string, currentLabel: string | undefined) {
this.entryId = entryId;
this.input = new Input();
if (currentLabel) {
this.input.setValue(currentLabel);
}
}
invalidate(): void {}
render(width: number): string[] {
const lines: string[] = [];
const indent = " ";
const availableWidth = width - indent.length;
lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width));
return lines;
}
handleInput(keyData: string): void {
if (isEnter(keyData)) {
const value = this.input.getValue().trim();
this.onSubmit?.(this.entryId, value || undefined);
} else if (isEscape(keyData)) {
this.onCancel?.();
} else {
this.input.handleInput(keyData);
}
}
}
/**
* Component that renders a session tree selector for navigation
*/
export class TreeSelectorComponent extends Container {
private treeList: TreeList;
private labelInput: LabelInput | null = null;
private labelInputContainer: Container;
private treeContainer: Container;
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
constructor(
tree: SessionTreeNode[],
currentLeafId: string | null,
terminalHeight: number,
onSelect: (entryId: string) => void,
onCancel: () => void,
onLabelChange?: (entryId: string, label: string | undefined) => void,
) {
super();
this.onLabelChangeCallback = onLabelChange;
const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines);
this.treeList.onSelect = onSelect;
this.treeList.onCancel = onCancel;
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
this.treeContainer = new Container();
this.treeContainer.addChild(this.treeList);
this.labelInputContainer = new Container();
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
this.addChild(
new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0),
);
this.addChild(new SearchLine(this.treeList));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(this.treeContainer);
this.addChild(this.labelInputContainer);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
if (tree.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
private showLabelInput(entryId: string, currentLabel: string | undefined): void {
this.labelInput = new LabelInput(entryId, currentLabel);
this.labelInput.onSubmit = (id, label) => {
this.treeList.updateNodeLabel(id, label);
this.onLabelChangeCallback?.(id, label);
this.hideLabelInput();
};
this.labelInput.onCancel = () => this.hideLabelInput();
this.treeContainer.clear();
this.labelInputContainer.clear();
this.labelInputContainer.addChild(this.labelInput);
}
private hideLabelInput(): void {
this.labelInput = null;
this.labelInputContainer.clear();
this.treeContainer.clear();
this.treeContainer.addChild(this.treeList);
}
handleInput(keyData: string): void {
if (this.labelInput) {
this.labelInput.handleInput(keyData);
} else {
this.treeList.handleInput(keyData);
}
}
getTreeList(): TreeList {
return this.treeList;
}
}

View file

@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface UserMessageItem {
index: number; // Index in the full messages array
id: string; // Entry ID in the session
text: string; // The message text
timestamp?: string; // Optional timestamp if available
}
@ -25,7 +25,7 @@ interface UserMessageItem {
class UserMessageList implements Component {
private messages: UserMessageItem[] = [];
private selectedIndex: number = 0;
public onSelect?: (messageIndex: number) => void;
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
private maxVisible: number = 10; // Max messages visible
@ -101,7 +101,7 @@ class UserMessageList implements Component {
else if (isEnter(keyData)) {
const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.index);
this.onSelect(selected.id);
}
}
// Escape - cancel
@ -125,7 +125,7 @@ class UserMessageList implements Component {
export class UserMessageSelectorComponent extends Container {
private messageList: UserMessageList;
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) {
super();
// Add header

View file

@ -5,13 +5,9 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
* Component that renders a user message
*/
export class UserMessageComponent extends Container {
constructor(text: string, isFirst: boolean) {
constructor(text: string) {
super();
// Add spacer before user message (except first one)
if (!isFirst) {
this.addChild(new Spacer(1));
}
this.addChild(new Spacer(1));
this.addChild(
new Markdown(text, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),

View file

@ -11,10 +11,12 @@
"dimGray": "#666666",
"darkGray": "#505050",
"accent": "#8abeb7",
"selectedBg": "#3a3a4a",
"userMsgBg": "#343541",
"toolPendingBg": "#282832",
"toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828"
"toolErrorBg": "#3c2828",
"customMsgBg": "#2d2838"
},
"colors": {
"accent": "accent",
@ -27,9 +29,14 @@
"muted": "gray",
"dim": "dimGray",
"text": "",
"thinkingText": "gray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "#9575cd",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",

View file

@ -10,10 +10,12 @@
"mediumGray": "#6c6c6c",
"dimGray": "#8a8a8a",
"lightGray": "#b0b0b0",
"selectedBg": "#d0d0e0",
"userMsgBg": "#e8e8e8",
"toolPendingBg": "#e8e8f0",
"toolSuccessBg": "#e8f0e8",
"toolErrorBg": "#f0e8e8"
"toolErrorBg": "#f0e8e8",
"customMsgBg": "#ede7f6"
},
"colors": {
"accent": "teal",
@ -26,9 +28,14 @@
"muted": "mediumGray",
"dim": "dimGray",
"text": "",
"thinkingText": "mediumGray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "#7e57c2",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",

View file

@ -47,6 +47,9 @@
"text",
"userMessageBg",
"userMessageText",
"customMessageBg",
"customMessageText",
"customMessageLabel",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
@ -122,6 +125,18 @@
"$ref": "#/$defs/colorValue",
"description": "User message text color"
},
"customMessageBg": {
"$ref": "#/$defs/colorValue",
"description": "Custom message background (hook-injected messages)"
},
"customMessageText": {
"$ref": "#/$defs/colorValue",
"description": "Custom message text color"
},
"customMessageLabel": {
"$ref": "#/$defs/colorValue",
"description": "Custom message type label color"
},
"toolPendingBg": {
"$ref": "#/$defs/colorValue",
"description": "Tool execution box (pending state)"

View file

@ -34,9 +34,14 @@ const ThemeJsonSchema = Type.Object({
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
// Backgrounds & Content Text (7 colors)
thinkingText: ColorValueSchema,
// Backgrounds & Content Text (11 colors)
selectedBg: ColorValueSchema,
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
customMessageBg: ColorValueSchema,
customMessageText: ColorValueSchema,
customMessageLabel: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
@ -94,7 +99,10 @@ export type ThemeColor =
| "muted"
| "dim"
| "text"
| "thinkingText"
| "userMessageText"
| "customMessageText"
| "customMessageLabel"
| "toolTitle"
| "toolOutput"
| "mdHeading"
@ -127,7 +135,13 @@ export type ThemeColor =
| "thinkingXhigh"
| "bashMode";
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
export type ThemeBg =
| "selectedBg"
| "userMessageBg"
| "customMessageBg"
| "toolPendingBg"
| "toolSuccessBg"
| "toolErrorBg";
type ColorMode = "truecolor" | "256color";
@ -482,7 +496,14 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
const bgColorKeys: Set<string> = new Set([
"selectedBg",
"userMessageBg",
"customMessageBg",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
]);
for (const [key, value] of Object.entries(resolvedColors)) {
if (bgColorKeys.has(key)) {
bgColors[key as ThemeBg] = value;

View file

@ -6,8 +6,7 @@
* - `pi --mode json "prompt"` - JSON event stream
*/
import type { Attachment } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
import type { AgentSession } from "../core/agent-session.js";
/**
@ -18,38 +17,36 @@ import type { AgentSession } from "../core/agent-session.js";
* @param mode Output mode: "text" for final response only, "json" for all events
* @param messages Array of prompts to send
* @param initialMessage Optional first message (may contain @file content)
* @param initialAttachments Optional attachments for the initial message
* @param initialImages Optional images for the initial message
*/
export async function runPrintMode(
session: AgentSession,
mode: "text" | "json",
messages: string[],
initialMessage?: string,
initialAttachments?: Attachment[],
initialImages?: ImageContent[],
): Promise<void> {
// Load entries once for session start events
const entries = session.sessionManager.getEntries();
// Hook runner already has no-op UI context by default (set in main.ts)
// Set up hooks for print mode (no UI)
const hookRunner = session.hookRunner;
if (hookRunner) {
// Use actual session file if configured (via --session), otherwise null
hookRunner.setSessionFile(session.sessionFile);
hookRunner.initialize({
getModel: () => session.model,
sendMessageHandler: (message, triggerTurn) => {
session.sendHookMessage(message, triggerTurn).catch((e) => {
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
});
hookRunner.onError((err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`);
});
// No-op send handler for print mode (single-shot, no async messages)
hookRunner.setSendHandler(() => {
console.error("Warning: pi.send() is not supported in print mode");
});
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
type: "session_start",
});
}
@ -57,12 +54,17 @@ export async function runPrintMode(
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
await tool.onSession(
{
reason: "start",
previousSessionFile: undefined,
},
{
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
},
);
} catch (_err) {
// Silently ignore tool errors
}
@ -79,7 +81,7 @@ export async function runPrintMode(
// Send initial message with attachments
if (initialMessage) {
await session.prompt(initialMessage, { attachments: initialAttachments });
await session.prompt(initialMessage, { images: initialImages });
}
// Send remaining messages

View file

@ -6,9 +6,11 @@
import { type ChildProcess, spawn } from "node:child_process";
import * as readline from "node:readline";
import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
// ============================================================================
@ -166,8 +168,8 @@ export class RpcClient {
* Returns immediately after sending; use onEvent() to receive streaming events.
* Use waitForIdle() to wait for completion.
*/
async prompt(message: string, attachments?: Attachment[]): Promise<void> {
await this.send({ type: "prompt", message, attachments });
async prompt(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "prompt", message, images });
}
/**
@ -324,17 +326,17 @@ export class RpcClient {
* Branch from a specific message.
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
*/
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryIndex });
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryId });
return this.getData(response);
}
/**
* Get messages available for branching.
*/
async getBranchMessages(): Promise<Array<{ entryIndex: number; text: string }>> {
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_branch_messages" });
return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages;
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages;
}
/**
@ -348,9 +350,9 @@ export class RpcClient {
/**
* Get all messages in the session.
*/
async getMessages(): Promise<AppMessage[]> {
async getMessages(): Promise<AgentMessage[]> {
const response = await this.send({ type: "get_messages" });
return this.getData<{ messages: AppMessage[] }>(response).messages;
return this.getData<{ messages: AgentMessage[] }>(response).messages;
}
// =========================================================================
@ -403,9 +405,9 @@ export class RpcClient {
/**
* Send prompt and wait for completion, returning all events.
*/
async promptAndWait(message: string, attachments?: Attachment[], timeout = 60000): Promise<AgentEvent[]> {
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<AgentEvent[]> {
const eventsPromise = this.collectEvents(timeout);
await this.prompt(message, attachments);
await this.prompt(message, images);
return eventsPromise;
}

Some files were not shown because too many files have changed in this diff Show more