mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
Merge origin/main into feature/detect-vertex-ai
This commit is contained in:
commit
eede2e1644
136 changed files with 5747 additions and 7517 deletions
|
|
@ -19,7 +19,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t
|
|||
- Always ask before removing functionality or code that appears to be intentional
|
||||
|
||||
## Commands
|
||||
- After code changes: `npm run check` (get full output, no tail)
|
||||
- After code changes (not documentation changes): `npm run check` (get full output, no tail)
|
||||
- NEVER run: `npm run dev`, `npm run build`, `npm test`
|
||||
- Only run specific tests if user instructs: `npm test -- test/specific.test.ts`
|
||||
- NEVER commit unless user asks
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ cd packages/coding-agent && npx tsx src/cli.ts
|
|||
cd packages/pods && npx tsx src/cli.ts
|
||||
```
|
||||
|
||||
To run tests that don't require an LLM endpoint:
|
||||
```bash
|
||||
./test.sh
|
||||
```
|
||||
|
||||
### Versioning (Lockstep)
|
||||
|
||||
**All packages MUST always have the same version number.** Use these commands to bump versions:
|
||||
|
|
|
|||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -7076,11 +7076,11 @@
|
|||
},
|
||||
"packages/agent": {
|
||||
"name": "@mariozechner/pi-agent-core",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-tui": "^0.34.2"
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-tui": "^0.35.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
|
@ -7110,7 +7110,7 @@
|
|||
},
|
||||
"packages/ai": {
|
||||
"name": "@mariozechner/pi-ai",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
|
|
@ -7155,13 +7155,13 @@
|
|||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@mariozechner/pi-coding-agent",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@crosscopy/clipboard": "^0.2.8",
|
||||
"@mariozechner/pi-agent-core": "^0.34.2",
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-tui": "^0.34.2",
|
||||
"@mariozechner/pi-agent-core": "^0.35.0",
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-tui": "^0.35.0",
|
||||
"chalk": "^5.5.0",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"diff": "^8.0.2",
|
||||
|
|
@ -7203,13 +7203,13 @@
|
|||
},
|
||||
"packages/mom": {
|
||||
"name": "@mariozechner/pi-mom",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
||||
"@mariozechner/pi-agent-core": "^0.34.2",
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.34.2",
|
||||
"@mariozechner/pi-agent-core": "^0.35.0",
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.35.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"@slack/socket-mode": "^2.0.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
|
|
@ -7248,10 +7248,10 @@
|
|||
},
|
||||
"packages/pods": {
|
||||
"name": "@mariozechner/pi",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "^0.34.2",
|
||||
"@mariozechner/pi-agent-core": "^0.35.0",
|
||||
"chalk": "^5.5.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -7264,7 +7264,7 @@
|
|||
},
|
||||
"packages/tui": {
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
|
|
@ -7308,12 +7308,12 @@
|
|||
},
|
||||
"packages/web-ui": {
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-tui": "^0.34.2",
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-tui": "^0.35.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
|
|
@ -7334,7 +7334,7 @@
|
|||
},
|
||||
"packages/web-ui/example": {
|
||||
"name": "pi-web-ui-example",
|
||||
"version": "1.22.2",
|
||||
"version": "1.23.0",
|
||||
"dependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"@mariozechner/pi-ai": "file:../../ai",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.35.0] - 2026-01-05
|
||||
|
||||
## [0.34.2] - 2026-01-04
|
||||
|
||||
## [0.34.1] - 2026-01-04
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-agent-core",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -17,8 +17,8 @@
|
|||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-tui": "^0.34.2"
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-tui": "^0.35.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
* @example
|
||||
* ```typescript
|
||||
* convertToLlm: (messages) => messages.flatMap(m => {
|
||||
* if (m.role === "hookMessage") {
|
||||
* if (m.role === "custom") {
|
||||
* // Convert custom message to user message
|
||||
* return [{ role: "user", content: m.content, timestamp: m.timestamp }];
|
||||
* }
|
||||
|
|
|
|||
|
|
@ -474,31 +474,31 @@ describe("agentLoopContinue with AgentMessage", () => {
|
|||
|
||||
it("should allow custom message types as last message (caller responsibility)", async () => {
|
||||
// Custom message that will be converted to user message by convertToLlm
|
||||
interface HookMessage {
|
||||
role: "hookMessage";
|
||||
interface CustomMessage {
|
||||
role: "custom";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const hookMessage: HookMessage = {
|
||||
role: "hookMessage",
|
||||
const customMessage: CustomMessage = {
|
||||
role: "custom",
|
||||
text: "Hook content",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [hookMessage as unknown as AgentMessage],
|
||||
messages: [customMessage as unknown as AgentMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: (messages) => {
|
||||
// Convert hookMessage to user message
|
||||
// Convert custom to user message
|
||||
return messages
|
||||
.map((m) => {
|
||||
if ((m as any).role === "hookMessage") {
|
||||
if ((m as any).role === "custom") {
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: (m as any).text,
|
||||
|
|
@ -514,13 +514,13 @@ describe("agentLoopContinue with AgentMessage", () => {
|
|||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response to hook" }]);
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response to custom message" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
// Should not throw - the hookMessage will be converted to user message
|
||||
// Should not throw - the custom message will be converted to user message
|
||||
const stream = agentLoopContinue(context, config, undefined, streamFn);
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
- Vertex AI dummy value for `getEnvApiKey()`: Returns `"<authenticated>"` when Application Default Credentials are configured (`~/.config/gcloud/application_default_credentials.json` exists) and both `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) and `GOOGLE_CLOUD_LOCATION` are set. This allows `streamSimple()` to work with Vertex AI without explicit `apiKey` option. The ADC credentials file existence check is cached per-process to avoid repeated filesystem access.
|
||||
|
||||
## [0.35.0] - 2026-01-05
|
||||
|
||||
## [0.34.2] - 2026-01-04
|
||||
|
||||
## [0.34.1] - 2026-01-04
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-ai",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
|
|||
|
|
@ -424,13 +424,15 @@ describe("Context overflow error handling", () => {
|
|||
// Ollama (local)
|
||||
// =============================================================================
|
||||
|
||||
// Check if ollama is installed
|
||||
// Check if ollama is installed and local LLM tests are enabled
|
||||
let ollamaInstalled = false;
|
||||
try {
|
||||
execSync("which ollama", { stdio: "ignore" });
|
||||
ollamaInstalled = true;
|
||||
} catch {
|
||||
ollamaInstalled = false;
|
||||
if (!process.env.PI_NO_LOCAL_LLM) {
|
||||
try {
|
||||
execSync("which ollama", { stdio: "ignore" });
|
||||
ollamaInstalled = true;
|
||||
} catch {
|
||||
ollamaInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!ollamaInstalled)("Ollama (local)", () => {
|
||||
|
|
@ -514,15 +516,17 @@ describe("Context overflow error handling", () => {
|
|||
});
|
||||
|
||||
// =============================================================================
|
||||
// LM Studio (local) - Skip if not running
|
||||
// LM Studio (local) - Skip if not running or local LLM tests disabled
|
||||
// =============================================================================
|
||||
|
||||
let lmStudioRunning = false;
|
||||
try {
|
||||
execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" });
|
||||
lmStudioRunning = true;
|
||||
} catch {
|
||||
lmStudioRunning = false;
|
||||
if (!process.env.PI_NO_LOCAL_LLM) {
|
||||
try {
|
||||
execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" });
|
||||
lmStudioRunning = true;
|
||||
} catch {
|
||||
lmStudioRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!lmStudioRunning)("LM Studio (local)", () => {
|
||||
|
|
|
|||
|
|
@ -878,13 +878,15 @@ describe("Generate E2E Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Check if ollama is installed
|
||||
// Check if ollama is installed and local LLM tests are enabled
|
||||
let ollamaInstalled = false;
|
||||
try {
|
||||
execSync("which ollama", { stdio: "ignore" });
|
||||
ollamaInstalled = true;
|
||||
} catch {
|
||||
ollamaInstalled = false;
|
||||
if (!process.env.PI_NO_LOCAL_LLM) {
|
||||
try {
|
||||
execSync("which ollama", { stdio: "ignore" });
|
||||
ollamaInstalled = true;
|
||||
} catch {
|
||||
ollamaInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!ollamaInstalled)("Ollama Provider (gpt-oss-20b via OpenAI Completions)", () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,207 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.35.0] - 2026-01-05
|
||||
|
||||
This release unifies hooks and custom tools into a single "extensions" system and renames "slash commands" to "prompt templates". ([#454](https://github.com/badlogic/pi-mono/issues/454))
|
||||
|
||||
**Before migrating, read:**
|
||||
- [docs/extensions.md](docs/extensions.md) - Full API reference
|
||||
- [README.md](README.md) - Extensions section with examples
|
||||
- [examples/extensions/](examples/extensions/) - Working examples
|
||||
|
||||
### Extensions Migration
|
||||
|
||||
Hooks and custom tools are now unified as **extensions**. Both were TypeScript modules exporting a factory function that receives an API object. Now there's one concept, one discovery location, one CLI flag, one settings.json entry.
|
||||
|
||||
**Automatic migration:**
|
||||
- `commands/` directories are automatically renamed to `prompts/` on startup (both `~/.pi/agent/commands/` and `.pi/commands/`)
|
||||
|
||||
**Manual migration required:**
|
||||
1. Move files from `hooks/` and `tools/` directories to `extensions/` (deprecation warnings shown on startup)
|
||||
2. Update imports and type names in your extension code
|
||||
3. Update `settings.json` if you have explicit hook and custom tool paths configured
|
||||
|
||||
**Directory changes:**
|
||||
```
|
||||
# Before
|
||||
~/.pi/agent/hooks/*.ts → ~/.pi/agent/extensions/*.ts
|
||||
~/.pi/agent/tools/*.ts → ~/.pi/agent/extensions/*.ts
|
||||
.pi/hooks/*.ts → .pi/extensions/*.ts
|
||||
.pi/tools/*.ts → .pi/extensions/*.ts
|
||||
```
|
||||
|
||||
**Extension discovery rules** (in `extensions/` directories):
|
||||
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
|
||||
2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension
|
||||
3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"pi"` field → loads declared paths
|
||||
|
||||
```json
|
||||
// extensions/my-package/package.json
|
||||
{
|
||||
"name": "my-extension-package",
|
||||
"dependencies": { "zod": "^3.0.0" },
|
||||
"pi": {
|
||||
"extensions": ["./src/main.ts", "./src/tools.ts"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No recursion beyond one level. Complex packages must use the `package.json` manifest. Dependencies are resolved via jiti, and extensions can be published to and installed from npm.
|
||||
|
||||
**Type renames:**
|
||||
- `HookAPI` → `ExtensionAPI`
|
||||
- `HookContext` → `ExtensionContext`
|
||||
- `HookCommandContext` → `ExtensionCommandContext`
|
||||
- `HookUIContext` → `ExtensionUIContext`
|
||||
- `CustomToolAPI` → `ExtensionAPI` (merged)
|
||||
- `CustomToolContext` → `ExtensionContext` (merged)
|
||||
- `CustomToolUIContext` → `ExtensionUIContext`
|
||||
- `CustomTool` → `ToolDefinition`
|
||||
- `CustomToolFactory` → `ExtensionFactory`
|
||||
- `HookMessage` → `CustomMessage`
|
||||
|
||||
**Import changes:**
|
||||
```typescript
|
||||
// Before (hook)
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
|
||||
export default function (pi: HookAPI) { ... }
|
||||
|
||||
// Before (custom tool)
|
||||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
const factory: CustomToolFactory = (pi) => ({ name: "my_tool", ... });
|
||||
export default factory;
|
||||
|
||||
// After (both are now extensions)
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => { ... });
|
||||
pi.registerTool({ name: "my_tool", ... });
|
||||
}
|
||||
```
|
||||
|
||||
**Custom tools now have full context access.** Tools registered via `pi.registerTool()` now receive the same `ctx` object that event handlers receive. Previously, custom tools had limited context. Now all extension code shares the same capabilities:
|
||||
|
||||
- `pi.registerTool()` - Register tools the LLM can call
|
||||
- `pi.registerCommand()` - Register commands like `/mycommand`
|
||||
- `pi.registerShortcut()` - Register keyboard shortcuts (shown in `/hotkeys`)
|
||||
- `pi.registerFlag()` - Register CLI flags (shown in `--help`)
|
||||
- `pi.registerMessageRenderer()` - Custom TUI rendering for message types
|
||||
- `pi.on()` - Subscribe to lifecycle events (tool_call, session_start, etc.)
|
||||
- `pi.sendMessage()` - Inject messages into the conversation
|
||||
- `pi.appendEntry()` - Persist custom data in session (survives restart/branch)
|
||||
- `pi.exec()` - Run shell commands
|
||||
- `pi.getActiveTools()` / `pi.setActiveTools()` - Dynamic tool enable/disable
|
||||
- `pi.getAllTools()` - List all available tools
|
||||
- `pi.events` - Event bus for cross-extension communication
|
||||
- `ctx.ui.confirm()` / `select()` / `input()` - User prompts
|
||||
- `ctx.ui.notify()` - Toast notifications
|
||||
- `ctx.ui.setStatus()` - Persistent status in footer (multiple extensions can set their own)
|
||||
- `ctx.ui.setWidget()` - Widget display above editor
|
||||
- `ctx.ui.setTitle()` - Set terminal window title
|
||||
- `ctx.ui.custom()` - Full TUI component with keyboard handling
|
||||
- `ctx.ui.editor()` - Multi-line text editor with external editor support
|
||||
- `ctx.sessionManager` - Read session entries, get branch history
|
||||
|
||||
**Settings changes:**
|
||||
```json
|
||||
// Before
|
||||
{
|
||||
"hooks": ["./my-hook.ts"],
|
||||
"customTools": ["./my-tool.ts"]
|
||||
}
|
||||
|
||||
// After
|
||||
{
|
||||
"extensions": ["./my-extension.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
**CLI changes:**
|
||||
```bash
|
||||
# Before
|
||||
pi --hook ./safety.ts --tool ./todo.ts
|
||||
|
||||
# After
|
||||
pi --extension ./safety.ts -e ./todo.ts
|
||||
```
|
||||
|
||||
### Prompt Templates Migration
|
||||
|
||||
"Slash commands" (markdown files defining reusable prompts invoked via `/name`) are renamed to "prompt templates" to avoid confusion with extension-registered commands.
|
||||
|
||||
**Automatic migration:** The `commands/` directory is automatically renamed to `prompts/` on startup (if `prompts/` doesn't exist). Works for both regular directories and symlinks.
|
||||
|
||||
**Directory changes:**
|
||||
```
|
||||
~/.pi/agent/commands/*.md → ~/.pi/agent/prompts/*.md
|
||||
.pi/commands/*.md → .pi/prompts/*.md
|
||||
```
|
||||
|
||||
**SDK type renames:**
|
||||
- `FileSlashCommand` → `PromptTemplate`
|
||||
- `LoadSlashCommandsOptions` → `LoadPromptTemplatesOptions`
|
||||
|
||||
**SDK function renames:**
|
||||
- `discoverSlashCommands()` → `discoverPromptTemplates()`
|
||||
- `loadSlashCommands()` → `loadPromptTemplates()`
|
||||
- `expandSlashCommand()` → `expandPromptTemplate()`
|
||||
- `getCommandsDir()` → `getPromptsDir()`
|
||||
|
||||
**SDK option renames:**
|
||||
- `CreateAgentSessionOptions.slashCommands` → `.promptTemplates`
|
||||
- `AgentSession.fileCommands` → `.promptTemplates`
|
||||
- `PromptOptions.expandSlashCommands` → `.expandPromptTemplates`
|
||||
|
||||
### SDK Migration
|
||||
|
||||
**Discovery functions:**
|
||||
- `discoverAndLoadHooks()` → `discoverAndLoadExtensions()`
|
||||
- `discoverAndLoadCustomTools()` → merged into `discoverAndLoadExtensions()`
|
||||
- `loadHooks()` → `loadExtensions()`
|
||||
- `loadCustomTools()` → merged into `loadExtensions()`
|
||||
|
||||
**Runner and wrapper:**
|
||||
- `HookRunner` → `ExtensionRunner`
|
||||
- `wrapToolsWithHooks()` → `wrapToolsWithExtensions()`
|
||||
- `wrapToolWithHooks()` → `wrapToolWithExtensions()`
|
||||
|
||||
**CreateAgentSessionOptions:**
|
||||
- `.hooks` → removed (use `.additionalExtensionPaths` for paths)
|
||||
- `.additionalHookPaths` → `.additionalExtensionPaths`
|
||||
- `.preloadedHooks` → `.preloadedExtensions`
|
||||
- `.customTools` type changed: `Array<{ path?; tool: CustomTool }>` → `ToolDefinition[]`
|
||||
- `.additionalCustomToolPaths` → merged into `.additionalExtensionPaths`
|
||||
- `.slashCommands` → `.promptTemplates`
|
||||
|
||||
**AgentSession:**
|
||||
- `.hookRunner` → `.extensionRunner`
|
||||
- `.fileCommands` → `.promptTemplates`
|
||||
- `.sendHookMessage()` → `.sendCustomMessage()`
|
||||
|
||||
### Session Migration
|
||||
|
||||
**Automatic.** Session version bumped from 2 to 3. Existing sessions are migrated on first load:
|
||||
- Message role `"hookMessage"` → `"custom"`
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Settings:** `hooks` and `customTools` arrays replaced with single `extensions` array
|
||||
- **CLI:** `--hook` and `--tool` flags replaced with `--extension` / `-e`
|
||||
- **Directories:** `hooks/`, `tools/` → `extensions/`; `commands/` → `prompts/`
|
||||
- **Types:** See type renames above
|
||||
- **SDK:** See SDK migration above
|
||||
|
||||
### Changed
|
||||
|
||||
- Extensions can have their own `package.json` with dependencies (resolved via jiti)
|
||||
- Documentation: `docs/hooks.md` and `docs/custom-tools.md` merged into `docs/extensions.md`
|
||||
- Examples: `examples/hooks/` and `examples/custom-tools/` merged into `examples/extensions/`
|
||||
- README: Extensions section expanded with custom tools, commands, events, state persistence, shortcuts, flags, and UI examples
|
||||
- SDK: `customTools` option now accepts `ToolDefinition[]` directly (simplified from `Array<{ path?, tool }>`)
|
||||
- SDK: `extensions` option accepts `ExtensionFactory[]` for inline extensions
|
||||
- SDK: `additionalExtensionPaths` replaces both `additionalHookPaths` and `additionalCustomToolPaths`
|
||||
|
||||
## [0.34.2] - 2026-01-04
|
||||
|
||||
## [0.34.1] - 2026-01-04
|
||||
|
|
|
|||
|
|
@ -36,12 +36,11 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
|||
- [Custom System Prompt](#custom-system-prompt)
|
||||
- [Custom Models and Providers](#custom-models-and-providers)
|
||||
- [Settings File](#settings-file)
|
||||
- [Extensions](#extensions)
|
||||
- [Customization](#customization)
|
||||
- [Themes](#themes)
|
||||
- [Custom Slash Commands](#custom-slash-commands)
|
||||
- [Prompt Templates](#prompt-templates)
|
||||
- [Skills](#skills)
|
||||
- [Hooks](#hooks)
|
||||
- [Custom Tools](#custom-tools)
|
||||
- [Extensions](#extensions)
|
||||
- [CLI Reference](#cli-reference)
|
||||
- [Tools](#tools)
|
||||
- [Programmatic Usage](#programmatic-usage)
|
||||
|
|
@ -374,10 +373,9 @@ The output becomes part of your next prompt, formatted as:
|
|||
|
||||
```
|
||||
Ran `ls -la`
|
||||
```
|
||||
|
||||
<output here>
|
||||
```
|
||||
```
|
||||
|
||||
Run multiple commands before prompting; all outputs are included together.
|
||||
|
||||
|
|
@ -453,7 +451,7 @@ When disabled, neither case triggers automatic compaction (use `/compact` manual
|
|||
|
||||
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
|
||||
|
||||
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
|
||||
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via extensions.
|
||||
|
||||
### Branching
|
||||
|
||||
|
|
@ -667,8 +665,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
|||
"images": {
|
||||
"autoResize": true
|
||||
},
|
||||
"hooks": ["/path/to/hook.ts"],
|
||||
"customTools": ["/path/to/tool.ts"]
|
||||
"extensions": ["/path/to/extension.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -694,12 +691,11 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
|||
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
|
||||
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
|
||||
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` |
|
||||
| `hooks` | Additional hook file paths | `[]` |
|
||||
| `customTools` | Additional custom tool file paths | `[]` |
|
||||
| `extensions` | Additional extension file paths | `[]` |
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
## Customization
|
||||
|
||||
### Themes
|
||||
|
||||
|
|
@ -720,13 +716,13 @@ Select with `/settings`, then edit the file. Changes apply on save.
|
|||
|
||||
**VS Code terminal fix:** Set `terminal.integrated.minimumContrastRatio` to `1` for accurate colors.
|
||||
|
||||
### Custom Slash Commands
|
||||
### Prompt Templates
|
||||
|
||||
Define reusable prompts as Markdown files:
|
||||
|
||||
**Locations:**
|
||||
- Global: `~/.pi/agent/commands/*.md`
|
||||
- Project: `.pi/commands/*.md`
|
||||
- Global: `~/.pi/agent/prompts/*.md`
|
||||
- Project: `.pi/prompts/*.md`
|
||||
|
||||
**Format:**
|
||||
|
||||
|
|
@ -755,7 +751,7 @@ Usage: `/component Button "onClick handler" "disabled support"`
|
|||
- `$1` = `Button`
|
||||
- `$@` or `$ARGUMENTS` = all arguments joined (`Button onClick handler disabled support`)
|
||||
|
||||
**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)`
|
||||
**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md` → `/component (project:frontend)`
|
||||
|
||||
|
||||
### Skills
|
||||
|
|
@ -807,120 +803,251 @@ cd /path/to/brave-search && npm install
|
|||
|
||||
> See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills.
|
||||
|
||||
### Hooks
|
||||
### Extensions
|
||||
|
||||
Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. Use them to:
|
||||
Extensions are TypeScript modules that extend pi's behavior.
|
||||
|
||||
- **Block dangerous commands** (permission gates for `rm -rf`, `sudo`, etc.)
|
||||
- **Checkpoint code state** (git stash at each turn, restore on `/branch`)
|
||||
- **Protect paths** (block writes to `.env`, `node_modules/`, etc.)
|
||||
- **Modify tool output** (filter or transform results before the LLM sees them)
|
||||
- **Inject messages from external sources to wake up the agent** (file watchers, webhooks, CI systems)
|
||||
**Use cases:**
|
||||
- **Custom tools** - Register tools callable by the LLM with custom UI and rendering
|
||||
- **Custom commands** - Add `/commands` for users (e.g., `/deploy`, `/stats`)
|
||||
- **Event interception** - Block tool calls, modify results, customize compaction
|
||||
- **State persistence** - Store data in session, reconstruct on reload/branch
|
||||
- **External integrations** - File watchers, webhooks, git checkpointing
|
||||
- **Custom UI** - Full TUI control from tools, commands, or event handlers
|
||||
|
||||
**Hook locations:**
|
||||
- Global: `~/.pi/agent/hooks/*.ts`
|
||||
- Project: `.pi/hooks/*.ts`
|
||||
- CLI: `--hook <path>` (for debugging)
|
||||
**Locations:**
|
||||
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
|
||||
- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts`
|
||||
- CLI: `--extension <path>` or `-e <path>`
|
||||
|
||||
**Quick example** (permission gate):
|
||||
**Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/).
|
||||
|
||||
#### Custom Tools
|
||||
|
||||
Tools are functions the LLM can call. They appear in the system prompt and can have custom rendering.
|
||||
|
||||
```typescript
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "deploy",
|
||||
label: "Deploy",
|
||||
description: "Deploy the application to production",
|
||||
parameters: Type.Object({
|
||||
environment: Type.String({ description: "Target environment" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Show progress via onUpdate
|
||||
onUpdate({ status: "Deploying..." });
|
||||
|
||||
// Ask user for confirmation
|
||||
const ok = await ctx.ui.confirm("Deploy?", `Deploy to ${params.environment}?`);
|
||||
if (!ok) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } };
|
||||
}
|
||||
|
||||
// Run shell commands
|
||||
const result = await ctx.exec("./deploy.sh", [params.environment], { signal });
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: result.stdout }],
|
||||
details: { environment: params.environment, exitCode: result.exitCode },
|
||||
};
|
||||
},
|
||||
|
||||
// Custom TUI rendering (optional)
|
||||
renderCall(args, theme) {
|
||||
return new Text(theme.bold("deploy ") + theme.fg("accent", args.environment), 0, 0);
|
||||
},
|
||||
renderResult(result, options, theme) {
|
||||
const ok = result.details?.exitCode === 0;
|
||||
return new Text(ok ? theme.fg("success", "✓ Deployed") : theme.fg("error", "✗ Failed"), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Commands
|
||||
|
||||
Commands are user-invoked via `/name`. They can show custom UI, modify state, or trigger agent turns.
|
||||
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("stats", {
|
||||
description: "Show session statistics",
|
||||
handler: async (args, ctx) => {
|
||||
// Simple notification
|
||||
ctx.ui.notify(`${ctx.sessionManager.getEntries().length} entries`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("todos", {
|
||||
description: "Interactive todo viewer",
|
||||
handler: async (args, ctx) => {
|
||||
// Full custom UI with keyboard handling
|
||||
await ctx.ui.custom((tui, theme, done) => {
|
||||
return {
|
||||
render(width) {
|
||||
return [
|
||||
theme.bold("Todos"),
|
||||
"- [ ] Item 1",
|
||||
"- [x] Item 2",
|
||||
"",
|
||||
theme.fg("dim", "Press Escape to close"),
|
||||
];
|
||||
},
|
||||
handleInput(data) {
|
||||
if (matchesKey(data, "escape")) done();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Event Interception
|
||||
|
||||
Subscribe to lifecycle events to block, modify, or observe agent behavior.
|
||||
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Block dangerous commands
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) {
|
||||
const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string);
|
||||
if (event.toolName === "bash" && /rm -rf/.test(event.input.command as string)) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Modify tool results
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (event.toolName === "read") {
|
||||
// Redact secrets from file contents
|
||||
return { modifiedResult: event.result.replace(/API_KEY=\w+/g, "API_KEY=***") };
|
||||
}
|
||||
});
|
||||
|
||||
// Custom compaction
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
return { customSummary: "My custom summary of the conversation so far..." };
|
||||
});
|
||||
|
||||
// Git checkpoint on each turn
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
await ctx.exec("git", ["stash", "push", "-m", `pi-checkpoint-${Date.now()}`]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Sending messages from hooks:**
|
||||
#### State Persistence
|
||||
|
||||
Use `pi.sendMessage(message, options?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM.
|
||||
|
||||
Options:
|
||||
- `triggerTurn`: If true and agent is idle, starts a new agent turn. Default: false.
|
||||
- `deliverAs`: When agent is streaming, controls delivery timing:
|
||||
- `"steer"` (default): Delivered after current tool execution, interrupts remaining tools.
|
||||
- `"followUp"`: Delivered only after agent finishes all work.
|
||||
Store state in session entries that survive reload and work correctly with branching.
|
||||
|
||||
```typescript
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let counter = 0;
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session_start", async () => {
|
||||
fs.watch("/tmp/trigger.txt", () => {
|
||||
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
|
||||
if (content) {
|
||||
pi.sendMessage({
|
||||
customType: "file-trigger",
|
||||
content,
|
||||
display: true,
|
||||
}, true); // triggerTurn: start agent loop
|
||||
// Reconstruct state from session history
|
||||
const reconstruct = (ctx) => {
|
||||
counter = 0;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "custom" && entry.customType === "my_counter") {
|
||||
counter = entry.data.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("session_start", async (e, ctx) => reconstruct(ctx));
|
||||
pi.on("session_branch", async (e, ctx) => reconstruct(ctx));
|
||||
pi.on("session_tree", async (e, ctx) => reconstruct(ctx));
|
||||
|
||||
pi.registerCommand("increment", {
|
||||
handler: async (args, ctx) => {
|
||||
counter++;
|
||||
ctx.appendEntry("my_counter", { value: counter }); // Persisted in session
|
||||
ctx.ui.notify(`Counter: ${counter}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
> See [Hooks Documentation](docs/hooks.md) for full API reference. pi can help you create new hooks
|
||||
#### Keyboard Shortcuts
|
||||
|
||||
> See [examples/hooks/](examples/hooks/) for working examples including permission gates, git checkpointing, and path protection.
|
||||
|
||||
### Custom Tools
|
||||
|
||||
Custom tools let you extend the built-in toolset (read, write, edit, bash, ...) and are called by the LLM directly. They are TypeScript modules that define tools with optional custom TUI integration for getting user input and custom tool call and result rendering.
|
||||
|
||||
**Tool locations (auto-discovered):**
|
||||
- Global: `~/.pi/agent/tools/*/index.ts`
|
||||
- Project: `.pi/tools/*/index.ts`
|
||||
|
||||
**Explicit paths:**
|
||||
- CLI: `--tool <path>` (any .ts file)
|
||||
- Settings: `customTools` array in `settings.json`
|
||||
|
||||
**Quick example:**
|
||||
Register custom keyboard shortcuts (shown in `/hotkeys`):
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "greet",
|
||||
label: "Greeting",
|
||||
description: "Generate a greeting",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default factory;
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerShortcut("ctrl+shift+d", {
|
||||
description: "Deploy to production",
|
||||
handler: async (ctx) => {
|
||||
ctx.ui.notify("Deploying...", "info");
|
||||
await ctx.exec("./deploy.sh", []);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Access to `pi.cwd`, `pi.exec()`, `pi.ui` (select/confirm/input dialogs)
|
||||
- Session lifecycle via `onSession` callback (for state reconstruction)
|
||||
- Custom rendering via `renderCall()` and `renderResult()` methods
|
||||
- Streaming results via `onUpdate` callback
|
||||
- Abort handling via `signal` parameter
|
||||
- Multiple tools from one factory (return an array)
|
||||
#### CLI Flags
|
||||
|
||||
> See [Custom Tools Documentation](docs/custom-tools.md) for the full API reference, TUI component guide, and examples. pi can help you create custom tools.
|
||||
Register custom CLI flags (parsed automatically, shown in `--help`):
|
||||
|
||||
> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.
|
||||
```typescript
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerFlag("--dry-run", {
|
||||
description: "Run without making changes",
|
||||
type: "boolean",
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (pi.getFlag("dry-run") && event.toolName === "write") {
|
||||
return { block: true, reason: "Dry run mode" };
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom UI
|
||||
|
||||
Extensions have full TUI access via `ctx.ui`:
|
||||
|
||||
```typescript
|
||||
// Simple prompts
|
||||
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
|
||||
const choice = await ctx.ui.select("Pick one", ["Option A", "Option B"]);
|
||||
const text = await ctx.ui.input("Enter value");
|
||||
|
||||
// Notifications
|
||||
ctx.ui.notify("Done!", "success"); // success, info, warning, error
|
||||
|
||||
// Status line (persistent in footer, multiple extensions can set their own)
|
||||
ctx.ui.setStatus("my-ext", "Processing...");
|
||||
ctx.ui.setStatus("my-ext", null); // Clear
|
||||
|
||||
// Widgets (above editor)
|
||||
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]);
|
||||
|
||||
// Full custom component with keyboard handling
|
||||
await ctx.ui.custom((tui, theme, done) => ({
|
||||
render(width) {
|
||||
return [
|
||||
theme.bold("My Component"),
|
||||
theme.fg("dim", "Press Escape to close"),
|
||||
];
|
||||
},
|
||||
handleInput(data) {
|
||||
if (matchesKey(data, "escape")) done();
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
> See [docs/extensions.md](docs/extensions.md) for full API reference.
|
||||
> See [docs/tui.md](docs/tui.md) for TUI components and custom rendering.
|
||||
> See [examples/extensions/](examples/extensions/) for working examples.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -949,7 +1076,7 @@ pi [options] [@files...] [messages...]
|
|||
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) |
|
||||
| `--tools <tools>` | Comma-separated tool list (default: `read,bash,edit,write`) |
|
||||
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
||||
| `--hook <path>` | Load a hook file (can be used multiple times) |
|
||||
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
|
||||
| `--no-skills` | Disable skills discovery and loading |
|
||||
| `--skills <patterns>` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) |
|
||||
| `--export <file> [output]` | Export session to HTML |
|
||||
|
|
@ -1033,7 +1160,7 @@ Available via `--tools` flag:
|
|||
|
||||
Example: `--tools read,grep,find,ls` for code review without modification.
|
||||
|
||||
For adding new tools, see [Custom Tools](#custom-tools) in the Configuration section.
|
||||
For adding new tools, see [Extensions](#extensions) in the Customization section.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1068,8 +1195,8 @@ The SDK provides full control over:
|
|||
- Model selection and thinking level
|
||||
- System prompt (replace or modify)
|
||||
- Tools (built-in subsets, custom tools)
|
||||
- Hooks (inline or discovered)
|
||||
- Skills, context files, slash commands
|
||||
- Extensions (discovered or via paths)
|
||||
- Skills, context files, prompt templates
|
||||
- Session persistence (`SessionManager`)
|
||||
- Settings (`SettingsManager`)
|
||||
- API key resolution and OAuth
|
||||
|
|
@ -1101,7 +1228,7 @@ pi --export session.jsonl # Auto-generated filename
|
|||
pi --export session.jsonl output.html # Custom filename
|
||||
```
|
||||
|
||||
Works with both session files and streaming event logs from `--mode json`.
|
||||
Works with session files.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1111,13 +1238,13 @@ Pi is opinionated about what it won't do. These are intentional design decisions
|
|||
|
||||
**No MCP.** Build CLI tools with READMEs (see [Skills](#skills)). The agent reads them on demand. [Would you like to know more?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)
|
||||
|
||||
**No sub-agents.** Spawn pi instances via tmux, or [build your own sub-agent tool](examples/custom-tools/subagent/) with [custom tools](#custom-tools). Full observability and steerability.
|
||||
**No sub-agents.** Spawn pi instances via tmux, or [build your own sub-agent tool](examples/extensions/subagent/) with [Extensions](#extensions). Full observability and steerability.
|
||||
|
||||
**No permission popups.** Security theater. Run in a container or build your own with [Hooks](#hooks).
|
||||
**No permission popups.** Security theater. Run in a container or build your own with [Extensions](#extensions).
|
||||
|
||||
**No plan mode.** Gather context in one session, write plans to file, start fresh for implementation.
|
||||
|
||||
**No built-in to-dos.** They confuse models. Use a TODO.md file, or [build your own](examples/custom-tools/todo/) with [custom tools](#custom-tools).
|
||||
**No built-in to-dos.** They confuse models. Use a TODO.md file, or [build your own](examples/extensions/todo.ts) with [Extensions](#extensions).
|
||||
|
||||
**No background bash.** Use tmux. Full observability, direct interaction.
|
||||
|
||||
|
|
@ -1168,3 +1295,4 @@ MIT
|
|||
|
||||
- [@mariozechner/pi-ai](https://www.npmjs.com/package/@mariozechner/pi-ai): Core LLM toolkit
|
||||
- [@mariozechner/pi-agent](https://www.npmjs.com/package/@mariozechner/pi-agent): Agent framework
|
||||
- [@mariozechner/pi-tui](https://www.npmjs.com/package/@mariozechner/pi-tui): Terminal UI components
|
||||
|
|
|
|||
|
|
@ -1,550 +0,0 @@
|
|||
> pi can create custom tools. Ask it to build one for your use case.
|
||||
|
||||
# Custom Tools
|
||||
|
||||
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
|
||||
|
||||
**Key capabilities:**
|
||||
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
|
||||
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
|
||||
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
|
||||
- **State management** - Persist state in tool result `details` for proper branching support
|
||||
- **Streaming results** - Send partial updates via `onUpdate` callback
|
||||
|
||||
**Example use cases:**
|
||||
- Interactive dialogs (questions with selectable options)
|
||||
- Stateful tools (todo lists, connection pools)
|
||||
- Rich output rendering (progress indicators, structured views)
|
||||
- External service integrations with confirmation flows
|
||||
|
||||
**When to use custom tools vs. alternatives:**
|
||||
|
||||
| Need | Solution |
|
||||
|------|----------|
|
||||
| Always-needed context (conventions, commands) | AGENTS.md |
|
||||
| User triggers a specific prompt template | Slash command |
|
||||
| On-demand capability package (workflows, scripts, setup) | Skill |
|
||||
| Additional tool directly callable by the LLM | **Custom tool** |
|
||||
|
||||
See [examples/custom-tools/](../examples/custom-tools/) for working examples.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a file `~/.pi/agent/tools/hello/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "hello",
|
||||
label: "Hello",
|
||||
description: "A simple greeting tool",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default factory;
|
||||
```
|
||||
|
||||
The tool is automatically discovered and available in your next pi session.
|
||||
|
||||
## Tool Locations
|
||||
|
||||
Tools must be in a subdirectory with an `index.ts` entry point:
|
||||
|
||||
| Location | Scope | Auto-discovered |
|
||||
|----------|-------|-----------------|
|
||||
| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes |
|
||||
| `.pi/tools/*/index.ts` | Project-local | Yes |
|
||||
| `settings.json` `customTools` array | Configured paths | Yes |
|
||||
| `--tool <path>` CLI flag | One-off/debugging | No |
|
||||
|
||||
**Example structure:**
|
||||
```
|
||||
~/.pi/agent/tools/
|
||||
├── hello/
|
||||
│ └── index.ts # Entry point (auto-discovered)
|
||||
└── complex-tool/
|
||||
├── index.ts # Entry point (auto-discovered)
|
||||
├── helpers.ts # Helper module (not loaded directly)
|
||||
└── types.ts # Type definitions (not loaded directly)
|
||||
```
|
||||
|
||||
**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
|
||||
|
||||
**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
|
||||
|
||||
## Available Imports
|
||||
|
||||
Custom tools can import from these packages (automatically resolved by pi):
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) |
|
||||
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
|
||||
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
|
||||
|
||||
Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
|
||||
|
||||
## Tool Definition
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "What this tool does (be specific for LLM)",
|
||||
parameters: Type.Object({
|
||||
// Use StringEnum for string enums (Google API compatible)
|
||||
action: StringEnum(["list", "add", "remove"] as const),
|
||||
text: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// signal - AbortSignal for cancellation
|
||||
// onUpdate - Callback for streaming partial results
|
||||
// ctx - CustomToolContext with sessionManager, modelRegistry, model
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: { /* structured data for rendering */ },
|
||||
};
|
||||
},
|
||||
|
||||
// Optional: Session lifecycle callback
|
||||
onSession(event, ctx) {
|
||||
if (event.reason === "shutdown") {
|
||||
// Cleanup resources (close connections, save state, etc.)
|
||||
return;
|
||||
}
|
||||
// Reconstruct state from ctx.sessionManager.getBranch()
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { /* return Component */ },
|
||||
renderResult(result, options, theme) { /* return Component */ },
|
||||
});
|
||||
|
||||
export default factory;
|
||||
```
|
||||
|
||||
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
|
||||
|
||||
## CustomToolAPI Object
|
||||
|
||||
The factory receives a `CustomToolAPI` object (named `pi` by convention):
|
||||
|
||||
```typescript
|
||||
interface CustomToolAPI {
|
||||
cwd: string; // Current working directory
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
ui: ToolUIContext;
|
||||
hasUI: boolean; // false in --print or --mode rpc
|
||||
events: EventBus; // Shared event bus for tool/hook communication
|
||||
sendMessage(message, options?): void; // Send messages to the agent session
|
||||
}
|
||||
|
||||
interface ToolUIContext {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
signal?: AbortSignal; // Cancel the process
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
killed?: boolean; // True if process was killed by signal/timeout
|
||||
}
|
||||
```
|
||||
|
||||
Always check `pi.hasUI` before using UI methods.
|
||||
|
||||
### Event Bus
|
||||
|
||||
Tools can emit events that hooks (or other tools) listen for via `pi.events`:
|
||||
|
||||
```typescript
|
||||
// Emit an event
|
||||
pi.events.emit("mytool:completed", { result: "success", itemCount: 42 });
|
||||
|
||||
// Listen for events (tools can also subscribe)
|
||||
const unsubscribe = pi.events.on("other:event", (data) => {
|
||||
console.log("Received:", data);
|
||||
});
|
||||
```
|
||||
|
||||
Event handlers persist across session switches (registered once at tool load time). Use namespaced channel names like `"toolname:event"` to avoid collisions. Handler errors (sync and async) are caught and logged.
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Tools can send messages to the agent session via `pi.sendMessage()`:
|
||||
|
||||
```typescript
|
||||
pi.sendMessage({
|
||||
customType: "mytool-notify",
|
||||
content: "Configuration was updated",
|
||||
display: true,
|
||||
}, {
|
||||
deliverAs: "nextTurn",
|
||||
});
|
||||
```
|
||||
|
||||
**Delivery modes:** `"steer"` (default) interrupts streaming, `"followUp"` waits for completion, `"nextTurn"` queues for next user message. Use `triggerTurn: true` to wake an idle agent immediately.
|
||||
|
||||
See [hooks documentation](hooks.md#pisendmessagemessage-options) for full details.
|
||||
|
||||
### Cancellation Example
|
||||
|
||||
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const result = await pi.exec("long-running-command", ["arg"], { signal });
|
||||
if (result.killed) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.stdout }] };
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Throw an error** when the tool fails. Do not return an error message as content.
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { path } = params as { path: string };
|
||||
|
||||
// Throw on error - pi will catch it and report to the LLM
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
// Return content only on success
|
||||
return { content: [{ type: "text", text: "Success" }] };
|
||||
}
|
||||
```
|
||||
|
||||
Thrown errors are:
|
||||
- Reported to the LLM as tool errors (with `isError: true`)
|
||||
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
|
||||
- Displayed in the TUI with error styling
|
||||
|
||||
## CustomToolContext
|
||||
|
||||
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
||||
|
||||
```typescript
|
||||
interface CustomToolContext {
|
||||
sessionManager: ReadonlySessionManager; // Read-only access to session
|
||||
modelRegistry: ModelRegistry; // For API key resolution
|
||||
model: Model | undefined; // Current model (may be undefined)
|
||||
isIdle(): boolean; // Whether agent is streaming
|
||||
hasQueuedMessages(): boolean; // Whether user has queued messages
|
||||
abort(): void; // Abort current operation (fire-and-forget)
|
||||
}
|
||||
```
|
||||
|
||||
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
|
||||
|
||||
### Checking Queue State
|
||||
|
||||
Interactive tools can skip prompts when the user has already queued a message:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// If user already queued a message, skip the interactive prompt
|
||||
if (ctx.hasQueuedMessages()) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Skipped - user has queued input" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, prompt for input
|
||||
const answer = await pi.ui.input("What would you like to do?");
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-line Editor
|
||||
|
||||
For longer text editing, use `pi.ui.editor()` which supports Ctrl+G for external editor:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const text = await pi.ui.editor("Edit your response:", "prefilled text");
|
||||
// Returns edited text or undefined if cancelled (Escape)
|
||||
// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
|
||||
|
||||
if (!text) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
Tools can implement `onSession` to react to session changes:
|
||||
|
||||
```typescript
|
||||
interface CustomToolSessionEvent {
|
||||
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
**Reasons:**
|
||||
- `start`: Initial session load on startup
|
||||
- `switch`: User started a new session (`/new`) or switched to a different session (`/resume`)
|
||||
- `branch`: User branched from a previous message (`/branch`)
|
||||
- `tree`: User navigated to a different point in the session tree (`/tree`)
|
||||
- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
|
||||
|
||||
To check if a session is fresh (no messages), use `ctx.sessionManager.getEntries().length === 0`.
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.
|
||||
|
||||
```typescript
|
||||
interface MyToolDetails {
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
// In-memory state
|
||||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session entries
|
||||
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") return;
|
||||
|
||||
items = [];
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (msg.role !== "toolResult") continue;
|
||||
if (msg.toolName !== "my_tool") continue;
|
||||
|
||||
const details = msg.details as MyToolDetails | undefined;
|
||||
if (details) {
|
||||
items = details.items;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "...",
|
||||
parameters: Type.Object({ ... }),
|
||||
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Modify items...
|
||||
items.push("new item");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Added item" }],
|
||||
// Store current state in details for reconstruction
|
||||
details: { items: [...items] },
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
This pattern ensures:
|
||||
- When user branches, state is correct for that point in history
|
||||
- When user switches sessions, state matches that session
|
||||
- When user starts a new session, state resets
|
||||
|
||||
## Custom Rendering
|
||||
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API.
|
||||
|
||||
### How It Works
|
||||
|
||||
Tool output is wrapped in a `Box` component that handles:
|
||||
- Padding (1 character horizontal, 1 line vertical)
|
||||
- Background color based on state (pending/success/error)
|
||||
|
||||
Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding.
|
||||
|
||||
### renderCall
|
||||
|
||||
Renders the tool call (before/during execution):
|
||||
|
||||
```typescript
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("my_tool "));
|
||||
text += theme.fg("muted", args.action);
|
||||
if (args.text) {
|
||||
text += " " + theme.fg("dim", `"${args.text}"`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
Called when:
|
||||
- Tool call starts (may have partial args during streaming)
|
||||
- Args are updated during streaming
|
||||
|
||||
### renderResult
|
||||
|
||||
Renders the tool result:
|
||||
|
||||
```typescript
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
const { details } = result;
|
||||
|
||||
// Handle streaming/partial results
|
||||
if (isPartial) {
|
||||
return new Text(theme.fg("warning", "Processing..."), 0, 0);
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (details?.error) {
|
||||
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
||||
}
|
||||
|
||||
// Normal result
|
||||
let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");
|
||||
|
||||
// Support expanded view (Ctrl+O)
|
||||
if (expanded && details?.items) {
|
||||
for (const item of details.items) {
|
||||
text += "\n" + theme.fg("dim", ` ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `expanded`: User pressed Ctrl+O to expand
|
||||
- `isPartial`: Result is from `onUpdate` (streaming), not final
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use `Text` with padding `(0, 0)`** - The Box handles padding
|
||||
2. **Use `\n` for multi-line content** - Not multiple Text components
|
||||
3. **Handle `isPartial`** - Show progress during streaming
|
||||
4. **Support `expanded`** - Show more detail when user requests
|
||||
5. **Use theme colors** - For consistent appearance
|
||||
6. **Keep it compact** - Show summary by default, details when expanded
|
||||
|
||||
### Theme Colors
|
||||
|
||||
```typescript
|
||||
// Foreground
|
||||
theme.fg("toolTitle", text) // Tool names
|
||||
theme.fg("accent", text) // Highlights
|
||||
theme.fg("success", text) // Success
|
||||
theme.fg("error", text) // Errors
|
||||
theme.fg("warning", text) // Warnings
|
||||
theme.fg("muted", text) // Secondary text
|
||||
theme.fg("dim", text) // Tertiary text
|
||||
theme.fg("toolOutput", text) // Output content
|
||||
|
||||
// Styles
|
||||
theme.bold(text)
|
||||
theme.italic(text)
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If `renderCall` or `renderResult` is not defined or throws an error:
|
||||
- `renderCall`: Shows tool name
|
||||
- `renderResult`: Shows raw text output from `content`
|
||||
|
||||
## Execute Function
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, args, onUpdate, ctx, signal) {
|
||||
// Type assertion for params (TypeBox schema doesn't flow through)
|
||||
const params = args as { action: "list" | "add"; text?: string };
|
||||
|
||||
// Check for abort
|
||||
if (signal?.aborted) {
|
||||
return { content: [...], details: { status: "aborted" } };
|
||||
}
|
||||
|
||||
// Stream progress
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: "Working..." }],
|
||||
details: { progress: 50 },
|
||||
});
|
||||
|
||||
// Return final result
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }], // Sent to LLM
|
||||
details: { data: result }, // For rendering only
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Tools from One File
|
||||
|
||||
Return an array to share state between related tools:
|
||||
|
||||
```typescript
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
// Shared state
|
||||
let connection = null;
|
||||
|
||||
const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") {
|
||||
connection?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "db_connect", onSession: handleSession, ... },
|
||||
{ name: "db_query", onSession: handleSession, ... },
|
||||
{ name: "db_close", onSession: handleSession, ... },
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See [`examples/custom-tools/todo/index.ts`](../examples/custom-tools/todo/index.ts) for a complete example with:
|
||||
- `onSession` for state reconstruction
|
||||
- Custom `renderCall` and `renderResult`
|
||||
- Proper branching support via details storage
|
||||
|
||||
Test with:
|
||||
```bash
|
||||
pi --tool packages/coding-agent/examples/custom-tools/todo/index.ts
|
||||
```
|
||||
File diff suppressed because it is too large
Load diff
1053
packages/coding-agent/docs/extensions.md
Normal file
1053
packages/coding-agent/docs/extensions.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -52,9 +52,9 @@ With images:
|
|||
|
||||
If the agent is streaming and no `streamingBehavior` is specified, the command returns an error.
|
||||
|
||||
**Hook commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Hook commands manage their own LLM interaction via `pi.sendMessage()`.
|
||||
**Extension commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`.
|
||||
|
||||
**Slash commands**: File-based slash commands (from `.md` files) are expanded before sending/queueing.
|
||||
**Prompt templates**: File-based prompt templates (from `.md` files) are expanded before sending/queueing.
|
||||
|
||||
Response:
|
||||
```json
|
||||
|
|
@ -65,7 +65,7 @@ The `images` field is optional. Each image uses `ImageContent` format with base6
|
|||
|
||||
#### steer
|
||||
|
||||
Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead).
|
||||
Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
|
||||
|
||||
```json
|
||||
{"type": "steer", "message": "Stop and do this instead"}
|
||||
|
|
@ -80,7 +80,7 @@ See [set_steering_mode](#set_steering_mode) for controlling how steering message
|
|||
|
||||
#### follow_up
|
||||
|
||||
Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead).
|
||||
Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
|
||||
|
||||
```json
|
||||
{"type": "follow_up", "message": "After you're done, also do this"}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ interface AgentSession {
|
|||
|
||||
### Prompting and Message Queueing
|
||||
|
||||
The `prompt()` method handles slash commands, hook commands, and message sending:
|
||||
The `prompt()` method handles prompt templates, extension commands, and message sending:
|
||||
|
||||
```typescript
|
||||
// Basic prompt (when not streaming)
|
||||
|
|
@ -146,8 +146,8 @@ await session.prompt("After you're done, also check X", { streamingBehavior: "fo
|
|||
```
|
||||
|
||||
**Behavior:**
|
||||
- **Hook commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`.
|
||||
- **File-based slash commands** (from `.md` files): Expanded to their content before sending/queueing.
|
||||
- **Extension commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`.
|
||||
- **File-based prompt templates** (from `.md` files): Expanded to their content before sending/queueing.
|
||||
- **During streaming without `streamingBehavior`**: Throws an error. Use `steer()` or `followUp()` directly, or specify the option.
|
||||
|
||||
For explicit queueing during streaming:
|
||||
|
|
@ -160,7 +160,7 @@ await session.steer("New instruction");
|
|||
await session.followUp("After you're done, also do this");
|
||||
```
|
||||
|
||||
Both `steer()` and `followUp()` expand file-based slash commands but error on hook commands (hook commands cannot be queued).
|
||||
Both `steer()` and `followUp()` expand file-based prompt templates but error on extension commands (extension commands cannot be queued).
|
||||
|
||||
### Agent and AgentState
|
||||
|
||||
|
|
@ -260,18 +260,16 @@ const { session } = await createAgentSession({
|
|||
```
|
||||
|
||||
`cwd` is used for:
|
||||
- Project hooks (`.pi/hooks/`)
|
||||
- Project tools (`.pi/tools/`)
|
||||
- Project extensions (`.pi/extensions/`)
|
||||
- Project skills (`.pi/skills/`)
|
||||
- Project commands (`.pi/commands/`)
|
||||
- Project prompts (`.pi/prompts/`)
|
||||
- Context files (`AGENTS.md` walking up from cwd)
|
||||
- Session directory naming
|
||||
|
||||
`agentDir` is used for:
|
||||
- Global hooks (`hooks/`)
|
||||
- Global tools (`tools/`)
|
||||
- Global extensions (`extensions/`)
|
||||
- Global skills (`skills/`)
|
||||
- Global commands (`commands/`)
|
||||
- Global prompts (`prompts/`)
|
||||
- Global context file (`AGENTS.md`)
|
||||
- Settings (`settings.json`)
|
||||
- Custom models (`models.json`)
|
||||
|
|
@ -442,113 +440,79 @@ const { session } = await createAgentSession({
|
|||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, type ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline custom tool
|
||||
const myTool: CustomTool = {
|
||||
const myTool: ToolDefinition = {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Does something useful",
|
||||
parameters: Type.Object({
|
||||
input: Type.String({ description: "Input value" }),
|
||||
}),
|
||||
execute: async (toolCallId, params) => ({
|
||||
execute: async (toolCallId, params, onUpdate, ctx, signal) => ({
|
||||
content: [{ type: "text", text: `Result: ${params.input}` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
// Replace discovery with inline tools
|
||||
// Pass custom tools directly
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [{ tool: myTool }],
|
||||
});
|
||||
|
||||
// Merge with discovered tools
|
||||
const discovered = await discoverCustomTools();
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [...discovered, { tool: myTool }],
|
||||
});
|
||||
|
||||
// Add paths without replacing discovery
|
||||
const { session } = await createAgentSession({
|
||||
additionalCustomToolPaths: ["/extra/tools"],
|
||||
customTools: [myTool],
|
||||
});
|
||||
```
|
||||
|
||||
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions discovered from `~/.pi/agent/extensions/` and `.pi/extensions/` can also register tools via `pi.registerTool()`.
|
||||
|
||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
||||
|
||||
### Hooks
|
||||
### Extensions
|
||||
|
||||
Extensions are discovered from `~/.pi/agent/extensions/` and `.pi/extensions/`. You can also pass inline extensions or additional paths:
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
// Log tool calls
|
||||
api.on("tool_call", async (event) => {
|
||||
// Inline extension
|
||||
const myExtension: ExtensionFactory = (pi) => {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
// Block dangerous commands
|
||||
api.on("tool_call", async (event) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Register custom slash command
|
||||
api.registerCommand("stats", {
|
||||
description: "Show session stats",
|
||||
handler: async (ctx) => {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
ctx.ui.notify(`${entries.length} entries`, "info");
|
||||
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify("Hello!", "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Inject messages
|
||||
api.sendMessage({
|
||||
customType: "my-hook",
|
||||
content: "Hook initialized",
|
||||
display: false, // Hidden from TUI
|
||||
}, false); // Don't trigger agent turn
|
||||
|
||||
// Persist hook state
|
||||
api.appendEntry("my-hook", { initialized: true });
|
||||
};
|
||||
|
||||
// Replace discovery
|
||||
// Pass inline extensions (merged with discovery)
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [{ factory: loggingHook }],
|
||||
extensions: [myExtension],
|
||||
});
|
||||
|
||||
// Disable all hooks
|
||||
// Or add paths to load (merged with discovery)
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [],
|
||||
});
|
||||
|
||||
// Merge with discovered
|
||||
const discovered = await discoverHooks();
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [...discovered, { factory: loggingHook }],
|
||||
});
|
||||
|
||||
// Add paths without replacing
|
||||
const { session } = await createAgentSession({
|
||||
additionalHookPaths: ["/extra/hooks"],
|
||||
additionalExtensionPaths: ["/path/to/my-extension.ts"],
|
||||
});
|
||||
```
|
||||
|
||||
Hook API methods:
|
||||
- `api.on(event, handler)` - Subscribe to events
|
||||
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
|
||||
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
|
||||
- `api.registerCommand(name, options)` - Register custom slash command
|
||||
- `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
|
||||
- `api.exec(command, args, options?)` - Execute shell commands
|
||||
Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) and [docs/hooks.md](hooks.md)
|
||||
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `createAgentSession()` if you need to emit/listen from outside:
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, createEventBus } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const eventBus = createEventBus();
|
||||
const { session } = await createAgentSession({ eventBus });
|
||||
|
||||
// Listen for events from extensions
|
||||
eventBus.on("my-extension:status", (data) => console.log(data));
|
||||
```
|
||||
|
||||
> See [examples/sdk/06-extensions.ts](../examples/sdk/06-extensions.ts) and [docs/extensions.md](extensions.md)
|
||||
|
||||
### Skills
|
||||
|
||||
|
|
@ -616,11 +580,11 @@ const { session } = await createAgentSession({
|
|||
### Slash Commands
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const discovered = discoverSlashCommands();
|
||||
const discovered = discoverPromptTemplates();
|
||||
|
||||
const customCommand: FileSlashCommand = {
|
||||
const customCommand: PromptTemplate = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
|
|
@ -628,11 +592,11 @@ const customCommand: FileSlashCommand = {
|
|||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
slashCommands: [...discovered, customCommand],
|
||||
promptTemplates: [...discovered, customCommand],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts)
|
||||
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
|
||||
|
||||
### Session Management
|
||||
|
||||
|
|
@ -761,7 +725,7 @@ import {
|
|||
discoverHooks,
|
||||
discoverCustomTools,
|
||||
discoverContextFiles,
|
||||
discoverSlashCommands,
|
||||
discoverPromptTemplates,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
|
@ -778,16 +742,18 @@ const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
|
|||
const skills = discoverSkills(cwd, agentDir, skillsSettings);
|
||||
|
||||
// Hooks (async - loads TypeScript)
|
||||
const hooks = await discoverHooks(cwd, agentDir);
|
||||
// Pass eventBus to share pi.events across hooks/tools
|
||||
const eventBus = createEventBus();
|
||||
const hooks = await discoverHooks(eventBus, cwd, agentDir);
|
||||
|
||||
// Custom tools (async - loads TypeScript)
|
||||
const tools = await discoverCustomTools(cwd, agentDir);
|
||||
const tools = await discoverCustomTools(eventBus, cwd, agentDir);
|
||||
|
||||
// Context files
|
||||
const contextFiles = discoverContextFiles(cwd, agentDir);
|
||||
|
||||
// Slash commands
|
||||
const commands = discoverSlashCommands(cwd, agentDir);
|
||||
// Prompt templates
|
||||
const commands = discoverPromptTemplates(cwd, agentDir);
|
||||
|
||||
// Settings (global + project merged)
|
||||
const settings = loadSettings(cwd, agentDir);
|
||||
|
|
@ -894,7 +860,7 @@ const { session } = await createAgentSession({
|
|||
hooks: [{ factory: auditHook }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
promptTemplates: [],
|
||||
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
|
|
@ -949,7 +915,10 @@ discoverSkills
|
|||
discoverHooks
|
||||
discoverCustomTools
|
||||
discoverContextFiles
|
||||
discoverSlashCommands
|
||||
discoverPromptTemplates
|
||||
|
||||
// Event Bus (for shared hook/tool communication)
|
||||
createEventBus
|
||||
|
||||
// Helpers
|
||||
loadSettings
|
||||
|
|
@ -977,7 +946,7 @@ type CreateAgentSessionResult
|
|||
type CustomTool
|
||||
type HookFactory
|
||||
type Skill
|
||||
type FileSlashCommand
|
||||
type PromptTemplate
|
||||
type Settings
|
||||
type SkillsSettings
|
||||
type Tool
|
||||
|
|
|
|||
|
|
@ -1,441 +0,0 @@
|
|||
# Session Tree Implementation Plan
|
||||
|
||||
Reference: [session-tree.md](./session-tree.md)
|
||||
|
||||
## Phase 1: SessionManager Core ✅
|
||||
|
||||
- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase)
|
||||
- [x] Add `version` field to `SessionHeader`
|
||||
- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId`
|
||||
- [x] Add `BranchSummaryEntry` type
|
||||
- [x] Add `CustomEntry` type for hooks
|
||||
- [x] Add `byId: Map<string, SessionEntry>` index
|
||||
- [x] Add `leafId: string` tracking
|
||||
- [x] Implement `getPath(fromId?)` tree traversal
|
||||
- [x] Implement `getTree()` returning `SessionTreeNode[]`
|
||||
- [x] Implement `getEntry(id)` lookup
|
||||
- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers
|
||||
- [x] Update `_buildIndex()` to populate `byId` map
|
||||
- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf)
|
||||
- [x] Add `appendCustomEntry(customType, data)` for hooks
|
||||
- [x] Update `buildSessionContext()` to use `getPath()` traversal
|
||||
|
||||
## Phase 2: Migration ✅
|
||||
|
||||
- [x] Add `CURRENT_SESSION_VERSION = 2` constant
|
||||
- [x] Implement `migrateV1ToV2()` with extensible migration chain
|
||||
- [x] Update `setSessionFile()` to detect version and migrate
|
||||
- [x] Implement `_rewriteFile()` for post-migration persistence
|
||||
- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration
|
||||
|
||||
## Phase 3: Branching ✅
|
||||
|
||||
- [x] Implement `branch(id)` - switch leaf pointer
|
||||
- [x] Implement `branchWithSummary(id, summary)` - create summary entry
|
||||
- [x] Implement `createBranchedSession(leafId)` - extract path to new file
|
||||
- [x] Update `AgentSession.branch()` to use new API
|
||||
|
||||
## Phase 4: Compaction Integration ✅
|
||||
|
||||
- [x] Update `compaction.ts` to work with IDs
|
||||
- [x] Update `prepareCompaction()` to return `firstKeptEntryId`
|
||||
- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId`
|
||||
- [x] Update `AgentSession` compaction methods
|
||||
- [x] Add `firstKeptEntryId` to `before_compact` hook event
|
||||
|
||||
## Phase 5: Testing ✅
|
||||
|
||||
- [x] `migration.test.ts` - v1 to v2 migration, idempotency
|
||||
- [x] `build-context.test.ts` - context building with tree structure, compaction, branches
|
||||
- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching
|
||||
- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession
|
||||
- [x] `save-entry.test.ts` - custom entry integration
|
||||
- [x] Update existing compaction tests for new types
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Compaction Refactor
|
||||
|
||||
- [x] Use `CompactionResult` type for hook return value
|
||||
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [x] Make `CompactionResult<T>` generic to match
|
||||
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [x] Update `before_compact` event:
|
||||
- Pass `preparation: CompactionPreparation` instead of individual fields
|
||||
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
|
||||
- Keep: `customInstructions`, `model`, `signal`
|
||||
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
|
||||
- [x] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
|
||||
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
|
||||
|
||||
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
|
||||
|
||||
### Branch Summary Design ✅
|
||||
|
||||
Current type:
|
||||
```typescript
|
||||
export interface BranchSummaryEntry extends SessionEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string;
|
||||
fromId: string; // References the abandoned leaf
|
||||
fromHook?: boolean; // Whether summary was generated by a hook
|
||||
details?: unknown; // File tracking: { readFiles, modifiedFiles }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] `fromId` field references the abandoned leaf
|
||||
- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries
|
||||
- [x] `details` field for file tracking
|
||||
- [x] Branch summarizer implemented with structured output format
|
||||
- [x] Uses serialization approach (same as compaction) to prevent model confusion
|
||||
- [x] Tests for `branchWithSummary()` flow
|
||||
|
||||
### Entry Labels ✅
|
||||
|
||||
- [x] Add `LabelEntry` type with `targetId` and `label` fields
|
||||
- [x] Add `labelsById: Map<string, string>` private field
|
||||
- [x] Build labels map in `_buildIndex()` via linear scan
|
||||
- [x] Add `getLabel(id)` method
|
||||
- [x] Add `appendLabelChange(targetId, label)` method (undefined clears)
|
||||
- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map
|
||||
- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types)
|
||||
- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()`
|
||||
- [x] Display labels in UI (tree-selector shows labels)
|
||||
- [x] `/label` command (implemented in tree-selector)
|
||||
|
||||
### CustomMessageEntry<T>
|
||||
|
||||
Hook-injected messages that participate in LLM context. Unlike `CustomEntry<T>` (for hook state only), these are sent to the model.
|
||||
|
||||
```typescript
|
||||
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string; // Hook identifier
|
||||
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
|
||||
details?: T; // Hook-specific data for state reconstruction on reload
|
||||
display: boolean; // Whether to display in TUI
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- [x] Type definition matching plan
|
||||
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
|
||||
- [x] `buildSessionContext()` includes custom_message entries as user messages
|
||||
- [x] Exported from main index
|
||||
- [x] TUI rendering:
|
||||
- `display: false` - hidden entirely
|
||||
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
|
||||
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
|
||||
- [x] Renderer returns inner Component, TUI wraps in styled Box
|
||||
|
||||
### Hook API Changes ✅
|
||||
|
||||
**Renamed:**
|
||||
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
||||
|
||||
**New: `sendMessage()` ✅**
|
||||
|
||||
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
||||
|
||||
```typescript
|
||||
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
|
||||
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
|
||||
- `message_end` handler routes based on marker presence
|
||||
- `AgentSession.sendHookMessage()` handles three cases:
|
||||
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
|
||||
- Not streaming + triggerTurn: direct append + `agent.continue()`
|
||||
- Not streaming + no trigger: direct append only
|
||||
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
|
||||
|
||||
**New: `appendEntry()` ✅**
|
||||
|
||||
For hook state persistence (NOT in LLM context):
|
||||
|
||||
```typescript
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
```
|
||||
|
||||
Calls `sessionManager.appendCustomEntry()` directly.
|
||||
|
||||
**New: `registerCommand()` (types ✅, wiring TODO)**
|
||||
|
||||
```typescript
|
||||
// HookAPI (the `pi` object) - utilities available to all hooks:
|
||||
interface HookAPI {
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
registerCommand(name: string, options: RegisteredCommand): void;
|
||||
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void;
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
// HookEventContext - passed to event handlers, has stable context:
|
||||
interface HookEventContext {
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase
|
||||
|
||||
// HookCommandContext - passed to command handlers:
|
||||
interface HookCommandContext {
|
||||
args: string; // Everything after /commandname
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec and sendMessage accessed via `pi` closure
|
||||
|
||||
registerCommand(name: string, options: {
|
||||
description?: string;
|
||||
handler: (ctx: HookCommandContext) => Promise<void>;
|
||||
}): void;
|
||||
```
|
||||
|
||||
Handler return:
|
||||
- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM)
|
||||
|
||||
Wiring (all in AgentSession.prompt()):
|
||||
- [x] Add hook commands to autocomplete in interactive-mode
|
||||
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
|
||||
- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc.
|
||||
- [x] If handler returns string, use as prompt text
|
||||
- [x] If handler returns undefined, return early (no LLM call)
|
||||
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
|
||||
|
||||
**New: `ui.custom()` ✅**
|
||||
|
||||
For arbitrary hook UI with keyboard focus:
|
||||
|
||||
```typescript
|
||||
interface HookUIContext {
|
||||
// ... existing: select, confirm, input, notify
|
||||
|
||||
/** Show custom component with keyboard focus. Call done() when finished. */
|
||||
custom(component: Component, done: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
||||
|
||||
**New: `context` event ✅**
|
||||
|
||||
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages that will be sent to the LLM */
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface ContextEventResult {
|
||||
/** Modified messages to send instead */
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
// In HookAPI:
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
|
||||
```
|
||||
|
||||
Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330))
|
||||
|
||||
Non-destructive pruning of tool results to reduce context size:
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
// Register /prune command
|
||||
pi.registerCommand("prune", {
|
||||
description: "Mark tool results for pruning",
|
||||
handler: async (ctx) => {
|
||||
// Show UI to select which tool results to prune
|
||||
// Append custom entry recording pruning decisions:
|
||||
// { toolResultId, strategy: "summary" | "truncate" | "remove" }
|
||||
pi.appendEntry("tool-result-pruning", { ... });
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept context before LLM call
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// Find all pruning entries in session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const pruningRules = entries
|
||||
.filter(e => e.type === "custom" && e.customType === "tool-result-pruning")
|
||||
.map(e => e.data);
|
||||
|
||||
// Apply pruning rules to messages
|
||||
const prunedMessages = applyPruning(event.messages, pruningRules);
|
||||
return { messages: prunedMessages };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Original tool results stay intact in session
|
||||
- Pruning is stored as custom entries, survives session reload
|
||||
- Works with branching (pruning entries are part of the tree)
|
||||
- Trade-off: cache busting on first submission after pruning
|
||||
|
||||
### Investigate: `context` event vs `before_agent_start` ✅
|
||||
|
||||
References:
|
||||
- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal
|
||||
- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added)
|
||||
|
||||
**Current `context` event:**
|
||||
- Fires before each LLM call within the agent loop
|
||||
- Receives `AgentMessage[]` (deep copy, safe to modify)
|
||||
- Returns `Message[]` (inconsistent with input type)
|
||||
- Modifications are transient (not persisted to session)
|
||||
- No TUI visibility of what was changed
|
||||
- Use case: non-destructive pruning, dynamic context manipulation
|
||||
|
||||
**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`:
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
messages: AgentMessage[]; // Input
|
||||
}
|
||||
interface ContextEventResult {
|
||||
messages?: Message[]; // Output - different type!
|
||||
}
|
||||
```
|
||||
|
||||
Questions:
|
||||
- [ ] Should input/output both be `Message[]` (LLM format)?
|
||||
- [ ] Or both be `AgentMessage[]` with conversion happening after?
|
||||
- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen?
|
||||
|
||||
**Proposed `before_agent_start` event:**
|
||||
- Fires once when user submits a prompt, before `agent_start`
|
||||
- Allows hooks to inject additional content that gets **persisted** to session
|
||||
- Injected content is visible in TUI (observability)
|
||||
- Does not bust prompt cache (appended after user message, not modifying system prompt)
|
||||
|
||||
**Key difference:**
|
||||
| Aspect | `context` | `before_agent_start` |
|
||||
|--------|-----------|---------------------|
|
||||
| When | Before each LLM call | Once per user prompt |
|
||||
| Persisted | No | Yes (as SystemMessage) |
|
||||
| TUI visible | No | Yes (collapsible) |
|
||||
| Cache impact | Can bust cache | Append-only, cache-safe |
|
||||
| Use case | Transient manipulation | Persistent context injection |
|
||||
|
||||
**Implementation (completed):**
|
||||
- Reuses `HookMessage` type (no new message type needed)
|
||||
- Handler returns `{ message: Pick<HookMessage, "customType" | "content" | "display" | "details"> }`
|
||||
- Message is appended to agent state AND persisted to session before `agent.prompt()` is called
|
||||
- Renders using existing `HookMessageComponent` (or custom renderer if registered)
|
||||
- [ ] How does it interact with compaction? (treated like user messages?)
|
||||
- [ ] Can hook return multiple messages or just one?
|
||||
|
||||
**Implementation sketch:**
|
||||
```typescript
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
userMessage: UserMessage; // The prompt user just submitted
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
/** Additional context to inject (persisted as SystemMessage) */
|
||||
inject?: {
|
||||
label: string; // Shown in collapsed TUI state
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Export
|
||||
|
||||
- [ ] Add collapsible sidebar showing full tree structure
|
||||
- [ ] Allow selecting any node in tree to view that path
|
||||
- [ ] Add "reset to session leaf" button
|
||||
- [ ] Render full path (no compaction resolution needed)
|
||||
- [ ] Responsive: collapse sidebar on mobile
|
||||
|
||||
### UI Commands ✅
|
||||
|
||||
- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`)
|
||||
- [x] `/tree` - In-session tree navigation via tree-selector component
|
||||
- Shows full tree structure with labels
|
||||
- Navigate between branches (moves leaf pointer)
|
||||
- Shows current position
|
||||
- Generates branch summaries when switching branches
|
||||
|
||||
### Tree Selector Improvements ✅
|
||||
|
||||
- [x] Active line highlight using `selectedBg` theme color
|
||||
- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward):
|
||||
- `default`: hides label/custom entries
|
||||
- `no-tools`: default minus tool results
|
||||
- `user-only`: just user messages
|
||||
- `labeled-only`: just labeled entries
|
||||
- `all`: everything
|
||||
|
||||
### Documentation
|
||||
|
||||
Review and update all docs:
|
||||
|
||||
- [ ] `docs/hooks.md` - Major update for hook API:
|
||||
- `pi.send()` → `pi.sendMessage()` with new signature
|
||||
- New `pi.appendEntry()` for state persistence
|
||||
- New `pi.registerCommand()` for custom slash commands
|
||||
- New `pi.registerCustomMessageRenderer()` for custom TUI rendering
|
||||
- `HookCommandContext` interface and handler patterns
|
||||
- `HookMessage<T>` type
|
||||
- Updated event signatures (`SessionEventBase`, `before_compact`, etc.)
|
||||
- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete
|
||||
- [ ] `docs/sdk.md` - Update for:
|
||||
- `HookMessage` and `isHookMessage()`
|
||||
- `Agent.prompt(AppMessage)` overload
|
||||
- Session v2 tree structure
|
||||
- SessionManager API changes
|
||||
- [ ] `docs/session.md` - Update for v2 tree structure, new entry types
|
||||
- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools
|
||||
- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode
|
||||
- [ ] `docs/skills.md` - Review for any hook-related updates
|
||||
- [ ] `docs/extension-loading.md` - Review
|
||||
- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total)
|
||||
- [ ] `README.md` - Update hook examples if any
|
||||
|
||||
### Examples
|
||||
|
||||
Review and update examples:
|
||||
|
||||
- [ ] `examples/hooks/` - Update existing, add new examples:
|
||||
- [ ] Review `custom-compaction.ts` for new API
|
||||
- [ ] Add `registerCommand()` example
|
||||
- [ ] Add `sendMessage()` example
|
||||
- [ ] Add `registerCustomMessageRenderer()` example
|
||||
- [ ] `examples/sdk/` - Update for new session/hook APIs
|
||||
- [ ] `examples/custom-tools/` - Review for compatibility
|
||||
|
||||
---
|
||||
|
||||
## Before Release
|
||||
|
||||
- [ ] Run full automated test suite: `npm test`
|
||||
- [ ] Manual testing of tree navigation and branch summarization
|
||||
- [ ] Verify compaction with file tracking works correctly
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All append methods return the new entry's ID
|
||||
- Migration rewrites file on first load if version < CURRENT_VERSION
|
||||
- Existing sessions become linear chains after migration (parentId = previous entry)
|
||||
- Tree features available immediately after migration
|
||||
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
|
||||
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer
|
||||
|
|
@ -16,14 +16,15 @@ Sessions have a version field in the header:
|
|||
|
||||
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
|
||||
- **Version 2**: Tree structure with `id`/`parentId` linking
|
||||
- **Version 3**: Renamed `hookMessage` role to `custom` (extensions unification)
|
||||
|
||||
Existing v1 sessions are automatically migrated to v2 when loaded.
|
||||
Existing sessions are automatically migrated to the current version (v3) when loaded.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
|
||||
- [`packages/agent-core/src/types.ts`](../../agent-core/src/types.ts) - `AgentMessage`
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`, `ImageContent`, `TextContent`
|
||||
|
||||
## Entry Base
|
||||
|
||||
|
|
@ -45,13 +46,13 @@ interface SessionEntryBase {
|
|||
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
|
||||
|
||||
```json
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
|
||||
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
|
||||
```
|
||||
|
||||
For sessions with a parent (created via `/branch` or `newSession({ parentSession })`):
|
||||
|
||||
```json
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
|
||||
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
|
||||
```
|
||||
|
||||
### SessionMessageEntry
|
||||
|
|
@ -89,8 +90,8 @@ Created when context is compacted. Stores a summary of earlier messages.
|
|||
```
|
||||
|
||||
Optional fields:
|
||||
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations)
|
||||
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated
|
||||
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for extension implementations)
|
||||
- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated
|
||||
|
||||
### BranchSummaryEntry
|
||||
|
||||
|
|
@ -102,30 +103,30 @@ Created when switching branches via `/tree` with an LLM generated summary of the
|
|||
|
||||
Optional fields:
|
||||
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation
|
||||
- `fromHook`: `true` if generated by a hook
|
||||
- `fromHook`: `true` if generated by an extension
|
||||
|
||||
### CustomEntry
|
||||
|
||||
Hook state persistence. Does NOT participate in LLM context.
|
||||
Extension state persistence. Does NOT participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}}
|
||||
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}
|
||||
```
|
||||
|
||||
Use `customType` to identify your hook's entries on reload.
|
||||
Use `customType` to identify your extension's entries on reload.
|
||||
|
||||
### CustomMessageEntry
|
||||
|
||||
Hook-injected messages that DO participate in LLM context.
|
||||
Extension-injected messages that DO participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true}
|
||||
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-extension","content":"Injected context...","display":true}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
|
||||
- `display`: `true` = show in TUI with purple styling, `false` = hidden
|
||||
- `details`: Optional hook-specific metadata (not sent to LLM)
|
||||
- `display`: `true` = show in TUI with distinct styling, `false` = hidden
|
||||
- `details`: Optional extension-specific metadata (not sent to LLM)
|
||||
|
||||
### LabelEntry
|
||||
|
||||
|
|
@ -190,7 +191,7 @@ for (const line of lines) {
|
|||
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
|
||||
break;
|
||||
case "custom_message":
|
||||
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
|
||||
console.log(`[${entry.id}] Extension message (${entry.customType}): ${entry.content}`);
|
||||
break;
|
||||
case "label":
|
||||
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
|
||||
|
|
@ -220,18 +221,20 @@ Key methods for working with sessions programmatically:
|
|||
- `appendThinkingLevelChange(level)` - Record thinking change
|
||||
- `appendModelChange(provider, modelId)` - Record model change
|
||||
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
|
||||
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
|
||||
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
|
||||
- `appendCustomEntry(customType, data?)` - Extension state (not in context)
|
||||
- `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context)
|
||||
- `appendLabelChange(targetId, label)` - Set/clear label
|
||||
|
||||
### Tree Navigation
|
||||
- `getLeafId()` - Current position
|
||||
- `getLeafEntry()` - Get current leaf entry
|
||||
- `getEntry(id)` - Get entry by ID
|
||||
- `getPath(fromId?)` - Walk from entry to root
|
||||
- `getBranch(fromId?)` - Walk from entry to root
|
||||
- `getTree()` - Get full tree structure
|
||||
- `getChildren(parentId)` - Get direct children
|
||||
- `getLabel(id)` - Get label for entry
|
||||
- `branch(entryId)` - Move leaf to earlier entry
|
||||
- `resetLeaf()` - Reset leaf to null (before any entries)
|
||||
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
||||
|
||||
### Context
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Skills are loaded when:
|
|||
|
||||
**Not a good fit for skills:**
|
||||
- "Always use TypeScript strict mode" → put in AGENTS.md
|
||||
- "Review my code" → make a slash command
|
||||
- "Review my code" → make a prompt template
|
||||
- Need user confirmation dialogs or custom TUI rendering → make a custom tool
|
||||
|
||||
## Skill Structure
|
||||
|
|
|
|||
|
|
@ -343,4 +343,4 @@ Call `invalidate()` when state changes, then `handle.requestRender()` to trigger
|
|||
## Examples
|
||||
|
||||
- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
|
||||
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`
|
||||
- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - Custom `renderCall` and `renderResult`
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
# Examples
|
||||
|
||||
Example code for pi-coding-agent SDK, hooks, and custom tools.
|
||||
Example code for pi-coding-agent SDK and extensions.
|
||||
|
||||
## Directories
|
||||
|
||||
### [sdk/](sdk/)
|
||||
Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, hooks, and session management.
|
||||
Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, extensions, and session management.
|
||||
|
||||
### [hooks/](hooks/)
|
||||
Example hooks for intercepting tool calls, adding safety gates, and integrating with external systems.
|
||||
|
||||
### [custom-tools/](custom-tools/)
|
||||
Example custom tools that extend the agent's capabilities.
|
||||
|
||||
## Tool + Hook Combinations
|
||||
|
||||
Some examples are designed to work together:
|
||||
|
||||
- **todo/** - The [custom tool](custom-tools/todo/) lets the LLM manage a todo list, while the [hook](hooks/todo/) adds a `/todos` command for users to view todos at any time.
|
||||
### [extensions/](extensions/)
|
||||
Example extensions demonstrating:
|
||||
- Lifecycle event handlers (tool interception, safety gates, context modifications)
|
||||
- Custom tools (todo lists, subagents)
|
||||
- Commands and keyboard shortcuts
|
||||
- External integrations (git, file watchers)
|
||||
|
||||
## Documentation
|
||||
|
||||
- [SDK Reference](sdk/README.md)
|
||||
- [Hooks Documentation](../docs/hooks.md)
|
||||
- [Custom Tools Documentation](../docs/custom-tools.md)
|
||||
- [Extensions Documentation](../docs/extensions.md)
|
||||
- [Skills Documentation](../docs/skills.md)
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
# Custom Tools Examples
|
||||
|
||||
Example custom tools for pi-coding-agent.
|
||||
|
||||
## Examples
|
||||
|
||||
Each example uses the `subdirectory/index.ts` structure required for tool discovery.
|
||||
|
||||
### hello/
|
||||
Minimal example showing the basic structure of a custom tool.
|
||||
|
||||
### question/
|
||||
Demonstrates `pi.ui.select()` for asking the user questions with options.
|
||||
|
||||
### todo/
|
||||
Full-featured example demonstrating:
|
||||
- `onSession` for state reconstruction from session history
|
||||
- Custom `renderCall` and `renderResult`
|
||||
- Proper branching support via details storage
|
||||
- State management without external files
|
||||
|
||||
**Companion hook:** [hooks/todo/](../hooks/todo/) adds a `/todos` command for users to view the todo list.
|
||||
|
||||
### subagent/
|
||||
Delegate tasks to specialized subagents with isolated context windows. Includes:
|
||||
- `index.ts` - The custom tool (single, parallel, and chain modes)
|
||||
- `agents.ts` - Agent discovery helper
|
||||
- `agents/` - Sample agent definitions (scout, planner, reviewer, worker)
|
||||
- `commands/` - Workflow presets (/implement, /scout-and-plan, /implement-and-review)
|
||||
|
||||
See [subagent/README.md](subagent/README.md) for full documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test directly (can point to any .ts file)
|
||||
pi --tool examples/custom-tools/todo/index.ts
|
||||
|
||||
# Or copy entire folder to tools directory for persistent use
|
||||
cp -r todo ~/.pi/agent/tools/
|
||||
```
|
||||
|
||||
Then in pi:
|
||||
```
|
||||
> add a todo "test custom tools"
|
||||
> list todos
|
||||
> toggle todo #1
|
||||
> clear todos
|
||||
```
|
||||
|
||||
## Writing Custom Tools
|
||||
|
||||
See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation.
|
||||
|
||||
### Key Points
|
||||
|
||||
**Factory pattern:**
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Tool description for LLM",
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["list", "add"] as const),
|
||||
}),
|
||||
|
||||
// Called on session start/switch/branch/clear
|
||||
onSession(event) {
|
||||
// Reconstruct state from event.entries
|
||||
},
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Result" }],
|
||||
details: { /* for rendering and state reconstruction */ },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default factory;
|
||||
```
|
||||
|
||||
**Custom rendering:**
|
||||
```typescript
|
||||
renderCall(args, theme) {
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("my_tool ")) + args.action,
|
||||
0, 0 // No padding - Box handles it
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) {
|
||||
return new Text(theme.fg("warning", "Working..."), 0, 0);
|
||||
}
|
||||
return new Text(theme.fg("success", "✓ Done"), 0, 0);
|
||||
},
|
||||
```
|
||||
|
||||
**Use StringEnum for string parameters** (required for Google API compatibility):
|
||||
```typescript
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
// Good
|
||||
action: StringEnum(["list", "add"] as const)
|
||||
|
||||
// Bad - doesn't work with Google
|
||||
action: Type.Union([Type.Literal("list"), Type.Literal("add")])
|
||||
```
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const factory: CustomToolFactory = (_pi) => ({
|
||||
name: "hello",
|
||||
label: "Hello",
|
||||
description: "A simple greeting tool",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default factory;
|
||||
141
packages/coding-agent/examples/extensions/README.md
Normal file
141
packages/coding-agent/examples/extensions/README.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Extension Examples
|
||||
|
||||
Example extensions for pi-coding-agent.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Load an extension with --extension flag
|
||||
pi --extension examples/extensions/permission-gate.ts
|
||||
|
||||
# Or copy to extensions directory for auto-discovery
|
||||
cp permission-gate.ts ~/.pi/agent/extensions/
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Lifecycle & Safety
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
|
||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
|
||||
### Custom Tools
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
|
||||
| `hello.ts` | Minimal custom tool example |
|
||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
|
||||
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
||||
|
||||
### Commands & UI
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
|
||||
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
||||
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
||||
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
||||
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
|
||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||
|
||||
### Git Integration
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
|
||||
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
|
||||
|
||||
### System Prompt & Compaction
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
||||
|
||||
### External Dependencies
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) |
|
||||
| `with-deps/` | Extension with its own package.json and dependencies |
|
||||
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
|
||||
|
||||
## Writing Extensions
|
||||
|
||||
See [docs/extensions.md](../../docs/extensions.md) for full documentation.
|
||||
|
||||
```typescript
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Subscribe to lifecycle events
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
});
|
||||
|
||||
// Register custom tools
|
||||
pi.registerTool({
|
||||
name: "greet",
|
||||
label: "Greeting",
|
||||
description: "Generate a greeting",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register commands
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify("Hello!", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**Use StringEnum for string parameters** (required for Google API compatibility):
|
||||
```typescript
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
|
||||
// Good
|
||||
action: StringEnum(["list", "add"] as const)
|
||||
|
||||
// Bad - doesn't work with Google
|
||||
action: Type.Union([Type.Literal("list"), Type.Literal("add")])
|
||||
```
|
||||
|
||||
**State persistence via details:**
|
||||
```typescript
|
||||
// Store state in tool result details for proper branching support
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
details: { todos: [...todos], nextId }, // Persisted in session
|
||||
};
|
||||
|
||||
// Reconstruct on session events
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.toolName === "my_tool") {
|
||||
const details = entry.message.details;
|
||||
// Reconstruct state from details
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Auto-Commit on Exit Hook
|
||||
* Auto-Commit on Exit Extension
|
||||
*
|
||||
* Automatically commits changes when the agent exits.
|
||||
* Uses the last assistant message to generate a commit message.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
26
packages/coding-agent/examples/extensions/chalk-logger.ts
Normal file
26
packages/coding-agent/examples/extensions/chalk-logger.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Example extension that uses a 3rd party dependency (chalk).
|
||||
* Tests that jiti can resolve npm modules correctly.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import chalk from "chalk";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Log with colors using chalk
|
||||
console.log(`${chalk.green("✓")} ${chalk.bold("chalk-logger extension loaded")}`);
|
||||
|
||||
pi.on("agent_start", async () => {
|
||||
console.log(`${chalk.blue("[chalk-logger]")} Agent starting`);
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
console.log(`${chalk.yellow("[chalk-logger]")} Tool: ${chalk.cyan(event.toolName)}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (event) => {
|
||||
const count = event.messages.length;
|
||||
console.log(`${chalk.green("[chalk-logger]")} Done with ${chalk.bold(String(count))} messages`);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Confirm Destructive Actions Hook
|
||||
* Confirm Destructive Actions Extension
|
||||
*
|
||||
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
||||
* Demonstrates how to cancel session events using the before_* events.
|
||||
*/
|
||||
|
||||
import type { HookAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Custom Compaction Hook
|
||||
* Custom Compaction Extension
|
||||
*
|
||||
* Replaces the default compaction behavior with a full summary of the entire context.
|
||||
* Instead of keeping the last 20k tokens of conversation turns, this hook:
|
||||
* Instead of keeping the last 20k tokens of conversation turns, this extension:
|
||||
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
||||
* 2. Discards all old turns completely, keeping only the summary
|
||||
*
|
||||
|
|
@ -10,16 +10,16 @@
|
|||
* which can be cheaper/faster than the main conversation model.
|
||||
*
|
||||
* Usage:
|
||||
* pi --hook examples/hooks/custom-compaction.ts
|
||||
* pi --extension examples/extensions/custom-compaction.ts
|
||||
*/
|
||||
|
||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
ctx.ui.notify("Custom compaction hook triggered", "info");
|
||||
ctx.ui.notify("Custom compaction extension triggered", "info");
|
||||
|
||||
const { preparation, branchEntries: _, signal } = event;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
/**
|
||||
* Dirty Repo Guard Hook
|
||||
* Dirty Repo Guard Extension
|
||||
*
|
||||
* Prevents session changes when there are uncommitted git changes.
|
||||
* Useful to ensure work is committed before switching context.
|
||||
*/
|
||||
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
|
||||
async function checkDirtyRepo(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
action: string,
|
||||
): Promise<{ cancel: boolean } | undefined> {
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
|
|
@ -40,7 +44,7 @@ async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Pr
|
|||
}
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_switch", async (event, ctx) => {
|
||||
const action = event.reason === "new" ? "new session" : "switch session";
|
||||
return checkDirtyRepo(pi, ctx, action);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* File Trigger Hook
|
||||
* File Trigger Extension
|
||||
*
|
||||
* Watches a trigger file and injects its contents into the conversation.
|
||||
* Useful for external systems to send messages to the agent.
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const triggerFile = "/tmp/agent-trigger.txt";
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Git Checkpoint Hook
|
||||
* Git Checkpoint Extension
|
||||
*
|
||||
* Creates git stash checkpoints at each turn so /branch can restore code state.
|
||||
* When branching, offers to restore code to that point in history.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const checkpoints = new Map<string, string>();
|
||||
let currentEntryId: string | undefined;
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Handoff hook - transfer context to a new focused session
|
||||
* Handoff extension - transfer context to a new focused session
|
||||
*
|
||||
* Instead of compacting (which is lossy), handoff extracts what matters
|
||||
* for your next task and creates a new session with a generated prompt.
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
*/
|
||||
|
||||
import { complete, type Message } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
|
||||
|
|
@ -38,7 +38,7 @@ Files involved:
|
|||
## Task
|
||||
[Clear description of what to do next based on user's goal]`;
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("handoff", {
|
||||
description: "Transfer context to a new focused session",
|
||||
handler: async (args, ctx) => {
|
||||
25
packages/coding-agent/examples/extensions/hello.ts
Normal file
25
packages/coding-agent/examples/extensions/hello.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Hello Tool - Minimal custom tool example
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "hello",
|
||||
label: "Hello",
|
||||
description: "A simple greeting tool",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Permission Gate Hook
|
||||
* Permission Gate Extension
|
||||
*
|
||||
* Prompts for confirmation before running potentially dangerous bash commands.
|
||||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
/**
|
||||
* Pirate Hook
|
||||
* Pirate Extension
|
||||
*
|
||||
* Demonstrates using systemPromptAppend in before_agent_start to dynamically
|
||||
* modify the system prompt based on hook state.
|
||||
* modify the system prompt based on extension state.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
||||
* 2. Use /pirate to toggle pirate mode
|
||||
* 3. When enabled, the agent will respond like a pirate
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function pirateHook(pi: HookAPI) {
|
||||
export default function pirateExtension(pi: ExtensionAPI) {
|
||||
let pirateMode = false;
|
||||
|
||||
// Register /pirate command to toggle pirate mode
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Plan Mode Hook
|
||||
* Plan Mode Extension
|
||||
*
|
||||
* Provides a Claude Code-style "plan mode" for safe code exploration.
|
||||
* When enabled, the agent can only use read-only tools and cannot modify files.
|
||||
|
|
@ -14,12 +14,12 @@
|
|||
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
||||
* 2. Use /plan to toggle plan mode on/off
|
||||
* 3. Or start in plan mode with --plan flag
|
||||
*/
|
||||
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Key } from "@mariozechner/pi-tui";
|
||||
|
||||
// Read-only tools for plan mode
|
||||
|
|
@ -207,7 +207,7 @@ function extractTodoItems(message: string): TodoItem[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
export default function planModeHook(pi: HookAPI) {
|
||||
export default function planModeExtension(pi: ExtensionAPI) {
|
||||
let planModeEnabled = false;
|
||||
let toolsCalledThisTurn = false;
|
||||
let executionMode = false;
|
||||
|
|
@ -221,7 +221,7 @@ export default function planModeHook(pi: HookAPI) {
|
|||
});
|
||||
|
||||
// Helper to update status displays
|
||||
function updateStatus(ctx: HookContext) {
|
||||
function updateStatus(ctx: ExtensionContext) {
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const completed = todoItems.filter((t) => t.completed).length;
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
||||
|
|
@ -249,7 +249,7 @@ export default function planModeHook(pi: HookAPI) {
|
|||
}
|
||||
}
|
||||
|
||||
function togglePlanMode(ctx: HookContext) {
|
||||
function togglePlanMode(ctx: ExtensionContext) {
|
||||
planModeEnabled = !planModeEnabled;
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Protected Paths Hook
|
||||
* Protected Paths Extension
|
||||
*
|
||||
* Blocks write and edit operations to protected paths.
|
||||
* Useful for preventing accidental modifications to sensitive files.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Q&A extraction hook - extracts questions from assistant responses
|
||||
* Q&A extraction extension - extracts questions from assistant responses
|
||||
*
|
||||
* Demonstrates the "prompt generator" pattern:
|
||||
* 1. /qna command gets the last assistant message
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } 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.
|
||||
|
|
@ -27,7 +27,7 @@ A:
|
|||
|
||||
Keep questions in the order they appeared. Be concise.`;
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("qna", {
|
||||
description: "Extract questions from last assistant message into editor",
|
||||
handler: async (_args, ctx) => {
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Question Tool - Let the LLM ask the user a question with options
|
||||
*/
|
||||
|
||||
import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
|
|
@ -17,40 +17,40 @@ const QuestionParams = Type.Object({
|
|||
options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
|
||||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomTool<typeof QuestionParams, QuestionDetails> = {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "question",
|
||||
label: "Question",
|
||||
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
|
||||
parameters: QuestionParams,
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
if (!pi.hasUI) {
|
||||
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||
details: { question: params.question, options: params.options, answer: null },
|
||||
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.options.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No options provided" }],
|
||||
details: { question: params.question, options: [], answer: null },
|
||||
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const answer = await pi.ui.select(params.question, params.options);
|
||||
const answer = await ctx.ui.select(params.question, params.options);
|
||||
|
||||
if (answer === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||
details: { question: params.question, options: params.options, answer: null },
|
||||
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `User selected: ${answer}` }],
|
||||
details: { question: params.question, options: params.options, answer },
|
||||
details: { question: params.question, options: params.options, answer } as QuestionDetails,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const { details } = result;
|
||||
const details = result.details as QuestionDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
|
|
@ -75,9 +75,5 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
|
||||
},
|
||||
};
|
||||
|
||||
return tool;
|
||||
};
|
||||
|
||||
export default factory;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Snake game hook - play snake with /snake command
|
||||
* Snake game extension - play snake with /snake command
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
const GAME_WIDTH = 40;
|
||||
|
|
@ -306,7 +306,7 @@ class SnakeComponent {
|
|||
|
||||
const SNAKE_SAVE_TYPE = "snake-save";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("snake", {
|
||||
description: "Play Snake!",
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Status Line Hook
|
||||
* Status Line Extension
|
||||
*
|
||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||
* Shows turn progress with themed colors.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let turnCount = 0;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
|
|
@ -16,14 +16,14 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|||
```
|
||||
subagent/
|
||||
├── README.md # This file
|
||||
├── index.ts # The custom tool (entry point)
|
||||
├── index.ts # The extension (entry point)
|
||||
├── agents.ts # Agent discovery logic
|
||||
├── agents/ # Sample agent definitions
|
||||
│ ├── scout.md # Fast recon, returns compressed context
|
||||
│ ├── planner.md # Creates implementation plans
|
||||
│ ├── reviewer.md # Code review
|
||||
│ └── worker.md # General-purpose (full capabilities)
|
||||
└── commands/ # Workflow presets
|
||||
└── prompts/ # Workflow presets (prompt templates)
|
||||
├── implement.md # scout -> planner -> worker
|
||||
├── scout-and-plan.md # scout -> planner (no implementation)
|
||||
└── implement-and-review.md # worker -> reviewer -> worker
|
||||
|
|
@ -34,21 +34,21 @@ subagent/
|
|||
From the repository root, symlink the files:
|
||||
|
||||
```bash
|
||||
# Symlink the tool (must be in a subdirectory with index.ts)
|
||||
mkdir -p ~/.pi/agent/tools/subagent
|
||||
ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/index.ts" ~/.pi/agent/tools/subagent/index.ts
|
||||
ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/agents.ts" ~/.pi/agent/tools/subagent/agents.ts
|
||||
# Symlink the extension (must be in a subdirectory with index.ts)
|
||||
mkdir -p ~/.pi/agent/extensions/subagent
|
||||
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
|
||||
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts
|
||||
|
||||
# Symlink agents
|
||||
mkdir -p ~/.pi/agent/agents
|
||||
for f in packages/coding-agent/examples/custom-tools/subagent/agents/*.md; do
|
||||
for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
|
||||
ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
|
||||
done
|
||||
|
||||
# Symlink workflow commands
|
||||
mkdir -p ~/.pi/agent/commands
|
||||
for f in packages/coding-agent/examples/custom-tools/subagent/commands/*.md; do
|
||||
ln -sf "$(pwd)/$f" ~/.pi/agent/commands/$(basename "$f")
|
||||
# Symlink workflow prompts
|
||||
mkdir -p ~/.pi/agent/prompts
|
||||
for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
|
||||
ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
|
||||
done
|
||||
```
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ Run 2 scouts in parallel: one to find models, one to find providers
|
|||
Use a chain: first have scout find the read tool, then have planner suggest improvements
|
||||
```
|
||||
|
||||
### Workflow commands
|
||||
### Workflow prompts
|
||||
```
|
||||
/implement add Redis caching to the session store
|
||||
/scout-and-plan refactor auth to support OAuth
|
||||
|
|
@ -150,10 +150,10 @@ Project agents override user agents with the same name when `agentScope: "both"`
|
|||
| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash |
|
||||
| `worker` | General-purpose | Sonnet | (all default) |
|
||||
|
||||
## Workflow Commands
|
||||
## Workflow Prompts
|
||||
|
||||
| Command | Flow |
|
||||
|---------|------|
|
||||
| Prompt | Flow |
|
||||
|--------|------|
|
||||
| `/implement <query>` | scout → planner → worker |
|
||||
| `/scout-and-plan <query>` | scout → planner |
|
||||
| `/implement-and-review <query>` | worker → reviewer → worker |
|
||||
|
|
@ -19,19 +19,13 @@ import * as path from "node:path";
|
|||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type CustomTool,
|
||||
type CustomToolAPI,
|
||||
type CustomToolFactory,
|
||||
getMarkdownTheme,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
|
||||
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
|
||||
|
||||
const MAX_PARALLEL_TASKS = 8;
|
||||
const MAX_CONCURRENCY = 4;
|
||||
const MAX_AGENTS_IN_DESCRIPTION = 10;
|
||||
const COLLAPSED_ITEM_COUNT = 10;
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
|
|
@ -224,7 +218,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string
|
|||
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
||||
|
||||
async function runSingleAgent(
|
||||
pi: CustomToolAPI,
|
||||
defaultCwd: string,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
|
|
@ -289,7 +283,7 @@ async function runSingleAgent(
|
|||
let wasAborted = false;
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const proc = spawn("pi", args, { cwd: cwd ?? pi.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let buffer = "";
|
||||
|
||||
const processLine = (line: string) => {
|
||||
|
|
@ -410,32 +404,21 @@ const SubagentParams = Type.Object({
|
|||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
||||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomTool<typeof SubagentParams, SubagentDetails> = {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "subagent",
|
||||
label: "Subagent",
|
||||
get description() {
|
||||
const user = discoverAgents(pi.cwd, "user");
|
||||
const project = discoverAgents(pi.cwd, "project");
|
||||
const userList = formatAgentList(user.agents, MAX_AGENTS_IN_DESCRIPTION);
|
||||
const projectList = formatAgentList(project.agents, MAX_AGENTS_IN_DESCRIPTION);
|
||||
const userSuffix = userList.remaining > 0 ? `; ... and ${userList.remaining} more` : "";
|
||||
const projectSuffix = projectList.remaining > 0 ? `; ... and ${projectList.remaining} more` : "";
|
||||
const projectDirNote = project.projectAgentsDir ? ` (from ${project.projectAgentsDir})` : "";
|
||||
return [
|
||||
"Delegate tasks to specialized subagents with isolated context.",
|
||||
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
||||
'Default agent scope is "user" (from ~/.pi/agent/agents).',
|
||||
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
||||
`User agents: ${userList.text}${userSuffix}.`,
|
||||
`Project agents${projectDirNote}: ${projectList.text}${projectSuffix}.`,
|
||||
].join(" ");
|
||||
},
|
||||
description: [
|
||||
"Delegate tasks to specialized subagents with isolated context.",
|
||||
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
||||
'Default agent scope is "user" (from ~/.pi/agent/agents).',
|
||||
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
||||
].join(" "),
|
||||
parameters: SubagentParams,
|
||||
|
||||
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
||||
async execute(_toolCallId, params, onUpdate, ctx, signal) {
|
||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||
const discovery = discoverAgents(pi.cwd, agentScope);
|
||||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
||||
|
||||
|
|
@ -466,7 +449,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
};
|
||||
}
|
||||
|
||||
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && pi.hasUI) {
|
||||
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
|
||||
const requestedAgentNames = new Set<string>();
|
||||
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
||||
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
||||
|
|
@ -479,7 +462,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
if (projectAgentsRequested.length > 0) {
|
||||
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
||||
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
||||
const ok = await pi.ui.confirm(
|
||||
const ok = await ctx.ui.confirm(
|
||||
"Run project-local agents?",
|
||||
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
||||
);
|
||||
|
|
@ -515,7 +498,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
: undefined;
|
||||
|
||||
const result = await runSingleAgent(
|
||||
pi,
|
||||
ctx.cwd,
|
||||
agents,
|
||||
step.agent,
|
||||
taskWithContext,
|
||||
|
|
@ -589,7 +572,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
||||
const result = await runSingleAgent(
|
||||
pi,
|
||||
ctx.cwd,
|
||||
agents,
|
||||
t.agent,
|
||||
t.task,
|
||||
|
|
@ -629,7 +612,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
if (params.agent && params.task) {
|
||||
const result = await runSingleAgent(
|
||||
pi,
|
||||
ctx.cwd,
|
||||
agents,
|
||||
params.agent,
|
||||
params.task,
|
||||
|
|
@ -707,7 +690,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
const { details } = result;
|
||||
const details = result.details as SubagentDetails | undefined;
|
||||
if (!details || details.results.length === 0) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
||||
|
|
@ -976,9 +959,5 @@ const factory: CustomToolFactory = (pi) => {
|
|||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
||||
},
|
||||
};
|
||||
|
||||
return tool;
|
||||
};
|
||||
|
||||
export default factory;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,21 +1,18 @@
|
|||
/**
|
||||
* Todo Tool - Demonstrates state management via session entries
|
||||
* Todo Extension - Demonstrates state management via session entries
|
||||
*
|
||||
* This tool stores state in tool result details (not external files),
|
||||
* which allows proper branching - when you branch, the todo state
|
||||
* is automatically correct for that point in history.
|
||||
* This extension:
|
||||
* - Registers a `todo` tool for the LLM to manage todos
|
||||
* - Registers a `/todos` command for users to view the list
|
||||
*
|
||||
* The onSession callback reconstructs state by scanning past tool results.
|
||||
* State is stored in tool result details (not external files), which allows
|
||||
* proper branching - when you branch, the todo state is automatically
|
||||
* correct for that point in history.
|
||||
*/
|
||||
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface Todo {
|
||||
|
|
@ -24,7 +21,6 @@ interface Todo {
|
|||
done: boolean;
|
||||
}
|
||||
|
||||
// State stored in tool result details
|
||||
interface TodoDetails {
|
||||
action: "list" | "add" | "toggle" | "clear";
|
||||
todos: Todo[];
|
||||
|
|
@ -32,14 +28,81 @@ interface TodoDetails {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// Define schema separately for proper type inference
|
||||
const TodoParams = Type.Object({
|
||||
action: StringEnum(["list", "add", "toggle", "clear"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
|
||||
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
|
||||
});
|
||||
|
||||
const factory: CustomToolFactory = (_pi) => {
|
||||
/**
|
||||
* UI component for the /todos command
|
||||
*/
|
||||
class TodoListComponent {
|
||||
private todos: Todo[];
|
||||
private theme: Theme;
|
||||
private onClose: () => void;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
||||
this.todos = todos;
|
||||
this.theme = theme;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const th = this.theme;
|
||||
|
||||
lines.push("");
|
||||
const title = th.fg("accent", " Todos ");
|
||||
const headerLine =
|
||||
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
|
||||
lines.push(truncateToWidth(headerLine, width));
|
||||
lines.push("");
|
||||
|
||||
if (this.todos.length === 0) {
|
||||
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
|
||||
} else {
|
||||
const done = this.todos.filter((t) => t.done).length;
|
||||
const total = this.todos.length;
|
||||
lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
|
||||
lines.push("");
|
||||
|
||||
for (const todo of this.todos) {
|
||||
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
|
||||
const id = th.fg("accent", `#${todo.id}`);
|
||||
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
|
||||
lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
||||
lines.push("");
|
||||
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// In-memory state (reconstructed from session on load)
|
||||
let todos: Todo[] = [];
|
||||
let nextId = 1;
|
||||
|
|
@ -48,18 +111,14 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
* Reconstruct state from session entries.
|
||||
* Scans tool results for this tool and applies them in order.
|
||||
*/
|
||||
const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
const reconstructState = (ctx: ExtensionContext) => {
|
||||
todos = [];
|
||||
nextId = 1;
|
||||
|
||||
// Use getBranch() to get entries on the current branch
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
|
||||
// Tool results have role "toolResult"
|
||||
if (msg.role !== "toolResult") continue;
|
||||
if (msg.toolName !== "todo") continue;
|
||||
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
||||
|
||||
const details = msg.details as TodoDetails | undefined;
|
||||
if (details) {
|
||||
|
|
@ -69,15 +128,19 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
}
|
||||
};
|
||||
|
||||
const tool: CustomTool<typeof TodoParams, TodoDetails> = {
|
||||
// Reconstruct state on session events
|
||||
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_branch", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
||||
|
||||
// Register the todo tool for the LLM
|
||||
pi.registerTool({
|
||||
name: "todo",
|
||||
label: "Todo",
|
||||
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
|
||||
parameters: TodoParams,
|
||||
|
||||
// Called on session start/switch/branch/clear
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
|
|
@ -90,21 +153,21 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
: "No todos",
|
||||
},
|
||||
],
|
||||
details: { action: "list", todos: [...todos], nextId },
|
||||
details: { action: "list", todos: [...todos], nextId } as TodoDetails,
|
||||
};
|
||||
|
||||
case "add": {
|
||||
if (!params.text) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: text required for add" }],
|
||||
details: { action: "add", todos: [...todos], nextId, error: "text required" },
|
||||
details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
|
||||
};
|
||||
}
|
||||
const newTodo: Todo = { id: nextId++, text: params.text, done: false };
|
||||
todos.push(newTodo);
|
||||
return {
|
||||
content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
|
||||
details: { action: "add", todos: [...todos], nextId },
|
||||
details: { action: "add", todos: [...todos], nextId } as TodoDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -112,20 +175,25 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
if (params.id === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: id required for toggle" }],
|
||||
details: { action: "toggle", todos: [...todos], nextId, error: "id required" },
|
||||
details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
|
||||
};
|
||||
}
|
||||
const todo = todos.find((t) => t.id === params.id);
|
||||
if (!todo) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Todo #${params.id} not found` }],
|
||||
details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` },
|
||||
details: {
|
||||
action: "toggle",
|
||||
todos: [...todos],
|
||||
nextId,
|
||||
error: `#${params.id} not found`,
|
||||
} as TodoDetails,
|
||||
};
|
||||
}
|
||||
todo.done = !todo.done;
|
||||
return {
|
||||
content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
|
||||
details: { action: "toggle", todos: [...todos], nextId },
|
||||
details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -135,14 +203,19 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
nextId = 1;
|
||||
return {
|
||||
content: [{ type: "text", text: `Cleared ${count} todos` }],
|
||||
details: { action: "clear", todos: [], nextId: 1 },
|
||||
details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
|
||||
details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` },
|
||||
details: {
|
||||
action: "list",
|
||||
todos: [...todos],
|
||||
nextId,
|
||||
error: `unknown action: ${params.action}`,
|
||||
} as TodoDetails,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -155,13 +228,12 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
const { details } = result;
|
||||
const details = result.details as TodoDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (details.error) {
|
||||
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
||||
}
|
||||
|
|
@ -208,9 +280,20 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return tool;
|
||||
};
|
||||
// Register the /todos command for users
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show all todos on the current branch",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/todos requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
export default factory;
|
||||
await ctx.ui.custom<void>((_tui, theme, done) => {
|
||||
return new TodoListComponent(todos, theme, () => done());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
/**
|
||||
* Tools Hook
|
||||
* Tools Extension
|
||||
*
|
||||
* Provides a /tools command to enable/disable tools interactively.
|
||||
* Tool selection persists across session reloads and respects branch navigation.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
||||
* 2. Use /tools to open the tool selector
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
|
||||
|
||||
// State persisted to session
|
||||
|
|
@ -18,7 +18,7 @@ interface ToolsState {
|
|||
enabledTools: string[];
|
||||
}
|
||||
|
||||
export default function toolsHook(pi: HookAPI) {
|
||||
export default function toolsExtension(pi: ExtensionAPI) {
|
||||
// Track enabled tools
|
||||
let enabledTools: Set<string> = new Set();
|
||||
let allTools: string[] = [];
|
||||
|
|
@ -36,7 +36,7 @@ export default function toolsHook(pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Find the last tools-config entry in the current branch
|
||||
function restoreFromBranch(ctx: HookContext) {
|
||||
function restoreFromBranch(ctx: ExtensionContext) {
|
||||
allTools = pi.getAllTools();
|
||||
|
||||
// Get entries in current branch only
|
||||
1
packages/coding-agent/examples/extensions/with-deps/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/with-deps/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
36
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal file
36
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Example extension with its own npm dependencies.
|
||||
* Tests that jiti resolves modules from the extension's own node_modules.
|
||||
*
|
||||
* Requires: npm install in this directory
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import ms from "ms";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Register a tool that uses ms
|
||||
pi.registerTool({
|
||||
name: "parse_duration",
|
||||
label: "Parse Duration",
|
||||
description: "Parse a human-readable duration string (e.g., '2 days', '1h', '5m') to milliseconds",
|
||||
parameters: Type.Object({
|
||||
duration: Type.String({ description: "Duration string like '2 days', '1h', '5m'" }),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const result = ms(params.duration as ms.StringValue);
|
||||
if (result === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Invalid duration: "${params.duration}"` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `${params.duration} = ${result} milliseconds` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
31
packages/coding-agent/examples/extensions/with-deps/package-lock.json
generated
Normal file
31
packages/coding-agent/examples/extensions/with-deps/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ms": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ms": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# Hooks Examples
|
||||
|
||||
Example hooks for pi-coding-agent.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Load a hook with --hook flag
|
||||
pi --hook examples/hooks/permission-gate.ts
|
||||
|
||||
# Or copy to hooks directory for auto-discovery
|
||||
cp permission-gate.ts ~/.pi/agent/hooks/
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
|
||||
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
||||
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
||||
| `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 |
|
||||
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
|
||||
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
||||
| `todo/` | Adds `/todos` command to view todos managed by the [todo custom tool](../custom-tools/todo/) |
|
||||
|
||||
## Writing Hooks
|
||||
|
||||
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
|
||||
|
||||
```typescript
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
// Subscribe to events
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
});
|
||||
|
||||
// Register custom commands
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify("Hello!", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
/**
|
||||
* Todo Hook - Companion to the todo custom tool
|
||||
*
|
||||
* Registers a /todos command that displays all todos on the current branch
|
||||
* with a nice custom UI.
|
||||
*/
|
||||
|
||||
import type { HookAPI, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
interface Todo {
|
||||
id: number;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
interface TodoDetails {
|
||||
action: "list" | "add" | "toggle" | "clear";
|
||||
todos: Todo[];
|
||||
nextId: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class TodoListComponent {
|
||||
private todos: Todo[];
|
||||
private theme: Theme;
|
||||
private onClose: () => void;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
||||
this.todos = todos;
|
||||
this.theme = theme;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const th = this.theme;
|
||||
|
||||
// Header
|
||||
lines.push("");
|
||||
const title = th.fg("accent", " Todos ");
|
||||
const headerLine =
|
||||
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
|
||||
lines.push(truncateToWidth(headerLine, width));
|
||||
lines.push("");
|
||||
|
||||
if (this.todos.length === 0) {
|
||||
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
|
||||
} else {
|
||||
// Stats
|
||||
const done = this.todos.filter((t) => t.done).length;
|
||||
const total = this.todos.length;
|
||||
const statsText = ` ${th.fg("muted", `${done}/${total} completed`)}`;
|
||||
lines.push(truncateToWidth(statsText, width));
|
||||
lines.push("");
|
||||
|
||||
// Todo items
|
||||
for (const todo of this.todos) {
|
||||
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
|
||||
const id = th.fg("accent", `#${todo.id}`);
|
||||
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
|
||||
const line = ` ${check} ${id} ${text}`;
|
||||
lines.push(truncateToWidth(line, width));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
||||
lines.push("");
|
||||
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
/**
|
||||
* Reconstruct todos from session entries on the current branch.
|
||||
*/
|
||||
function getTodos(ctx: {
|
||||
sessionManager: {
|
||||
getBranch: () => Array<{ type: string; message?: { role?: string; toolName?: string; details?: unknown } }>;
|
||||
};
|
||||
}): Todo[] {
|
||||
let todos: Todo[] = [];
|
||||
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
||||
|
||||
const details = msg.details as TodoDetails | undefined;
|
||||
if (details) {
|
||||
todos = details.todos;
|
||||
}
|
||||
}
|
||||
|
||||
return todos;
|
||||
}
|
||||
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show all todos on the current branch",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/todos requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const todos = getTodos(ctx);
|
||||
|
||||
await ctx.ui.custom<void>((_tui, theme, done) => {
|
||||
return new TodoListComponent(todos, theme, () => done());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Minimal SDK Usage
|
||||
*
|
||||
* Uses all defaults: discovers skills, hooks, tools, context files
|
||||
* Uses all defaults: discovers skills, extensions, tools, context files
|
||||
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,28 @@
|
|||
/**
|
||||
* Tools Configuration
|
||||
*
|
||||
* Use built-in tool sets, individual tools, or add custom tools.
|
||||
* Use built-in tool sets or individual tools.
|
||||
*
|
||||
* IMPORTANT: When using a custom `cwd`, you must use the tool factory functions
|
||||
* (createCodingTools, createReadOnlyTools, createReadTool, etc.) to ensure
|
||||
* tools resolve paths relative to your cwd, not process.cwd().
|
||||
*
|
||||
* For custom tools, see 06-extensions.ts - custom tools are now registered
|
||||
* via the extensions system using pi.registerTool().
|
||||
*/
|
||||
|
||||
import {
|
||||
bashTool, // read, bash, edit, write - uses process.cwd()
|
||||
type CustomTool,
|
||||
bashTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createCodingTools, // Factory: creates tools for specific cwd
|
||||
createCodingTools,
|
||||
createGrepTool,
|
||||
createReadTool,
|
||||
grepTool,
|
||||
readOnlyTools, // read, grep, find, ls - uses process.cwd()
|
||||
readOnlyTools,
|
||||
readTool,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Read-only mode (no edit/write) - uses process.cwd()
|
||||
await createAgentSession({
|
||||
|
|
@ -53,38 +54,3 @@ await createAgentSession({
|
|||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Specific tools with custom cwd session created");
|
||||
|
||||
// Inline custom tool (needs TypeBox schema)
|
||||
const weatherTool: CustomTool = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
parameters: Type.Object({
|
||||
city: Type.String({ description: "City name" }),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => ({
|
||||
content: [{ type: "text", text: `Weather in ${(params as { city: string }).city}: 22°C, sunny` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [{ tool: weatherTool }],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("What's the weather in Tokyo?");
|
||||
console.log();
|
||||
|
||||
// Merge with discovered tools from cwd/.pi/tools and ~/.pi/agent/tools:
|
||||
// const discovered = await discoverCustomTools();
|
||||
// customTools: [...discovered, { tool: myTool }]
|
||||
|
||||
// Or add paths without replacing discovery:
|
||||
// additionalCustomToolPaths: ["/extra/tools"]
|
||||
|
|
|
|||
81
packages/coding-agent/examples/sdk/06-extensions.ts
Normal file
81
packages/coding-agent/examples/sdk/06-extensions.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Extensions Configuration
|
||||
*
|
||||
* Extensions intercept agent events and can register custom tools.
|
||||
* They provide a unified system for extensions, custom tools, commands, and more.
|
||||
*
|
||||
* Extension files are discovered from:
|
||||
* - ~/.pi/agent/extensions/
|
||||
* - <cwd>/.pi/extensions/
|
||||
* - Paths specified in settings.json "extensions" array
|
||||
* - Paths passed via --extension CLI flag
|
||||
*
|
||||
* An extension is a TypeScript file that exports a default function:
|
||||
* export default function (pi: ExtensionAPI) { ... }
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Extensions are loaded from disk, not passed inline to createAgentSession.
|
||||
// Use the discovery mechanism:
|
||||
// 1. Place extension files in ~/.pi/agent/extensions/ or .pi/extensions/
|
||||
// 2. Add paths to settings.json: { "extensions": ["./my-extension.ts"] }
|
||||
// 3. Use --extension flag: pi --extension ./my-extension.ts
|
||||
|
||||
// To add additional extension paths beyond discovery:
|
||||
const { session } = await createAgentSession({
|
||||
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("List files in the current directory.");
|
||||
console.log();
|
||||
|
||||
// Example extension file (./my-logging-extension.ts):
|
||||
/*
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_start", async () => {
|
||||
console.log("[Extension] Agent starting");
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
console.log(\`[Extension] Tool: \${event.toolName}\`);
|
||||
// Return { block: true, reason: "..." } to block execution
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (event) => {
|
||||
console.log(\`[Extension] Done, \${event.messages.length} messages\`);
|
||||
});
|
||||
|
||||
// Register a custom tool
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Does something useful",
|
||||
parameters: Type.Object({
|
||||
input: Type.String(),
|
||||
}),
|
||||
execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({
|
||||
content: [{ type: "text", text: \`Processed: \${params.input}\` }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
|
||||
// Register a command
|
||||
pi.registerCommand("mycommand", {
|
||||
description: "Do something",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify(\`Command executed with: \${args}\`);
|
||||
},
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* Hooks Configuration
|
||||
*
|
||||
* Hooks intercept agent events for logging, blocking, or modification.
|
||||
*/
|
||||
|
||||
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Logging hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
api.on("agent_start", async () => {
|
||||
console.log("[Hook] Agent starting");
|
||||
});
|
||||
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`[Hook] Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
api.on("agent_end", async (event) => {
|
||||
console.log(`[Hook] Done, ${event.messages.length} messages`);
|
||||
});
|
||||
};
|
||||
|
||||
// Blocking hook (returns { block: true, reason: "..." })
|
||||
const safetyHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
if (event.toolName === "bash") {
|
||||
const cmd = (event.input as { command?: string }).command ?? "";
|
||||
if (cmd.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command blocked" };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Use inline hooks
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [{ factory: loggingHook }, { factory: safetyHook }],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("List files in the current directory.");
|
||||
console.log();
|
||||
|
||||
// Disable all hooks:
|
||||
// hooks: []
|
||||
|
||||
// Merge with discovered hooks:
|
||||
// const discovered = await discoverHooks();
|
||||
// hooks: [...discovered, { factory: myHook }]
|
||||
|
||||
// Add paths without replacing discovery:
|
||||
// additionalHookPaths: ["/extra/hooks"]
|
||||
42
packages/coding-agent/examples/sdk/08-prompt-templates.ts
Normal file
42
packages/coding-agent/examples/sdk/08-prompt-templates.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Prompt Templates
|
||||
*
|
||||
* File-based templates that inject content when invoked with /templatename.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverPromptTemplates,
|
||||
type PromptTemplate,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
|
||||
const discovered = discoverPromptTemplates();
|
||||
console.log("Discovered prompt templates:");
|
||||
for (const template of discovered) {
|
||||
console.log(` /${template.name}: ${template.description}`);
|
||||
}
|
||||
|
||||
// Define custom templates
|
||||
const deployTemplate: PromptTemplate = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
content: `# Deploy Instructions
|
||||
|
||||
1. Build: npm run build
|
||||
2. Test: npm test
|
||||
3. Deploy: npm run deploy`,
|
||||
};
|
||||
|
||||
// Use discovered + custom templates
|
||||
await createAgentSession({
|
||||
promptTemplates: [...discovered, deployTemplate],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${discovered.length + 1} prompt templates`);
|
||||
|
||||
// Disable prompt templates:
|
||||
// promptTemplates: []
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Slash Commands
|
||||
*
|
||||
* File-based commands that inject content when invoked with /commandname.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
|
||||
const discovered = discoverSlashCommands();
|
||||
console.log("Discovered slash commands:");
|
||||
for (const cmd of discovered) {
|
||||
console.log(` /${cmd.name}: ${cmd.description}`);
|
||||
}
|
||||
|
||||
// Define custom commands
|
||||
const deployCommand: FileSlashCommand = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
content: `# Deploy Instructions
|
||||
|
||||
1. Build: npm run build
|
||||
2. Test: npm test
|
||||
3. Deploy: npm run deploy`,
|
||||
};
|
||||
|
||||
// Use discovered + custom commands
|
||||
await createAgentSession({
|
||||
slashCommands: [...discovered, deployCommand],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${discovered.length + 1} slash commands`);
|
||||
|
||||
// Disable slash commands:
|
||||
// slashCommands: []
|
||||
|
|
@ -6,21 +6,22 @@
|
|||
* IMPORTANT: When providing `tools` with a custom `cwd`, use the tool factory
|
||||
* functions (createReadTool, createBashTool, etc.) to ensure tools resolve
|
||||
* paths relative to your cwd.
|
||||
*
|
||||
* NOTE: Extensions (extensions, custom tools) are always loaded via discovery.
|
||||
* To use custom extensions, place them in the extensions directory or
|
||||
* pass paths via additionalExtensionPaths.
|
||||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
AuthStorage,
|
||||
type CustomTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createReadTool,
|
||||
type HookFactory,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Custom auth storage location
|
||||
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
|
||||
|
|
@ -33,27 +34,7 @@ if (process.env.MY_ANTHROPIC_KEY) {
|
|||
// Model registry with no custom models.json
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// Inline hook
|
||||
const auditHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`[Audit] ${event.toolName}`);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Inline custom tool
|
||||
const statusTool: CustomTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: `Uptime: ${process.uptime()}s, Node: ${process.version}` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
const model = getModel("anthropic", "claude-opus-4-5");
|
||||
const model = getModel("anthropic", "claude-sonnet-4-20250514");
|
||||
if (!model) throw new Error("Model not found");
|
||||
|
||||
// In-memory settings with overrides
|
||||
|
|
@ -73,14 +54,14 @@ const { session } = await createAgentSession({
|
|||
authStorage,
|
||||
modelRegistry,
|
||||
systemPrompt: `You are a minimal assistant.
|
||||
Available: read, bash, status. Be concise.`,
|
||||
Available: read, bash. Be concise.`,
|
||||
// Use factory functions with the same cwd to ensure path resolution works correctly
|
||||
tools: [createReadTool(cwd), createBashTool(cwd)],
|
||||
customTools: [{ tool: statusTool }],
|
||||
hooks: [{ factory: auditHook }],
|
||||
// Extensions are loaded from disk - use additionalExtensionPaths to add custom ones
|
||||
// additionalExtensionPaths: ["./my-extension.ts"],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
promptTemplates: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
|
|
@ -91,5 +72,5 @@ session.subscribe((event) => {
|
|||
}
|
||||
});
|
||||
|
||||
await session.prompt("Get status and list files.");
|
||||
await session.prompt("List files in the current directory.");
|
||||
console.log();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Programmatic usage of pi-coding-agent via `createAgentSession()`.
|
|||
| `03-custom-prompt.ts` | Replace or modify system prompt |
|
||||
| `04-skills.ts` | Discover, filter, or replace skills |
|
||||
| `05-tools.ts` | Built-in tools, custom tools |
|
||||
| `06-hooks.ts` | Logging, blocking, result modification |
|
||||
| `06-extensions.ts` | Logging, blocking, result modification |
|
||||
| `07-context-files.ts` | AGENTS.md context files |
|
||||
| `08-slash-commands.ts` | File-based slash commands |
|
||||
| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config |
|
||||
|
|
@ -36,7 +36,7 @@ import {
|
|||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
discoverSkills,
|
||||
discoverHooks,
|
||||
discoverExtensions,
|
||||
discoverCustomTools,
|
||||
discoverContextFiles,
|
||||
discoverSlashCommands,
|
||||
|
|
@ -89,7 +89,7 @@ const { session } = await createAgentSession({
|
|||
systemPrompt: "You are helpful.",
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [{ tool: myTool }],
|
||||
hooks: [{ factory: myHook }],
|
||||
extensions: [{ factory: myExtension }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
|
|
@ -119,8 +119,8 @@ await session.prompt("Hello");
|
|||
| `tools` | `codingTools` | Built-in tools |
|
||||
| `customTools` | Discovered | Replaces discovery |
|
||||
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
|
||||
| `hooks` | Discovered | Replaces discovery |
|
||||
| `additionalHookPaths` | `[]` | Merge with discovery |
|
||||
| `extensions` | Discovered | Replaces discovery |
|
||||
| `additionalExtensionPaths` | `[]` | Merge with discovery |
|
||||
| `skills` | Discovered | Skills for prompt |
|
||||
| `contextFiles` | Discovered | AGENTS.md files |
|
||||
| `slashCommands` | Discovered | File commands |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-coding-agent",
|
||||
"version": "0.34.2",
|
||||
"version": "0.35.0",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
|
|
@ -39,9 +39,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@crosscopy/clipboard": "^0.2.8",
|
||||
"@mariozechner/pi-agent-core": "^0.34.2",
|
||||
"@mariozechner/pi-ai": "^0.34.2",
|
||||
"@mariozechner/pi-tui": "^0.34.2",
|
||||
"@mariozechner/pi-agent-core": "^0.35.0",
|
||||
"@mariozechner/pi-ai": "^0.35.0",
|
||||
"@mariozechner/pi-tui": "^0.35.0",
|
||||
"chalk": "^5.5.0",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"diff": "^8.0.2",
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ export interface Args {
|
|||
sessionDir?: string;
|
||||
models?: string[];
|
||||
tools?: ToolName[];
|
||||
hooks?: string[];
|
||||
customTools?: string[];
|
||||
extensions?: string[];
|
||||
print?: boolean;
|
||||
export?: string;
|
||||
noSkills?: boolean;
|
||||
|
|
@ -35,7 +34,7 @@ export interface Args {
|
|||
listModels?: string | true;
|
||||
messages: string[];
|
||||
fileArgs: string[];
|
||||
/** Unknown flags (potentially hook flags) - map of flag name to value */
|
||||
/** Unknown flags (potentially extension flags) - map of flag name to value */
|
||||
unknownFlags: Map<string, boolean | string>;
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +44,7 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
|
|||
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
|
||||
}
|
||||
|
||||
export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
||||
export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
||||
const result: Args = {
|
||||
messages: [],
|
||||
fileArgs: [],
|
||||
|
|
@ -114,12 +113,9 @@ export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boole
|
|||
result.print = true;
|
||||
} else if (arg === "--export" && i + 1 < args.length) {
|
||||
result.export = args[++i];
|
||||
} else if (arg === "--hook" && i + 1 < args.length) {
|
||||
result.hooks = result.hooks ?? [];
|
||||
result.hooks.push(args[++i]);
|
||||
} else if (arg === "--tool" && i + 1 < args.length) {
|
||||
result.customTools = result.customTools ?? [];
|
||||
result.customTools.push(args[++i]);
|
||||
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
|
||||
result.extensions = result.extensions ?? [];
|
||||
result.extensions.push(args[++i]);
|
||||
} else if (arg === "--no-skills") {
|
||||
result.noSkills = true;
|
||||
} else if (arg === "--skills" && i + 1 < args.length) {
|
||||
|
|
@ -134,18 +130,18 @@ export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boole
|
|||
}
|
||||
} else if (arg.startsWith("@")) {
|
||||
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||
} else if (arg.startsWith("--") && hookFlags) {
|
||||
// Check if it's a hook-registered flag
|
||||
} else if (arg.startsWith("--") && extensionFlags) {
|
||||
// Check if it's an extension-registered flag
|
||||
const flagName = arg.slice(2);
|
||||
const hookFlag = hookFlags.get(flagName);
|
||||
if (hookFlag) {
|
||||
if (hookFlag.type === "boolean") {
|
||||
const extFlag = extensionFlags.get(flagName);
|
||||
if (extFlag) {
|
||||
if (extFlag.type === "boolean") {
|
||||
result.unknownFlags.set(flagName, true);
|
||||
} else if (hookFlag.type === "string" && i + 1 < args.length) {
|
||||
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
||||
result.unknownFlags.set(flagName, args[++i]);
|
||||
}
|
||||
}
|
||||
// Unknown flags without hookFlags are silently ignored (first pass)
|
||||
// Unknown flags without extensionFlags are silently ignored (first pass)
|
||||
} else if (!arg.startsWith("-")) {
|
||||
result.messages.push(arg);
|
||||
}
|
||||
|
|
@ -178,8 +174,7 @@ ${chalk.bold("Options:")}
|
|||
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
|
||||
Available: read, bash, edit, write, grep, find, ls
|
||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
||||
--hook <path> Load a hook file (can be used multiple times)
|
||||
--tool <path> Load a custom tool file (can be used multiple times)
|
||||
--extension, -e <path> Load an extension file (can be used multiple times)
|
||||
--no-skills Disable skills discovery and loading
|
||||
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
|
||||
--export <file> Export session file to HTML and exit
|
||||
|
|
@ -187,7 +182,7 @@ ${chalk.bold("Options:")}
|
|||
--help, -h Show this help
|
||||
--version, -v Show version number
|
||||
|
||||
Hooks can register additional flags (e.g., --plan from plan-mode hook).
|
||||
Extensions can register additional flags (e.g., --plan from plan-mode extension).
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
# Interactive mode
|
||||
|
|
|
|||
|
|
@ -147,9 +147,9 @@ export function getToolsDir(): string {
|
|||
return join(getAgentDir(), "tools");
|
||||
}
|
||||
|
||||
/** Get path to slash commands directory */
|
||||
export function getCommandsDir(): string {
|
||||
return join(getAgentDir(), "commands");
|
||||
/** Get path to prompt templates directory */
|
||||
export function getPromptsDir(): string {
|
||||
return join(getAgentDir(), "prompts");
|
||||
}
|
||||
|
||||
/** Get path to sessions directory */
|
||||
|
|
|
|||
|
|
@ -34,10 +34,9 @@ import {
|
|||
prepareCompaction,
|
||||
shouldCompact,
|
||||
} from "./compaction/index.js";
|
||||
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
|
||||
import { exportSessionToHtml } from "./export-html/index.js";
|
||||
import type {
|
||||
HookRunner,
|
||||
ExtensionRunner,
|
||||
SessionBeforeBranchResult,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeSwitchResult,
|
||||
|
|
@ -45,12 +44,12 @@ import type {
|
|||
TreePreparation,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
} from "./hooks/index.js";
|
||||
import type { BashExecutionMessage, HookMessage } from "./messages.js";
|
||||
} from "./extensions/index.js";
|
||||
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
||||
import type { ModelRegistry } from "./model-registry.js";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
||||
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
||||
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
|
||||
|
||||
/** Session-specific events that extend the core AgentEvent */
|
||||
export type AgentSessionEvent =
|
||||
|
|
@ -73,16 +72,14 @@ export interface AgentSessionConfig {
|
|||
settingsManager: SettingsManager;
|
||||
/** Models to cycle through with Ctrl+P (from --models flag) */
|
||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||
/** File-based slash commands for expansion */
|
||||
fileCommands?: FileSlashCommand[];
|
||||
/** Hook runner (created in main.ts with wrapped tools) */
|
||||
hookRunner?: HookRunner;
|
||||
/** Custom tools for session lifecycle events */
|
||||
customTools?: LoadedCustomTool[];
|
||||
/** File-based prompt templates for expansion */
|
||||
promptTemplates?: PromptTemplate[];
|
||||
/** Extension runner (created in sdk.ts with wrapped tools) */
|
||||
extensionRunner?: ExtensionRunner;
|
||||
skillsSettings?: Required<SkillsSettings>;
|
||||
/** Model registry for API key resolution and model discovery */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Tool registry for hook getTools/setTools - maps name to tool */
|
||||
/** Tool registry for extension getTools/setTools - maps name to tool */
|
||||
toolRegistry?: Map<string, AgentTool>;
|
||||
/** Function to rebuild system prompt when tools change */
|
||||
rebuildSystemPrompt?: (toolNames: string[]) => string;
|
||||
|
|
@ -90,8 +87,8 @@ export interface AgentSessionConfig {
|
|||
|
||||
/** Options for AgentSession.prompt() */
|
||||
export interface PromptOptions {
|
||||
/** Whether to expand file-based slash commands (default: true) */
|
||||
expandSlashCommands?: boolean;
|
||||
/** Whether to expand file-based prompt templates (default: true) */
|
||||
expandPromptTemplates?: boolean;
|
||||
/** Image attachments */
|
||||
images?: ImageContent[];
|
||||
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */
|
||||
|
|
@ -145,7 +142,7 @@ export class AgentSession {
|
|||
readonly settingsManager: SettingsManager;
|
||||
|
||||
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||
private _fileCommands: FileSlashCommand[];
|
||||
private _promptTemplates: PromptTemplate[];
|
||||
|
||||
// Event subscription state
|
||||
private _unsubscribeAgent?: () => void;
|
||||
|
|
@ -156,7 +153,7 @@ export class AgentSession {
|
|||
/** Tracks pending follow-up messages for UI display. Removed when delivered. */
|
||||
private _followUpMessages: string[] = [];
|
||||
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
||||
private _pendingNextTurnMessages: HookMessage[] = [];
|
||||
private _pendingNextTurnMessages: CustomMessage[] = [];
|
||||
|
||||
// Compaction state
|
||||
private _compactionAbortController: AbortController | undefined = undefined;
|
||||
|
|
@ -175,25 +172,22 @@ export class AgentSession {
|
|||
private _bashAbortController: AbortController | undefined = undefined;
|
||||
private _pendingBashMessages: BashExecutionMessage[] = [];
|
||||
|
||||
// Hook system
|
||||
private _hookRunner: HookRunner | undefined = undefined;
|
||||
// Extension system
|
||||
private _extensionRunner: ExtensionRunner | undefined = undefined;
|
||||
private _turnIndex = 0;
|
||||
|
||||
// Custom tools for session lifecycle
|
||||
private _customTools: LoadedCustomTool[] = [];
|
||||
|
||||
private _skillsSettings: Required<SkillsSettings> | undefined;
|
||||
|
||||
// Model registry for API key resolution
|
||||
private _modelRegistry: ModelRegistry;
|
||||
|
||||
// Tool registry for hook getTools/setTools
|
||||
// Tool registry for extension getTools/setTools
|
||||
private _toolRegistry: Map<string, AgentTool>;
|
||||
|
||||
// Function to rebuild system prompt when tools change
|
||||
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
|
||||
|
||||
// Base system prompt (without hook appends) - used to apply fresh appends each turn
|
||||
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
||||
private _baseSystemPrompt: string;
|
||||
|
||||
constructor(config: AgentSessionConfig) {
|
||||
|
|
@ -201,9 +195,8 @@ export class AgentSession {
|
|||
this.sessionManager = config.sessionManager;
|
||||
this.settingsManager = config.settingsManager;
|
||||
this._scopedModels = config.scopedModels ?? [];
|
||||
this._fileCommands = config.fileCommands ?? [];
|
||||
this._hookRunner = config.hookRunner;
|
||||
this._customTools = config.customTools ?? [];
|
||||
this._promptTemplates = config.promptTemplates ?? [];
|
||||
this._extensionRunner = config.extensionRunner;
|
||||
this._skillsSettings = config.skillsSettings;
|
||||
this._modelRegistry = config.modelRegistry;
|
||||
this._toolRegistry = config.toolRegistry ?? new Map();
|
||||
|
|
@ -211,7 +204,7 @@ export class AgentSession {
|
|||
this._baseSystemPrompt = config.agent.state.systemPrompt;
|
||||
|
||||
// Always subscribe to agent events for internal handling
|
||||
// (session persistence, hooks, auto-compaction, retry logic)
|
||||
// (session persistence, extensions, auto-compaction, retry logic)
|
||||
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
||||
}
|
||||
|
||||
|
|
@ -255,16 +248,16 @@ export class AgentSession {
|
|||
}
|
||||
}
|
||||
|
||||
// Emit to hooks first
|
||||
await this._emitHookEvent(event);
|
||||
// Emit to extensions first
|
||||
await this._emitExtensionEvent(event);
|
||||
|
||||
// Notify all listeners
|
||||
this._emit(event);
|
||||
|
||||
// Handle session persistence
|
||||
if (event.type === "message_end") {
|
||||
// Check if this is a hook message
|
||||
if (event.message.role === "hookMessage") {
|
||||
// Check if this is a custom message from extensions
|
||||
if (event.message.role === "custom") {
|
||||
// Persist as CustomMessageEntry
|
||||
this.sessionManager.appendCustomMessageEntry(
|
||||
event.message.customType,
|
||||
|
|
@ -343,30 +336,30 @@ export class AgentSession {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/** Emit hook events based on agent events */
|
||||
private async _emitHookEvent(event: AgentEvent): Promise<void> {
|
||||
if (!this._hookRunner) return;
|
||||
/** Emit extension events based on agent events */
|
||||
private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
|
||||
if (!this._extensionRunner) return;
|
||||
|
||||
if (event.type === "agent_start") {
|
||||
this._turnIndex = 0;
|
||||
await this._hookRunner.emit({ type: "agent_start" });
|
||||
await this._extensionRunner.emit({ type: "agent_start" });
|
||||
} else if (event.type === "agent_end") {
|
||||
await this._hookRunner.emit({ type: "agent_end", messages: event.messages });
|
||||
await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
|
||||
} else if (event.type === "turn_start") {
|
||||
const hookEvent: TurnStartEvent = {
|
||||
const extensionEvent: TurnStartEvent = {
|
||||
type: "turn_start",
|
||||
turnIndex: this._turnIndex,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await this._hookRunner.emit(hookEvent);
|
||||
await this._extensionRunner.emit(extensionEvent);
|
||||
} else if (event.type === "turn_end") {
|
||||
const hookEvent: TurnEndEvent = {
|
||||
const extensionEvent: TurnEndEvent = {
|
||||
type: "turn_end",
|
||||
turnIndex: this._turnIndex,
|
||||
message: event.message,
|
||||
toolResults: event.toolResults,
|
||||
};
|
||||
await this._hookRunner.emit(hookEvent);
|
||||
await this._extensionRunner.emit(extensionEvent);
|
||||
this._turnIndex++;
|
||||
}
|
||||
}
|
||||
|
|
@ -517,9 +510,9 @@ export class AgentSession {
|
|||
return this._scopedModels;
|
||||
}
|
||||
|
||||
/** File-based slash commands */
|
||||
get fileCommands(): ReadonlyArray<FileSlashCommand> {
|
||||
return this._fileCommands;
|
||||
/** File-based prompt templates */
|
||||
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
||||
return this._promptTemplates;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -528,28 +521,28 @@ export class AgentSession {
|
|||
|
||||
/**
|
||||
* Send a prompt to the agent.
|
||||
* - Handles hook commands (registered via pi.registerCommand) immediately, even during streaming
|
||||
* - Expands file-based slash commands by default
|
||||
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
|
||||
* - Expands file-based prompt templates by default
|
||||
* - During streaming, queues via steer() or followUp() based on streamingBehavior option
|
||||
* - Validates model and API key before sending (when not streaming)
|
||||
* @throws Error if streaming and no streamingBehavior specified
|
||||
* @throws Error if no model selected or no API key available (when not streaming)
|
||||
*/
|
||||
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
||||
const expandCommands = options?.expandSlashCommands ?? true;
|
||||
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
||||
|
||||
// Handle hook commands first (execute immediately, even during streaming)
|
||||
// Hook commands manage their own LLM interaction via pi.sendMessage()
|
||||
if (expandCommands && text.startsWith("/")) {
|
||||
const handled = await this._tryExecuteHookCommand(text);
|
||||
// Handle extension commands first (execute immediately, even during streaming)
|
||||
// Extension commands manage their own LLM interaction via pi.sendMessage()
|
||||
if (expandPromptTemplates && text.startsWith("/")) {
|
||||
const handled = await this._tryExecuteExtensionCommand(text);
|
||||
if (handled) {
|
||||
// Hook command executed, no prompt to send
|
||||
// Extension command executed, no prompt to send
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand file-based slash commands if requested
|
||||
const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
|
||||
// Expand file-based prompt templates if requested
|
||||
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text;
|
||||
|
||||
// If streaming, queue via steer() or followUp() based on option
|
||||
if (this.isStreaming) {
|
||||
|
|
@ -593,7 +586,7 @@ export class AgentSession {
|
|||
await this._checkCompaction(lastAssistant, false);
|
||||
}
|
||||
|
||||
// Build messages array (hook message if any, then user message)
|
||||
// Build messages array (custom message if any, then user message)
|
||||
const messages: AgentMessage[] = [];
|
||||
|
||||
// Add user message
|
||||
|
|
@ -613,14 +606,14 @@ export class AgentSession {
|
|||
}
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
// Emit before_agent_start hook event
|
||||
if (this._hookRunner) {
|
||||
const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
|
||||
// Add all hook messages
|
||||
// Emit before_agent_start extension event
|
||||
if (this._extensionRunner) {
|
||||
const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images);
|
||||
// Add all custom messages from extensions
|
||||
if (result?.messages) {
|
||||
for (const msg of result.messages) {
|
||||
messages.push({
|
||||
role: "hookMessage",
|
||||
role: "custom",
|
||||
customType: msg.customType,
|
||||
content: msg.content,
|
||||
display: msg.display,
|
||||
|
|
@ -629,7 +622,7 @@ export class AgentSession {
|
|||
});
|
||||
}
|
||||
}
|
||||
// Apply hook systemPromptAppend on top of base prompt
|
||||
// Apply extension systemPromptAppend on top of base prompt
|
||||
if (result?.systemPromptAppend) {
|
||||
this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`);
|
||||
} else {
|
||||
|
|
@ -643,29 +636,29 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
/**
|
||||
* Try to execute a hook command. Returns true if command was found and executed.
|
||||
* Try to execute an extension command. Returns true if command was found and executed.
|
||||
*/
|
||||
private async _tryExecuteHookCommand(text: string): Promise<boolean> {
|
||||
if (!this._hookRunner) return false;
|
||||
private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
|
||||
if (!this._extensionRunner) return false;
|
||||
|
||||
// Parse command name and args
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||||
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
||||
|
||||
const command = this._hookRunner.getCommand(commandName);
|
||||
const command = this._extensionRunner.getCommand(commandName);
|
||||
if (!command) return false;
|
||||
|
||||
// Get command context from hook runner (includes session control methods)
|
||||
const ctx = this._hookRunner.createCommandContext();
|
||||
// Get command context from extension runner (includes session control methods)
|
||||
const ctx = this._extensionRunner.createCommandContext();
|
||||
|
||||
try {
|
||||
await command.handler(args, ctx);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Emit error via hook runner
|
||||
this._hookRunner.emitError({
|
||||
hookPath: `command:${commandName}`,
|
||||
// Emit error via extension runner
|
||||
this._extensionRunner.emitError({
|
||||
extensionPath: `command:${commandName}`,
|
||||
event: "command",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
|
@ -676,17 +669,17 @@ export class AgentSession {
|
|||
/**
|
||||
* Queue a steering message to interrupt the agent mid-run.
|
||||
* Delivered after current tool execution, skips remaining tools.
|
||||
* Expands file-based slash commands. Errors on hook commands.
|
||||
* @throws Error if text is a hook command
|
||||
* Expands file-based prompt templates. Errors on extension commands.
|
||||
* @throws Error if text is an extension command
|
||||
*/
|
||||
async steer(text: string): Promise<void> {
|
||||
// Check for hook commands (cannot be queued)
|
||||
// Check for extension commands (cannot be queued)
|
||||
if (text.startsWith("/")) {
|
||||
this._throwIfHookCommand(text);
|
||||
this._throwIfExtensionCommand(text);
|
||||
}
|
||||
|
||||
// Expand file-based slash commands
|
||||
const expandedText = expandSlashCommand(text, [...this._fileCommands]);
|
||||
// Expand file-based prompt templates
|
||||
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
|
||||
|
||||
await this._queueSteer(expandedText);
|
||||
}
|
||||
|
|
@ -694,23 +687,23 @@ export class AgentSession {
|
|||
/**
|
||||
* Queue a follow-up message to be processed after the agent finishes.
|
||||
* Delivered only when agent has no more tool calls or steering messages.
|
||||
* Expands file-based slash commands. Errors on hook commands.
|
||||
* @throws Error if text is a hook command
|
||||
* Expands file-based prompt templates. Errors on extension commands.
|
||||
* @throws Error if text is an extension command
|
||||
*/
|
||||
async followUp(text: string): Promise<void> {
|
||||
// Check for hook commands (cannot be queued)
|
||||
// Check for extension commands (cannot be queued)
|
||||
if (text.startsWith("/")) {
|
||||
this._throwIfHookCommand(text);
|
||||
this._throwIfExtensionCommand(text);
|
||||
}
|
||||
|
||||
// Expand file-based slash commands
|
||||
const expandedText = expandSlashCommand(text, [...this._fileCommands]);
|
||||
// Expand file-based prompt templates
|
||||
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
|
||||
|
||||
await this._queueFollowUp(expandedText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Queue a steering message (already expanded, no hook command check).
|
||||
* Internal: Queue a steering message (already expanded, no extension command check).
|
||||
*/
|
||||
private async _queueSteer(text: string): Promise<void> {
|
||||
this._steeringMessages.push(text);
|
||||
|
|
@ -722,7 +715,7 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
/**
|
||||
* Internal: Queue a follow-up message (already expanded, no hook command check).
|
||||
* Internal: Queue a follow-up message (already expanded, no extension command check).
|
||||
*/
|
||||
private async _queueFollowUp(text: string): Promise<void> {
|
||||
this._followUpMessages.push(text);
|
||||
|
|
@ -734,46 +727,46 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
/**
|
||||
* Throw an error if the text is a hook command.
|
||||
* Throw an error if the text is an extension command.
|
||||
*/
|
||||
private _throwIfHookCommand(text: string): void {
|
||||
if (!this._hookRunner) return;
|
||||
private _throwIfExtensionCommand(text: string): void {
|
||||
if (!this._extensionRunner) return;
|
||||
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||||
const command = this._hookRunner.getCommand(commandName);
|
||||
const command = this._extensionRunner.getCommand(commandName);
|
||||
|
||||
if (command) {
|
||||
throw new Error(
|
||||
`Hook command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
|
||||
`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a hook message to the session. Creates a CustomMessageEntry.
|
||||
* Send a custom message to the session. Creates a CustomMessageEntry.
|
||||
*
|
||||
* Handles three cases:
|
||||
* - Streaming: queues message, processed when loop pulls from queue
|
||||
* - Not streaming + triggerTurn: appends to state/session, starts new turn
|
||||
* - Not streaming + no trigger: appends to state/session, no turn
|
||||
*
|
||||
* @param message Hook message with customType, content, display, details
|
||||
* @param message Custom message with customType, content, display, details
|
||||
* @param options.triggerTurn If true and not streaming, triggers a new LLM turn
|
||||
* @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
|
||||
*/
|
||||
async sendHookMessage<T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
async sendCustomMessage<T = unknown>(
|
||||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
): Promise<void> {
|
||||
const appMessage = {
|
||||
role: "hookMessage" as const,
|
||||
role: "custom" as const,
|
||||
customType: message.customType,
|
||||
content: message.content,
|
||||
display: message.display,
|
||||
details: message.details,
|
||||
timestamp: Date.now(),
|
||||
} satisfies HookMessage<T>;
|
||||
} satisfies CustomMessage<T>;
|
||||
if (options?.deliverAs === "nextTurn") {
|
||||
this._pendingNextTurnMessages.push(appMessage);
|
||||
} else if (this.isStreaming) {
|
||||
|
|
@ -842,14 +835,14 @@ export class AgentSession {
|
|||
* Clears all messages and starts a new session.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @param options - Optional initial messages and parent session path
|
||||
* @returns true if completed, false if cancelled by hook
|
||||
* @returns true if completed, false if cancelled by extension
|
||||
*/
|
||||
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
|
||||
// Emit session_before_switch event with reason "new" (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_switch",
|
||||
reason: "new",
|
||||
})) as SessionBeforeSwitchResult | undefined;
|
||||
|
|
@ -865,11 +858,12 @@ export class AgentSession {
|
|||
this.sessionManager.newSession(options);
|
||||
this._steeringMessages = [];
|
||||
this._followUpMessages = [];
|
||||
this._pendingNextTurnMessages = [];
|
||||
this._reconnectToAgent();
|
||||
|
||||
// Emit session_switch event with reason "new" to hooks
|
||||
if (this._hookRunner) {
|
||||
await this._hookRunner.emit({
|
||||
// Emit session_switch event with reason "new" to extensions
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_switch",
|
||||
reason: "new",
|
||||
previousSessionFile,
|
||||
|
|
@ -877,7 +871,6 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
// Emit session event to custom tools
|
||||
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1096,11 +1089,11 @@ export class AgentSession {
|
|||
throw new Error("Nothing to compact (session too small)");
|
||||
}
|
||||
|
||||
let hookCompaction: CompactionResult | undefined;
|
||||
let fromHook = false;
|
||||
let extensionCompaction: CompactionResult | undefined;
|
||||
let fromExtension = false;
|
||||
|
||||
if (this._hookRunner?.hasHandlers("session_before_compact")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_compact")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_compact",
|
||||
preparation,
|
||||
branchEntries: pathEntries,
|
||||
|
|
@ -1113,8 +1106,8 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
if (result?.compaction) {
|
||||
hookCompaction = result.compaction;
|
||||
fromHook = true;
|
||||
extensionCompaction = result.compaction;
|
||||
fromExtension = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1123,12 +1116,12 @@ export class AgentSession {
|
|||
let tokensBefore: number;
|
||||
let details: unknown;
|
||||
|
||||
if (hookCompaction) {
|
||||
// Hook provided compaction content
|
||||
summary = hookCompaction.summary;
|
||||
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
||||
tokensBefore = hookCompaction.tokensBefore;
|
||||
details = hookCompaction.details;
|
||||
if (extensionCompaction) {
|
||||
// Extension provided compaction content
|
||||
summary = extensionCompaction.summary;
|
||||
firstKeptEntryId = extensionCompaction.firstKeptEntryId;
|
||||
tokensBefore = extensionCompaction.tokensBefore;
|
||||
details = extensionCompaction.details;
|
||||
} else {
|
||||
// Generate compaction result
|
||||
const result = await compact(
|
||||
|
|
@ -1148,21 +1141,21 @@ export class AgentSession {
|
|||
throw new Error("Compaction cancelled");
|
||||
}
|
||||
|
||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
|
||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
||||
const newEntries = this.sessionManager.getEntries();
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
||||
// Get the saved compaction entry for the hook
|
||||
// Get the saved compaction entry for the extension event
|
||||
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
||||
| CompactionEntry
|
||||
| undefined;
|
||||
|
||||
if (this._hookRunner && savedCompactionEntry) {
|
||||
await this._hookRunner.emit({
|
||||
if (this._extensionRunner && savedCompactionEntry) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_compact",
|
||||
compactionEntry: savedCompactionEntry,
|
||||
fromHook,
|
||||
fromExtension,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1264,11 +1257,11 @@ export class AgentSession {
|
|||
return;
|
||||
}
|
||||
|
||||
let hookCompaction: CompactionResult | undefined;
|
||||
let fromHook = false;
|
||||
let extensionCompaction: CompactionResult | undefined;
|
||||
let fromExtension = false;
|
||||
|
||||
if (this._hookRunner?.hasHandlers("session_before_compact")) {
|
||||
const hookResult = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_compact")) {
|
||||
const extensionResult = (await this._extensionRunner.emit({
|
||||
type: "session_before_compact",
|
||||
preparation,
|
||||
branchEntries: pathEntries,
|
||||
|
|
@ -1276,14 +1269,14 @@ export class AgentSession {
|
|||
signal: this._autoCompactionAbortController.signal,
|
||||
})) as SessionBeforeCompactResult | undefined;
|
||||
|
||||
if (hookResult?.cancel) {
|
||||
if (extensionResult?.cancel) {
|
||||
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hookResult?.compaction) {
|
||||
hookCompaction = hookResult.compaction;
|
||||
fromHook = true;
|
||||
if (extensionResult?.compaction) {
|
||||
extensionCompaction = extensionResult.compaction;
|
||||
fromExtension = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1292,12 +1285,12 @@ export class AgentSession {
|
|||
let tokensBefore: number;
|
||||
let details: unknown;
|
||||
|
||||
if (hookCompaction) {
|
||||
// Hook provided compaction content
|
||||
summary = hookCompaction.summary;
|
||||
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
||||
tokensBefore = hookCompaction.tokensBefore;
|
||||
details = hookCompaction.details;
|
||||
if (extensionCompaction) {
|
||||
// Extension provided compaction content
|
||||
summary = extensionCompaction.summary;
|
||||
firstKeptEntryId = extensionCompaction.firstKeptEntryId;
|
||||
tokensBefore = extensionCompaction.tokensBefore;
|
||||
details = extensionCompaction.details;
|
||||
} else {
|
||||
// Generate compaction result
|
||||
const compactResult = await compact(
|
||||
|
|
@ -1318,21 +1311,21 @@ export class AgentSession {
|
|||
return;
|
||||
}
|
||||
|
||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
|
||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
||||
const newEntries = this.sessionManager.getEntries();
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
||||
// Get the saved compaction entry for the hook
|
||||
// Get the saved compaction entry for the extension event
|
||||
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
||||
| CompactionEntry
|
||||
| undefined;
|
||||
|
||||
if (this._hookRunner && savedCompactionEntry) {
|
||||
await this._hookRunner.emit({
|
||||
if (this._extensionRunner && savedCompactionEntry) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_compact",
|
||||
compactionEntry: savedCompactionEntry,
|
||||
fromHook,
|
||||
fromExtension,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1631,14 +1624,14 @@ export class AgentSession {
|
|||
* Switch to a different session file.
|
||||
* Aborts current operation, loads messages, restores model/thinking.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @returns true if switch completed, false if cancelled by hook
|
||||
* @returns true if switch completed, false if cancelled by extension
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionManager.getSessionFile();
|
||||
|
||||
// Emit session_before_switch event (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_switch",
|
||||
reason: "resume",
|
||||
targetSessionFile: sessionPath,
|
||||
|
|
@ -1653,6 +1646,7 @@ export class AgentSession {
|
|||
await this.abort();
|
||||
this._steeringMessages = [];
|
||||
this._followUpMessages = [];
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
// Set new session
|
||||
this.sessionManager.setSessionFile(sessionPath);
|
||||
|
|
@ -1660,9 +1654,9 @@ export class AgentSession {
|
|||
// Reload messages
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
|
||||
// Emit session_switch event to hooks
|
||||
if (this._hookRunner) {
|
||||
await this._hookRunner.emit({
|
||||
// Emit session_switch event to extensions
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_switch",
|
||||
reason: "resume",
|
||||
previousSessionFile,
|
||||
|
|
@ -1670,7 +1664,6 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
// Emit session event to custom tools
|
||||
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
|
||||
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
||||
|
|
@ -1696,12 +1689,12 @@ export class AgentSession {
|
|||
|
||||
/**
|
||||
* Create a branch from a specific entry.
|
||||
* Emits before_branch/branch session events to hooks.
|
||||
* Emits before_branch/branch session events to extensions.
|
||||
*
|
||||
* @param entryId ID of the entry to branch from
|
||||
* @returns Object with:
|
||||
* - selectedText: The text of the selected user message (for editor pre-fill)
|
||||
* - cancelled: True if a hook cancelled the branch
|
||||
* - cancelled: True if an extension cancelled the branch
|
||||
*/
|
||||
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
|
|
@ -1716,8 +1709,8 @@ export class AgentSession {
|
|||
let skipConversationRestore = false;
|
||||
|
||||
// Emit session_before_branch event (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session_before_branch")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_branch")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_branch",
|
||||
entryId,
|
||||
})) as SessionBeforeBranchResult | undefined;
|
||||
|
|
@ -1728,6 +1721,9 @@ export class AgentSession {
|
|||
skipConversationRestore = result?.skipConversationRestore ?? false;
|
||||
}
|
||||
|
||||
// Clear pending messages (bound to old session state)
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
if (!selectedEntry.parentId) {
|
||||
this.sessionManager.newSession();
|
||||
} else {
|
||||
|
|
@ -1737,16 +1733,15 @@ export class AgentSession {
|
|||
// Reload messages from entries (works for both file and in-memory mode)
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
|
||||
// Emit session_branch event to hooks (after branch completes)
|
||||
if (this._hookRunner) {
|
||||
await this._hookRunner.emit({
|
||||
// Emit session_branch event to extensions (after branch completes)
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_branch",
|
||||
previousSessionFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit session event to custom tools (with reason "branch")
|
||||
await this.emitCustomToolSessionEvent("branch", previousSessionFile);
|
||||
|
||||
if (!skipConversationRestore) {
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
|
@ -1807,12 +1802,12 @@ export class AgentSession {
|
|||
|
||||
// Set up abort controller for summarization
|
||||
this._branchSummaryAbortController = new AbortController();
|
||||
let hookSummary: { summary: string; details?: unknown } | undefined;
|
||||
let fromHook = false;
|
||||
let extensionSummary: { summary: string; details?: unknown } | undefined;
|
||||
let fromExtension = false;
|
||||
|
||||
// Emit session_before_tree event
|
||||
if (this._hookRunner?.hasHandlers("session_before_tree")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
if (this._extensionRunner?.hasHandlers("session_before_tree")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_tree",
|
||||
preparation,
|
||||
signal: this._branchSummaryAbortController.signal,
|
||||
|
|
@ -1823,15 +1818,15 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
if (result?.summary && options.summarize) {
|
||||
hookSummary = result.summary;
|
||||
fromHook = true;
|
||||
extensionSummary = result.summary;
|
||||
fromExtension = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Run default summarizer if needed
|
||||
let summaryText: string | undefined;
|
||||
let summaryDetails: unknown;
|
||||
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
||||
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
||||
const model = this.model!;
|
||||
const apiKey = await this._modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
|
|
@ -1857,9 +1852,9 @@ export class AgentSession {
|
|||
readFiles: result.readFiles || [],
|
||||
modifiedFiles: result.modifiedFiles || [],
|
||||
};
|
||||
} else if (hookSummary) {
|
||||
summaryText = hookSummary.summary;
|
||||
summaryDetails = hookSummary.details;
|
||||
} else if (extensionSummary) {
|
||||
summaryText = extensionSummary.summary;
|
||||
summaryDetails = extensionSummary.details;
|
||||
}
|
||||
|
||||
// Determine the new leaf position based on target type
|
||||
|
|
@ -1890,7 +1885,7 @@ export class AgentSession {
|
|||
let summaryEntry: BranchSummaryEntry | undefined;
|
||||
if (summaryText) {
|
||||
// Create summary at target position (can be null for root)
|
||||
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook);
|
||||
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
||||
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
||||
} else if (newLeafId === null) {
|
||||
// No summary, navigating to root - reset leaf
|
||||
|
|
@ -1905,18 +1900,17 @@ export class AgentSession {
|
|||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
||||
// Emit session_tree event
|
||||
if (this._hookRunner) {
|
||||
await this._hookRunner.emit({
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_tree",
|
||||
newLeafId: this.sessionManager.getLeafId(),
|
||||
oldLeafId,
|
||||
summaryEntry,
|
||||
fromHook: summaryText ? fromHook : undefined,
|
||||
fromExtension: summaryText ? fromExtension : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit to custom tools
|
||||
await this.emitCustomToolSessionEvent("tree", this.sessionFile);
|
||||
|
||||
this._branchSummaryAbortController = undefined;
|
||||
return { editorText, cancelled: false, summaryEntry };
|
||||
|
|
@ -2044,60 +2038,20 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hook System
|
||||
// Extension System
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if hooks have handlers for a specific event type.
|
||||
* Check if extensions have handlers for a specific event type.
|
||||
*/
|
||||
hasHookHandlers(eventType: string): boolean {
|
||||
return this._hookRunner?.hasHandlers(eventType) ?? false;
|
||||
hasExtensionHandlers(eventType: string): boolean {
|
||||
return this._extensionRunner?.hasHandlers(eventType) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hook runner (for setting UI context and error handlers).
|
||||
* Get the extension runner (for setting UI context and error handlers).
|
||||
*/
|
||||
get hookRunner(): HookRunner | undefined {
|
||||
return this._hookRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom tools (for setting UI context in modes).
|
||||
*/
|
||||
get customTools(): LoadedCustomTool[] {
|
||||
return this._customTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit session event to all custom tools.
|
||||
* Called on session switch, branch, tree navigation, and shutdown.
|
||||
*/
|
||||
async emitCustomToolSessionEvent(
|
||||
reason: CustomToolSessionEvent["reason"],
|
||||
previousSessionFile?: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!this._customTools) return;
|
||||
|
||||
const event: CustomToolSessionEvent = { reason, previousSessionFile };
|
||||
const ctx: CustomToolContext = {
|
||||
sessionManager: this.sessionManager,
|
||||
modelRegistry: this._modelRegistry,
|
||||
model: this.agent.state.model,
|
||||
isIdle: () => !this.isStreaming,
|
||||
hasPendingMessages: () => this.pendingMessageCount > 0,
|
||||
abort: () => {
|
||||
this.abort();
|
||||
},
|
||||
};
|
||||
|
||||
for (const { tool } of this._customTools) {
|
||||
if (tool.onSession) {
|
||||
try {
|
||||
await tool.onSession(event, ctx);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors during session events
|
||||
}
|
||||
}
|
||||
}
|
||||
get extensionRunner(): ExtensionRunner | undefined {
|
||||
return this._extensionRunner;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createHookMessage,
|
||||
createCustomMessage,
|
||||
} from "../messages.js";
|
||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
|
||||
import { estimateTokens } from "./compaction.js";
|
||||
|
|
@ -147,7 +147,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|||
return entry.message;
|
||||
|
||||
case "custom_message":
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
|
||||
case "branch_summary":
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
|
@ -184,7 +184,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
|
|||
|
||||
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
|
||||
// This ensures we capture cumulative file tracking from nested branch summaries
|
||||
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
|
||||
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
|
||||
const details = entry.details as BranchSummaryDetails;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||
import { complete, completeSimple } from "@mariozechner/pi-ai";
|
||||
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
|
||||
import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages.js";
|
||||
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
|
|
@ -44,6 +44,7 @@ function extractFileOperations(
|
|||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (!prevCompaction.fromHook && prevCompaction.details) {
|
||||
// fromHook field kept for session file compatibility
|
||||
const details = prevCompaction.details as CompactionDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
|
|
@ -75,7 +76,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|||
return entry.message;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
|
@ -88,7 +89,7 @@ export interface CompactionResult<T = unknown> {
|
|||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +195,7 @@ export function estimateTokens(message: AgentMessage): number {
|
|||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "hookMessage":
|
||||
case "custom":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
|
|
@ -240,7 +241,7 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
|
|||
const role = entry.message.role;
|
||||
switch (role) {
|
||||
case "bashExecution":
|
||||
case "hookMessage":
|
||||
case "custom":
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
|
|
@ -477,7 +478,7 @@ export async function generateSummary(
|
|||
}
|
||||
|
||||
// Serialize conversation to text so model doesn't try to continue it
|
||||
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
|
||||
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
|
|
@ -515,7 +516,7 @@ export async function generateSummary(
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for hooks)
|
||||
// Compaction Preparation (for extensions)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionPreparation {
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/**
|
||||
* Custom tools module.
|
||||
*/
|
||||
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
|
||||
export type {
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
CustomTool,
|
||||
CustomToolAPI,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolResult,
|
||||
CustomToolSessionEvent,
|
||||
CustomToolsLoadResult,
|
||||
CustomToolUIContext,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
RenderResultOptions,
|
||||
} from "./types.js";
|
||||
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
/**
|
||||
* Custom tool loader - loads TypeScript tool modules using jiti.
|
||||
*
|
||||
* For Bun compiled binaries, custom tools that import from @mariozechner/* packages
|
||||
* are not supported because Bun's plugin system doesn't intercept imports from
|
||||
* external files loaded at runtime. Users should use the npm-installed version
|
||||
* for custom tools that depend on pi packages.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions } from "../exec.js";
|
||||
import { execCommand } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
|
||||
|
||||
// Create require function to resolve module paths at runtime
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Lazily computed aliases - resolved at runtime to handle global installs
|
||||
let _aliases: Record<string, string> | null = null;
|
||||
function getAliases(): Record<string, string> {
|
||||
if (_aliases) return _aliases;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageIndex = path.resolve(__dirname, "../..", "index.js");
|
||||
|
||||
// For typebox, we need the package root directory (not the entry file)
|
||||
// because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler"
|
||||
// get the alias prepended. If we alias to the entry file (.../build/cjs/index.js),
|
||||
// then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid).
|
||||
// By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly.
|
||||
const typeboxEntry = require.resolve("@sinclair/typebox");
|
||||
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
|
||||
|
||||
_aliases = {
|
||||
"@mariozechner/pi-coding-agent": packageIndex,
|
||||
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
||||
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
||||
"@sinclair/typebox": typeboxRoot,
|
||||
};
|
||||
return _aliases;
|
||||
}
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
|
||||
function normalizeUnicodeSpaces(str: string): string {
|
||||
return str.replace(UNICODE_SPACES, " ");
|
||||
}
|
||||
|
||||
function expandPath(p: string): string {
|
||||
const normalized = normalizeUnicodeSpaces(p);
|
||||
if (normalized.startsWith("~/")) {
|
||||
return path.join(os.homedir(), normalized.slice(2));
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
return path.join(os.homedir(), normalized.slice(1));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tool path.
|
||||
* - Absolute paths used as-is
|
||||
* - Paths starting with ~ expanded to home directory
|
||||
* - Relative paths resolved from cwd
|
||||
*/
|
||||
function resolveToolPath(toolPath: string, cwd: string): string {
|
||||
const expanded = expandPath(toolPath);
|
||||
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Relative paths resolved from cwd
|
||||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op UI context for headless modes.
|
||||
*/
|
||||
function createNoOpUIContext(): HookUIContext {
|
||||
return {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a tool in Bun binary mode.
|
||||
*
|
||||
* Since Bun plugins don't work for dynamically loaded external files,
|
||||
* custom tools that import from @mariozechner/* packages won't work.
|
||||
* Tools that only use standard npm packages (installed in the tool's directory)
|
||||
* may still work.
|
||||
*/
|
||||
async function loadToolWithBun(
|
||||
resolvedPath: string,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
try {
|
||||
// Try to import directly - will work for tools without @mariozechner/* imports
|
||||
const module = await import(resolvedPath);
|
||||
const factory = (module.default ?? module) as CustomToolFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { tools: null, error: "Tool must export a default function" };
|
||||
}
|
||||
|
||||
const toolResult = await factory(sharedApi);
|
||||
const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
|
||||
|
||||
const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({
|
||||
path: resolvedPath,
|
||||
resolvedPath,
|
||||
tool,
|
||||
}));
|
||||
|
||||
return { tools: loadedTools, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Check if it's a module resolution error for our packages
|
||||
if (message.includes("Cannot find module") && message.includes("@mariozechner/")) {
|
||||
return {
|
||||
tools: null,
|
||||
error:
|
||||
`${message}\n` +
|
||||
"Note: Custom tools importing from @mariozechner/* packages are not supported in the standalone binary.\n" +
|
||||
"Please install pi via npm: npm install -g @mariozechner/pi-coding-agent",
|
||||
};
|
||||
}
|
||||
|
||||
return { tools: null, error: `Failed to load tool: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single tool module using jiti (or Bun.build for compiled binaries).
|
||||
*/
|
||||
async function loadTool(
|
||||
toolPath: string,
|
||||
cwd: string,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
const resolvedPath = resolveToolPath(toolPath, cwd);
|
||||
|
||||
// Use Bun.build for compiled binaries since jiti can't resolve bundled modules
|
||||
if (isBunBinary) {
|
||||
return loadToolWithBun(resolvedPath, sharedApi);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create jiti instance for TypeScript/ESM loading
|
||||
// Use aliases to resolve package imports since tools are loaded from user directories
|
||||
// (e.g. ~/.pi/agent/tools) but import from packages installed with pi-coding-agent
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: getAliases(),
|
||||
});
|
||||
|
||||
// Import the module
|
||||
const module = await jiti.import(resolvedPath, { default: true });
|
||||
const factory = module as CustomToolFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { tools: null, error: "Tool must export a default function" };
|
||||
}
|
||||
|
||||
// Call factory with shared API
|
||||
const result = await factory(sharedApi);
|
||||
|
||||
// Handle single tool or array of tools
|
||||
const toolsArray = Array.isArray(result) ? result : [result];
|
||||
|
||||
const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({
|
||||
path: toolPath,
|
||||
resolvedPath,
|
||||
tool,
|
||||
}));
|
||||
|
||||
return { tools: loadedTools, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { tools: null, error: `Failed to load tool: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all tools from configuration.
|
||||
* @param paths - Array of tool file paths
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
||||
*/
|
||||
export async function loadCustomTools(
|
||||
paths: string[],
|
||||
cwd: string,
|
||||
builtInToolNames: string[],
|
||||
eventBus?: EventBus,
|
||||
): Promise<CustomToolsLoadResult> {
|
||||
const tools: LoadedCustomTool[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const seenNames = new Set<string>(builtInToolNames);
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
|
||||
// Shared API object - all tools get the same instance
|
||||
const sharedApi: CustomToolAPI = {
|
||||
cwd,
|
||||
exec: (command: string, args: string[], options?: ExecOptions) =>
|
||||
execCommand(command, args, options?.cwd ?? cwd, options),
|
||||
ui: createNoOpUIContext(),
|
||||
hasUI: false,
|
||||
events: resolvedEventBus,
|
||||
sendMessage: () => {},
|
||||
};
|
||||
|
||||
for (const toolPath of paths) {
|
||||
const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: toolPath, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadedTools) {
|
||||
for (const loadedTool of loadedTools) {
|
||||
// Check for name conflicts
|
||||
if (seenNames.has(loadedTool.tool.name)) {
|
||||
errors.push({
|
||||
path: toolPath,
|
||||
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
seenNames.add(loadedTool.tool.name);
|
||||
tools.push(loadedTool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
errors,
|
||||
setUIContext(uiContext, hasUI) {
|
||||
sharedApi.ui = uiContext;
|
||||
sharedApi.hasUI = hasUI;
|
||||
},
|
||||
setSendMessageHandler(handler) {
|
||||
sharedApi.sendMessage = handler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tool files from a directory.
|
||||
* Only loads index.ts files from subdirectories (e.g., tools/mytool/index.ts).
|
||||
*/
|
||||
function discoverToolsInDir(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tools: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
// Check for index.ts in subdirectory
|
||||
const indexPath = path.join(dir, entry.name, "index.ts");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
tools.push(indexPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load tools from standard locations:
|
||||
* 1. agentDir/tools/*.ts (global)
|
||||
* 2. cwd/.pi/tools/*.ts (project-local)
|
||||
*
|
||||
* Plus any explicitly configured paths from settings or CLI.
|
||||
*
|
||||
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
|
||||
* @param cwd - Current working directory
|
||||
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
||||
* @param agentDir - Agent config directory. Default: from getAgentDir()
|
||||
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
|
||||
*/
|
||||
export async function discoverAndLoadCustomTools(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
builtInToolNames: string[],
|
||||
agentDir: string = getAgentDir(),
|
||||
eventBus?: EventBus,
|
||||
): Promise<CustomToolsLoadResult> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Helper to add paths without duplicates
|
||||
const addPaths = (paths: string[]) => {
|
||||
for (const p of paths) {
|
||||
const resolved = path.resolve(p);
|
||||
if (!seen.has(resolved)) {
|
||||
seen.add(resolved);
|
||||
allPaths.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Global tools: agentDir/tools/
|
||||
const globalToolsDir = path.join(agentDir, "tools");
|
||||
addPaths(discoverToolsInDir(globalToolsDir));
|
||||
|
||||
// 2. Project-local tools: cwd/.pi/tools/
|
||||
const localToolsDir = path.join(cwd, ".pi", "tools");
|
||||
addPaths(discoverToolsInDir(localToolsDir));
|
||||
|
||||
// 3. Explicitly configured paths (can override/add)
|
||||
addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd)));
|
||||
|
||||
return loadCustomTools(allPaths, cwd, builtInToolNames, eventBus);
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
/**
|
||||
* Custom tool types.
|
||||
*
|
||||
* Custom tools are TypeScript modules that define additional tools for the agent.
|
||||
* They can provide custom rendering for tool calls and results in the TUI.
|
||||
*/
|
||||
|
||||
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { ReadonlySessionManager } from "../session-manager.js";
|
||||
|
||||
/** Alias for clarity */
|
||||
export type CustomToolUIContext = HookUIContext;
|
||||
|
||||
/** Re-export for custom tools to use in execute signature */
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
|
||||
/** API passed to custom tool factory (stable across session changes) */
|
||||
export interface CustomToolAPI {
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Execute a command */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
/** UI methods for user interaction (select, confirm, input, notify, custom) */
|
||||
ui: CustomToolUIContext;
|
||||
/** Whether UI is available (false in print/RPC mode) */
|
||||
hasUI: boolean;
|
||||
/** Shared event bus for tool/hook communication */
|
||||
events: EventBus;
|
||||
/**
|
||||
* Send a message to the agent session.
|
||||
*
|
||||
* Delivery behavior depends on agent state and options:
|
||||
* - Streaming + "steer" (default): Interrupt mid-run, delivered after current tool.
|
||||
* - Streaming + "followUp": Wait until agent finishes before delivery.
|
||||
* - Idle + triggerTurn: Triggers a new LLM turn immediately.
|
||||
* - Idle + "nextTurn": Queue to be included with the next user message as context.
|
||||
* - Idle + neither: Append to session history as standalone entry.
|
||||
*
|
||||
* @param message - The message to send
|
||||
* @param message.customType - Identifier for your tool
|
||||
* @param message.content - Message content (string or TextContent/ImageContent array)
|
||||
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
|
||||
* @param message.details - Optional tool-specific metadata (not sent to LLM)
|
||||
* @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn
|
||||
* @param options.deliverAs - Delivery mode: "steer", "followUp", or "nextTurn"
|
||||
*/
|
||||
sendMessage<T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to tool execute and onSession callbacks.
|
||||
* Provides access to session state and model information.
|
||||
*/
|
||||
export interface CustomToolContext {
|
||||
/** Session manager (read-only) */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry - use for API key resolution and model retrieval */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** Whether there are queued messages waiting to be processed */
|
||||
hasPendingMessages(): boolean;
|
||||
/** Abort the current agent operation (fire-and-forget, does not wait) */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/** Session event passed to onSession callback */
|
||||
export interface CustomToolSessionEvent {
|
||||
/** Reason for the session event */
|
||||
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
|
||||
/** Previous session file path, or undefined for "start" and "shutdown" */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Rendering options passed to renderResult */
|
||||
export interface RenderResultOptions {
|
||||
/** Whether the result view is expanded */
|
||||
expanded: boolean;
|
||||
/** Whether this is a partial/streaming result */
|
||||
isPartial: boolean;
|
||||
}
|
||||
|
||||
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
|
||||
|
||||
/**
|
||||
* Custom tool definition.
|
||||
*
|
||||
* Custom tools are standalone - they don't extend AgentTool directly.
|
||||
* When loaded, they are wrapped in an AgentTool for the agent to use.
|
||||
*
|
||||
* The execute callback receives a ToolContext with access to session state,
|
||||
* model registry, and current model.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const factory: CustomToolFactory = (pi) => ({
|
||||
* name: "my_tool",
|
||||
* label: "My Tool",
|
||||
* description: "Does something useful",
|
||||
* parameters: Type.Object({ input: Type.String() }),
|
||||
*
|
||||
* async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
* // Access session state via ctx.sessionManager
|
||||
* // Access model registry via ctx.modelRegistry
|
||||
* // Current model via ctx.model
|
||||
* return { content: [{ type: "text", text: "Done" }] };
|
||||
* },
|
||||
*
|
||||
* onSession(event, ctx) {
|
||||
* if (event.reason === "shutdown") {
|
||||
* // Cleanup
|
||||
* }
|
||||
* // Reconstruct state from ctx.sessionManager.getEntries()
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
|
||||
/** Tool name (used in LLM tool calls) */
|
||||
name: string;
|
||||
/** Human-readable label for UI */
|
||||
label: string;
|
||||
/** Description for LLM */
|
||||
description: string;
|
||||
/** Parameter schema (TypeBox) */
|
||||
parameters: TParams;
|
||||
|
||||
/**
|
||||
* Execute the tool.
|
||||
* @param toolCallId - Unique ID for this tool call
|
||||
* @param params - Parsed parameters matching the schema
|
||||
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
|
||||
* @param ctx - Context with session manager, model registry, and current model
|
||||
* @param signal - Optional abort signal for cancellation
|
||||
*/
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParams>,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
ctx: CustomToolContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
|
||||
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
|
||||
/** Custom rendering for tool call display - return a Component */
|
||||
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
|
||||
|
||||
/** Custom rendering for tool result display - return a Component */
|
||||
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
|
||||
}
|
||||
|
||||
/** Factory function that creates a custom tool or array of tools */
|
||||
export type CustomToolFactory = (
|
||||
pi: CustomToolAPI,
|
||||
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
|
||||
|
||||
/** Loaded custom tool with metadata and wrapped AgentTool */
|
||||
export interface LoadedCustomTool {
|
||||
/** Original path (as specified) */
|
||||
path: string;
|
||||
/** Resolved absolute path */
|
||||
resolvedPath: string;
|
||||
/** The original custom tool instance */
|
||||
tool: CustomTool;
|
||||
}
|
||||
|
||||
/** Send message handler type for tool sendMessage */
|
||||
export type ToolSendMessageHandler = <T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
) => void;
|
||||
|
||||
/** Result from loading custom tools */
|
||||
export interface CustomToolsLoadResult {
|
||||
tools: LoadedCustomTool[];
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
/** Update the UI context for all loaded tools. Call when mode initializes. */
|
||||
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
|
||||
/** Set the sendMessage handler for all loaded tools. Call when session initializes. */
|
||||
setSendMessageHandler(handler: ToolSendMessageHandler): void;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* Wraps CustomTool instances into AgentTool for use with the agent.
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a CustomTool into an AgentTool.
|
||||
* The wrapper injects the ToolContext into execute calls.
|
||||
*/
|
||||
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
|
||||
return {
|
||||
name: tool.name,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
execute: (toolCallId, params, signal, onUpdate) =>
|
||||
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all loaded custom tools into AgentTools.
|
||||
*/
|
||||
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
|
||||
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
|
||||
}
|
||||
|
|
@ -16,9 +16,9 @@ export function createEventBus(): EventBusController {
|
|||
emitter.emit(channel, data);
|
||||
},
|
||||
on: (channel, handler) => {
|
||||
const safeHandler = (data: unknown) => {
|
||||
const safeHandler = async (data: unknown) => {
|
||||
try {
|
||||
handler(data);
|
||||
await handler(data);
|
||||
} catch (err) {
|
||||
console.error(`Event handler error (${channel}):`, err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Shared command execution utilities for hooks and custom tools.
|
||||
* Shared command execution utilities for extensions and custom tools.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
|
|
|||
100
packages/coding-agent/src/core/extensions/index.ts
Normal file
100
packages/coding-agent/src/core/extensions/index.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Extension system for lifecycle events and custom tools.
|
||||
*/
|
||||
|
||||
export { discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader.js";
|
||||
export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner.js";
|
||||
export { ExtensionRunner } from "./runner.js";
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
// Re-exports
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
AppendEntryHandler,
|
||||
BashToolResultEvent,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
// Events - Agent
|
||||
ContextEvent,
|
||||
// Event Results
|
||||
ContextEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
// API
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
// Context
|
||||
ExtensionContext,
|
||||
// Errors
|
||||
ExtensionError,
|
||||
ExtensionEvent,
|
||||
ExtensionFactory,
|
||||
ExtensionFlag,
|
||||
ExtensionHandler,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
FindToolResultEvent,
|
||||
GetActiveToolsHandler,
|
||||
GetAllToolsHandler,
|
||||
GrepToolResultEvent,
|
||||
LoadExtensionsResult,
|
||||
// Loaded Extension
|
||||
LoadedExtension,
|
||||
LsToolResultEvent,
|
||||
// Message Rendering
|
||||
MessageRenderer,
|
||||
MessageRenderOptions,
|
||||
ReadToolResultEvent,
|
||||
// Commands
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
SendMessageHandler,
|
||||
SessionBeforeBranchEvent,
|
||||
SessionBeforeBranchResult,
|
||||
SessionBeforeCompactEvent,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionBeforeSwitchResult,
|
||||
SessionBeforeTreeEvent,
|
||||
SessionBeforeTreeResult,
|
||||
SessionBranchEvent,
|
||||
SessionCompactEvent,
|
||||
SessionEvent,
|
||||
SessionShutdownEvent,
|
||||
// Events - Session
|
||||
SessionStartEvent,
|
||||
SessionSwitchEvent,
|
||||
SessionTreeEvent,
|
||||
SetActiveToolsHandler,
|
||||
// Events - Tool
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
// Tools
|
||||
ToolDefinition,
|
||||
ToolRenderResultOptions,
|
||||
ToolResultEvent,
|
||||
ToolResultEventResult,
|
||||
TreePreparation,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./types.js";
|
||||
// Type guards
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./types.js";
|
||||
export {
|
||||
wrapRegisteredTool,
|
||||
wrapRegisteredTools,
|
||||
wrapToolsWithExtensions,
|
||||
wrapToolWithExtensions,
|
||||
} from "./wrapper.js";
|
||||
579
packages/coding-agent/src/core/extensions/loader.ts
Normal file
579
packages/coding-agent/src/core/extensions/loader.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
/**
|
||||
* Extension loader - loads TypeScript extension modules using jiti.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { KeyId } from "@mariozechner/pi-tui";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions } from "../exec.js";
|
||||
import { execCommand } from "../exec.js";
|
||||
import type {
|
||||
AppendEntryHandler,
|
||||
ExtensionAPI,
|
||||
ExtensionFactory,
|
||||
ExtensionFlag,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
GetActiveToolsHandler,
|
||||
GetAllToolsHandler,
|
||||
LoadExtensionsResult,
|
||||
LoadedExtension,
|
||||
MessageRenderer,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
SendMessageHandler,
|
||||
SetActiveToolsHandler,
|
||||
ToolDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let _aliases: Record<string, string> | null = null;
|
||||
function getAliases(): Record<string, string> {
|
||||
if (_aliases) return _aliases;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageIndex = path.resolve(__dirname, "../..", "index.js");
|
||||
|
||||
const typeboxEntry = require.resolve("@sinclair/typebox");
|
||||
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
|
||||
|
||||
_aliases = {
|
||||
"@mariozechner/pi-coding-agent": packageIndex,
|
||||
"@mariozechner/pi-coding-agent/extensions": path.resolve(__dirname, "index.js"),
|
||||
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
||||
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
||||
"@sinclair/typebox": typeboxRoot,
|
||||
};
|
||||
return _aliases;
|
||||
}
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
|
||||
function normalizeUnicodeSpaces(str: string): string {
|
||||
return str.replace(UNICODE_SPACES, " ");
|
||||
}
|
||||
|
||||
function expandPath(p: string): string {
|
||||
const normalized = normalizeUnicodeSpaces(p);
|
||||
if (normalized.startsWith("~/")) {
|
||||
return path.join(os.homedir(), normalized.slice(2));
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
return path.join(os.homedir(), normalized.slice(1));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePath(extPath: string, cwd: string): string {
|
||||
const expanded = expandPath(extPath);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
function createNoOpUIContext(): ExtensionUIContext {
|
||||
return {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
function createExtensionAPI(
|
||||
handlers: Map<string, HandlerFn[]>,
|
||||
tools: Map<string, RegisteredTool>,
|
||||
cwd: string,
|
||||
extensionPath: string,
|
||||
eventBus: EventBus,
|
||||
_sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||
): {
|
||||
api: ExtensionAPI;
|
||||
messageRenderers: Map<string, MessageRenderer>;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
flags: Map<string, ExtensionFlag>;
|
||||
flagValues: Map<string, boolean | string>;
|
||||
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||
setFlagValue: (name: string, value: boolean | string) => void;
|
||||
} {
|
||||
let sendMessageHandler: SendMessageHandler = () => {};
|
||||
let appendEntryHandler: AppendEntryHandler = () => {};
|
||||
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
|
||||
let getAllToolsHandler: GetAllToolsHandler = () => [];
|
||||
let setActiveToolsHandler: SetActiveToolsHandler = () => {};
|
||||
|
||||
const messageRenderers = new Map<string, MessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const flags = new Map<string, ExtensionFlag>();
|
||||
const flagValues = new Map<string, boolean | string>();
|
||||
const shortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||
|
||||
const api = {
|
||||
on(event: string, handler: HandlerFn): void {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
|
||||
registerTool(tool: ToolDefinition): void {
|
||||
tools.set(tool.name, {
|
||||
definition: tool,
|
||||
extensionPath,
|
||||
});
|
||||
},
|
||||
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
|
||||
registerShortcut(
|
||||
shortcut: KeyId,
|
||||
options: {
|
||||
description?: string;
|
||||
handler: (ctx: import("./types.js").ExtensionContext) => Promise<void> | void;
|
||||
},
|
||||
): void {
|
||||
shortcuts.set(shortcut, { shortcut, extensionPath, ...options });
|
||||
},
|
||||
|
||||
registerFlag(
|
||||
name: string,
|
||||
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
|
||||
): void {
|
||||
flags.set(name, { name, extensionPath, ...options });
|
||||
if (options.default !== undefined) {
|
||||
flagValues.set(name, options.default);
|
||||
}
|
||||
},
|
||||
|
||||
getFlag(name: string): boolean | string | undefined {
|
||||
return flagValues.get(name);
|
||||
},
|
||||
|
||||
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {
|
||||
messageRenderers.set(customType, renderer as MessageRenderer);
|
||||
},
|
||||
|
||||
sendMessage(message, options): void {
|
||||
sendMessageHandler(message, options);
|
||||
},
|
||||
|
||||
appendEntry(customType: string, data?: unknown): void {
|
||||
appendEntryHandler(customType, data);
|
||||
},
|
||||
|
||||
exec(command: string, args: string[], options?: ExecOptions) {
|
||||
return execCommand(command, args, options?.cwd ?? cwd, options);
|
||||
},
|
||||
|
||||
getActiveTools(): string[] {
|
||||
return getActiveToolsHandler();
|
||||
},
|
||||
|
||||
getAllTools(): string[] {
|
||||
return getAllToolsHandler();
|
||||
},
|
||||
|
||||
setActiveTools(toolNames: string[]): void {
|
||||
setActiveToolsHandler(toolNames);
|
||||
},
|
||||
|
||||
events: eventBus,
|
||||
} as ExtensionAPI;
|
||||
|
||||
return {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => {
|
||||
getActiveToolsHandler = handler;
|
||||
},
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => {
|
||||
getAllToolsHandler = handler;
|
||||
},
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => {
|
||||
setActiveToolsHandler = handler;
|
||||
},
|
||||
setFlagValue: (name: string, value: boolean | string) => {
|
||||
flagValues.set(name, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadExtensionWithBun(
|
||||
resolvedPath: string,
|
||||
cwd: string,
|
||||
extensionPath: string,
|
||||
eventBus: EventBus,
|
||||
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||
): Promise<{ extension: LoadedExtension | null; error: string | null }> {
|
||||
try {
|
||||
const module = await import(resolvedPath);
|
||||
const factory = (module.default ?? module) as ExtensionFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { extension: null, error: "Extension must export a default function" };
|
||||
}
|
||||
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
} = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI);
|
||||
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
extension: {
|
||||
path: extensionPath,
|
||||
resolvedPath,
|
||||
handlers,
|
||||
tools,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (message.includes("Cannot find module") && message.includes("@mariozechner/")) {
|
||||
return {
|
||||
extension: null,
|
||||
error:
|
||||
`${message}\n` +
|
||||
"Note: Extensions importing from @mariozechner/* packages are not supported in the standalone binary.\n" +
|
||||
"Please install pi via npm: npm install -g @mariozechner/pi-coding-agent",
|
||||
};
|
||||
}
|
||||
|
||||
return { extension: null, error: `Failed to load extension: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExtension(
|
||||
extensionPath: string,
|
||||
cwd: string,
|
||||
eventBus: EventBus,
|
||||
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||
): Promise<{ extension: LoadedExtension | null; error: string | null }> {
|
||||
const resolvedPath = resolvePath(extensionPath, cwd);
|
||||
|
||||
if (isBunBinary) {
|
||||
return loadExtensionWithBun(resolvedPath, cwd, extensionPath, eventBus, sharedUI);
|
||||
}
|
||||
|
||||
try {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: getAliases(),
|
||||
});
|
||||
|
||||
const module = await jiti.import(resolvedPath, { default: true });
|
||||
const factory = module as ExtensionFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { extension: null, error: "Extension must export a default function" };
|
||||
}
|
||||
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
} = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI);
|
||||
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
extension: {
|
||||
path: extensionPath,
|
||||
resolvedPath,
|
||||
handlers,
|
||||
tools,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { extension: null, error: `Failed to load extension: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LoadedExtension from an inline factory function.
|
||||
*/
|
||||
export function loadExtensionFromFactory(
|
||||
factory: ExtensionFactory,
|
||||
cwd: string,
|
||||
eventBus: EventBus,
|
||||
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||
name = "<inline>",
|
||||
): LoadedExtension {
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
} = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI);
|
||||
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
path: name,
|
||||
resolvedPath: name,
|
||||
handlers,
|
||||
tools,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load extensions from paths.
|
||||
*/
|
||||
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||
const extensions: LoadedExtension[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
const sharedUI = { ui: createNoOpUIContext(), hasUI: false };
|
||||
|
||||
for (const extPath of paths) {
|
||||
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, sharedUI);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: extPath, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extensions,
|
||||
errors,
|
||||
setUIContext(uiContext, hasUI) {
|
||||
sharedUI.ui = uiContext;
|
||||
sharedUI.hasUI = hasUI;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface PiManifest {
|
||||
extensions?: string[];
|
||||
themes?: string[];
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
function readPiManifest(packageJsonPath: string): PiManifest | null {
|
||||
try {
|
||||
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(content);
|
||||
if (pkg.pi && typeof pkg.pi === "object") {
|
||||
return pkg.pi as PiManifest;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionFile(name: string): boolean {
|
||||
return name.endsWith(".ts") || name.endsWith(".js");
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover extensions in a directory.
|
||||
*
|
||||
* Discovery rules:
|
||||
* 1. Direct files: `extensions/*.ts` or `*.js` → load
|
||||
* 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load
|
||||
* 3. Subdirectory with package.json: `extensions/* /package.json` with "pi" field → load what it declares
|
||||
*
|
||||
* No recursion beyond one level. Complex packages must use package.json manifest.
|
||||
*/
|
||||
function discoverExtensionsInDir(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const discovered: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
|
||||
// 1. Direct files: *.ts or *.js
|
||||
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
|
||||
discovered.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2 & 3. Subdirectories
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
// Check for package.json with "pi" field first
|
||||
const packageJsonPath = path.join(entryPath, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const manifest = readPiManifest(packageJsonPath);
|
||||
if (manifest?.extensions) {
|
||||
// Load paths declared in manifest (relative to package.json dir)
|
||||
for (const extPath of manifest.extensions) {
|
||||
const resolvedExtPath = path.resolve(entryPath, extPath);
|
||||
if (fs.existsSync(resolvedExtPath)) {
|
||||
discovered.push(resolvedExtPath);
|
||||
}
|
||||
}
|
||||
continue; // package.json found, don't check for index
|
||||
}
|
||||
}
|
||||
|
||||
// Check for index.ts or index.js
|
||||
const indexTs = path.join(entryPath, "index.ts");
|
||||
const indexJs = path.join(entryPath, "index.js");
|
||||
if (fs.existsSync(indexTs)) {
|
||||
discovered.push(indexTs);
|
||||
} else if (fs.existsSync(indexJs)) {
|
||||
discovered.push(indexJs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load extensions from standard locations.
|
||||
*/
|
||||
export async function discoverAndLoadExtensions(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
agentDir: string = getAgentDir(),
|
||||
eventBus?: EventBus,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addPaths = (paths: string[]) => {
|
||||
for (const p of paths) {
|
||||
const resolved = path.resolve(p);
|
||||
if (!seen.has(resolved)) {
|
||||
seen.add(resolved);
|
||||
allPaths.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Global extensions: agentDir/extensions/
|
||||
const globalExtDir = path.join(agentDir, "extensions");
|
||||
addPaths(discoverExtensionsInDir(globalExtDir));
|
||||
|
||||
// 2. Project-local extensions: cwd/.pi/extensions/
|
||||
const localExtDir = path.join(cwd, ".pi", "extensions");
|
||||
addPaths(discoverExtensionsInDir(localExtDir));
|
||||
|
||||
// 3. Explicitly configured paths
|
||||
addPaths(configuredPaths.map((p) => resolvePath(p, cwd)));
|
||||
|
||||
return loadExtensions(allPaths, cwd, eventBus);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Hook runner - executes hooks and manages their lifecycle.
|
||||
* Extension runner - executes extensions and manages their lifecycle.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
|
@ -10,49 +10,53 @@ import type { ModelRegistry } from "../model-registry.js";
|
|||
import type { SessionManager } from "../session-manager.js";
|
||||
import type {
|
||||
AppendEntryHandler,
|
||||
BranchHandler,
|
||||
HookFlag,
|
||||
HookShortcut,
|
||||
LoadedHook,
|
||||
NavigateTreeHandler,
|
||||
NewSessionHandler,
|
||||
SendMessageHandler,
|
||||
} from "./loader.js";
|
||||
import type {
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
ContextEvent,
|
||||
ContextEventResult,
|
||||
HookCommandContext,
|
||||
HookContext,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookMessageRenderer,
|
||||
HookUIContext,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
ExtensionError,
|
||||
ExtensionEvent,
|
||||
ExtensionFlag,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
GetActiveToolsHandler,
|
||||
GetAllToolsHandler,
|
||||
LoadedExtension,
|
||||
MessageRenderer,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
SendMessageHandler,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeTreeResult,
|
||||
SetActiveToolsHandler,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEventResult,
|
||||
} from "./types.js";
|
||||
|
||||
/** Combined result from all before_agent_start handlers (internal) */
|
||||
/** Combined result from all before_agent_start handlers */
|
||||
interface BeforeAgentStartCombinedResult {
|
||||
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
|
||||
systemPromptAppend?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for hook errors.
|
||||
*/
|
||||
export type HookErrorListener = (error: HookError) => void;
|
||||
export type ExtensionErrorListener = (error: ExtensionError) => void;
|
||||
|
||||
// Re-export execCommand for backward compatibility
|
||||
export { execCommand } from "../exec.js";
|
||||
export type NewSessionHandler = (options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/** No-op UI context used when no UI is available */
|
||||
const noOpUIContext: HookUIContext = {
|
||||
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
export type NavigateTreeHandler = (
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean },
|
||||
) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
const noOpUIContext: ExtensionUIContext = {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
|
|
@ -69,17 +73,14 @@ const noOpUIContext: HookUIContext = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HookRunner executes hooks and manages event emission.
|
||||
*/
|
||||
export class HookRunner {
|
||||
private hooks: LoadedHook[];
|
||||
private uiContext: HookUIContext;
|
||||
export class ExtensionRunner {
|
||||
private extensions: LoadedExtension[];
|
||||
private uiContext: ExtensionUIContext;
|
||||
private hasUI: boolean;
|
||||
private cwd: string;
|
||||
private sessionManager: SessionManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private errorListeners: Set<HookErrorListener> = new Set();
|
||||
private errorListeners: Set<ExtensionErrorListener> = new Set();
|
||||
private getModel: () => Model<any> | undefined = () => undefined;
|
||||
private isIdleFn: () => boolean = () => true;
|
||||
private waitForIdleFn: () => Promise<void> = async () => {};
|
||||
|
|
@ -89,8 +90,13 @@ export class HookRunner {
|
|||
private branchHandler: BranchHandler = async () => ({ cancelled: false });
|
||||
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||
|
||||
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
|
||||
this.hooks = hooks;
|
||||
constructor(
|
||||
extensions: LoadedExtension[],
|
||||
cwd: string,
|
||||
sessionManager: SessionManager,
|
||||
modelRegistry: ModelRegistry,
|
||||
) {
|
||||
this.extensions = extensions;
|
||||
this.uiContext = noOpUIContext;
|
||||
this.hasUI = false;
|
||||
this.cwd = cwd;
|
||||
|
|
@ -98,40 +104,21 @@ export class HookRunner {
|
|||
this.modelRegistry = modelRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HookRunner with all required context.
|
||||
* Modes call this once the agent session is fully set up.
|
||||
*/
|
||||
initialize(options: {
|
||||
/** Function to get the current model */
|
||||
getModel: () => Model<any> | undefined;
|
||||
/** Handler for hooks to send messages */
|
||||
sendMessageHandler: SendMessageHandler;
|
||||
/** Handler for hooks to append entries */
|
||||
appendEntryHandler: AppendEntryHandler;
|
||||
/** Handler for getting current active tools */
|
||||
getActiveToolsHandler: () => string[];
|
||||
/** Handler for getting all configured tools */
|
||||
getAllToolsHandler: () => string[];
|
||||
/** Handler for setting active tools */
|
||||
setActiveToolsHandler: (toolNames: string[]) => void;
|
||||
/** Handler for creating new sessions (for HookCommandContext) */
|
||||
getActiveToolsHandler: GetActiveToolsHandler;
|
||||
getAllToolsHandler: GetAllToolsHandler;
|
||||
setActiveToolsHandler: SetActiveToolsHandler;
|
||||
newSessionHandler?: NewSessionHandler;
|
||||
/** Handler for branching sessions (for HookCommandContext) */
|
||||
branchHandler?: BranchHandler;
|
||||
/** Handler for navigating session tree (for HookCommandContext) */
|
||||
navigateTreeHandler?: NavigateTreeHandler;
|
||||
/** Function to check if agent is idle */
|
||||
isIdle?: () => boolean;
|
||||
/** Function to wait for agent to be idle */
|
||||
waitForIdle?: () => Promise<void>;
|
||||
/** Function to abort current operation (fire-and-forget) */
|
||||
abort?: () => void;
|
||||
/** Function to check if there are queued messages */
|
||||
hasPendingMessages?: () => boolean;
|
||||
/** UI context for interactive prompts */
|
||||
uiContext?: HookUIContext;
|
||||
/** Whether UI is available */
|
||||
uiContext?: ExtensionUIContext;
|
||||
hasUI?: boolean;
|
||||
}): void {
|
||||
this.getModel = options.getModel;
|
||||
|
|
@ -139,7 +126,7 @@ export class HookRunner {
|
|||
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
|
||||
this.abortFn = options.abort ?? (() => {});
|
||||
this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false);
|
||||
// Store session handlers for HookCommandContext
|
||||
|
||||
if (options.newSessionHandler) {
|
||||
this.newSessionHandler = options.newSessionHandler;
|
||||
}
|
||||
|
|
@ -149,64 +136,60 @@ export class HookRunner {
|
|||
if (options.navigateTreeHandler) {
|
||||
this.navigateTreeHandler = options.navigateTreeHandler;
|
||||
}
|
||||
// Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getActiveTools(), pi.getAllTools(), pi.setActiveTools()
|
||||
for (const hook of this.hooks) {
|
||||
hook.setSendMessageHandler(options.sendMessageHandler);
|
||||
hook.setAppendEntryHandler(options.appendEntryHandler);
|
||||
hook.setGetActiveToolsHandler(options.getActiveToolsHandler);
|
||||
hook.setGetAllToolsHandler(options.getAllToolsHandler);
|
||||
hook.setSetActiveToolsHandler(options.setActiveToolsHandler);
|
||||
|
||||
for (const ext of this.extensions) {
|
||||
ext.setSendMessageHandler(options.sendMessageHandler);
|
||||
ext.setAppendEntryHandler(options.appendEntryHandler);
|
||||
ext.setGetActiveToolsHandler(options.getActiveToolsHandler);
|
||||
ext.setGetAllToolsHandler(options.getAllToolsHandler);
|
||||
ext.setSetActiveToolsHandler(options.setActiveToolsHandler);
|
||||
}
|
||||
|
||||
this.uiContext = options.uiContext ?? noOpUIContext;
|
||||
this.hasUI = options.hasUI ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UI context (set by mode).
|
||||
*/
|
||||
getUIContext(): HookUIContext | null {
|
||||
getUIContext(): ExtensionUIContext | null {
|
||||
return this.uiContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether UI is available.
|
||||
*/
|
||||
getHasUI(): boolean {
|
||||
return this.hasUI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the paths of all loaded hooks.
|
||||
*/
|
||||
getHookPaths(): string[] {
|
||||
return this.hooks.map((h) => h.path);
|
||||
getExtensionPaths(): string[] {
|
||||
return this.extensions.map((e) => e.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all CLI flags registered by hooks.
|
||||
*/
|
||||
getFlags(): Map<string, HookFlag> {
|
||||
const allFlags = new Map<string, HookFlag>();
|
||||
for (const hook of this.hooks) {
|
||||
for (const [name, flag] of hook.flags) {
|
||||
/** Get all registered tools from all extensions. */
|
||||
getAllRegisteredTools(): RegisteredTool[] {
|
||||
const tools: RegisteredTool[] = [];
|
||||
for (const ext of this.extensions) {
|
||||
for (const tool of ext.tools.values()) {
|
||||
tools.push(tool);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
getFlags(): Map<string, ExtensionFlag> {
|
||||
const allFlags = new Map<string, ExtensionFlag>();
|
||||
for (const ext of this.extensions) {
|
||||
for (const [name, flag] of ext.flags) {
|
||||
allFlags.set(name, flag);
|
||||
}
|
||||
}
|
||||
return allFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a flag value (after CLI parsing).
|
||||
*/
|
||||
setFlagValue(name: string, value: boolean | string): void {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.flags.has(name)) {
|
||||
hook.setFlagValue(name, value);
|
||||
for (const ext of this.extensions) {
|
||||
if (ext.flags.has(name)) {
|
||||
ext.setFlagValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in shortcuts that hooks should not override
|
||||
private static readonly RESERVED_SHORTCUTS = new Set([
|
||||
"ctrl+c",
|
||||
"ctrl+d",
|
||||
|
|
@ -224,32 +207,23 @@ export class HookRunner {
|
|||
"enter",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get all keyboard shortcuts registered by hooks.
|
||||
* When multiple hooks register the same shortcut, the last one wins.
|
||||
* Conflicts with built-in shortcuts are skipped with a warning.
|
||||
* Conflicts between hooks are logged as warnings.
|
||||
*/
|
||||
getShortcuts(): Map<KeyId, HookShortcut> {
|
||||
const allShortcuts = new Map<KeyId, HookShortcut>();
|
||||
for (const hook of this.hooks) {
|
||||
for (const [key, shortcut] of hook.shortcuts) {
|
||||
// Normalize to lowercase for comparison (KeyId is string at runtime)
|
||||
getShortcuts(): Map<KeyId, ExtensionShortcut> {
|
||||
const allShortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||
for (const ext of this.extensions) {
|
||||
for (const [key, shortcut] of ext.shortcuts) {
|
||||
const normalizedKey = key.toLowerCase() as KeyId;
|
||||
|
||||
// Check for built-in shortcut conflicts
|
||||
if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {
|
||||
if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {
|
||||
console.warn(
|
||||
`Hook shortcut '${key}' from ${shortcut.hookPath} conflicts with built-in shortcut. Skipping.`,
|
||||
`Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = allShortcuts.get(normalizedKey);
|
||||
if (existing) {
|
||||
// Log conflict between hooks - last one wins
|
||||
console.warn(
|
||||
`Hook shortcut conflict: '${key}' registered by both ${existing.hookPath} and ${shortcut.hookPath}. Using ${shortcut.hookPath}.`,
|
||||
`Extension shortcut conflict: '${key}' registered by both ${existing.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`,
|
||||
);
|
||||
}
|
||||
allShortcuts.set(normalizedKey, shortcut);
|
||||
|
|
@ -258,33 +232,20 @@ export class HookRunner {
|
|||
return allShortcuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to hook errors.
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
onError(listener: HookErrorListener): () => void {
|
||||
onError(listener: ExtensionErrorListener): () => void {
|
||||
this.errorListeners.add(listener);
|
||||
return () => this.errorListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an error to all listeners.
|
||||
*/
|
||||
/**
|
||||
* Emit an error to all error listeners.
|
||||
*/
|
||||
emitError(error: HookError): void {
|
||||
emitError(error: ExtensionError): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any hooks have handlers for the given event type.
|
||||
*/
|
||||
hasHandlers(eventType: string): boolean {
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(eventType);
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get(eventType);
|
||||
if (handlers && handlers.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -292,13 +253,9 @@ export class HookRunner {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a message renderer for the given customType.
|
||||
* Returns the first renderer found across all hooks, or undefined if none.
|
||||
*/
|
||||
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
|
||||
for (const hook of this.hooks) {
|
||||
const renderer = hook.messageRenderers.get(customType);
|
||||
getMessageRenderer(customType: string): MessageRenderer | undefined {
|
||||
for (const ext of this.extensions) {
|
||||
const renderer = ext.messageRenderers.get(customType);
|
||||
if (renderer) {
|
||||
return renderer;
|
||||
}
|
||||
|
|
@ -306,26 +263,19 @@ export class HookRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered commands from all hooks.
|
||||
*/
|
||||
getRegisteredCommands(): RegisteredCommand[] {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
for (const hook of this.hooks) {
|
||||
for (const command of hook.commands.values()) {
|
||||
for (const ext of this.extensions) {
|
||||
for (const command of ext.commands.values()) {
|
||||
commands.push(command);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered command by name.
|
||||
* Returns the first command found across all hooks, or undefined if none.
|
||||
*/
|
||||
getCommand(name: string): RegisteredCommand | undefined {
|
||||
for (const hook of this.hooks) {
|
||||
const command = hook.commands.get(name);
|
||||
for (const ext of this.extensions) {
|
||||
const command = ext.commands.get(name);
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
|
|
@ -333,10 +283,7 @@ export class HookRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the event context for handlers.
|
||||
*/
|
||||
private createContext(): HookContext {
|
||||
private createContext(): ExtensionContext {
|
||||
return {
|
||||
ui: this.uiContext,
|
||||
hasUI: this.hasUI,
|
||||
|
|
@ -350,11 +297,7 @@ export class HookRunner {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the command context for slash command handlers.
|
||||
* Extends HookContext with session control methods that are only safe in commands.
|
||||
*/
|
||||
createCommandContext(): HookCommandContext {
|
||||
createCommandContext(): ExtensionCommandContext {
|
||||
return {
|
||||
...this.createContext(),
|
||||
waitForIdle: () => this.waitForIdleFn(),
|
||||
|
|
@ -364,9 +307,6 @@ export class HookRunner {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event type is a session "before_*" event that can be cancelled.
|
||||
*/
|
||||
private isSessionBeforeEvent(
|
||||
type: string,
|
||||
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
|
||||
|
|
@ -378,34 +318,27 @@ export class HookRunner {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all hooks.
|
||||
* Returns the result from session before_* / tool_result events (if any handler returns one).
|
||||
*/
|
||||
async emit(
|
||||
event: HookEvent,
|
||||
event: ExtensionEvent,
|
||||
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(event.type);
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get(event.type);
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const handlerResult = await handler(event, ctx);
|
||||
|
||||
// For session before_* events, capture the result (for cancellation)
|
||||
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
|
||||
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
|
||||
// If cancelled, stop processing further hooks
|
||||
if (result.cancel) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// For tool_result events, capture the result
|
||||
if (event.type === "tool_result" && handlerResult) {
|
||||
result = handlerResult as ToolResultEventResult;
|
||||
}
|
||||
|
|
@ -413,7 +346,7 @@ export class HookRunner {
|
|||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
extensionPath: ext.path,
|
||||
event: event.type,
|
||||
error: message,
|
||||
stack,
|
||||
|
|
@ -425,26 +358,19 @@ export class HookRunner {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a tool_call event to all hooks.
|
||||
* No timeout - user prompts can take as long as needed.
|
||||
* Errors are thrown (not swallowed) so caller can block on failure.
|
||||
*/
|
||||
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: ToolCallEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get("tool_call");
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get("tool_call");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
// No timeout - let user take their time
|
||||
const handlerResult = await handler(event, ctx);
|
||||
|
||||
if (handlerResult) {
|
||||
result = handlerResult as ToolCallEventResult;
|
||||
// If blocked, stop processing further hooks
|
||||
if (result.block) {
|
||||
return result;
|
||||
}
|
||||
|
|
@ -455,19 +381,12 @@ export class HookRunner {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a context event to all hooks.
|
||||
* Handlers are chained - each gets the previous handler's output (if any).
|
||||
* Returns the final modified messages, or the original if no modifications.
|
||||
*
|
||||
* Messages are deep-copied before passing to hooks, so mutations are safe.
|
||||
*/
|
||||
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
||||
const ctx = this.createContext();
|
||||
let currentMessages = structuredClone(messages);
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get("context");
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get("context");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
|
|
@ -482,7 +401,7 @@ export class HookRunner {
|
|||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
extensionPath: ext.path,
|
||||
event: "context",
|
||||
error: message,
|
||||
stack,
|
||||
|
|
@ -494,10 +413,6 @@ export class HookRunner {
|
|||
return currentMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit before_agent_start event to all hooks.
|
||||
* Returns combined result: all messages and all systemPromptAppend strings concatenated.
|
||||
*/
|
||||
async emitBeforeAgentStart(
|
||||
prompt: string,
|
||||
images?: ImageContent[],
|
||||
|
|
@ -506,8 +421,8 @@ export class HookRunner {
|
|||
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
|
||||
const systemPromptAppends: string[] = [];
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get("before_agent_start");
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get("before_agent_start");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
|
|
@ -517,11 +432,9 @@ export class HookRunner {
|
|||
|
||||
if (handlerResult) {
|
||||
const result = handlerResult as BeforeAgentStartEventResult;
|
||||
// Collect all messages
|
||||
if (result.message) {
|
||||
messages.push(result.message);
|
||||
}
|
||||
// Collect all systemPromptAppend strings
|
||||
if (result.systemPromptAppend) {
|
||||
systemPromptAppends.push(result.systemPromptAppend);
|
||||
}
|
||||
|
|
@ -530,7 +443,7 @@ export class HookRunner {
|
|||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
extensionPath: ext.path,
|
||||
event: "before_agent_start",
|
||||
error: message,
|
||||
stack,
|
||||
|
|
@ -539,7 +452,6 @@ export class HookRunner {
|
|||
}
|
||||
}
|
||||
|
||||
// Return combined result
|
||||
if (messages.length > 0 || systemPromptAppends.length > 0) {
|
||||
return {
|
||||
messages: messages.length > 0 ? messages : undefined,
|
||||
688
packages/coding-agent/src/core/extensions/types.ts
Normal file
688
packages/coding-agent/src/core/extensions/types.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
/**
|
||||
* Extension system types.
|
||||
*
|
||||
* Extensions are TypeScript modules that can:
|
||||
* - Subscribe to agent lifecycle events
|
||||
* - Register LLM-callable tools
|
||||
* - Register commands, keyboard shortcuts, and CLI flags
|
||||
* - Interact with the user via UI primitives
|
||||
*/
|
||||
|
||||
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { CustomMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
ReadonlySessionManager,
|
||||
SessionEntry,
|
||||
SessionManager,
|
||||
} from "../session-manager.js";
|
||||
import type { EditToolDetails } from "../tools/edit.js";
|
||||
import type {
|
||||
BashToolDetails,
|
||||
FindToolDetails,
|
||||
GrepToolDetails,
|
||||
LsToolDetails,
|
||||
ReadToolDetails,
|
||||
} from "../tools/index.js";
|
||||
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
// ============================================================================
|
||||
// UI Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* UI context for extensions to request interactive UI.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
*/
|
||||
export interface ExtensionUIContext {
|
||||
/** Show a selector and return the user's choice. */
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
|
||||
/** Show a confirmation dialog. */
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
|
||||
/** Show a text input dialog. */
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
|
||||
/** Show a notification to the user. */
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
|
||||
/** Set status text in the footer/status bar. Pass undefined to clear. */
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
|
||||
/** Set a widget to display above the editor. Accepts string array or component factory. */
|
||||
setWidget(key: string, content: string[] | undefined): void;
|
||||
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||
|
||||
/** Set the terminal window/tab title. */
|
||||
setTitle(title: string): void;
|
||||
|
||||
/** Show a custom component with keyboard focus. */
|
||||
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. */
|
||||
setEditorText(text: string): void;
|
||||
|
||||
/** Get the current text from the core input editor. */
|
||||
getEditorText(): string;
|
||||
|
||||
/** Show a multi-line editor for text editing. */
|
||||
editor(title: string, prefill?: string): Promise<string | undefined>;
|
||||
|
||||
/** Get the current theme for styling. */
|
||||
readonly theme: Theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context passed to extension event handlers.
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/** UI methods for user interaction */
|
||||
ui: ExtensionUIContext;
|
||||
/** Whether UI is available (false in print/RPC mode) */
|
||||
hasUI: boolean;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Session manager (read-only) */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry for API key resolution */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined) */
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** Abort the current agent operation */
|
||||
abort(): void;
|
||||
/** Whether there are queued messages waiting */
|
||||
hasPendingMessages(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended context for command handlers.
|
||||
* Includes session control methods only safe in user-initiated commands.
|
||||
*/
|
||||
export interface ExtensionCommandContext extends ExtensionContext {
|
||||
/** Wait for the agent to finish streaming */
|
||||
waitForIdle(): Promise<void>;
|
||||
|
||||
/** Start a new session, optionally with initialization. */
|
||||
newSession(options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/** Branch from a specific entry, creating a new session file. */
|
||||
branch(entryId: string): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/** Navigate to a different point in the session tree. */
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Types
|
||||
// ============================================================================
|
||||
|
||||
/** Rendering options for tool results */
|
||||
export interface ToolRenderResultOptions {
|
||||
/** Whether the result view is expanded */
|
||||
expanded: boolean;
|
||||
/** Whether this is a partial/streaming result */
|
||||
isPartial: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition for registerTool().
|
||||
*/
|
||||
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
|
||||
/** Tool name (used in LLM tool calls) */
|
||||
name: string;
|
||||
/** Human-readable label for UI */
|
||||
label: string;
|
||||
/** Description for LLM */
|
||||
description: string;
|
||||
/** Parameter schema (TypeBox) */
|
||||
parameters: TParams;
|
||||
|
||||
/** Execute the tool. */
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParams>,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
ctx: ExtensionContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Custom rendering for tool call display */
|
||||
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
|
||||
|
||||
/** Custom rendering for tool result display */
|
||||
renderResult?: (result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme) => Component;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired on initial session load */
|
||||
export interface SessionStartEvent {
|
||||
type: "session_start";
|
||||
}
|
||||
|
||||
/** Fired before switching to another session (can be cancelled) */
|
||||
export interface SessionBeforeSwitchEvent {
|
||||
type: "session_before_switch";
|
||||
reason: "new" | "resume";
|
||||
targetSessionFile?: string;
|
||||
}
|
||||
|
||||
/** Fired after switching to another session */
|
||||
export interface SessionSwitchEvent {
|
||||
type: "session_switch";
|
||||
reason: "new" | "resume";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before branching a session (can be cancelled) */
|
||||
export interface SessionBeforeBranchEvent {
|
||||
type: "session_before_branch";
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
/** Fired after branching a session */
|
||||
export interface SessionBranchEvent {
|
||||
type: "session_branch";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before context compaction (can be cancelled or customized) */
|
||||
export interface SessionBeforeCompactEvent {
|
||||
type: "session_before_compact";
|
||||
preparation: CompactionPreparation;
|
||||
branchEntries: SessionEntry[];
|
||||
customInstructions?: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after context compaction */
|
||||
export interface SessionCompactEvent {
|
||||
type: "session_compact";
|
||||
compactionEntry: CompactionEntry;
|
||||
fromExtension: boolean;
|
||||
}
|
||||
|
||||
/** Fired on process exit */
|
||||
export interface SessionShutdownEvent {
|
||||
type: "session_shutdown";
|
||||
}
|
||||
|
||||
/** Preparation data for tree navigation */
|
||||
export interface TreePreparation {
|
||||
targetId: string;
|
||||
oldLeafId: string | null;
|
||||
commonAncestorId: string | null;
|
||||
entriesToSummarize: SessionEntry[];
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
/** Fired before navigating in the session tree (can be cancelled) */
|
||||
export interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
preparation: TreePreparation;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after navigating in the session tree */
|
||||
export interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
newLeafId: string | null;
|
||||
oldLeafId: string | null;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
fromExtension?: boolean;
|
||||
}
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionStartEvent
|
||||
| SessionBeforeSwitchEvent
|
||||
| SessionSwitchEvent
|
||||
| SessionBeforeBranchEvent
|
||||
| SessionBranchEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionShutdownEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Agent Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired before each LLM call. Can modify messages. */
|
||||
export interface ContextEvent {
|
||||
type: "context";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Fired after user submits prompt but before agent loop. */
|
||||
export interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
prompt: string;
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
/** Fired when an agent loop starts */
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
|
||||
/** Fired when an agent loop ends */
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Fired at the start of each turn */
|
||||
export interface TurnStartEvent {
|
||||
type: "turn_start";
|
||||
turnIndex: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Fired at the end of each turn */
|
||||
export interface TurnEndEvent {
|
||||
type: "turn_end";
|
||||
turnIndex: number;
|
||||
message: AgentMessage;
|
||||
toolResults: ToolResultMessage[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired before a tool executes. Can block. */
|
||||
export interface ToolCallEvent {
|
||||
type: "tool_call";
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolResultEventBase {
|
||||
type: "tool_result";
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
content: (TextContent | ImageContent)[];
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface BashToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "bash";
|
||||
details: BashToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface ReadToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "read";
|
||||
details: ReadToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "edit";
|
||||
details: EditToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface WriteToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "write";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
export interface GrepToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "grep";
|
||||
details: GrepToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface FindToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "find";
|
||||
details: FindToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface LsToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "ls";
|
||||
details: LsToolDetails | undefined;
|
||||
}
|
||||
|
||||
export interface CustomToolResultEvent extends ToolResultEventBase {
|
||||
toolName: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
/** Fired after a tool executes. Can modify result. */
|
||||
export type ToolResultEvent =
|
||||
| BashToolResultEvent
|
||||
| ReadToolResultEvent
|
||||
| EditToolResultEvent
|
||||
| WriteToolResultEvent
|
||||
| GrepToolResultEvent
|
||||
| FindToolResultEvent
|
||||
| LsToolResultEvent
|
||||
| CustomToolResultEvent;
|
||||
|
||||
// Type guards
|
||||
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
|
||||
return e.toolName === "bash";
|
||||
}
|
||||
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
|
||||
return e.toolName === "read";
|
||||
}
|
||||
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
|
||||
return e.toolName === "edit";
|
||||
}
|
||||
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
|
||||
return e.toolName === "write";
|
||||
}
|
||||
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
|
||||
return e.toolName === "grep";
|
||||
}
|
||||
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
|
||||
return e.toolName === "find";
|
||||
}
|
||||
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||
return e.toolName === "ls";
|
||||
}
|
||||
|
||||
/** Union of all event types */
|
||||
export type ExtensionEvent =
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| BeforeAgentStartEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| TurnStartEvent
|
||||
| TurnEndEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Event Results
|
||||
// ============================================================================
|
||||
|
||||
export interface ContextEventResult {
|
||||
messages?: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface ToolCallEventResult {
|
||||
block?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolResultEventResult {
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface BeforeAgentStartEventResult {
|
||||
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details">;
|
||||
systemPromptAppend?: string;
|
||||
}
|
||||
|
||||
export interface SessionBeforeSwitchResult {
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionBeforeBranchResult {
|
||||
cancel?: boolean;
|
||||
skipConversationRestore?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionBeforeCompactResult {
|
||||
cancel?: boolean;
|
||||
compaction?: CompactionResult;
|
||||
}
|
||||
|
||||
export interface SessionBeforeTreeResult {
|
||||
cancel?: boolean;
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Rendering
|
||||
// ============================================================================
|
||||
|
||||
export interface MessageRenderOptions {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export type MessageRenderer<T = unknown> = (
|
||||
message: CustomMessage<T>,
|
||||
options: MessageRenderOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
// ============================================================================
|
||||
// Command Registration
|
||||
// ============================================================================
|
||||
|
||||
export interface RegisteredCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension API
|
||||
// ============================================================================
|
||||
|
||||
/** Handler function type for events */
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
|
||||
export type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;
|
||||
|
||||
/**
|
||||
* ExtensionAPI passed to extension factory functions.
|
||||
*/
|
||||
export interface ExtensionAPI {
|
||||
// =========================================================================
|
||||
// Event Subscription
|
||||
// =========================================================================
|
||||
|
||||
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||
on(
|
||||
event: "session_before_switch",
|
||||
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
|
||||
): void;
|
||||
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
|
||||
on(
|
||||
event: "session_before_branch",
|
||||
handler: ExtensionHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>,
|
||||
): void;
|
||||
on(event: "session_branch", handler: ExtensionHandler<SessionBranchEvent>): void;
|
||||
on(
|
||||
event: "session_before_compact",
|
||||
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
|
||||
): void;
|
||||
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
|
||||
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
|
||||
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
|
||||
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
|
||||
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;
|
||||
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
|
||||
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
||||
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||
|
||||
// =========================================================================
|
||||
// Tool Registration
|
||||
// =========================================================================
|
||||
|
||||
/** Register a tool that the LLM can call. */
|
||||
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
|
||||
|
||||
// =========================================================================
|
||||
// Command, Shortcut, Flag Registration
|
||||
// =========================================================================
|
||||
|
||||
/** Register a custom command. */
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
|
||||
|
||||
/** Register a keyboard shortcut. */
|
||||
registerShortcut(
|
||||
shortcut: KeyId,
|
||||
options: {
|
||||
description?: string;
|
||||
handler: (ctx: ExtensionContext) => Promise<void> | void;
|
||||
},
|
||||
): void;
|
||||
|
||||
/** Register a CLI flag. */
|
||||
registerFlag(
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
type: "boolean" | "string";
|
||||
default?: boolean | string;
|
||||
},
|
||||
): void;
|
||||
|
||||
/** Get the value of a registered CLI flag. */
|
||||
getFlag(name: string): boolean | string | undefined;
|
||||
|
||||
// =========================================================================
|
||||
// Message Rendering
|
||||
// =========================================================================
|
||||
|
||||
/** Register a custom renderer for CustomMessageEntry. */
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
|
||||
|
||||
// =========================================================================
|
||||
// Actions
|
||||
// =========================================================================
|
||||
|
||||
/** Send a custom message to the session. */
|
||||
sendMessage<T = unknown>(
|
||||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
): void;
|
||||
|
||||
/** Append a custom entry to the session for state persistence (not sent to LLM). */
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void;
|
||||
|
||||
/** Execute a shell command. */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
|
||||
/** Get the list of currently active tool names. */
|
||||
getActiveTools(): string[];
|
||||
|
||||
/** Get all configured tools (built-in + extension tools). */
|
||||
getAllTools(): string[];
|
||||
|
||||
/** Set the active tools by name. */
|
||||
setActiveTools(toolNames: string[]): void;
|
||||
|
||||
/** Shared event bus for extension communication. */
|
||||
events: EventBus;
|
||||
}
|
||||
|
||||
/** Extension factory function type. */
|
||||
export type ExtensionFactory = (pi: ExtensionAPI) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Loaded Extension Types
|
||||
// ============================================================================
|
||||
|
||||
export interface RegisteredTool {
|
||||
definition: ToolDefinition;
|
||||
extensionPath: string;
|
||||
}
|
||||
|
||||
export interface ExtensionFlag {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "boolean" | "string";
|
||||
default?: boolean | string;
|
||||
extensionPath: string;
|
||||
}
|
||||
|
||||
export interface ExtensionShortcut {
|
||||
shortcut: KeyId;
|
||||
description?: string;
|
||||
handler: (ctx: ExtensionContext) => Promise<void> | void;
|
||||
extensionPath: string;
|
||||
}
|
||||
|
||||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
export type SendMessageHandler = <T = unknown>(
|
||||
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
) => void;
|
||||
|
||||
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||
|
||||
export type GetActiveToolsHandler = () => string[];
|
||||
|
||||
export type GetAllToolsHandler = () => string[];
|
||||
|
||||
export type SetActiveToolsHandler = (toolNames: string[]) => void;
|
||||
|
||||
/** Loaded extension with all registered items. */
|
||||
export interface LoadedExtension {
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
tools: Map<string, RegisteredTool>;
|
||||
messageRenderers: Map<string, MessageRenderer>;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
flags: Map<string, ExtensionFlag>;
|
||||
flagValues: Map<string, boolean | string>;
|
||||
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||
setFlagValue: (name: string, value: boolean | string) => void;
|
||||
}
|
||||
|
||||
/** Result of loading extensions. */
|
||||
export interface LoadExtensionsResult {
|
||||
extensions: LoadedExtension[];
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension Error
|
||||
// ============================================================================
|
||||
|
||||
export interface ExtensionError {
|
||||
extensionPath: string;
|
||||
event: string;
|
||||
error: string;
|
||||
stack?: string;
|
||||
}
|
||||
119
packages/coding-agent/src/core/extensions/wrapper.ts
Normal file
119
packages/coding-agent/src/core/extensions/wrapper.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Tool wrappers for extensions.
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionRunner } from "./runner.js";
|
||||
import type { ExtensionContext, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a RegisteredTool into an AgentTool.
|
||||
*/
|
||||
export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: () => ExtensionContext): AgentTool {
|
||||
const { definition } = registeredTool;
|
||||
return {
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: (toolCallId, params, signal, onUpdate) =>
|
||||
definition.execute(toolCallId, params, onUpdate, getContext(), signal),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all registered tools into AgentTools.
|
||||
*/
|
||||
export function wrapRegisteredTools(
|
||||
registeredTools: RegisteredTool[],
|
||||
getContext: () => ExtensionContext,
|
||||
): AgentTool[] {
|
||||
return registeredTools.map((rt) => wrapRegisteredTool(rt, getContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a tool with extension callbacks for interception.
|
||||
* - Emits tool_call event before execution (can block)
|
||||
* - Emits tool_result event after execution (can modify result)
|
||||
*/
|
||||
export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: ExtensionRunner): AgentTool<any, T> {
|
||||
return {
|
||||
...tool,
|
||||
execute: async (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<T>,
|
||||
) => {
|
||||
// Emit tool_call event - extensions can block execution
|
||||
if (runner.hasHandlers("tool_call")) {
|
||||
try {
|
||||
const callResult = (await runner.emitToolCall({
|
||||
type: "tool_call",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
})) as ToolCallEventResult | undefined;
|
||||
|
||||
if (callResult?.block) {
|
||||
const reason = callResult.reason || "Tool execution was blocked by an extension";
|
||||
throw new Error(reason);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the actual tool
|
||||
try {
|
||||
const result = await tool.execute(toolCallId, params, signal, onUpdate);
|
||||
|
||||
// Emit tool_result event - extensions can modify the result
|
||||
if (runner.hasHandlers("tool_result")) {
|
||||
const resultResult = (await runner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError: false,
|
||||
})) as ToolResultEventResult | undefined;
|
||||
|
||||
if (resultResult) {
|
||||
return {
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Emit tool_result event for errors
|
||||
if (runner.hasHandlers("tool_result")) {
|
||||
await runner.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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all tools with extension callbacks.
|
||||
*/
|
||||
export function wrapToolsWithExtensions<T>(tools: AgentTool<any, T>[], runner: ExtensionRunner): AgentTool<any, T>[] {
|
||||
return tools.map((tool) => wrapToolWithExtensions(tool, runner));
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// biome-ignore assist/source/organizeImports: biome is not smart
|
||||
export {
|
||||
discoverAndLoadHooks,
|
||||
loadHooks,
|
||||
type AppendEntryHandler,
|
||||
type BranchHandler,
|
||||
type GetActiveToolsHandler,
|
||||
type GetAllToolsHandler,
|
||||
type HookFlag,
|
||||
type HookShortcut,
|
||||
type LoadedHook,
|
||||
type LoadHooksResult,
|
||||
type NavigateTreeHandler,
|
||||
type NewSessionHandler,
|
||||
type SendMessageHandler,
|
||||
type SetActiveToolsHandler,
|
||||
} from "./loader.js";
|
||||
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
|
||||
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
||||
export * from "./types.js";
|
||||
export type { ReadonlySessionManager } from "../session-manager.js";
|
||||
|
|
@ -1,505 +0,0 @@
|
|||
/**
|
||||
* Hook loader - loads TypeScript hook modules using jiti.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { KeyId } from "@mariozechner/pi-tui";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir } from "../../config.js";
|
||||
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { SessionManager } from "../session-manager.js";
|
||||
import { execCommand } from "./runner.js";
|
||||
import type {
|
||||
ExecOptions,
|
||||
HookAPI,
|
||||
HookContext,
|
||||
HookFactory,
|
||||
HookMessageRenderer,
|
||||
RegisteredCommand,
|
||||
} from "./types.js";
|
||||
|
||||
// Create require function to resolve module paths at runtime
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Lazily computed aliases - resolved at runtime to handle global installs
|
||||
let _aliases: Record<string, string> | null = null;
|
||||
function getAliases(): Record<string, string> {
|
||||
if (_aliases) return _aliases;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageIndex = path.resolve(__dirname, "../..", "index.js");
|
||||
|
||||
// For typebox, we need the package root directory (not the entry file)
|
||||
// because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler"
|
||||
// get the alias prepended. If we alias to the entry file (.../build/cjs/index.js),
|
||||
// then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid).
|
||||
// By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly.
|
||||
const typeboxEntry = require.resolve("@sinclair/typebox");
|
||||
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
|
||||
|
||||
_aliases = {
|
||||
"@mariozechner/pi-coding-agent": packageIndex,
|
||||
"@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"),
|
||||
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
||||
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
||||
"@sinclair/typebox": typeboxRoot,
|
||||
};
|
||||
return _aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic handler function type.
|
||||
*/
|
||||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Send message handler type for pi.sendMessage().
|
||||
*/
|
||||
export type SendMessageHandler = <T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Append entry handler type for pi.appendEntry().
|
||||
*/
|
||||
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||
|
||||
/**
|
||||
* Get active tools handler type for pi.getActiveTools().
|
||||
*/
|
||||
export type GetActiveToolsHandler = () => string[];
|
||||
|
||||
/**
|
||||
* Get all tools handler type for pi.getAllTools().
|
||||
*/
|
||||
export type GetAllToolsHandler = () => string[];
|
||||
|
||||
/**
|
||||
* Set active tools handler type for pi.setActiveTools().
|
||||
*/
|
||||
export type SetActiveToolsHandler = (toolNames: string[]) => void;
|
||||
|
||||
/**
|
||||
* CLI flag definition registered by a hook.
|
||||
*/
|
||||
export interface HookFlag {
|
||||
/** Flag name (without --) */
|
||||
name: string;
|
||||
/** Description for --help */
|
||||
description?: string;
|
||||
/** Type: boolean or string */
|
||||
type: "boolean" | "string";
|
||||
/** Default value */
|
||||
default?: boolean | string;
|
||||
/** Hook path that registered this flag */
|
||||
hookPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard shortcut registered by a hook.
|
||||
*/
|
||||
export interface HookShortcut {
|
||||
/** Key identifier (e.g., Key.shift("p"), "ctrl+x") */
|
||||
shortcut: KeyId;
|
||||
/** Description for help */
|
||||
description?: string;
|
||||
/** Handler function */
|
||||
handler: (ctx: HookContext) => Promise<void> | void;
|
||||
/** Hook path that registered this shortcut */
|
||||
hookPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* New session handler type for ctx.newSession() in HookCommandContext.
|
||||
*/
|
||||
export type NewSessionHandler = (options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Branch handler type for ctx.branch() in HookCommandContext.
|
||||
*/
|
||||
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Navigate tree handler type for ctx.navigateTree() in HookCommandContext.
|
||||
*/
|
||||
export type NavigateTreeHandler = (
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean },
|
||||
) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Registered handlers for a loaded hook.
|
||||
*/
|
||||
export interface LoadedHook {
|
||||
/** Original path from config */
|
||||
path: string;
|
||||
/** Resolved absolute path */
|
||||
resolvedPath: string;
|
||||
/** Map of event type to handler functions */
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
/** Map of customType to hook message renderer */
|
||||
messageRenderers: Map<string, HookMessageRenderer>;
|
||||
/** Map of command name to registered command */
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
/** CLI flags registered by this hook */
|
||||
flags: Map<string, HookFlag>;
|
||||
/** Flag values (set after CLI parsing) */
|
||||
flagValues: Map<string, boolean | string>;
|
||||
/** Keyboard shortcuts registered by this hook */
|
||||
shortcuts: Map<KeyId, HookShortcut>;
|
||||
/** Set the send message handler for this hook's pi.sendMessage() */
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
/** Set the append entry handler for this hook's pi.appendEntry() */
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
/** Set the get active tools handler for this hook's pi.getActiveTools() */
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||
/** Set the get all tools handler for this hook's pi.getAllTools() */
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||
/** Set the set active tools handler for this hook's pi.setActiveTools() */
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||
/** Set a flag value (called after CLI parsing) */
|
||||
setFlagValue: (name: string, value: boolean | string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading hooks.
|
||||
*/
|
||||
export interface LoadHooksResult {
|
||||
/** Successfully loaded hooks */
|
||||
hooks: LoadedHook[];
|
||||
/** Errors encountered during loading */
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
|
||||
function normalizeUnicodeSpaces(str: string): string {
|
||||
return str.replace(UNICODE_SPACES, " ");
|
||||
}
|
||||
|
||||
function expandPath(p: string): string {
|
||||
const normalized = normalizeUnicodeSpaces(p);
|
||||
if (normalized.startsWith("~/")) {
|
||||
return path.join(os.homedir(), normalized.slice(2));
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
return path.join(os.homedir(), normalized.slice(1));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve hook path.
|
||||
* - Absolute paths used as-is
|
||||
* - Paths starting with ~ expanded to home directory
|
||||
* - Relative paths resolved from cwd
|
||||
*/
|
||||
function resolveHookPath(hookPath: string, cwd: string): string {
|
||||
const expanded = expandPath(hookPath);
|
||||
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Relative paths resolved from cwd
|
||||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a HookAPI instance that collects handlers, renderers, and commands.
|
||||
* Returns the API, maps, and functions to set handlers later.
|
||||
*/
|
||||
function createHookAPI(
|
||||
handlers: Map<string, HandlerFn[]>,
|
||||
cwd: string,
|
||||
hookPath: string,
|
||||
eventBus: EventBus,
|
||||
): {
|
||||
api: HookAPI;
|
||||
messageRenderers: Map<string, HookMessageRenderer>;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
flags: Map<string, HookFlag>;
|
||||
flagValues: Map<string, boolean | string>;
|
||||
shortcuts: Map<KeyId, HookShortcut>;
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||
setFlagValue: (name: string, value: boolean | string) => void;
|
||||
} {
|
||||
let sendMessageHandler: SendMessageHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
let appendEntryHandler: AppendEntryHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
|
||||
let getAllToolsHandler: GetAllToolsHandler = () => [];
|
||||
let setActiveToolsHandler: SetActiveToolsHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
const messageRenderers = new Map<string, HookMessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const flags = new Map<string, HookFlag>();
|
||||
const flagValues = new Map<string, boolean | string>();
|
||||
const shortcuts = new Map<KeyId, HookShortcut>();
|
||||
|
||||
// Cast to HookAPI - the implementation is more general (string event names)
|
||||
// but the interface has specific overloads for type safety in hooks
|
||||
const api = {
|
||||
on(event: string, handler: HandlerFn): void {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
sendMessage<T = unknown>(
|
||||
message: HookMessage<T>,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
|
||||
): void {
|
||||
sendMessageHandler(message, options);
|
||||
},
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void {
|
||||
appendEntryHandler(customType, data);
|
||||
},
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
|
||||
messageRenderers.set(customType, renderer as HookMessageRenderer);
|
||||
},
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
exec(command: string, args: string[], options?: ExecOptions) {
|
||||
return execCommand(command, args, options?.cwd ?? cwd, options);
|
||||
},
|
||||
getActiveTools(): string[] {
|
||||
return getActiveToolsHandler();
|
||||
},
|
||||
getAllTools(): string[] {
|
||||
return getAllToolsHandler();
|
||||
},
|
||||
setActiveTools(toolNames: string[]): void {
|
||||
setActiveToolsHandler(toolNames);
|
||||
},
|
||||
registerFlag(
|
||||
name: string,
|
||||
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
|
||||
): void {
|
||||
flags.set(name, { name, hookPath, ...options });
|
||||
if (options.default !== undefined) {
|
||||
flagValues.set(name, options.default);
|
||||
}
|
||||
},
|
||||
getFlag(name: string): boolean | string | undefined {
|
||||
return flagValues.get(name);
|
||||
},
|
||||
registerShortcut(
|
||||
shortcut: KeyId,
|
||||
options: {
|
||||
description?: string;
|
||||
handler: (ctx: HookContext) => Promise<void> | void;
|
||||
},
|
||||
): void {
|
||||
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
|
||||
},
|
||||
events: eventBus,
|
||||
} as HookAPI;
|
||||
|
||||
return {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => {
|
||||
getActiveToolsHandler = handler;
|
||||
},
|
||||
setGetAllToolsHandler: (handler: GetAllToolsHandler) => {
|
||||
getAllToolsHandler = handler;
|
||||
},
|
||||
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => {
|
||||
setActiveToolsHandler = handler;
|
||||
},
|
||||
setFlagValue: (name: string, value: boolean | string) => {
|
||||
flagValues.set(name, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single hook module using jiti.
|
||||
*/
|
||||
async function loadHook(
|
||||
hookPath: string,
|
||||
cwd: string,
|
||||
eventBus: EventBus,
|
||||
): Promise<{ hook: LoadedHook | null; error: string | null }> {
|
||||
const resolvedPath = resolveHookPath(hookPath, cwd);
|
||||
|
||||
try {
|
||||
// Create jiti instance for TypeScript/ESM loading
|
||||
// Use aliases to resolve package imports since hooks are loaded from user directories
|
||||
// (e.g. ~/.pi/agent/hooks) but import from packages installed with pi-coding-agent
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: getAliases(),
|
||||
});
|
||||
|
||||
// Import the module
|
||||
const module = await jiti.import(resolvedPath, { default: true });
|
||||
const factory = module as HookFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { hook: null, error: "Hook must export a default function" };
|
||||
}
|
||||
|
||||
// Create handlers map and API
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
} = createHookAPI(handlers, cwd, hookPath, eventBus);
|
||||
|
||||
// Call factory to register handlers
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
hook: {
|
||||
path: hookPath,
|
||||
resolvedPath,
|
||||
handlers,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setGetActiveToolsHandler,
|
||||
setGetAllToolsHandler,
|
||||
setSetActiveToolsHandler,
|
||||
setFlagValue,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { hook: null, error: `Failed to load hook: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all hooks from configuration.
|
||||
* @param paths - Array of hook file paths
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
|
||||
*/
|
||||
export async function loadHooks(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadHooksResult> {
|
||||
const hooks: LoadedHook[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
|
||||
for (const hookPath of paths) {
|
||||
const { hook, error } = await loadHook(hookPath, cwd, resolvedEventBus);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: hookPath, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hook) {
|
||||
hooks.push(hook);
|
||||
}
|
||||
}
|
||||
|
||||
return { hooks, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover hook files from a directory.
|
||||
* Returns all .ts files (and symlinks to .ts files) in the directory (non-recursive).
|
||||
*/
|
||||
function discoverHooksInDir(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts"))
|
||||
.map((e) => path.join(dir, e.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load hooks from standard locations:
|
||||
* 1. agentDir/hooks/*.ts (global)
|
||||
* 2. cwd/.pi/hooks/*.ts (project-local)
|
||||
*
|
||||
* Plus any explicitly configured paths from settings.
|
||||
*
|
||||
* @param configuredPaths - Explicitly configured hook paths
|
||||
* @param cwd - Current working directory
|
||||
* @param agentDir - Agent configuration directory
|
||||
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
|
||||
*/
|
||||
export async function discoverAndLoadHooks(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
agentDir: string = getAgentDir(),
|
||||
eventBus?: EventBus,
|
||||
): Promise<LoadHooksResult> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Helper to add paths without duplicates
|
||||
const addPaths = (paths: string[]) => {
|
||||
for (const p of paths) {
|
||||
const resolved = path.resolve(p);
|
||||
if (!seen.has(resolved)) {
|
||||
seen.add(resolved);
|
||||
allPaths.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Global hooks: agentDir/hooks/
|
||||
const globalHooksDir = path.join(agentDir, "hooks");
|
||||
addPaths(discoverHooksInDir(globalHooksDir));
|
||||
|
||||
// 2. Project-local hooks: cwd/.pi/hooks/
|
||||
const localHooksDir = path.join(cwd, ".pi", "hooks");
|
||||
addPaths(discoverHooksInDir(localHooksDir));
|
||||
|
||||
// 3. Explicitly configured paths (can override/add)
|
||||
addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd)));
|
||||
|
||||
return loadHooks(allPaths, cwd, eventBus);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
/**
|
||||
* Tool wrapper - wraps tools with hook callbacks for interception.
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { HookRunner } from "./runner.js";
|
||||
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a tool with hook callbacks.
|
||||
* - Emits tool_call event before execution (can block)
|
||||
* - Emits tool_result event after execution (can modify result)
|
||||
* - Forwards onUpdate callback to wrapped tool for progress streaming
|
||||
*/
|
||||
export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRunner): AgentTool<any, T> {
|
||||
return {
|
||||
...tool,
|
||||
execute: async (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<T>,
|
||||
) => {
|
||||
// Emit tool_call event - hooks can block execution
|
||||
// If hook errors/times out, block by default (fail-safe)
|
||||
if (hookRunner.hasHandlers("tool_call")) {
|
||||
try {
|
||||
const callResult = (await hookRunner.emitToolCall({
|
||||
type: "tool_call",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
})) as ToolCallEventResult | undefined;
|
||||
|
||||
if (callResult?.block) {
|
||||
const reason = callResult.reason || "Tool execution was blocked by a hook";
|
||||
throw new Error(reason);
|
||||
}
|
||||
} catch (err) {
|
||||
// Hook error or block - throw to mark as error
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Hook failed, blocking execution: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the actual tool, forwarding onUpdate for progress streaming
|
||||
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;
|
||||
|
||||
// Apply modifications if any
|
||||
if (resultResult) {
|
||||
return {
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all tools with hook callbacks.
|
||||
*/
|
||||
export function wrapToolsWithHooks<T>(tools: AgentTool<any, T>[], hookRunner: HookRunner): AgentTool<any, T>[] {
|
||||
return tools.map((tool) => wrapToolWithHooks(tool, hookRunner));
|
||||
}
|
||||
|
|
@ -1,942 +0,0 @@
|
|||
/**
|
||||
* Hook system types.
|
||||
*
|
||||
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
|
||||
* and interact with the user via UI primitives.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
ReadonlySessionManager,
|
||||
SessionEntry,
|
||||
SessionManager,
|
||||
} from "../session-manager.js";
|
||||
|
||||
import type { EditToolDetails } from "../tools/edit.js";
|
||||
import type {
|
||||
BashToolDetails,
|
||||
FindToolDetails,
|
||||
GrepToolDetails,
|
||||
LsToolDetails,
|
||||
ReadToolDetails,
|
||||
} from "../tools/index.js";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
|
||||
/**
|
||||
* UI context for hooks to request interactive UI from the harness.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
*/
|
||||
export interface HookUIContext {
|
||||
/**
|
||||
* Show a selector and return the user's choice.
|
||||
* @param title - Title to display
|
||||
* @param options - Array of string options
|
||||
* @returns Selected option string, or null if cancelled
|
||||
*/
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog.
|
||||
* @returns true if confirmed, false if cancelled
|
||||
*/
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Show a text input dialog.
|
||||
* @returns User input, or undefined if cancelled
|
||||
*/
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Show a notification to the user.
|
||||
*/
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
|
||||
/**
|
||||
* Set status text in the footer/status bar.
|
||||
* Pass undefined as text to clear the status for this key.
|
||||
* Text can include ANSI escape codes for styling.
|
||||
* Note: Newlines, tabs, and carriage returns are replaced with spaces.
|
||||
* The combined status line is truncated to terminal width.
|
||||
* @param key - Unique key to identify this status (e.g., hook name)
|
||||
* @param text - Status text to display, or undefined to clear
|
||||
*/
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
|
||||
/**
|
||||
* Set a widget to display in the status area (above the editor, below "Working..." indicator).
|
||||
* Supports multi-line content. Pass undefined to clear.
|
||||
* Text can include ANSI escape codes for styling.
|
||||
*
|
||||
* Accepts either an array of styled strings, or a factory function that creates a Component.
|
||||
*
|
||||
* @param key - Unique key to identify this widget (e.g., hook name)
|
||||
* @param content - Array of lines to display, or undefined to clear
|
||||
*
|
||||
* @example
|
||||
* // Show a todo list with styled strings
|
||||
* ctx.ui.setWidget("plan-todos", [
|
||||
* theme.fg("accent", "Plan Progress:"),
|
||||
* "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")),
|
||||
* "☐ Step 2: Modify code",
|
||||
* "☐ Step 3: Run tests",
|
||||
* ]);
|
||||
*
|
||||
* // Clear the widget
|
||||
* ctx.ui.setWidget("plan-todos", undefined);
|
||||
*/
|
||||
setWidget(key: string, content: string[] | undefined): void;
|
||||
|
||||
/**
|
||||
* Set a custom component as a widget (above the editor, below "Working..." indicator).
|
||||
* Unlike custom(), this does NOT take keyboard focus - the editor remains focused.
|
||||
* Pass undefined to clear the widget.
|
||||
*
|
||||
* The component should implement render(width) and optionally dispose().
|
||||
* Components are rendered inline without taking focus - they cannot handle keyboard input.
|
||||
*
|
||||
* @param key - Unique key to identify this widget (e.g., hook name)
|
||||
* @param content - Factory function that creates the component, or undefined to clear
|
||||
*
|
||||
* @example
|
||||
* // Show a custom progress component
|
||||
* ctx.ui.setWidget("my-progress", (tui, theme) => {
|
||||
* return new MyProgressComponent(tui, theme);
|
||||
* });
|
||||
*
|
||||
* // Clear the widget
|
||||
* ctx.ui.setWidget("my-progress", undefined);
|
||||
*/
|
||||
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||
|
||||
/**
|
||||
* Set the terminal window/tab title.
|
||||
* Uses OSC escape sequence (works in most modern terminals).
|
||||
* @param title - Title text to display
|
||||
*/
|
||||
setTitle(title: string): void;
|
||||
|
||||
/**
|
||||
* Show a custom component with keyboard focus.
|
||||
* The factory receives TUI, theme, and a done() callback to close the component.
|
||||
* Can be async for fire-and-forget work (don't await the work, just start it).
|
||||
*
|
||||
* @param factory - Function that creates the component. Call done() when finished.
|
||||
* @returns Promise that resolves with the value passed to done()
|
||||
*
|
||||
* @example
|
||||
* // Sync factory
|
||||
* const result = await ctx.ui.custom((tui, theme, done) => {
|
||||
* const component = new MyComponent(tui, theme);
|
||||
* component.onFinish = (value) => done(value);
|
||||
* return component;
|
||||
* });
|
||||
*
|
||||
* // Async factory with fire-and-forget work
|
||||
* const result = await ctx.ui.custom(async (tui, theme, done) => {
|
||||
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
|
||||
* loader.onAbort = () => done(null);
|
||||
* doWork(loader.signal).then(done); // Don't await - fire and forget
|
||||
* return loader;
|
||||
* });
|
||||
*/
|
||||
custom<T>(
|
||||
factory: (
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
done: (result: T) => void,
|
||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* Set the text in the core input editor.
|
||||
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
|
||||
* @param text - Text to set in the editor
|
||||
*/
|
||||
setEditorText(text: string): void;
|
||||
|
||||
/**
|
||||
* Get the current text from the core input editor.
|
||||
* @returns Current editor text
|
||||
*/
|
||||
getEditorText(): string;
|
||||
|
||||
/**
|
||||
* Show a multi-line editor for text editing.
|
||||
* Supports Ctrl+G to open external editor ($VISUAL or $EDITOR).
|
||||
* @param title - Title describing what is being edited
|
||||
* @param prefill - Optional initial text
|
||||
* @returns Edited text, or undefined if cancelled (Escape)
|
||||
*/
|
||||
editor(title: string, prefill?: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Get the current theme for styling text with ANSI codes.
|
||||
* Use theme.fg() and theme.bg() to style status text.
|
||||
*
|
||||
* @example
|
||||
* const theme = ctx.ui.theme;
|
||||
* ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + " Ready");
|
||||
*/
|
||||
readonly theme: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to hook event handlers.
|
||||
* For command handlers, see HookCommandContext which extends this with session control methods.
|
||||
*/
|
||||
export interface HookContext {
|
||||
/** UI methods for user interaction */
|
||||
ui: HookUIContext;
|
||||
/** Whether UI is available (false in print mode) */
|
||||
hasUI: boolean;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry - use for API key resolution and model retrieval */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** Abort the current agent operation (fire-and-forget, does not wait) */
|
||||
abort(): void;
|
||||
/** Whether there are queued messages waiting to be processed */
|
||||
hasPendingMessages(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended context for slash command handlers.
|
||||
* Includes session control methods that are only safe in user-initiated commands.
|
||||
*
|
||||
* These methods are not available in event handlers because they can cause
|
||||
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
|
||||
*/
|
||||
export interface HookCommandContext extends HookContext {
|
||||
/** Wait for the agent to finish streaming */
|
||||
waitForIdle(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Start a new session, optionally with a setup callback to initialize it.
|
||||
* The setup callback receives a writable SessionManager for the new session.
|
||||
*
|
||||
* @param options.parentSession - Path to parent session for lineage tracking
|
||||
* @param options.setup - Async callback to initialize the new session (e.g., append messages)
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the new session
|
||||
*
|
||||
* @example
|
||||
* // Handoff: summarize current session and start fresh with context
|
||||
* await ctx.newSession({
|
||||
* parentSession: ctx.sessionManager.getSessionFile(),
|
||||
* setup: async (sm) => {
|
||||
* sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] });
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
newSession(options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Branch from a specific entry, creating a new session file.
|
||||
*
|
||||
* @param entryId - ID of the entry to branch from
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the branch
|
||||
*/
|
||||
branch(entryId: string): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Navigate to a different point in the session tree (in-place).
|
||||
*
|
||||
* @param targetId - ID of the entry to navigate to
|
||||
* @param options.summarize - Whether to summarize the abandoned branch
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the navigation
|
||||
*/
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired on initial session load */
|
||||
export interface SessionStartEvent {
|
||||
type: "session_start";
|
||||
}
|
||||
|
||||
/** Fired before switching to another session (can be cancelled) */
|
||||
export interface SessionBeforeSwitchEvent {
|
||||
type: "session_before_switch";
|
||||
/** Reason for the switch */
|
||||
reason: "new" | "resume";
|
||||
/** Session file we're switching to (only for "resume") */
|
||||
targetSessionFile?: string;
|
||||
}
|
||||
|
||||
/** Fired after switching to another session */
|
||||
export interface SessionSwitchEvent {
|
||||
type: "session_switch";
|
||||
/** Reason for the switch */
|
||||
reason: "new" | "resume";
|
||||
/** Session file we came from */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before branching a session (can be cancelled) */
|
||||
export interface SessionBeforeBranchEvent {
|
||||
type: "session_before_branch";
|
||||
/** ID of the entry to branch from */
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
/** Fired after branching a session */
|
||||
export interface SessionBranchEvent {
|
||||
type: "session_branch";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before context compaction (can be cancelled or customized) */
|
||||
export interface SessionBeforeCompactEvent {
|
||||
type: "session_before_compact";
|
||||
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
|
||||
preparation: CompactionPreparation;
|
||||
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
|
||||
branchEntries: SessionEntry[];
|
||||
/** Optional user-provided instructions for the summary */
|
||||
customInstructions?: string;
|
||||
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after context compaction */
|
||||
export interface SessionCompactEvent {
|
||||
type: "session_compact";
|
||||
compactionEntry: CompactionEntry;
|
||||
/** Whether the compaction entry was provided by a hook */
|
||||
fromHook: boolean;
|
||||
}
|
||||
|
||||
/** Fired on process exit (SIGINT/SIGTERM) */
|
||||
export interface SessionShutdownEvent {
|
||||
type: "session_shutdown";
|
||||
}
|
||||
|
||||
/** Preparation data for tree navigation (used by session_before_tree event) */
|
||||
export interface TreePreparation {
|
||||
/** Node being switched to */
|
||||
targetId: string;
|
||||
/** Current active leaf (being abandoned), null if no current position */
|
||||
oldLeafId: string | null;
|
||||
/** Common ancestor of target and old leaf, null if no common ancestor */
|
||||
commonAncestorId: string | null;
|
||||
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
||||
entriesToSummarize: SessionEntry[];
|
||||
/** Whether user chose to summarize */
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
/** Fired before navigating to a different node in the session tree (can be cancelled) */
|
||||
export interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
/** Preparation data for the navigation */
|
||||
preparation: TreePreparation;
|
||||
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after navigating to a different node in the session tree */
|
||||
export interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
/** The new active leaf, null if navigated to before first entry */
|
||||
newLeafId: string | null;
|
||||
/** Previous active leaf, null if there was no position */
|
||||
oldLeafId: string | null;
|
||||
/** Branch summary entry if one was created */
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
/** Whether summary came from hook */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/** Union of all session event types */
|
||||
export type SessionEvent =
|
||||
| SessionStartEvent
|
||||
| SessionBeforeSwitchEvent
|
||||
| SessionSwitchEvent
|
||||
| SessionBeforeBranchEvent
|
||||
| SessionBranchEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionShutdownEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent;
|
||||
|
||||
/**
|
||||
* Event data for context event.
|
||||
* Fired before each LLM call, allowing hooks to modify context non-destructively.
|
||||
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
|
||||
*/
|
||||
export interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for before_agent_start event.
|
||||
* Fired after user submits a prompt but before the agent loop starts.
|
||||
* Allows hooks to inject context that will be persisted and visible in TUI.
|
||||
*/
|
||||
export interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
/** The user's prompt text */
|
||||
prompt: string;
|
||||
/** Any images attached to the prompt */
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for agent_start event.
|
||||
* Fired when an agent loop starts (once per user prompt).
|
||||
*/
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for agent_end event.
|
||||
*/
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for turn_start event.
|
||||
*/
|
||||
export interface TurnStartEvent {
|
||||
type: "turn_start";
|
||||
turnIndex: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for turn_end event.
|
||||
*/
|
||||
export interface TurnEndEvent {
|
||||
type: "turn_end";
|
||||
turnIndex: number;
|
||||
message: AgentMessage;
|
||||
toolResults: ToolResultMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_call event.
|
||||
* Fired before a tool is executed. Hooks can block execution.
|
||||
*/
|
||||
export interface ToolCallEvent {
|
||||
type: "tool_call";
|
||||
/** Tool name (e.g., "bash", "edit", "write") */
|
||||
toolName: string;
|
||||
/** Tool call ID */
|
||||
toolCallId: string;
|
||||
/** Tool input parameters */
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for tool_result events.
|
||||
*/
|
||||
interface ToolResultEventBase {
|
||||
type: "tool_result";
|
||||
/** Tool call ID */
|
||||
toolCallId: string;
|
||||
/** Tool input parameters */
|
||||
input: Record<string, unknown>;
|
||||
/** Full content array (text and images) */
|
||||
content: (TextContent | ImageContent)[];
|
||||
/** Whether the tool execution was an error */
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/** Tool result event for bash tool */
|
||||
export interface BashToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "bash";
|
||||
details: BashToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for read tool */
|
||||
export interface ReadToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "read";
|
||||
details: ReadToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for edit tool */
|
||||
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "edit";
|
||||
details: EditToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for write tool */
|
||||
export interface WriteToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "write";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for grep tool */
|
||||
export interface GrepToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "grep";
|
||||
details: GrepToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for find tool */
|
||||
export interface FindToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "find";
|
||||
details: FindToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for ls tool */
|
||||
export interface LsToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "ls";
|
||||
details: LsToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for custom/unknown tools */
|
||||
export interface CustomToolResultEvent extends ToolResultEventBase {
|
||||
toolName: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_result event.
|
||||
* Fired after a tool is executed. Hooks can modify the result.
|
||||
* Use toolName to discriminate and get typed details.
|
||||
*/
|
||||
export type ToolResultEvent =
|
||||
| BashToolResultEvent
|
||||
| ReadToolResultEvent
|
||||
| EditToolResultEvent
|
||||
| WriteToolResultEvent
|
||||
| GrepToolResultEvent
|
||||
| FindToolResultEvent
|
||||
| LsToolResultEvent
|
||||
| CustomToolResultEvent;
|
||||
|
||||
// Type guards for narrowing ToolResultEvent to specific tool types
|
||||
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
|
||||
return e.toolName === "bash";
|
||||
}
|
||||
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
|
||||
return e.toolName === "read";
|
||||
}
|
||||
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
|
||||
return e.toolName === "edit";
|
||||
}
|
||||
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
|
||||
return e.toolName === "write";
|
||||
}
|
||||
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
|
||||
return e.toolName === "grep";
|
||||
}
|
||||
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
|
||||
return e.toolName === "find";
|
||||
}
|
||||
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||
return e.toolName === "ls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all hook event types.
|
||||
*/
|
||||
export type HookEvent =
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| BeforeAgentStartEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| TurnStartEvent
|
||||
| TurnEndEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Event Results
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Return type for context event handlers.
|
||||
* Allows hooks to modify messages before they're sent to the LLM.
|
||||
*/
|
||||
export interface ContextEventResult {
|
||||
/** Modified messages to send instead of the original */
|
||||
messages?: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for tool_call event handlers.
|
||||
* Allows hooks to block tool execution.
|
||||
*/
|
||||
export interface ToolCallEventResult {
|
||||
/** If true, block the tool from executing */
|
||||
block?: boolean;
|
||||
/** Reason for blocking (returned to LLM as error) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for tool_result event handlers.
|
||||
* Allows hooks to modify tool results.
|
||||
*/
|
||||
export interface ToolResultEventResult {
|
||||
/** Replacement content array (text and images) */
|
||||
content?: (TextContent | ImageContent)[];
|
||||
/** Replacement details */
|
||||
details?: unknown;
|
||||
/** Override isError flag */
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for before_agent_start event handlers.
|
||||
* Allows hooks to inject context before the agent runs.
|
||||
*/
|
||||
export interface BeforeAgentStartEventResult {
|
||||
/** Message to inject into context (persisted to session, visible in TUI) */
|
||||
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
|
||||
/** Text to append to the system prompt for this agent run */
|
||||
systemPromptAppend?: string;
|
||||
}
|
||||
|
||||
/** Return type for session_before_switch handlers */
|
||||
export interface SessionBeforeSwitchResult {
|
||||
/** If true, cancel the switch */
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
/** Return type for session_before_branch handlers */
|
||||
export interface SessionBeforeBranchResult {
|
||||
/**
|
||||
* If true, abort the branch entirely. No new session file is created,
|
||||
* conversation stays unchanged.
|
||||
*/
|
||||
cancel?: boolean;
|
||||
/**
|
||||
* If true, the branch proceeds (new session file created, session state updated)
|
||||
* but the in-memory conversation is NOT rewound to the branch point.
|
||||
*
|
||||
* Use case: git-checkpoint hook that restores code state separately.
|
||||
* The hook handles state restoration itself, so it doesn't want the
|
||||
* agent's conversation to be rewound (which would lose recent context).
|
||||
*
|
||||
* - `cancel: true` → nothing happens, user stays in current session
|
||||
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
|
||||
* - neither → branch happens AND messages rewind to branch point (default)
|
||||
*/
|
||||
skipConversationRestore?: boolean;
|
||||
}
|
||||
|
||||
/** Return type for session_before_compact handlers */
|
||||
export interface SessionBeforeCompactResult {
|
||||
/** If true, cancel the compaction */
|
||||
cancel?: boolean;
|
||||
/** Custom compaction result - SessionManager adds id/parentId */
|
||||
compaction?: CompactionResult;
|
||||
}
|
||||
|
||||
/** Return type for session_before_tree handlers */
|
||||
export interface SessionBeforeTreeResult {
|
||||
/** If true, cancel the navigation entirely */
|
||||
cancel?: boolean;
|
||||
/**
|
||||
* Custom summary (skips default summarizer).
|
||||
* Only used if preparation.userWantsSummary is true.
|
||||
*/
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handler function type for each event.
|
||||
* Handlers can return R, undefined, or void (bare return statements).
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
|
||||
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
|
||||
|
||||
export interface HookMessageRenderOptions {
|
||||
/** Whether the view is expanded */
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for hook messages.
|
||||
* Hooks register these to provide custom TUI rendering for their message types.
|
||||
*/
|
||||
export type HookMessageRenderer<T = unknown> = (
|
||||
message: HookMessage<T>,
|
||||
options: HookMessageRenderOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
/**
|
||||
* Command registration options.
|
||||
*/
|
||||
export interface RegisteredCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
handler: (args: string, ctx: HookCommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HookAPI passed to hook factory functions.
|
||||
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
|
||||
*/
|
||||
export interface HookAPI {
|
||||
// Session events
|
||||
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
|
||||
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
|
||||
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
|
||||
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
|
||||
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
|
||||
on(
|
||||
event: "session_before_compact",
|
||||
handler: HookHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
|
||||
): void;
|
||||
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
|
||||
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
|
||||
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
|
||||
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
|
||||
|
||||
// Context and agent events
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
|
||||
on(event: "before_agent_start", handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
|
||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||
|
||||
/**
|
||||
* Send a custom message to the session. Creates a CustomMessageEntry that
|
||||
* participates in LLM context and can be displayed in the TUI.
|
||||
*
|
||||
* Use this when you want the LLM to see the message content.
|
||||
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
|
||||
*
|
||||
* @param message - The message to send
|
||||
* @param message.customType - Identifier for your hook (used for filtering on reload)
|
||||
* @param message.content - Message content (string or TextContent/ImageContent array)
|
||||
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
|
||||
* @param message.details - Optional hook-specific metadata (not sent to LLM)
|
||||
* @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn.
|
||||
* Required for async patterns where you want the agent to respond.
|
||||
* If agent is streaming, message is queued and triggerTurn is ignored.
|
||||
* @param options.deliverAs - How to deliver the message. Default: "steer".
|
||||
* - "steer": (streaming) Interrupt mid-run, delivered after current tool execution.
|
||||
* - "followUp": (streaming) Wait until agent finishes all work before delivery.
|
||||
* - "nextTurn": (idle) Queue to be included with the next user message as context.
|
||||
* The message becomes an "aside" - context for the next turn without
|
||||
* triggering a turn or appearing as a standalone entry.
|
||||
*/
|
||||
sendMessage<T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Append a custom entry to the session for hook state persistence.
|
||||
* Creates a CustomEntry that does NOT participate in LLM context.
|
||||
*
|
||||
* Use this to store hook-specific data that should persist across session reloads
|
||||
* but should NOT be sent to the LLM. On reload, scan session entries for your
|
||||
* customType to reconstruct hook state.
|
||||
*
|
||||
* For messages that SHOULD be sent to the LLM, use sendMessage() instead.
|
||||
*
|
||||
* @param customType - Identifier for your hook (used for filtering on reload)
|
||||
* @param data - Hook-specific data to persist (must be JSON-serializable)
|
||||
*
|
||||
* @example
|
||||
* // Store permission state
|
||||
* pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() });
|
||||
*
|
||||
* // On reload, reconstruct state from entries
|
||||
* pi.on("session", async (event, ctx) => {
|
||||
* if (event.reason === "start") {
|
||||
* const entries = event.sessionManager.getEntries();
|
||||
* const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions");
|
||||
* // Reconstruct state from myEntries...
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void;
|
||||
|
||||
/**
|
||||
* Register a custom renderer for CustomMessageEntry with a specific customType.
|
||||
* The renderer is called when rendering the entry in the TUI.
|
||||
* Return nothing to use the default renderer.
|
||||
*/
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
|
||||
|
||||
/**
|
||||
* Register a custom slash command.
|
||||
* Handler receives HookCommandContext with session control methods.
|
||||
*/
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout/stderr/code.
|
||||
* Supports timeout and abort signal.
|
||||
*/
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
|
||||
/**
|
||||
* Get the list of currently active tool names.
|
||||
* @returns Array of tool names (e.g., ["read", "bash", "edit", "write"])
|
||||
*/
|
||||
getActiveTools(): string[];
|
||||
|
||||
/**
|
||||
* Get all configured tools (built-in via --tools or default, plus custom tools).
|
||||
* @returns Array of all tool names
|
||||
*/
|
||||
getAllTools(): string[];
|
||||
|
||||
/**
|
||||
* Set the active tools by name.
|
||||
* Both built-in and custom tools can be enabled/disabled.
|
||||
* Changes take effect on the next agent turn.
|
||||
* Note: This will invalidate prompt caching for the next request.
|
||||
*
|
||||
* @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"])
|
||||
*
|
||||
* @example
|
||||
* // Switch to read-only mode (plan mode)
|
||||
* pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
|
||||
*
|
||||
* // Restore full access
|
||||
* pi.setActiveTools(["read", "bash", "edit", "write"]);
|
||||
*/
|
||||
setActiveTools(toolNames: string[]): void;
|
||||
|
||||
/**
|
||||
* Register a CLI flag for this hook.
|
||||
* Flags are parsed from command line and values accessible via getFlag().
|
||||
*
|
||||
* @param name - Flag name (will be --name on CLI)
|
||||
* @param options - Flag configuration
|
||||
*
|
||||
* @example
|
||||
* pi.registerFlag("plan", {
|
||||
* description: "Start in plan mode (read-only)",
|
||||
* type: "boolean",
|
||||
* });
|
||||
*/
|
||||
registerFlag(
|
||||
name: string,
|
||||
options: {
|
||||
/** Description shown in --help */
|
||||
description?: string;
|
||||
/** Flag type: boolean (--flag) or string (--flag value) */
|
||||
type: "boolean" | "string";
|
||||
/** Default value */
|
||||
default?: boolean | string;
|
||||
},
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Get the value of a CLI flag registered by this hook.
|
||||
* Returns undefined if flag was not provided and has no default.
|
||||
*
|
||||
* @param name - Flag name (without --)
|
||||
* @returns Flag value, or undefined
|
||||
*
|
||||
* @example
|
||||
* if (pi.getFlag("plan")) {
|
||||
* // plan mode enabled
|
||||
* }
|
||||
*/
|
||||
getFlag(name: string): boolean | string | undefined;
|
||||
|
||||
/**
|
||||
* Register a keyboard shortcut for this hook.
|
||||
* The handler is called when the shortcut is pressed in interactive mode.
|
||||
*
|
||||
* @param shortcut - Key identifier (e.g., Key.shift("p"), "ctrl+x")
|
||||
* @param options - Shortcut configuration
|
||||
*
|
||||
* @example
|
||||
* import { Key } from "@mariozechner/pi-tui";
|
||||
*
|
||||
* pi.registerShortcut(Key.shift("p"), {
|
||||
* description: "Toggle plan mode",
|
||||
* handler: async (ctx) => {
|
||||
* // toggle plan mode
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
registerShortcut(
|
||||
shortcut: KeyId,
|
||||
options: {
|
||||
/** Description shown in help */
|
||||
description?: string;
|
||||
/** Handler called when shortcut is pressed */
|
||||
handler: (ctx: HookContext) => Promise<void> | void;
|
||||
},
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Shared event bus for tool/hook communication.
|
||||
* Tools can emit events, hooks can listen for them.
|
||||
*
|
||||
* @example
|
||||
* // Hook listening for events
|
||||
* pi.events.on("subagent:complete", (data) => {
|
||||
* pi.sendMessage({ customType: "notify", content: `Done: ${data.summary}` });
|
||||
* });
|
||||
*
|
||||
* // Tool emitting events (in custom tool)
|
||||
* pi.events.emit("my:event", { status: "complete" });
|
||||
*/
|
||||
events: EventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook factory function type.
|
||||
* Hooks export a default function that receives the HookAPI.
|
||||
*/
|
||||
export type HookFactory = (pi: HookAPI) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Errors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Error emitted when a hook fails.
|
||||
*/
|
||||
export interface HookError {
|
||||
hookPath: string;
|
||||
event: string;
|
||||
error: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
|
@ -13,26 +13,49 @@ export {
|
|||
} from "./agent-session.js";
|
||||
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
|
||||
export type { CompactionResult } from "./compaction/index.js";
|
||||
export {
|
||||
type CustomTool,
|
||||
type CustomToolAPI,
|
||||
type CustomToolFactory,
|
||||
type CustomToolsLoadResult,
|
||||
type CustomToolUIContext,
|
||||
discoverAndLoadCustomTools,
|
||||
type ExecResult,
|
||||
type LoadedCustomTool,
|
||||
loadCustomTools,
|
||||
type RenderResultOptions,
|
||||
} from "./custom-tools/index.js";
|
||||
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
|
||||
|
||||
// Extensions system
|
||||
export {
|
||||
type HookAPI,
|
||||
type HookContext,
|
||||
type HookError,
|
||||
type HookEvent,
|
||||
type HookFactory,
|
||||
HookRunner,
|
||||
type HookUIContext,
|
||||
loadHooks,
|
||||
} from "./hooks/index.js";
|
||||
type AgentEndEvent,
|
||||
type AgentStartEvent,
|
||||
type AgentToolResult,
|
||||
type AgentToolUpdateCallback,
|
||||
type BeforeAgentStartEvent,
|
||||
type ContextEvent,
|
||||
discoverAndLoadExtensions,
|
||||
type ExecOptions,
|
||||
type ExecResult,
|
||||
type ExtensionAPI,
|
||||
type ExtensionCommandContext,
|
||||
type ExtensionContext,
|
||||
type ExtensionError,
|
||||
type ExtensionEvent,
|
||||
type ExtensionFactory,
|
||||
type ExtensionFlag,
|
||||
type ExtensionHandler,
|
||||
ExtensionRunner,
|
||||
type ExtensionShortcut,
|
||||
type ExtensionUIContext,
|
||||
type LoadExtensionsResult,
|
||||
type LoadedExtension,
|
||||
type MessageRenderer,
|
||||
type RegisteredCommand,
|
||||
type SessionBeforeBranchEvent,
|
||||
type SessionBeforeCompactEvent,
|
||||
type SessionBeforeSwitchEvent,
|
||||
type SessionBeforeTreeEvent,
|
||||
type SessionBranchEvent,
|
||||
type SessionCompactEvent,
|
||||
type SessionShutdownEvent,
|
||||
type SessionStartEvent,
|
||||
type SessionSwitchEvent,
|
||||
type SessionTreeEvent,
|
||||
type ToolCallEvent,
|
||||
type ToolDefinition,
|
||||
type ToolRenderResultOptions,
|
||||
type ToolResultEvent,
|
||||
type TurnEndEvent,
|
||||
type TurnStartEvent,
|
||||
wrapToolsWithExtensions,
|
||||
} from "./extensions/index.js";
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ export interface BashExecutionMessage {
|
|||
}
|
||||
|
||||
/**
|
||||
* Message type for hook-injected messages via sendMessage().
|
||||
* These are custom messages that hooks can inject into the conversation.
|
||||
* Message type for extension-injected messages via sendMessage().
|
||||
* These are custom messages that extensions can inject into the conversation.
|
||||
*/
|
||||
export interface HookMessage<T = unknown> {
|
||||
role: "hookMessage";
|
||||
export interface CustomMessage<T = unknown> {
|
||||
role: "custom";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
display: boolean;
|
||||
|
|
@ -70,7 +70,7 @@ export interface CompactionSummaryMessage {
|
|||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
bashExecution: BashExecutionMessage;
|
||||
hookMessage: HookMessage;
|
||||
custom: CustomMessage;
|
||||
branchSummary: BranchSummaryMessage;
|
||||
compactionSummary: CompactionSummaryMessage;
|
||||
}
|
||||
|
|
@ -120,15 +120,15 @@ export function createCompactionSummaryMessage(
|
|||
}
|
||||
|
||||
/** Convert CustomMessageEntry to AgentMessage format */
|
||||
export function createHookMessage(
|
||||
export function createCustomMessage(
|
||||
customType: string,
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
display: boolean,
|
||||
details: unknown | undefined,
|
||||
timestamp: string,
|
||||
): HookMessage {
|
||||
): CustomMessage {
|
||||
return {
|
||||
role: "hookMessage",
|
||||
role: "custom",
|
||||
customType,
|
||||
content,
|
||||
display,
|
||||
|
|
@ -143,7 +143,7 @@ export function createHookMessage(
|
|||
* This is used by:
|
||||
* - Agent's transormToLlm option (for prompt calls and queued messages)
|
||||
* - Compaction's generateSummary (for summarization)
|
||||
* - Custom hooks and tools
|
||||
* - Custom extensions and tools
|
||||
*/
|
||||
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages
|
||||
|
|
@ -159,7 +159,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|||
content: [{ type: "text", text: bashExecutionToText(m) }],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
case "hookMessage": {
|
||||
case "custom": {
|
||||
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
|
||||
return {
|
||||
role: "user",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME, getCommandsDir } from "../config.js";
|
||||
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
|
||||
|
||||
/**
|
||||
* Represents a custom slash command loaded from a file
|
||||
* Represents a prompt template loaded from a markdown file
|
||||
*/
|
||||
export interface FileSlashCommand {
|
||||
export interface PromptTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
|
|
@ -80,7 +80,7 @@ export function parseCommandArgs(argsString: string): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Substitute argument placeholders in command content
|
||||
* Substitute argument placeholders in template content
|
||||
* Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
|
||||
*
|
||||
* Note: Replacement happens on the template string only. Argument values
|
||||
|
|
@ -109,13 +109,13 @@ export function substituteArgs(content: string, args: string[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
|
||||
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
|
||||
*/
|
||||
function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] {
|
||||
const commands: FileSlashCommand[] = [];
|
||||
function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] {
|
||||
const templates: PromptTemplate[] = [];
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
return commands;
|
||||
return templates;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -127,7 +127,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
if (entry.isDirectory()) {
|
||||
// Recurse into subdirectory
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
commands.push(...loadCommandsFromDir(fullPath, source, newSubdir));
|
||||
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir));
|
||||
} else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
|
|
@ -157,7 +157,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
// Append source to description
|
||||
description = description ? `${description} ${sourceStr}` : sourceStr;
|
||||
|
||||
commands.push({
|
||||
templates.push({
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
|
|
@ -172,54 +172,54 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
// Silently skip directories that can't be read
|
||||
}
|
||||
|
||||
return commands;
|
||||
return templates;
|
||||
}
|
||||
|
||||
export interface LoadSlashCommandsOptions {
|
||||
/** Working directory for project-local commands. Default: process.cwd() */
|
||||
export interface LoadPromptTemplatesOptions {
|
||||
/** Working directory for project-local templates. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory for global commands. Default: from getCommandsDir() */
|
||||
/** Agent config directory for global templates. Default: from getPromptsDir() */
|
||||
agentDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all custom slash commands from:
|
||||
* 1. Global: agentDir/commands/
|
||||
* 2. Project: cwd/{CONFIG_DIR_NAME}/commands/
|
||||
* Load all prompt templates from:
|
||||
* 1. Global: agentDir/prompts/
|
||||
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
|
||||
*/
|
||||
export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
|
||||
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
|
||||
const resolvedCwd = options.cwd ?? process.cwd();
|
||||
const resolvedAgentDir = options.agentDir ?? getCommandsDir();
|
||||
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
|
||||
|
||||
const commands: FileSlashCommand[] = [];
|
||||
const templates: PromptTemplate[] = [];
|
||||
|
||||
// 1. Load global commands from agentDir/commands/
|
||||
// Note: if agentDir is provided, it should be the agent dir, not the commands dir
|
||||
const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
|
||||
commands.push(...loadCommandsFromDir(globalCommandsDir, "user"));
|
||||
// 1. Load global templates from agentDir/prompts/
|
||||
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
|
||||
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
|
||||
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
|
||||
|
||||
// 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
|
||||
const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
|
||||
commands.push(...loadCommandsFromDir(projectCommandsDir, "project"));
|
||||
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
|
||||
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
|
||||
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
|
||||
|
||||
return commands;
|
||||
return templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a slash command if it matches a file-based command.
|
||||
* Returns the expanded content or the original text if not a slash command.
|
||||
* Expand a prompt template if it matches a template name.
|
||||
* Returns the expanded content or the original text if not a template.
|
||||
*/
|
||||
export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string {
|
||||
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
|
||||
if (!text.startsWith("/")) return text;
|
||||
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||||
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||||
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
||||
|
||||
const fileCommand = fileCommands.find((cmd) => cmd.name === commandName);
|
||||
if (fileCommand) {
|
||||
const template = templates.find((t) => t.name === templateName);
|
||||
if (template) {
|
||||
const args = parseCommandArgs(argsString);
|
||||
return substituteArgs(fileCommand.content, args);
|
||||
return substituteArgs(template.content, args);
|
||||
}
|
||||
|
||||
return text;
|
||||
|
|
@ -9,20 +9,11 @@
|
|||
* // Minimal - everything auto-discovered
|
||||
* const session = await createAgentSession();
|
||||
*
|
||||
* // With custom hooks
|
||||
* const session = await createAgentSession({
|
||||
* hooks: [
|
||||
* ...await discoverHooks(),
|
||||
* { factory: myHookFactory },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* // Full control
|
||||
* const session = await createAgentSession({
|
||||
* model: myModel,
|
||||
* getApiKey: async () => process.env.MY_KEY,
|
||||
* tools: [readTool, bashTool],
|
||||
* hooks: [],
|
||||
* skills: [],
|
||||
* sessionFile: false,
|
||||
* });
|
||||
|
|
@ -31,27 +22,28 @@
|
|||
|
||||
import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { KeyId } from "@mariozechner/pi-tui";
|
||||
import { join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import {
|
||||
type CustomToolsLoadResult,
|
||||
discoverAndLoadCustomTools,
|
||||
type LoadedCustomTool,
|
||||
wrapCustomTools,
|
||||
} from "./custom-tools/index.js";
|
||||
import type { CustomTool } from "./custom-tools/types.js";
|
||||
import { createEventBus, type EventBus } from "./event-bus.js";
|
||||
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
|
||||
import type { HookFactory } from "./hooks/types.js";
|
||||
import {
|
||||
discoverAndLoadExtensions,
|
||||
type ExtensionFactory,
|
||||
ExtensionRunner,
|
||||
type LoadExtensionsResult,
|
||||
type LoadedExtension,
|
||||
loadExtensionFromFactory,
|
||||
type ToolDefinition,
|
||||
wrapRegisteredTools,
|
||||
wrapToolsWithExtensions,
|
||||
} from "./extensions/index.js";
|
||||
import { convertToLlm } from "./messages.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
|
||||
import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js";
|
||||
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js";
|
||||
import {
|
||||
buildSystemPrompt as buildSystemPromptInternal,
|
||||
loadProjectContextFiles as loadContextFilesInternal,
|
||||
|
|
@ -107,27 +99,27 @@ export interface CreateAgentSessionOptions {
|
|||
|
||||
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
|
||||
tools?: Tool[];
|
||||
/** Custom tools (replaces discovery). */
|
||||
customTools?: Array<{ path?: string; tool: CustomTool }>;
|
||||
/** Additional custom tool paths to load (merged with discovery). */
|
||||
additionalCustomToolPaths?: string[];
|
||||
/** Custom tools to register (in addition to built-in tools). */
|
||||
customTools?: ToolDefinition[];
|
||||
/** Inline extensions (merged with discovery). */
|
||||
extensions?: ExtensionFactory[];
|
||||
/** Additional extension paths to load (merged with discovery). */
|
||||
additionalExtensionPaths?: string[];
|
||||
/**
|
||||
* Pre-loaded extensions (skips file discovery).
|
||||
* @internal Used by CLI when extensions are loaded early to parse custom flags.
|
||||
*/
|
||||
preloadedExtensions?: LoadedExtension[];
|
||||
|
||||
/** Hooks (replaces discovery). */
|
||||
hooks?: Array<{ path?: string; factory: HookFactory }>;
|
||||
/** Additional hook paths to load (merged with discovery). */
|
||||
additionalHookPaths?: string[];
|
||||
/** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */
|
||||
preloadedHooks?: LoadedHook[];
|
||||
|
||||
/** Shared event bus for tool/hook communication. Default: creates new bus. */
|
||||
/** Shared event bus for tool/extension communication. Default: creates new bus. */
|
||||
eventBus?: EventBus;
|
||||
|
||||
/** Skills. Default: discovered from multiple locations */
|
||||
skills?: Skill[];
|
||||
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
/** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */
|
||||
slashCommands?: FileSlashCommand[];
|
||||
/** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */
|
||||
promptTemplates?: PromptTemplate[];
|
||||
|
||||
/** Session manager. Default: SessionManager.create(cwd) */
|
||||
sessionManager?: SessionManager;
|
||||
|
|
@ -140,19 +132,24 @@ export interface CreateAgentSessionOptions {
|
|||
export interface CreateAgentSessionResult {
|
||||
/** The created session */
|
||||
session: AgentSession;
|
||||
/** Custom tools result (for UI context setup in interactive mode) */
|
||||
customToolsResult: CustomToolsLoadResult;
|
||||
/** Extensions result (for UI context setup in interactive mode) */
|
||||
extensionsResult: LoadExtensionsResult;
|
||||
/** Warning if session was restored with a different model than saved */
|
||||
modelFallbackMessage?: string;
|
||||
}
|
||||
|
||||
// Re-exports
|
||||
|
||||
export type { CustomTool } from "./custom-tools/types.js";
|
||||
export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js";
|
||||
export type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
ExtensionFactory,
|
||||
ToolDefinition,
|
||||
} from "./extensions/index.js";
|
||||
export type { PromptTemplate } from "./prompt-templates.js";
|
||||
export type { Settings, SkillsSettings } from "./settings-manager.js";
|
||||
export type { Skill } from "./skills.js";
|
||||
export type { FileSlashCommand } from "./slash-commands.js";
|
||||
export type { Tool } from "./tools/index.js";
|
||||
|
||||
export {
|
||||
|
|
@ -202,63 +199,27 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD
|
|||
}
|
||||
|
||||
/**
|
||||
* Discover hooks from cwd and agentDir.
|
||||
* Discover extensions from cwd and agentDir.
|
||||
* @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too.
|
||||
* @param cwd - Current working directory
|
||||
* @param agentDir - Agent configuration directory
|
||||
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
|
||||
*/
|
||||
export async function discoverHooks(
|
||||
export async function discoverExtensions(
|
||||
eventBus: EventBus,
|
||||
cwd?: string,
|
||||
agentDir?: string,
|
||||
eventBus?: EventBus,
|
||||
): Promise<Array<{ path: string; factory: HookFactory }>> {
|
||||
): Promise<LoadExtensionsResult> {
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
||||
|
||||
const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir, eventBus);
|
||||
const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus);
|
||||
|
||||
// Log errors but don't fail
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load hook "${path}": ${error}`);
|
||||
for (const { path, error } of result.errors) {
|
||||
console.error(`Failed to load extension "${path}": ${error}`);
|
||||
}
|
||||
|
||||
return hooks.map((h) => ({
|
||||
path: h.path,
|
||||
factory: createFactoryFromLoadedHook(h),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom tools from cwd and agentDir.
|
||||
* @param cwd - Current working directory
|
||||
* @param agentDir - Agent configuration directory
|
||||
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
|
||||
*/
|
||||
export async function discoverCustomTools(
|
||||
cwd?: string,
|
||||
agentDir?: string,
|
||||
eventBus?: EventBus,
|
||||
): Promise<Array<{ path: string; tool: CustomTool }>> {
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
||||
|
||||
const { tools, errors } = await discoverAndLoadCustomTools(
|
||||
[],
|
||||
resolvedCwd,
|
||||
Object.keys(allTools),
|
||||
resolvedAgentDir,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
// Log errors but don't fail
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load custom tool "${path}": ${error}`);
|
||||
}
|
||||
|
||||
return tools.map((t) => ({
|
||||
path: t.path,
|
||||
tool: t.tool,
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -284,10 +245,10 @@ export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ p
|
|||
}
|
||||
|
||||
/**
|
||||
* Discover slash commands from cwd and agentDir.
|
||||
* Discover prompt templates from cwd and agentDir.
|
||||
*/
|
||||
export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] {
|
||||
return loadSlashCommandsInternal({
|
||||
export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] {
|
||||
return loadPromptTemplatesInternal({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
||||
});
|
||||
|
|
@ -336,139 +297,12 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|||
hideThinkingBlock: manager.getHideThinkingBlock(),
|
||||
shellPath: manager.getShellPath(),
|
||||
collapseChangelog: manager.getCollapseChangelog(),
|
||||
hooks: manager.getHookPaths(),
|
||||
customTools: manager.getCustomToolPaths(),
|
||||
extensions: manager.getExtensionPaths(),
|
||||
skills: manager.getSkillsSettings(),
|
||||
terminal: { showImages: manager.getShowImages() },
|
||||
};
|
||||
}
|
||||
|
||||
// Internal Helpers
|
||||
|
||||
/**
|
||||
* Create a HookFactory from a LoadedHook.
|
||||
* This allows mixing discovered hooks with inline hooks.
|
||||
*/
|
||||
function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
|
||||
return (api) => {
|
||||
for (const [eventType, handlers] of loaded.handlers) {
|
||||
for (const handler of handlers) {
|
||||
api.on(eventType as any, handler as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hook definitions to LoadedHooks for the HookRunner.
|
||||
*/
|
||||
function createLoadedHooksFromDefinitions(
|
||||
definitions: Array<{ path?: string; factory: HookFactory }>,
|
||||
eventBus: EventBus,
|
||||
): LoadedHook[] {
|
||||
return definitions.map((def) => {
|
||||
const hookPath = def.path ?? "<inline>";
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
|
||||
const messageRenderers = new Map<string, any>();
|
||||
const commands = new Map<string, any>();
|
||||
const flags = new Map<string, any>();
|
||||
const flagValues = new Map<string, boolean | string>();
|
||||
const shortcuts = new Map<KeyId, any>();
|
||||
let sendMessageHandler: (
|
||||
message: any,
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
|
||||
) => void = () => {};
|
||||
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
|
||||
let getActiveToolsHandler: () => string[] = () => [];
|
||||
let getAllToolsHandler: () => string[] = () => [];
|
||||
let setActiveToolsHandler: (toolNames: string[]) => void = () => {};
|
||||
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
|
||||
let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
|
||||
let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
|
||||
cancelled: false,
|
||||
});
|
||||
|
||||
const api = {
|
||||
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
sendMessage: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => {
|
||||
sendMessageHandler(message, options);
|
||||
},
|
||||
appendEntry: (customType: string, data?: any) => {
|
||||
appendEntryHandler(customType, data);
|
||||
},
|
||||
registerMessageRenderer: (customType: string, renderer: any) => {
|
||||
messageRenderers.set(customType, renderer);
|
||||
},
|
||||
registerCommand: (name: string, options: any) => {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
registerFlag: (name: string, options: any) => {
|
||||
flags.set(name, { name, hookPath, ...options });
|
||||
if (options.default !== undefined) {
|
||||
flagValues.set(name, options.default);
|
||||
}
|
||||
},
|
||||
getFlag: (name: string) => flagValues.get(name),
|
||||
registerShortcut: (shortcut: KeyId, options: any) => {
|
||||
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
|
||||
},
|
||||
newSession: (options?: any) => newSessionHandler(options),
|
||||
branch: (entryId: string) => branchHandler(entryId),
|
||||
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
|
||||
getActiveTools: () => getActiveToolsHandler(),
|
||||
getAllTools: () => getAllToolsHandler(),
|
||||
setActiveTools: (toolNames: string[]) => setActiveToolsHandler(toolNames),
|
||||
events: eventBus,
|
||||
};
|
||||
|
||||
def.factory(api as any);
|
||||
|
||||
return {
|
||||
path: hookPath,
|
||||
resolvedPath: hookPath,
|
||||
handlers,
|
||||
messageRenderers,
|
||||
commands,
|
||||
flags,
|
||||
flagValues,
|
||||
shortcuts,
|
||||
setSendMessageHandler: (
|
||||
handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void,
|
||||
) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => {
|
||||
newSessionHandler = handler;
|
||||
},
|
||||
setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => {
|
||||
branchHandler = handler;
|
||||
},
|
||||
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
|
||||
navigateTreeHandler = handler;
|
||||
},
|
||||
setGetActiveToolsHandler: (handler: () => string[]) => {
|
||||
getActiveToolsHandler = handler;
|
||||
},
|
||||
setGetAllToolsHandler: (handler: () => string[]) => {
|
||||
getAllToolsHandler = handler;
|
||||
},
|
||||
setSetActiveToolsHandler: (handler: (toolNames: string[]) => void) => {
|
||||
setActiveToolsHandler = handler;
|
||||
},
|
||||
setFlagValue: (name: string, value: boolean | string) => {
|
||||
flagValues.set(name, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Factory
|
||||
|
||||
/**
|
||||
|
|
@ -497,7 +331,6 @@ function createLoadedHooksFromDefinitions(
|
|||
* getApiKey: async () => process.env.MY_KEY,
|
||||
* systemPrompt: 'You are helpful.',
|
||||
* tools: [readTool, bashTool],
|
||||
* hooks: [],
|
||||
* skills: [],
|
||||
* sessionManager: SessionManager.inMemory(),
|
||||
* });
|
||||
|
|
@ -592,7 +425,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
time("discoverContextFiles");
|
||||
|
||||
const autoResizeImages = settingsManager.getImageAutoResize();
|
||||
// Create ALL built-in tools for the registry (hooks can enable any of them)
|
||||
// Create ALL built-in tools for the registry (extensions can enable any of them)
|
||||
const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } });
|
||||
// Determine initially active built-in tools (default: read, bash, edit, write)
|
||||
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
|
||||
|
|
@ -602,62 +435,96 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
|
||||
time("createAllTools");
|
||||
|
||||
let customToolsResult: CustomToolsLoadResult;
|
||||
if (options.customTools !== undefined) {
|
||||
// Use provided custom tools
|
||||
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
|
||||
path: ct.path ?? "<inline>",
|
||||
resolvedPath: ct.path ?? "<inline>",
|
||||
tool: ct.tool,
|
||||
}));
|
||||
customToolsResult = {
|
||||
tools: loadedTools,
|
||||
// Load extensions (discovers from standard locations + configured paths)
|
||||
let extensionsResult: LoadExtensionsResult;
|
||||
if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) {
|
||||
// Use pre-loaded extensions (from early CLI flag discovery)
|
||||
extensionsResult = {
|
||||
extensions: options.preloadedExtensions,
|
||||
errors: [],
|
||||
setUIContext: () => {},
|
||||
setSendMessageHandler: () => {},
|
||||
};
|
||||
} else {
|
||||
// Discover custom tools, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
|
||||
customToolsResult = await discoverAndLoadCustomTools(
|
||||
configuredPaths,
|
||||
cwd,
|
||||
Object.keys(allTools),
|
||||
agentDir,
|
||||
eventBus,
|
||||
);
|
||||
time("discoverAndLoadCustomTools");
|
||||
for (const { path, error } of customToolsResult.errors) {
|
||||
console.error(`Failed to load custom tool "${path}": ${error}`);
|
||||
// Discover extensions, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];
|
||||
extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus);
|
||||
time("discoverAndLoadExtensions");
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
console.error(`Failed to load extension "${path}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
let hookRunner: HookRunner | undefined;
|
||||
if (options.preloadedHooks !== undefined && options.preloadedHooks.length > 0) {
|
||||
// Use pre-loaded hooks (from early CLI flag discovery)
|
||||
hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry);
|
||||
} else if (options.hooks !== undefined) {
|
||||
if (options.hooks.length > 0) {
|
||||
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks, eventBus);
|
||||
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
|
||||
}
|
||||
} else {
|
||||
// Discover hooks, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])];
|
||||
const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir, eventBus);
|
||||
time("discoverAndLoadHooks");
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load hook "${path}": ${error}`);
|
||||
}
|
||||
if (hooks.length > 0) {
|
||||
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
|
||||
// Load inline extensions from factories
|
||||
if (options.extensions && options.extensions.length > 0) {
|
||||
// Create shared UI context holder that will be set later
|
||||
const uiHolder: { ui: any; hasUI: boolean } = {
|
||||
ui: {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return {} as any;
|
||||
},
|
||||
},
|
||||
hasUI: false,
|
||||
};
|
||||
for (let i = 0; i < options.extensions.length; i++) {
|
||||
const factory = options.extensions[i];
|
||||
const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, `<inline-${i}>`);
|
||||
extensionsResult.extensions.push(loaded);
|
||||
}
|
||||
// Extend setUIContext to update inline extensions too
|
||||
const originalSetUIContext = extensionsResult.setUIContext;
|
||||
extensionsResult.setUIContext = (uiContext, hasUI) => {
|
||||
originalSetUIContext(uiContext, hasUI);
|
||||
uiHolder.ui = uiContext;
|
||||
uiHolder.hasUI = hasUI;
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap custom tools with context getter (agent/session assigned below, accessed at execute time)
|
||||
// Create extension runner if we have extensions
|
||||
let extensionRunner: ExtensionRunner | undefined;
|
||||
if (extensionsResult.extensions.length > 0) {
|
||||
extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
|
||||
}
|
||||
|
||||
// Wrap extension-registered tools and SDK-provided custom tools with context getter
|
||||
// (agent/session assigned below, accessed at execute time)
|
||||
let agent: Agent;
|
||||
let session: AgentSession;
|
||||
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
|
||||
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
|
||||
// Combine extension-registered tools with SDK-provided custom tools
|
||||
const allCustomTools = [
|
||||
...registeredTools,
|
||||
...(options.customTools?.map((def) => ({ definition: def, extensionPath: "<sdk>" })) ?? []),
|
||||
];
|
||||
const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, () => ({
|
||||
ui: extensionRunner?.getUIContext() ?? {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return {} as any;
|
||||
},
|
||||
},
|
||||
hasUI: extensionRunner?.getHasUI() ?? false,
|
||||
cwd,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
model: agent.state.model,
|
||||
|
|
@ -668,27 +535,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
},
|
||||
}));
|
||||
|
||||
// Create tool registry mapping name -> tool (for hook getTools/setTools)
|
||||
// Registry contains ALL built-in tools so hooks can enable any of them
|
||||
// Create tool registry mapping name -> tool (for extension getTools/setTools)
|
||||
// Registry contains ALL built-in tools so extensions can enable any of them
|
||||
const toolRegistry = new Map<string, AgentTool>();
|
||||
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
|
||||
toolRegistry.set(name, tool as AgentTool);
|
||||
}
|
||||
for (const tool of wrappedCustomTools as AgentTool[]) {
|
||||
for (const tool of wrappedExtensionTools as AgentTool[]) {
|
||||
toolRegistry.set(tool.name, tool);
|
||||
}
|
||||
|
||||
// Initially active tools = active built-in + custom
|
||||
let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedCustomTools];
|
||||
// Initially active tools = active built-in + extension tools
|
||||
let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedExtensionTools];
|
||||
time("combineTools");
|
||||
|
||||
// Wrap tools with hooks if available
|
||||
// Wrap tools with extensions if available
|
||||
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
|
||||
if (hookRunner) {
|
||||
activeToolsArray = wrapToolsWithHooks(activeToolsArray as AgentTool[], hookRunner);
|
||||
// Wrap ALL registry tools (not just active) so hooks can enable any
|
||||
if (extensionRunner) {
|
||||
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
|
||||
// Wrap ALL registry tools (not just active) so extensions can enable any
|
||||
const allRegistryTools = Array.from(toolRegistry.values());
|
||||
const wrappedAllTools = wrapToolsWithHooks(allRegistryTools, hookRunner);
|
||||
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
|
||||
wrappedToolRegistry = new Map<string, AgentTool>();
|
||||
for (const tool of wrappedAllTools) {
|
||||
wrappedToolRegistry.set(tool.name, tool);
|
||||
|
|
@ -727,8 +594,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
|
||||
time("buildSystemPrompt");
|
||||
|
||||
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
|
||||
time("discoverSlashCommands");
|
||||
const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir);
|
||||
time("discoverPromptTemplates");
|
||||
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
|
|
@ -738,9 +605,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
tools: activeToolsArray,
|
||||
},
|
||||
convertToLlm,
|
||||
transformContext: hookRunner
|
||||
transformContext: extensionRunner
|
||||
? async (messages) => {
|
||||
return hookRunner.emitContext(messages);
|
||||
return extensionRunner.emitContext(messages);
|
||||
}
|
||||
: undefined,
|
||||
steeringMode: settingsManager.getSteeringMode(),
|
||||
|
|
@ -775,9 +642,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
sessionManager,
|
||||
settingsManager,
|
||||
scopedModels: options.scopedModels,
|
||||
fileCommands: slashCommands,
|
||||
hookRunner,
|
||||
customTools: customToolsResult.tools,
|
||||
promptTemplates: promptTemplates,
|
||||
extensionRunner,
|
||||
skillsSettings: settingsManager.getSkillsSettings(),
|
||||
modelRegistry,
|
||||
toolRegistry: wrappedToolRegistry ?? toolRegistry,
|
||||
|
|
@ -785,14 +651,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
});
|
||||
time("createAgentSession");
|
||||
|
||||
// Wire up sendMessage for custom tools
|
||||
customToolsResult.setSendMessageHandler((msg, opts) => {
|
||||
session.sendHookMessage(msg, opts);
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
customToolsResult,
|
||||
extensionsResult,
|
||||
modelFallbackMessage,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ import { join, resolve } from "path";
|
|||
import { getAgentDir as getDefaultAgentDir } from "../config.js";
|
||||
import {
|
||||
type BashExecutionMessage,
|
||||
type CustomMessage,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createHookMessage,
|
||||
type HookMessage,
|
||||
createCustomMessage,
|
||||
} from "./messages.js";
|
||||
|
||||
export const CURRENT_SESSION_VERSION = 2;
|
||||
export const CURRENT_SESSION_VERSION = 3;
|
||||
|
||||
export interface SessionHeader {
|
||||
type: "session";
|
||||
|
|
@ -66,9 +66,9 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
details?: T;
|
||||
/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
|
||||
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -76,17 +76,17 @@ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
|
|||
type: "branch_summary";
|
||||
fromId: string;
|
||||
summary: string;
|
||||
/** Hook-specific data (not sent to LLM) */
|
||||
/** Extension-specific data (not sent to LLM) */
|
||||
details?: T;
|
||||
/** True if generated by a hook, false if pi-generated */
|
||||
/** True if generated by an extension, false if pi-generated */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom entry for hooks to store hook-specific data in the session.
|
||||
* Use customType to identify your hook's entries.
|
||||
* Custom entry for extensions to store extension-specific data in the session.
|
||||
* Use customType to identify your extension's entries.
|
||||
*
|
||||
* Purpose: Persist hook state across session reloads. On reload, hooks can
|
||||
* Purpose: Persist extension state across session reloads. On reload, extensions can
|
||||
* scan entries for their customType and reconstruct internal state.
|
||||
*
|
||||
* Does NOT participate in LLM context (ignored by buildSessionContext).
|
||||
|
|
@ -106,12 +106,12 @@ export interface LabelEntry extends SessionEntryBase {
|
|||
}
|
||||
|
||||
/**
|
||||
* Custom message entry for hooks to inject messages into LLM context.
|
||||
* Use customType to identify your hook's entries.
|
||||
* Custom message entry for extensions to inject messages into LLM context.
|
||||
* Use customType to identify your extension's entries.
|
||||
*
|
||||
* Unlike CustomEntry, this DOES participate in LLM context.
|
||||
* The content is converted to a user message in buildSessionContext().
|
||||
* Use details for hook-specific metadata (not sent to LLM).
|
||||
* Use details for extension-specific metadata (not sent to LLM).
|
||||
*
|
||||
* display controls TUI rendering:
|
||||
* - false: hidden entirely
|
||||
|
|
@ -218,8 +218,23 @@ function migrateV1ToV2(entries: FileEntry[]): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Add future migrations here:
|
||||
// function migrateV2ToV3(entries: FileEntry[]): void { ... }
|
||||
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
|
||||
function migrateV2ToV3(entries: FileEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "session") {
|
||||
entry.version = 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update message entries with hookMessage role
|
||||
if (entry.type === "message") {
|
||||
const msgEntry = entry as SessionMessageEntry;
|
||||
if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {
|
||||
(msgEntry.message as { role: string }).role = "custom";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all necessary migrations to bring entries to current version.
|
||||
|
|
@ -232,7 +247,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|||
if (version >= CURRENT_SESSION_VERSION) return false;
|
||||
|
||||
if (version < 2) migrateV1ToV2(entries);
|
||||
// if (version < 3) migrateV2ToV3(entries);
|
||||
if (version < 3) migrateV2ToV3(entries);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -342,7 +357,7 @@ export function buildSessionContext(
|
|||
messages.push(entry.message);
|
||||
} else if (entry.type === "custom_message") {
|
||||
messages.push(
|
||||
createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
|
||||
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
|
||||
);
|
||||
} else if (entry.type === "branch_summary" && entry.summary) {
|
||||
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
||||
|
|
@ -609,7 +624,7 @@ export class SessionManager {
|
|||
* so it is easier to find them.
|
||||
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
||||
*/
|
||||
appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
|
||||
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id: generateId(this.byId),
|
||||
|
|
@ -671,7 +686,7 @@ export class SessionManager {
|
|||
return entry.id;
|
||||
}
|
||||
|
||||
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
|
||||
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
|
||||
appendCustomEntry(customType: string, data?: unknown): string {
|
||||
const entry: CustomEntry = {
|
||||
type: "custom",
|
||||
|
|
@ -686,11 +701,11 @@ export class SessionManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Append a custom message entry (for hooks) that participates in LLM context.
|
||||
* @param customType Hook identifier for filtering on reload
|
||||
* Append a custom message entry (for extensions) that participates in LLM context.
|
||||
* @param customType Extension identifier for filtering on reload
|
||||
* @param content Message content (string or TextContent/ImageContent array)
|
||||
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
||||
* @param details Optional hook-specific metadata (not sent to LLM)
|
||||
* @param details Optional extension-specific metadata (not sent to LLM)
|
||||
* @returns Entry id
|
||||
*/
|
||||
appendCustomMessageEntry<T = unknown>(
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ export interface Settings {
|
|||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
hooks?: string[]; // Array of hook file paths
|
||||
customTools?: string[]; // Array of custom tool file paths
|
||||
extensions?: string[]; // Array of extension file paths
|
||||
skills?: SkillsSettings;
|
||||
terminal?: TerminalSettings;
|
||||
images?: ImageSettings;
|
||||
|
|
@ -340,21 +339,12 @@ export class SettingsManager {
|
|||
this.save();
|
||||
}
|
||||
|
||||
getHookPaths(): string[] {
|
||||
return [...(this.settings.hooks ?? [])];
|
||||
getExtensionPaths(): string[] {
|
||||
return [...(this.settings.extensions ?? [])];
|
||||
}
|
||||
|
||||
setHookPaths(paths: string[]): void {
|
||||
this.globalSettings.hooks = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getCustomToolPaths(): string[] {
|
||||
return [...(this.settings.customTools ?? [])];
|
||||
}
|
||||
|
||||
setCustomToolPaths(paths: string[]): void {
|
||||
this.globalSettings.customTools = paths;
|
||||
setExtensionPaths(paths: string[]): void {
|
||||
this.globalSettings.extensions = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -274,14 +274,16 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
Available tools:
|
||||
${toolsList}
|
||||
|
||||
In addition to the tools above, you may have access to other custom tools depending on the project.
|
||||
|
||||
Guidelines:
|
||||
${guidelines}
|
||||
|
||||
Documentation:
|
||||
- Main documentation: ${readmePath}
|
||||
- Additional docs: ${docsPath}
|
||||
- Examples: ${examplesPath} (hooks, custom tools, SDK)
|
||||
- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md)
|
||||
- Examples: ${examplesPath} (extensions, custom tools, SDK)
|
||||
- When asked to create: custom models/providers (README.md), extensions (docs/extensions.md, examples/extensions/), themes (docs/theme.md), skills (docs/skills.md)
|
||||
- Always read the doc, examples, AND follow .md cross-references before implementing`;
|
||||
|
||||
if (appendSection) {
|
||||
|
|
|
|||
|
|
@ -33,25 +33,53 @@ export {
|
|||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "./core/compaction/index.js";
|
||||
// Custom tools
|
||||
export type {
|
||||
AgentToolUpdateCallback,
|
||||
CustomTool,
|
||||
CustomToolAPI,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
CustomToolsLoadResult,
|
||||
CustomToolUIContext,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
RenderResultOptions,
|
||||
} from "./core/custom-tools/index.js";
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
|
||||
export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js";
|
||||
export type * from "./core/hooks/index.js";
|
||||
// Hook system types and type guards
|
||||
// Extension system
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
BeforeAgentStartEvent,
|
||||
ContextEvent,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
ExtensionError,
|
||||
ExtensionEvent,
|
||||
ExtensionFactory,
|
||||
ExtensionFlag,
|
||||
ExtensionHandler,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
LoadExtensionsResult,
|
||||
LoadedExtension,
|
||||
MessageRenderer,
|
||||
MessageRenderOptions,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
SessionBeforeBranchEvent,
|
||||
SessionBeforeCompactEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionBeforeTreeEvent,
|
||||
SessionBranchEvent,
|
||||
SessionCompactEvent,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
SessionSwitchEvent,
|
||||
SessionTreeEvent,
|
||||
ToolCallEvent,
|
||||
ToolDefinition,
|
||||
ToolRenderResultOptions,
|
||||
ToolResultEvent,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
} from "./core/extensions/index.js";
|
||||
export {
|
||||
discoverAndLoadExtensions,
|
||||
ExtensionRunner,
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
|
|
@ -59,7 +87,12 @@ export {
|
|||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./core/hooks/index.js";
|
||||
loadExtensions,
|
||||
wrapRegisteredTool,
|
||||
wrapRegisteredTools,
|
||||
wrapToolsWithExtensions,
|
||||
wrapToolWithExtensions,
|
||||
} from "./core/extensions/index.js";
|
||||
export { convertToLlm } from "./core/messages.js";
|
||||
export { ModelRegistry } from "./core/model-registry.js";
|
||||
// SDK for programmatic usage
|
||||
|
|
@ -83,17 +116,12 @@ export {
|
|||
// Discovery
|
||||
discoverAuthStorage,
|
||||
discoverContextFiles,
|
||||
discoverCustomTools,
|
||||
discoverHooks,
|
||||
discoverExtensions,
|
||||
discoverModels,
|
||||
discoverPromptTemplates,
|
||||
discoverSkills,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
// Hook types
|
||||
type HookAPI,
|
||||
type HookContext,
|
||||
type HookFactory,
|
||||
loadSettings,
|
||||
type PromptTemplate,
|
||||
// Pre-built tools (use process.cwd())
|
||||
readOnlyTools,
|
||||
} from "./core/sdk.js";
|
||||
|
|
@ -159,9 +187,9 @@ export {
|
|||
} from "./core/tools/index.js";
|
||||
// Main entry point
|
||||
export { main } from "./main.js";
|
||||
// UI components for hooks
|
||||
// UI components for extensions
|
||||
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
|
||||
// Theme utilities for custom tools and hooks
|
||||
// Theme utilities for custom tools and extensions
|
||||
export {
|
||||
getMarkdownTheme,
|
||||
getSelectListTheme,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@ import { selectSession } from "./cli/session-picker.js";
|
|||
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
|
||||
import type { AgentSession } from "./core/agent-session.js";
|
||||
|
||||
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
|
||||
import { createEventBus } from "./core/event-bus.js";
|
||||
import { exportFromFile } from "./core/export-html/index.js";
|
||||
import { discoverAndLoadHooks } from "./core/hooks/index.js";
|
||||
import type { HookUIContext } from "./core/index.js";
|
||||
import { discoverAndLoadExtensions, type ExtensionUIContext, type LoadedExtension } from "./core/extensions/index.js";
|
||||
import type { ModelRegistry } from "./core/model-registry.js";
|
||||
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
|
||||
|
|
@ -29,7 +27,7 @@ import { SettingsManager } from "./core/settings-manager.js";
|
|||
import { resolvePromptInput } from "./core/system-prompt.js";
|
||||
import { printTimings, time } from "./core/timings.js";
|
||||
import { allTools } from "./core/tools/index.js";
|
||||
import { runMigrations } from "./migrations.js";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
||||
|
|
@ -62,13 +60,13 @@ async function runInteractiveMode(
|
|||
migratedProviders: string[],
|
||||
versionCheckPromise: Promise<string | undefined>,
|
||||
initialMessages: string[],
|
||||
customTools: LoadedCustomTool[],
|
||||
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
|
||||
extensions: LoadedExtension[],
|
||||
setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
|
||||
initialMessage?: string,
|
||||
initialImages?: ImageContent[],
|
||||
fdPath: string | undefined = undefined,
|
||||
): Promise<void> {
|
||||
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
|
||||
const mode = new InteractiveMode(session, version, changelogMarkdown, extensions, setExtensionUIContext, fdPath);
|
||||
|
||||
await mode.init();
|
||||
|
||||
|
|
@ -214,7 +212,7 @@ function buildSessionOptions(
|
|||
scopedModels: ScopedModel[],
|
||||
sessionManager: SessionManager | undefined,
|
||||
modelRegistry: ModelRegistry,
|
||||
preloadedHooks?: import("./core/hooks/index.js").LoadedHook[],
|
||||
preloadedExtensions?: LoadedExtension[],
|
||||
): CreateAgentSessionOptions {
|
||||
const options: CreateAgentSessionOptions = {};
|
||||
|
||||
|
|
@ -273,14 +271,9 @@ function buildSessionOptions(
|
|||
options.skills = [];
|
||||
}
|
||||
|
||||
// Pre-loaded hooks (from early CLI flag discovery)
|
||||
if (preloadedHooks && preloadedHooks.length > 0) {
|
||||
options.preloadedHooks = preloadedHooks;
|
||||
}
|
||||
|
||||
// Additional custom tool paths from CLI
|
||||
if (parsed.customTools && parsed.customTools.length > 0) {
|
||||
options.additionalCustomToolPaths = parsed.customTools;
|
||||
// Pre-loaded extensions (from early CLI flag discovery)
|
||||
if (preloadedExtensions && preloadedExtensions.length > 0) {
|
||||
options.preloadedExtensions = preloadedExtensions;
|
||||
}
|
||||
|
||||
return options;
|
||||
|
|
@ -289,43 +282,43 @@ function buildSessionOptions(
|
|||
export async function main(args: string[]) {
|
||||
time("start");
|
||||
|
||||
// Run migrations
|
||||
const { migratedAuthProviders: migratedProviders } = runMigrations();
|
||||
// Run migrations (pass cwd for project-local migrations)
|
||||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
|
||||
|
||||
// Create AuthStorage and ModelRegistry upfront
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
time("discoverModels");
|
||||
|
||||
// First pass: parse args to get --hook paths
|
||||
// First pass: parse args to get --extension paths
|
||||
const firstPass = parseArgs(args);
|
||||
time("parseArgs-firstPass");
|
||||
|
||||
// Early load hooks to discover their CLI flags
|
||||
// Early load extensions to discover their CLI flags
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const eventBus = createEventBus();
|
||||
const hookPaths = firstPass.hooks ?? [];
|
||||
const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir, eventBus);
|
||||
time("discoverHookFlags");
|
||||
const extensionPaths = firstPass.extensions ?? [];
|
||||
const { extensions: loadedExtensions } = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
|
||||
time("discoverExtensionFlags");
|
||||
|
||||
// Collect all hook flags
|
||||
const hookFlags = new Map<string, { type: "boolean" | "string" }>();
|
||||
for (const hook of loadedHooks) {
|
||||
for (const [name, flag] of hook.flags) {
|
||||
hookFlags.set(name, { type: flag.type });
|
||||
// Collect all extension flags
|
||||
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
|
||||
for (const ext of loadedExtensions) {
|
||||
for (const [name, flag] of ext.flags) {
|
||||
extensionFlags.set(name, { type: flag.type });
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: parse args with hook flags
|
||||
const parsed = parseArgs(args, hookFlags);
|
||||
// Second pass: parse args with extension flags
|
||||
const parsed = parseArgs(args, extensionFlags);
|
||||
time("parseArgs");
|
||||
|
||||
// Pass flag values to hooks
|
||||
// Pass flag values to extensions
|
||||
for (const [name, value] of parsed.unknownFlags) {
|
||||
for (const hook of loadedHooks) {
|
||||
if (hook.flags.has(name)) {
|
||||
hook.setFlagValue(name, value);
|
||||
for (const ext of loadedExtensions) {
|
||||
if (ext.flags.has(name)) {
|
||||
ext.setFlagValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -373,6 +366,11 @@ export async function main(args: string[]) {
|
|||
initTheme(settingsManager.getTheme(), isInteractive);
|
||||
time("initTheme");
|
||||
|
||||
// Show deprecation warnings in interactive mode
|
||||
if (isInteractive && deprecationWarnings.length > 0) {
|
||||
await showDeprecationWarnings(deprecationWarnings);
|
||||
}
|
||||
|
||||
let scopedModels: ScopedModel[] = [];
|
||||
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
|
||||
if (modelPatterns && modelPatterns.length > 0) {
|
||||
|
|
@ -401,7 +399,7 @@ export async function main(args: string[]) {
|
|||
sessionManager = SessionManager.open(selectedPath);
|
||||
}
|
||||
|
||||
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks);
|
||||
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedExtensions);
|
||||
sessionOptions.authStorage = authStorage;
|
||||
sessionOptions.modelRegistry = modelRegistry;
|
||||
sessionOptions.eventBus = eventBus;
|
||||
|
|
@ -416,7 +414,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
time("buildSessionOptions");
|
||||
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||
time("createAgentSession");
|
||||
|
||||
if (!isInteractive && !session.model) {
|
||||
|
|
@ -469,8 +467,8 @@ export async function main(args: string[]) {
|
|||
migratedProviders,
|
||||
versionCheckPromise,
|
||||
parsed.messages,
|
||||
customToolsResult.tools,
|
||||
customToolsResult.setUIContext,
|
||||
extensionsResult.extensions,
|
||||
extensionsResult.setUIContext,
|
||||
initialMessage,
|
||||
initialImages,
|
||||
fdPath,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue