Merge main, resolve CHANGELOG conflict

This commit is contained in:
Mario Zechner 2026-01-01 00:10:46 +01:00
commit c15efdbcd9
41 changed files with 515 additions and 184 deletions

View file

@ -298,6 +298,22 @@ const readFileTool: AgentTool = {
agent.setTools([readFileTool]);
```
### Error Handling
**Throw an error** when a tool fails. Do not return error messages as content.
```typescript
execute: async (toolCallId, params, signal, onUpdate) => {
if (!fs.existsSync(params.path)) {
throw new Error(`File not found: ${params.path}`);
}
// Return content only on success
return { content: [{ type: "text", text: "..." }] };
}
```
Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
## Proxy Usage
For browser apps that proxy through a backend:

View file

@ -198,7 +198,8 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Fixed
- **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))
- **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))

View file

@ -198,6 +198,29 @@ async execute(toolCallId, params, onUpdate, ctx, 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`:

View file

@ -25,7 +25,7 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including
Create `~/.pi/agent/hooks/my-hook.ts`:
```typescript
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_start", async (_event, ctx) => {
@ -80,7 +80,7 @@ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
A hook exports a default function that receives `HookAPI`:
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
// Subscribe to events
@ -360,14 +360,20 @@ Tool inputs:
#### tool_result
Fired after tool executes. **Can modify result.**
Fired after tool executes (including errors). **Can modify result.**
Check `event.isError` to distinguish successful executions from failures.
```typescript
pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.toolCallId, event.input
// event.content - array of TextContent | ImageContent
// event.details - tool-specific (see below)
// event.isError
// event.isError - true if the tool threw an error
if (event.isError) {
// Handle error case
}
// Modify result:
return { content: [...], details: {...}, isError: false };
@ -377,7 +383,7 @@ pi.on("tool_result", async (event, ctx) => {
Use type guards for typed details:
```typescript
import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks";
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
if (isBashToolResult(event)) {
@ -416,25 +422,40 @@ const name = await ctx.ui.input("Name:", "placeholder");
// Notification (non-blocking)
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
// Set the core input editor text (pre-fill prompts, generated content)
ctx.ui.setEditorText("Generated prompt text here...");
// Get current editor text
const currentText = ctx.ui.getEditorText();
```
**Custom components:**
For full control, render your own TUI component with keyboard focus:
Show a custom TUI component with keyboard focus:
```typescript
const handle = ctx.ui.custom(myComponent);
// Returns { close: () => void, requestRender: () => void }
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
const result = await ctx.ui.custom((tui, theme, done) => {
const loader = new BorderedLoader(tui, theme, "Working...");
loader.onAbort = () => done(null);
doWork(loader.signal).then(done).catch(() => done(null));
return loader;
});
```
Your component can:
- Implement `handleInput(data: string)` to receive keyboard input
- Implement `render(width: number): string[]` to render lines
- Implement `invalidate()` to clear cached render
- Call `handle.requestRender()` to trigger re-render
- Call `handle.close()` when done to restore normal UI
- Implement `dispose()` for cleanup when closed
- Call `tui.requestRender()` to trigger re-render
- Call `done(result)` when done to restore normal UI
See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. See [tui.md](tui.md) for the full component API.
See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API.
### ctx.hasUI
@ -568,6 +589,8 @@ pi.registerCommand("stats", {
});
```
For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts).
To trigger LLM after command, call `pi.sendMessage(..., true)`.
### pi.registerMessageRenderer(customType, renderer)
@ -620,7 +643,7 @@ const result = await pi.exec("git", ["status"], {
### Permission Gate
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
@ -643,7 +666,7 @@ export default function (pi: HookAPI) {
### Protected Paths
```typescript
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/"];
@ -663,7 +686,7 @@ export default function (pi: HookAPI) {
### Git Checkpoint
```typescript
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<string, string>();
@ -708,7 +731,7 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example
| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this.
In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()` is a no-op. Design hooks to handle this by checking `ctx.hasUI`.
## Error Handling

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,7 +5,7 @@
* 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_shutdown", async (_event, ctx) => {

View file

@ -5,8 +5,7 @@
* Demonstrates how to cancel session events using the before_* events.
*/
import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent";
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_before_new", async (_event, ctx) => {

View file

@ -14,8 +14,8 @@
*/
import { complete, getModel } from "@mariozechner/pi-ai";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session_before_compact", async (event, ctx) => {

View file

@ -5,7 +5,7 @@
* Useful to ensure work is committed before switching context.
*/
import type { HookAPI, HookContext } 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

View file

@ -9,7 +9,7 @@
*/
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_start", async (_event, ctx) => {

View file

@ -5,7 +5,7 @@
* 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<string, string>();

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

@ -2,8 +2,8 @@
* 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";
import type { HookAPI } from "../../src/core/hooks/types.js";
const GAME_WIDTH = 40;
const GAME_HEIGHT = 15;
@ -56,7 +56,7 @@ class SnakeComponent {
private interval: ReturnType<typeof setInterval> | null = null;
private onClose: () => void;
private onSave: (state: GameState | null) => void;
private requestRender: () => void;
private tui: { requestRender: () => void };
private cachedLines: string[] = [];
private cachedWidth = 0;
private version = 0;
@ -64,11 +64,12 @@ class SnakeComponent {
private paused: boolean;
constructor(
tui: { requestRender: () => void },
onClose: () => void,
onSave: (state: GameState | null) => void,
requestRender: () => void,
savedState?: GameState,
) {
this.tui = tui;
if (savedState && !savedState.gameOver) {
// Resume from saved state, start paused
this.state = savedState;
@ -84,7 +85,6 @@ class SnakeComponent {
}
this.onClose = onClose;
this.onSave = onSave;
this.requestRender = requestRender;
}
private startGame(): void {
@ -92,7 +92,7 @@ class SnakeComponent {
if (!this.state.gameOver) {
this.tick();
this.version++;
this.requestRender();
this.tui.requestRender();
}
}, TICK_MS);
}
@ -196,7 +196,7 @@ class SnakeComponent {
this.state.highScore = highScore;
this.onSave(null); // Clear saved state on restart
this.version++;
this.requestRender();
this.tui.requestRender();
}
}
@ -327,19 +327,17 @@ export default function (pi: HookAPI) {
}
}
let ui: { close: () => void; requestRender: () => void } | null = null;
const component = new SnakeComponent(
() => ui?.close(),
(state) => {
// Save or clear state
pi.appendEntry(SNAKE_SAVE_TYPE, state);
},
() => ui?.requestRender(),
savedState,
);
ui = ctx.ui.custom(component);
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,7 +8,6 @@
* 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 CustomTool,
@ -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({

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,7 +9,6 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
AuthStorage,
type CustomTool,
@ -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");

View file

@ -90,7 +90,9 @@ function createNoOpUIContext(): HookUIContext {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
}

View file

@ -39,7 +39,9 @@ const noOpUIContext: HookUIContext = {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
/**

View file

@ -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

@ -7,7 +7,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
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";
@ -59,12 +59,48 @@ export interface HookUIContext {
/**
* Show a custom component with keyboard focus.
* The component receives keyboard input via handleInput() if implemented.
* 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 component - Component to display (implement handleInput for keyboard, dispose for cleanup)
* @returns Object with close() to restore normal UI and requestRender() to trigger redraw
* @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(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
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;
}
/**

View file

@ -140,7 +140,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookFactory } from "./hooks/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";

View file

@ -88,6 +88,10 @@ export {
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
// Pre-built tools (use process.cwd())
readOnlyTools,
@ -150,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

@ -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

@ -54,7 +54,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js";
import {
getAvailableThemes,
getEditorTheme,
getMarkdownTheme,
onThemeChange,
setTheme,
type Theme,
theme,
} from "./theme/theme.js";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@ -356,7 +364,9 @@ export class InteractiveMode {
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
custom: (component) => this.showHookCustom(component),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
};
this.setToolUIContext(uiContext, true);
@ -537,38 +547,37 @@ export class InteractiveMode {
/**
* Show a custom component with keyboard focus.
* Returns a function to call when done.
*/
private showHookCustom(component: Component & { dispose?(): void }): {
close: () => void;
requestRender: () => void;
} {
// Store current editor content
private async showHookCustom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T> {
const savedText = this.editor.getText();
// Replace editor with custom component
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
return new Promise((resolve) => {
let component: Component & { dispose?(): void };
// Return control object
return {
close: () => {
// Call dispose if available
const close = (result: T) => {
component.dispose?.();
// Restore editor
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
this.ui.requestRender();
},
requestRender: () => {
resolve(result);
};
Promise.resolve(factory(this.ui, theme, close)).then((c) => {
component = c;
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
},
};
});
});
}
/**

View file

@ -119,9 +119,25 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} as RpcHookUIRequest);
},
custom() {
async custom() {
// Custom UI not supported in RPC mode
return { close: () => {}, requestRender: () => {} };
return undefined as never;
},
setEditorText(text: string): void {
// Fire and forget - host can implement editor control
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "set_editor_text",
text,
} as RpcHookUIRequest);
},
getEditorText(): string {
// Synchronous method can't wait for RPC response
// Host should track editor state locally if needed
return "";
},
});

View file

@ -181,7 +181,8 @@ export type RpcHookUIRequest =
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
};
}
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
// ============================================================================
// Hook UI Commands (stdin)

View file

@ -108,7 +108,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
},
hasUI: false,
});

View file

@ -247,6 +247,26 @@ loader.setMessage("Still loading...");
loader.stop();
```
### CancellableLoader
Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
```typescript
const loader = new CancellableLoader(
tui, // TUI instance for render updates
(s) => chalk.cyan(s), // spinner color function
(s) => chalk.gray(s), // message color function
"Working..." // message
);
loader.onAbort = () => done(null); // Called when user presses Escape
doAsyncWork(loader.signal).then(done);
```
**Properties:**
- `signal: AbortSignal` - Aborted when user presses Escape
- `aborted: boolean` - Whether the loader was aborted
- `onAbort?: () => void` - Callback when user presses Escape
### SelectList
Interactive selection list with keyboard navigation.

View file

@ -0,0 +1,39 @@
import { isEscape } from "../keys.js";
import { Loader } from "./loader.js";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
if (isEscape(data)) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}

View file

@ -9,6 +9,7 @@ export {
} from "./autocomplete.js";
// Components
export { Box } from "./components/box.js";
export { CancellableLoader } from "./components/cancellable-loader.js";
export { Editor, type EditorTheme } from "./components/editor.js";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
export { Input } from "./components/input.js";