mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
Merge main, resolve CHANGELOG conflict
This commit is contained in:
commit
c15efdbcd9
41 changed files with 515 additions and 184 deletions
|
|
@ -298,6 +298,22 @@ const readFileTool: AgentTool = {
|
||||||
agent.setTools([readFileTool]);
|
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
|
## Proxy Usage
|
||||||
|
|
||||||
For browser apps that proxy through a backend:
|
For browser apps that proxy through a backend:
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,8 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
|
||||||
|
|
||||||
### Fixed
|
### 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))
|
- **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))
|
- **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))
|
- **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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
## CustomToolContext
|
||||||
|
|
||||||
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including
|
||||||
Create `~/.pi/agent/hooks/my-hook.ts`:
|
Create `~/.pi/agent/hooks/my-hook.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
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`:
|
A hook exports a default function that receives `HookAPI`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
|
|
@ -360,14 +360,20 @@ Tool inputs:
|
||||||
|
|
||||||
#### tool_result
|
#### 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
|
```typescript
|
||||||
pi.on("tool_result", async (event, ctx) => {
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
// event.toolName, event.toolCallId, event.input
|
// event.toolName, event.toolCallId, event.input
|
||||||
// event.content - array of TextContent | ImageContent
|
// event.content - array of TextContent | ImageContent
|
||||||
// event.details - tool-specific (see below)
|
// 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:
|
// Modify result:
|
||||||
return { content: [...], details: {...}, isError: false };
|
return { content: [...], details: {...}, isError: false };
|
||||||
|
|
@ -377,7 +383,7 @@ pi.on("tool_result", async (event, ctx) => {
|
||||||
Use type guards for typed details:
|
Use type guards for typed details:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks";
|
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
pi.on("tool_result", async (event, ctx) => {
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
if (isBashToolResult(event)) {
|
if (isBashToolResult(event)) {
|
||||||
|
|
@ -416,25 +422,40 @@ const name = await ctx.ui.input("Name:", "placeholder");
|
||||||
|
|
||||||
// Notification (non-blocking)
|
// Notification (non-blocking)
|
||||||
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
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:**
|
**Custom components:**
|
||||||
|
|
||||||
For full control, render your own TUI component with keyboard focus:
|
Show a custom TUI component with keyboard focus:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const handle = ctx.ui.custom(myComponent);
|
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
||||||
// Returns { close: () => void, requestRender: () => void }
|
|
||||||
|
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:
|
Your component can:
|
||||||
- Implement `handleInput(data: string)` to receive keyboard input
|
- Implement `handleInput(data: string)` to receive keyboard input
|
||||||
- Implement `render(width: number): string[]` to render lines
|
- Implement `render(width: number): string[]` to render lines
|
||||||
- Implement `invalidate()` to clear cached render
|
- Implement `invalidate()` to clear cached render
|
||||||
- Call `handle.requestRender()` to trigger re-render
|
- Implement `dispose()` for cleanup when closed
|
||||||
- Call `handle.close()` when done to restore normal UI
|
- 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
|
### 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)`.
|
To trigger LLM after command, call `pi.sendMessage(..., true)`.
|
||||||
|
|
||||||
### pi.registerMessageRenderer(customType, renderer)
|
### pi.registerMessageRenderer(customType, renderer)
|
||||||
|
|
@ -620,7 +643,7 @@ const result = await pi.exec("git", ["status"], {
|
||||||
### Permission Gate
|
### Permission Gate
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
|
const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
|
||||||
|
|
@ -643,7 +666,7 @@ export default function (pi: HookAPI) {
|
||||||
### Protected Paths
|
### Protected Paths
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||||
|
|
@ -663,7 +686,7 @@ export default function (pi: HookAPI) {
|
||||||
### Git Checkpoint
|
### Git Checkpoint
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
const checkpoints = new Map<string, string>();
|
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 |
|
| RPC | JSON protocol | Host handles UI |
|
||||||
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
|
| 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
|
## Error Handling
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,97 +2,53 @@
|
||||||
|
|
||||||
Example hooks for pi-coding-agent.
|
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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test directly
|
# Load a hook with --hook flag
|
||||||
pi --hook examples/hooks/permission-gate.ts
|
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/
|
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
|
## Writing Hooks
|
||||||
|
|
||||||
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
|
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
|
||||||
|
|
||||||
### Key Points
|
|
||||||
|
|
||||||
**Hook structure:**
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session", async (event, ctx) => {
|
// Subscribe to events
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
// Can block tool execution
|
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||||
if (dangerous) {
|
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||||
return { block: true, reason: "Blocked" };
|
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("tool_result", async (event, ctx) => {
|
// Register custom commands
|
||||||
// Can modify result
|
pi.registerCommand("hello", {
|
||||||
return { result: "modified result" };
|
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");
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Uses the last assistant message to generate a commit message.
|
* 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) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session_shutdown", async (_event, ctx) => {
|
pi.on("session_shutdown", async (_event, ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
* Demonstrates how to cancel session events using the before_* events.
|
* Demonstrates how to cancel session events using the before_* events.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session_before_new", async (_event, ctx) => {
|
pi.on("session_before_new", async (_event, ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
import { convertToLlm, serializeConversation } 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) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session_before_compact", async (event, ctx) => {
|
pi.on("session_before_compact", async (event, ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Useful to ensure work is committed before switching context.
|
* 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> {
|
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
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) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* When branching, offers to restore code to that point in history.
|
* 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) {
|
export default function (pi: HookAPI) {
|
||||||
const checkpoints = new Map<string, string>();
|
const checkpoints = new Map<string, string>();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
* 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) {
|
export default function (pi: HookAPI) {
|
||||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Useful for preventing accidental modifications to sensitive files.
|
* 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) {
|
export default function (pi: HookAPI) {
|
||||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||||
|
|
|
||||||
119
packages/coding-agent/examples/hooks/qna.ts
Normal file
119
packages/coding-agent/examples/hooks/qna.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* Snake game hook - play snake with /snake command
|
* 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 { 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_WIDTH = 40;
|
||||||
const GAME_HEIGHT = 15;
|
const GAME_HEIGHT = 15;
|
||||||
|
|
@ -56,7 +56,7 @@ class SnakeComponent {
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
private interval: ReturnType<typeof setInterval> | null = null;
|
||||||
private onClose: () => void;
|
private onClose: () => void;
|
||||||
private onSave: (state: GameState | null) => void;
|
private onSave: (state: GameState | null) => void;
|
||||||
private requestRender: () => void;
|
private tui: { requestRender: () => void };
|
||||||
private cachedLines: string[] = [];
|
private cachedLines: string[] = [];
|
||||||
private cachedWidth = 0;
|
private cachedWidth = 0;
|
||||||
private version = 0;
|
private version = 0;
|
||||||
|
|
@ -64,11 +64,12 @@ class SnakeComponent {
|
||||||
private paused: boolean;
|
private paused: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
tui: { requestRender: () => void },
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
onSave: (state: GameState | null) => void,
|
onSave: (state: GameState | null) => void,
|
||||||
requestRender: () => void,
|
|
||||||
savedState?: GameState,
|
savedState?: GameState,
|
||||||
) {
|
) {
|
||||||
|
this.tui = tui;
|
||||||
if (savedState && !savedState.gameOver) {
|
if (savedState && !savedState.gameOver) {
|
||||||
// Resume from saved state, start paused
|
// Resume from saved state, start paused
|
||||||
this.state = savedState;
|
this.state = savedState;
|
||||||
|
|
@ -84,7 +85,6 @@ class SnakeComponent {
|
||||||
}
|
}
|
||||||
this.onClose = onClose;
|
this.onClose = onClose;
|
||||||
this.onSave = onSave;
|
this.onSave = onSave;
|
||||||
this.requestRender = requestRender;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startGame(): void {
|
private startGame(): void {
|
||||||
|
|
@ -92,7 +92,7 @@ class SnakeComponent {
|
||||||
if (!this.state.gameOver) {
|
if (!this.state.gameOver) {
|
||||||
this.tick();
|
this.tick();
|
||||||
this.version++;
|
this.version++;
|
||||||
this.requestRender();
|
this.tui.requestRender();
|
||||||
}
|
}
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +196,7 @@ class SnakeComponent {
|
||||||
this.state.highScore = highScore;
|
this.state.highScore = highScore;
|
||||||
this.onSave(null); // Clear saved state on restart
|
this.onSave(null); // Clear saved state on restart
|
||||||
this.version++;
|
this.version++;
|
||||||
this.requestRender();
|
this.tui.requestRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,19 +327,17 @@ export default function (pi: HookAPI) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui: { close: () => void; requestRender: () => void } | null = null;
|
await ctx.ui.custom((tui, _theme, done) => {
|
||||||
|
return new SnakeComponent(
|
||||||
const component = new SnakeComponent(
|
tui,
|
||||||
() => ui?.close(),
|
() => done(undefined),
|
||||||
(state) => {
|
(state) => {
|
||||||
// Save or clear state
|
// Save or clear state
|
||||||
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
||||||
},
|
},
|
||||||
() => ui?.requestRender(),
|
savedState,
|
||||||
savedState,
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
ui = ctx.ui.custom(component);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
|
* 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();
|
const { session } = await createAgentSession();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
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
|
// Set up auth storage and model registry
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = discoverAuthStorage();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Shows how to replace or modify the default system prompt.
|
* 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
|
// Option 1: Replace prompt entirely
|
||||||
const { session: session1 } = await createAgentSession({
|
const { session: session1 } = await createAgentSession({
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Discover, filter, merge, or replace them.
|
* 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.
|
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
||||||
const allSkills = discoverSkills();
|
const allSkills = discoverSkills();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
* tools resolve paths relative to your cwd, not process.cwd().
|
* tools resolve paths relative to your cwd, not process.cwd().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import {
|
import {
|
||||||
bashTool, // read, bash, edit, write - uses process.cwd()
|
bashTool, // read, bash, edit, write - uses process.cwd()
|
||||||
type CustomTool,
|
type CustomTool,
|
||||||
|
|
@ -21,7 +20,8 @@ import {
|
||||||
readOnlyTools, // read, grep, find, ls - uses process.cwd()
|
readOnlyTools, // read, grep, find, ls - uses process.cwd()
|
||||||
readTool,
|
readTool,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} from "../../src/index.js";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
// Read-only mode (no edit/write) - uses process.cwd()
|
// Read-only mode (no edit/write) - uses process.cwd()
|
||||||
await createAgentSession({
|
await createAgentSession({
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Hooks intercept agent events for logging, blocking, or modification.
|
* 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
|
// Logging hook
|
||||||
const loggingHook: HookFactory = (api) => {
|
const loggingHook: HookFactory = (api) => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Context files provide project-specific instructions loaded into the system prompt.
|
* 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
|
// Discover AGENTS.md files walking up from cwd
|
||||||
const discovered = discoverContextFiles();
|
const discovered = discoverContextFiles();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@
|
||||||
* File-based commands that inject content when invoked with /commandname.
|
* 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/
|
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
|
||||||
const discovered = discoverSlashCommands();
|
const discovered = discoverSlashCommands();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
discoverModels,
|
discoverModels,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} from "../../src/index.js";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
||||||
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Override settings using SettingsManager.
|
* 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)
|
// Load current settings (merged global + project)
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Control session persistence: in-memory, new file, continue, or open specific.
|
* 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)
|
// In-memory (no persistence)
|
||||||
const { session: inMemory } = await createAgentSession({
|
const { session: inMemory } = await createAgentSession({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
type CustomTool,
|
type CustomTool,
|
||||||
|
|
@ -20,7 +19,8 @@ import {
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
} from "../../src/index.js";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
// Custom auth storage location
|
// Custom auth storage location
|
||||||
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
|
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ function createNoOpUIContext(): HookUIContext {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
custom: async () => undefined as never,
|
||||||
|
setEditorText: () => {},
|
||||||
|
getEditorText: () => "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ const noOpUIContext: HookUIContext = {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
custom: async () => undefined as never,
|
||||||
|
setEditorText: () => {},
|
||||||
|
getEditorText: () => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the actual tool, forwarding onUpdate for progress streaming
|
// 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
|
// Emit tool_result event - hooks can modify the result
|
||||||
if (hookRunner.hasHandlers("tool_result")) {
|
if (hookRunner.hasHandlers("tool_result")) {
|
||||||
const resultResult = (await hookRunner.emit({
|
const resultResult = (await hookRunner.emit({
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
toolName: tool.name,
|
toolName: tool.name,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
input: params,
|
input: params,
|
||||||
content: result.content,
|
content: result.content,
|
||||||
details: result.details,
|
details: result.details,
|
||||||
isError: false,
|
isError: false,
|
||||||
})) as ToolResultEventResult | undefined;
|
})) as ToolResultEventResult | undefined;
|
||||||
|
|
||||||
// Apply modifications if any
|
// Apply modifications if any
|
||||||
if (resultResult) {
|
if (resultResult) {
|
||||||
return {
|
return {
|
||||||
content: resultResult.content ?? result.content,
|
content: resultResult.content ?? result.content,
|
||||||
details: (resultResult.details ?? result.details) as T,
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
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 { Theme } from "../../modes/interactive/theme/theme.js";
|
||||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
|
@ -59,12 +59,48 @@ export interface HookUIContext {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a custom component with keyboard focus.
|
* 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)
|
* @param factory - Function that creates the component. Call done() when finished.
|
||||||
* @returns Object with close() to restore normal UI and requestRender() to trigger redraw
|
* @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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export interface CreateAgentSessionResult {
|
||||||
// Re-exports
|
// Re-exports
|
||||||
|
|
||||||
export type { CustomTool } from "./custom-tools/types.js";
|
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 { Settings, SkillsSettings } from "./settings-manager.js";
|
||||||
export type { Skill } from "./skills.js";
|
export type { Skill } from "./skills.js";
|
||||||
export type { FileSlashCommand } from "./slash-commands.js";
|
export type { FileSlashCommand } from "./slash-commands.js";
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ export {
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
discoverSlashCommands,
|
discoverSlashCommands,
|
||||||
type FileSlashCommand,
|
type FileSlashCommand,
|
||||||
|
// Hook types
|
||||||
|
type HookAPI,
|
||||||
|
type HookContext,
|
||||||
|
type HookFactory,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
// Pre-built tools (use process.cwd())
|
// Pre-built tools (use process.cwd())
|
||||||
readOnlyTools,
|
readOnlyTools,
|
||||||
|
|
@ -150,5 +154,7 @@ export {
|
||||||
} from "./core/tools/index.js";
|
} from "./core/tools/index.js";
|
||||||
// Main entry point
|
// Main entry point
|
||||||
export { main } from "./main.js";
|
export { main } from "./main.js";
|
||||||
|
// UI components for hooks
|
||||||
|
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
|
||||||
// Theme utilities for custom tools
|
// Theme utilities for custom tools
|
||||||
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";
|
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
|
||||||
import { TreeSelectorComponent } from "./components/tree-selector.js";
|
import { TreeSelectorComponent } from "./components/tree-selector.js";
|
||||||
import { UserMessageComponent } from "./components/user-message.js";
|
import { UserMessageComponent } from "./components/user-message.js";
|
||||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.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 for components that can be expanded/collapsed */
|
||||||
interface Expandable {
|
interface Expandable {
|
||||||
|
|
@ -356,7 +364,9 @@ export class InteractiveMode {
|
||||||
confirm: (title, message) => this.showHookConfirm(title, message),
|
confirm: (title, message) => this.showHookConfirm(title, message),
|
||||||
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
||||||
notify: (message, type) => this.showHookNotify(message, type),
|
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);
|
this.setToolUIContext(uiContext, true);
|
||||||
|
|
||||||
|
|
@ -537,38 +547,37 @@ export class InteractiveMode {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a custom component with keyboard focus.
|
* Show a custom component with keyboard focus.
|
||||||
* Returns a function to call when done.
|
|
||||||
*/
|
*/
|
||||||
private showHookCustom(component: Component & { dispose?(): void }): {
|
private async showHookCustom<T>(
|
||||||
close: () => void;
|
factory: (
|
||||||
requestRender: () => void;
|
tui: TUI,
|
||||||
} {
|
theme: Theme,
|
||||||
// Store current editor content
|
done: (result: T) => void,
|
||||||
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||||
|
): Promise<T> {
|
||||||
const savedText = this.editor.getText();
|
const savedText = this.editor.getText();
|
||||||
|
|
||||||
// Replace editor with custom component
|
return new Promise((resolve) => {
|
||||||
this.editorContainer.clear();
|
let component: Component & { dispose?(): void };
|
||||||
this.editorContainer.addChild(component);
|
|
||||||
this.ui.setFocus(component);
|
|
||||||
this.ui.requestRender();
|
|
||||||
|
|
||||||
// Return control object
|
const close = (result: T) => {
|
||||||
return {
|
|
||||||
close: () => {
|
|
||||||
// Call dispose if available
|
|
||||||
component.dispose?.();
|
component.dispose?.();
|
||||||
|
|
||||||
// Restore editor
|
|
||||||
this.editorContainer.clear();
|
this.editorContainer.clear();
|
||||||
this.editorContainer.addChild(this.editor);
|
this.editorContainer.addChild(this.editor);
|
||||||
this.editor.setText(savedText);
|
this.editor.setText(savedText);
|
||||||
this.ui.setFocus(this.editor);
|
this.ui.setFocus(this.editor);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
},
|
resolve(result);
|
||||||
requestRender: () => {
|
};
|
||||||
|
|
||||||
|
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();
|
this.ui.requestRender();
|
||||||
},
|
});
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,25 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
} as RpcHookUIRequest);
|
} as RpcHookUIRequest);
|
||||||
},
|
},
|
||||||
|
|
||||||
custom() {
|
async custom() {
|
||||||
// Custom UI not supported in RPC mode
|
// 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 "";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,8 @@ export type RpcHookUIRequest =
|
||||||
method: "notify";
|
method: "notify";
|
||||||
message: string;
|
message: string;
|
||||||
notifyType?: "info" | "warning" | "error";
|
notifyType?: "info" | "warning" | "error";
|
||||||
};
|
}
|
||||||
|
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Hook UI Commands (stdin)
|
// Hook UI Commands (stdin)
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
custom: async () => undefined as never,
|
||||||
|
setEditorText: () => {},
|
||||||
|
getEditorText: () => "",
|
||||||
},
|
},
|
||||||
hasUI: false,
|
hasUI: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,26 @@ loader.setMessage("Still loading...");
|
||||||
loader.stop();
|
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
|
### SelectList
|
||||||
|
|
||||||
Interactive selection list with keyboard navigation.
|
Interactive selection list with keyboard navigation.
|
||||||
|
|
|
||||||
39
packages/tui/src/components/cancellable-loader.ts
Normal file
39
packages/tui/src/components/cancellable-loader.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ export {
|
||||||
} from "./autocomplete.js";
|
} from "./autocomplete.js";
|
||||||
// Components
|
// Components
|
||||||
export { Box } from "./components/box.js";
|
export { Box } from "./components/box.js";
|
||||||
|
export { CancellableLoader } from "./components/cancellable-loader.js";
|
||||||
export { Editor, type EditorTheme } from "./components/editor.js";
|
export { Editor, type EditorTheme } from "./components/editor.js";
|
||||||
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
|
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
|
||||||
export { Input } from "./components/input.js";
|
export { Input } from "./components/input.js";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue