Refactor subagent tool, fix custom tool discovery, fix JSON mode stdout flush

Breaking changes:
- Custom tools now require index.ts entry point in subdirectory
  (e.g., tools/mytool/index.ts instead of tools/mytool.ts)

Subagent tool improvements:
- Refactored to use Message[] from ai package instead of custom types
- Extracted agent discovery to separate agents.ts module
- Added parallel mode streaming (shows progress from all tasks)
- Added turn count to usage stats footer
- Removed redundant Query section from scout output

Fixes:
- JSON mode stdout flush: Fixed race condition where pi --mode json
  could exit before all output was written, causing consumers to
  miss final events

Also:
- Added signal/timeout support to pi.exec() for custom tools and hooks
- Renamed pi-pods bin to avoid conflict with pi
This commit is contained in:
Mario Zechner 2025-12-19 04:10:09 +01:00
parent 1151975afe
commit 4fb3af93fb
15 changed files with 894 additions and 698 deletions

View file

@ -22,7 +22,7 @@ See [examples/custom-tools/](../examples/custom-tools/) for working examples.
## Quick Start
Create a file `~/.pi/agent/tools/hello.ts`:
Create a file `~/.pi/agent/tools/hello/index.ts`:
```typescript
import { Type } from "@sinclair/typebox";
@ -51,13 +51,26 @@ The tool is automatically discovered and available in your next pi session.
## Tool Locations
Tools must be in a subdirectory with an `index.ts` entry point:
| Location | Scope | Auto-discovered |
|----------|-------|-----------------|
| `~/.pi/agent/tools/*.ts` | Global (all projects) | Yes |
| `.pi/tools/*.ts` | Project-local | Yes |
| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes |
| `.pi/tools/*/index.ts` | Project-local | Yes |
| `settings.json` `customTools` array | Configured paths | Yes |
| `--tool <path>` CLI flag | One-off/debugging | No |
**Example structure:**
```
~/.pi/agent/tools/
├── hello/
│ └── index.ts # Entry point (auto-discovered)
└── complex-tool/
├── index.ts # Entry point (auto-discovered)
├── helpers.ts # Helper module (not loaded directly)
└── types.ts # Type definitions (not loaded directly)
```
**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
@ -125,7 +138,7 @@ The factory receives a `ToolAPI` object (named `pi` by convention):
```typescript
interface ToolAPI {
cwd: string; // Current working directory
exec(command: string, args: string[]): Promise<ExecResult>;
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
ui: {
select(title: string, options: string[]): Promise<string | null>;
confirm(title: string, message: string): Promise<boolean>;
@ -134,10 +147,36 @@ interface ToolAPI {
};
hasUI: boolean; // false in --print or --mode rpc
}
interface ExecOptions {
signal?: AbortSignal; // Cancel the process
timeout?: number; // Timeout in milliseconds
}
interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed?: boolean; // True if process was killed by signal/timeout
}
```
Always check `pi.hasUI` before using UI methods.
### Cancellation Example
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
```typescript
async execute(toolCallId, params, signal) {
const result = await pi.exec("long-running-command", ["arg"], { signal });
if (result.killed) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
return { content: [{ type: "text", text: result.stdout }] };
}
```
## Session Lifecycle
Tools can implement `onSession` to react to session changes:

View file

@ -363,15 +363,23 @@ ctx.ui.notify("Operation complete", "info");
ctx.ui.notify("Something went wrong", "error");
```
### ctx.exec(command, args)
### ctx.exec(command, args, options?)
Execute a command and get the result.
Execute a command and get the result. Supports cancellation via `AbortSignal` and timeout.
```typescript
const result = await ctx.exec("git", ["status"]);
// result.stdout: string
// result.stderr: string
// result.code: number
// result.killed?: boolean // True if killed by signal/timeout
// With timeout (5 seconds)
const result = await ctx.exec("slow-command", [], { timeout: 5000 });
// With abort signal
const controller = new AbortController();
const result = await ctx.exec("long-command", [], { signal: controller.signal });
```
### ctx.cwd