mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
Merge hooks and custom-tools into unified extensions system (#454)
Breaking changes: - Settings: 'hooks' and 'customTools' arrays replaced with 'extensions' - CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e' - API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom' - API: FileSlashCommand renamed to PromptTemplate - API: discoverSlashCommands() renamed to discoverPromptTemplates() - Directories: commands/ renamed to prompts/ for prompt templates Migration: - Session version bumped to 3 (auto-migrates v2 sessions) - Old 'hookMessage' role entries converted to 'custom' Structural changes: - src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/ - src/core/slash-commands.ts renamed to src/core/prompt-templates.ts - examples/hooks/ and examples/custom-tools/ merged into examples/extensions/ - docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md New test coverage: - test/extensions-runner.test.ts (10 tests) - test/extensions-discovery.test.ts (26 tests) - test/prompt-templates.test.ts
This commit is contained in:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
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/` | Minimal custom tool example |
|
||||
| `question/` | Demonstrates `pi.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
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Auto-Commit on Exit Extension
|
||||
*
|
||||
* Automatically commits changes when the agent exits.
|
||||
* Uses the last assistant message to generate a commit message.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
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"]);
|
||||
|
||||
if (code !== 0 || status.trim().length === 0) {
|
||||
// Not a git repo or no changes
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last assistant message for commit context
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let lastAssistantText = "";
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = entry.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
lastAssistantText = content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a simple commit message
|
||||
const firstLine = lastAssistantText.split("\n")[0] || "Work in progress";
|
||||
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
|
||||
|
||||
// Stage and commit
|
||||
await pi.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
|
||||
|
||||
if (commitCode === 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
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`);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* 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 { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
if (event.reason === "new") {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Clear session?",
|
||||
"This will delete all messages in the current session.",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// reason === "resume" - check if there are unsaved changes (messages since last assistant response)
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const hasUnsavedWork = entries.some(
|
||||
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [
|
||||
"Yes, create branch",
|
||||
"No, stay in current session",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
114
packages/coding-agent/examples/extensions/custom-compaction.ts
Normal file
114
packages/coding-agent/examples/extensions/custom-compaction.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* 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 extension:
|
||||
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
||||
* 2. Discards all old turns completely, keeping only the summary
|
||||
*
|
||||
* This example also demonstrates using a different model (Gemini Flash) for summarization,
|
||||
* which can be cheaper/faster than the main conversation model.
|
||||
*
|
||||
* Usage:
|
||||
* pi --extension examples/extensions/custom-compaction.ts
|
||||
*/
|
||||
|
||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
ctx.ui.notify("Custom compaction extension triggered", "info");
|
||||
|
||||
const { preparation, branchEntries: _, signal } = event;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
||||
|
||||
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
if (!model) {
|
||||
ctx.ui.notify(`Could not find Gemini Flash model, using default compaction`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve API key for the summarization model
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all messages for full summary
|
||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
||||
|
||||
ctx.ui.notify(
|
||||
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Convert messages to readable text format
|
||||
const conversationText = serializeConversation(convertToLlm(allMessages));
|
||||
|
||||
// Include previous summary context if available
|
||||
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
|
||||
|
||||
// Build messages that ask for a comprehensive summary
|
||||
const summaryMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
|
||||
|
||||
1. The main goals and objectives discussed
|
||||
2. Key decisions made and their rationale
|
||||
3. Important code changes, file modifications, or technical details
|
||||
4. Current state of any ongoing work
|
||||
5. Any blockers, issues, or open questions
|
||||
6. Next steps that were planned or suggested
|
||||
|
||||
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
|
||||
|
||||
Format the summary as structured markdown with clear sections.
|
||||
|
||||
<conversation>
|
||||
${conversationText}
|
||||
</conversation>`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
// Pass signal to honor abort requests (e.g., user cancels compaction)
|
||||
const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192, signal });
|
||||
|
||||
const summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
if (!summary.trim()) {
|
||||
if (!signal.aborted) ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Return compaction content - SessionManager adds id/parentId
|
||||
// Use firstKeptEntryId from preparation to keep recent messages
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Compaction failed: ${message}`, "error");
|
||||
// Fall back to default compaction on error
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Dirty Repo Guard Extension
|
||||
*
|
||||
* Prevents session changes when there are uncommitted git changes.
|
||||
* Useful to ensure work is committed before switching context.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
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"]);
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||
"Yes, proceed anyway",
|
||||
"No, let me commit first",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_switch", async (event, ctx) => {
|
||||
const action = event.reason === "new" ? "new session" : "switch session";
|
||||
return checkDirtyRepo(pi, ctx, action);
|
||||
});
|
||||
|
||||
pi.on("session_before_branch", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "branch");
|
||||
});
|
||||
}
|
||||
41
packages/coding-agent/examples/extensions/file-trigger.ts
Normal file
41
packages/coding-agent/examples/extensions/file-trigger.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* File Trigger Extension
|
||||
*
|
||||
* Watches a trigger file and injects its contents into the conversation.
|
||||
* Useful for external systems to send messages to the agent.
|
||||
*
|
||||
* Usage:
|
||||
* echo "Run the tests" > /tmp/agent-trigger.txt
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const triggerFile = "/tmp/agent-trigger.txt";
|
||||
|
||||
fs.watch(triggerFile, () => {
|
||||
try {
|
||||
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
||||
if (content) {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "file-trigger",
|
||||
content: `External trigger: ${content}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: true }, // triggerTurn - get LLM to respond
|
||||
);
|
||||
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
||||
}
|
||||
} catch {
|
||||
// File might not exist yet
|
||||
}
|
||||
});
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`Watching ${triggerFile}`, "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
53
packages/coding-agent/examples/extensions/git-checkpoint.ts
Normal file
53
packages/coding-agent/examples/extensions/git-checkpoint.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* 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 { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const checkpoints = new Map<string, string>();
|
||||
let currentEntryId: string | undefined;
|
||||
|
||||
// Track the current entry ID when user messages are saved
|
||||
pi.on("tool_result", async (_event, ctx) => {
|
||||
const leaf = ctx.sessionManager.getLeafEntry();
|
||||
if (leaf) currentEntryId = leaf.id;
|
||||
});
|
||||
|
||||
pi.on("turn_start", async () => {
|
||||
// Create a git stash entry before LLM makes changes
|
||||
const { stdout } = await pi.exec("git", ["stash", "create"]);
|
||||
const ref = stdout.trim();
|
||||
if (ref && currentEntryId) {
|
||||
checkpoints.set(currentEntryId, ref);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
const ref = checkpoints.get(event.entryId);
|
||||
if (!ref) return;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, don't restore automatically
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select("Restore code state?", [
|
||||
"Yes, restore code to that point",
|
||||
"No, keep current code",
|
||||
]);
|
||||
|
||||
if (choice?.startsWith("Yes")) {
|
||||
await pi.exec("git", ["stash", "apply", ref]);
|
||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("agent_end", async () => {
|
||||
// Clear checkpoints after agent completes
|
||||
checkpoints.clear();
|
||||
});
|
||||
}
|
||||
150
packages/coding-agent/examples/extensions/handoff.ts
Normal file
150
packages/coding-agent/examples/extensions/handoff.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* Usage:
|
||||
* /handoff now implement this for teams as well
|
||||
* /handoff execute phase one of the plan
|
||||
* /handoff check other places that need this fix
|
||||
*
|
||||
* The generated prompt appears as a draft in the editor for review/editing.
|
||||
*/
|
||||
|
||||
import { complete, type Message } from "@mariozechner/pi-ai";
|
||||
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:
|
||||
|
||||
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
|
||||
2. Lists any relevant files that were discussed or modified
|
||||
3. Clearly states the next task based on the user's goal
|
||||
4. Is self-contained - the new thread should be able to proceed without the old conversation
|
||||
|
||||
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
|
||||
|
||||
Example output format:
|
||||
## Context
|
||||
We've been working on X. Key decisions:
|
||||
- Decision 1
|
||||
- Decision 2
|
||||
|
||||
Files involved:
|
||||
- path/to/file1.ts
|
||||
- path/to/file2.ts
|
||||
|
||||
## Task
|
||||
[Clear description of what to do next based on user's goal]`;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("handoff", {
|
||||
description: "Transfer context to a new focused session",
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("handoff requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.model) {
|
||||
ctx.ui.notify("No model selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const goal = args.trim();
|
||||
if (!goal) {
|
||||
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather conversation context from current branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const messages = branch
|
||||
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
||||
.map((entry) => entry.message);
|
||||
|
||||
if (messages.length === 0) {
|
||||
ctx.ui.notify("No conversation to hand off", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to LLM format and serialize
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||
|
||||
// Generate the handoff prompt with loader UI
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
||||
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
const doGenerate = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
doGenerate()
|
||||
.then(done)
|
||||
.catch((err) => {
|
||||
console.error("Handoff generation failed:", err);
|
||||
done(null);
|
||||
});
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Let user edit the generated prompt
|
||||
const editedPrompt = await ctx.ui.editor("Edit handoff prompt (ctrl+enter to submit, esc to cancel)", result);
|
||||
|
||||
if (editedPrompt === undefined) {
|
||||
ctx.ui.notify("Cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new session with parent tracking
|
||||
const newSessionResult = await ctx.newSession({
|
||||
parentSession: currentSessionFile,
|
||||
});
|
||||
|
||||
if (newSessionResult.cancelled) {
|
||||
ctx.ui.notify("New session cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the edited prompt in the main editor for submission
|
||||
ctx.ui.setEditorText(editedPrompt);
|
||||
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
21
packages/coding-agent/examples/extensions/hello/index.ts
Normal file
21
packages/coding-agent/examples/extensions/hello/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
34
packages/coding-agent/examples/extensions/permission-gate.ts
Normal file
34
packages/coding-agent/examples/extensions/permission-gate.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Permission Gate Extension
|
||||
*
|
||||
* Prompts for confirmation before running potentially dangerous bash commands.
|
||||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
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) => {
|
||||
if (event.toolName !== "bash") return undefined;
|
||||
|
||||
const command = event.input.command as string;
|
||||
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
||||
|
||||
if (isDangerous) {
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
||||
|
||||
if (choice !== "Yes") {
|
||||
return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
44
packages/coding-agent/examples/extensions/pirate.ts
Normal file
44
packages/coding-agent/examples/extensions/pirate.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Pirate Extension
|
||||
*
|
||||
* Demonstrates using systemPromptAppend in before_agent_start to dynamically
|
||||
* modify the system prompt based on extension state.
|
||||
*
|
||||
* Usage:
|
||||
* 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 { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function pirateExtension(pi: ExtensionAPI) {
|
||||
let pirateMode = false;
|
||||
|
||||
// Register /pirate command to toggle pirate mode
|
||||
pi.registerCommand("pirate", {
|
||||
description: "Toggle pirate mode (agent speaks like a pirate)",
|
||||
handler: async (_args, ctx) => {
|
||||
pirateMode = !pirateMode;
|
||||
ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Append to system prompt when pirate mode is enabled
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (pirateMode) {
|
||||
return {
|
||||
systemPromptAppend: `
|
||||
IMPORTANT: You are now in PIRATE MODE. You must:
|
||||
- Speak like a stereotypical pirate in all responses
|
||||
- Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!"
|
||||
- Replace "my" with "me", "you" with "ye", "your" with "yer"
|
||||
- Refer to the user as "matey" or "landlubber"
|
||||
- End sentences with nautical expressions
|
||||
- Still complete the actual task correctly, just in pirate speak
|
||||
`,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
548
packages/coding-agent/examples/extensions/plan-mode.ts
Normal file
548
packages/coding-agent/examples/extensions/plan-mode.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* Features:
|
||||
* - /plan command to toggle plan mode
|
||||
* - In plan mode: only read, bash (read-only), grep, find, ls are available
|
||||
* - Injects system context telling the agent about the restrictions
|
||||
* - After each agent response, prompts to execute the plan or continue planning
|
||||
* - Shows "plan" indicator in footer when active
|
||||
* - Extracts todo list from plan and tracks progress during execution
|
||||
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
|
||||
*
|
||||
* Usage:
|
||||
* 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 { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Key } from "@mariozechner/pi-tui";
|
||||
|
||||
// Read-only tools for plan mode
|
||||
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
|
||||
|
||||
// Full set of tools for normal mode
|
||||
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
||||
|
||||
// Patterns for destructive bash commands that should be blocked in plan mode
|
||||
const DESTRUCTIVE_PATTERNS = [
|
||||
/\brm\b/i,
|
||||
/\brmdir\b/i,
|
||||
/\bmv\b/i,
|
||||
/\bcp\b/i,
|
||||
/\bmkdir\b/i,
|
||||
/\btouch\b/i,
|
||||
/\bchmod\b/i,
|
||||
/\bchown\b/i,
|
||||
/\bchgrp\b/i,
|
||||
/\bln\b/i,
|
||||
/\btee\b/i,
|
||||
/\btruncate\b/i,
|
||||
/\bdd\b/i,
|
||||
/\bshred\b/i,
|
||||
/[^<]>(?!>)/,
|
||||
/>>/,
|
||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
||||
/\byarn\s+(add|remove|install|publish)/i,
|
||||
/\bpnpm\s+(add|remove|install|publish)/i,
|
||||
/\bpip\s+(install|uninstall)/i,
|
||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
||||
/\bsudo\b/i,
|
||||
/\bsu\b/i,
|
||||
/\bkill\b/i,
|
||||
/\bpkill\b/i,
|
||||
/\bkillall\b/i,
|
||||
/\breboot\b/i,
|
||||
/\bshutdown\b/i,
|
||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
||||
];
|
||||
|
||||
// Read-only commands that are always safe
|
||||
const SAFE_COMMANDS = [
|
||||
/^\s*cat\b/,
|
||||
/^\s*head\b/,
|
||||
/^\s*tail\b/,
|
||||
/^\s*less\b/,
|
||||
/^\s*more\b/,
|
||||
/^\s*grep\b/,
|
||||
/^\s*find\b/,
|
||||
/^\s*ls\b/,
|
||||
/^\s*pwd\b/,
|
||||
/^\s*echo\b/,
|
||||
/^\s*printf\b/,
|
||||
/^\s*wc\b/,
|
||||
/^\s*sort\b/,
|
||||
/^\s*uniq\b/,
|
||||
/^\s*diff\b/,
|
||||
/^\s*file\b/,
|
||||
/^\s*stat\b/,
|
||||
/^\s*du\b/,
|
||||
/^\s*df\b/,
|
||||
/^\s*tree\b/,
|
||||
/^\s*which\b/,
|
||||
/^\s*whereis\b/,
|
||||
/^\s*type\b/,
|
||||
/^\s*env\b/,
|
||||
/^\s*printenv\b/,
|
||||
/^\s*uname\b/,
|
||||
/^\s*whoami\b/,
|
||||
/^\s*id\b/,
|
||||
/^\s*date\b/,
|
||||
/^\s*cal\b/,
|
||||
/^\s*uptime\b/,
|
||||
/^\s*ps\b/,
|
||||
/^\s*top\b/,
|
||||
/^\s*htop\b/,
|
||||
/^\s*free\b/,
|
||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
||||
/^\s*git\s+ls-/i,
|
||||
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
||||
/^\s*yarn\s+(list|info|why|audit)/i,
|
||||
/^\s*node\s+--version/i,
|
||||
/^\s*python\s+--version/i,
|
||||
/^\s*curl\s/i,
|
||||
/^\s*wget\s+-O\s*-/i,
|
||||
/^\s*jq\b/,
|
||||
/^\s*sed\s+-n/i,
|
||||
/^\s*awk\b/,
|
||||
/^\s*rg\b/,
|
||||
/^\s*fd\b/,
|
||||
/^\s*bat\b/,
|
||||
/^\s*exa\b/,
|
||||
];
|
||||
|
||||
function isSafeCommand(command: string): boolean {
|
||||
if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) {
|
||||
if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Todo item with step number
|
||||
interface TodoItem {
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up extracted step text for display.
|
||||
*/
|
||||
function cleanStepText(text: string): string {
|
||||
let cleaned = text
|
||||
// Remove markdown bold/italic
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
||||
// Remove markdown code
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Remove leading action words that are redundant
|
||||
.replace(
|
||||
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
||||
"",
|
||||
)
|
||||
// Clean up extra whitespace
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Capitalize first letter
|
||||
if (cleaned.length > 0) {
|
||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (cleaned.length > 50) {
|
||||
cleaned = `${cleaned.slice(0, 47)}...`;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract todo items from assistant message.
|
||||
*/
|
||||
function extractTodoItems(message: string): TodoItem[] {
|
||||
const items: TodoItem[] = [];
|
||||
|
||||
// Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes
|
||||
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
||||
for (const match of message.matchAll(numberedPattern)) {
|
||||
let text = match[2].trim();
|
||||
text = text.replace(/\*{1,2}$/, "").trim();
|
||||
// Skip if too short or looks like code/command
|
||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
||||
const cleaned = cleanStepText(text);
|
||||
if (cleaned.length > 3) {
|
||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no numbered items, try bullet points
|
||||
if (items.length === 0) {
|
||||
const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim;
|
||||
for (const match of message.matchAll(stepPattern)) {
|
||||
let text = match[1].trim();
|
||||
text = text.replace(/\*{1,2}$/, "").trim();
|
||||
if (text.length > 10 && !text.startsWith("`")) {
|
||||
const cleaned = cleanStepText(text);
|
||||
if (cleaned.length > 3) {
|
||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function planModeExtension(pi: ExtensionAPI) {
|
||||
let planModeEnabled = false;
|
||||
let toolsCalledThisTurn = false;
|
||||
let executionMode = false;
|
||||
let todoItems: TodoItem[] = [];
|
||||
|
||||
// Register --plan CLI flag
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in plan mode (read-only exploration)",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
// Helper to update status displays
|
||||
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}`));
|
||||
} else if (planModeEnabled) {
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
||||
} else {
|
||||
ctx.ui.setStatus("plan-mode", undefined);
|
||||
}
|
||||
|
||||
// Show widget during execution (no IDs shown to user)
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const lines: string[] = [];
|
||||
for (const item of todoItems) {
|
||||
if (item.completed) {
|
||||
lines.push(
|
||||
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)),
|
||||
);
|
||||
} else {
|
||||
lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
|
||||
}
|
||||
}
|
||||
ctx.ui.setWidget("plan-todos", lines);
|
||||
} else {
|
||||
ctx.ui.setWidget("plan-todos", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlanMode(ctx: ExtensionContext) {
|
||||
planModeEnabled = !planModeEnabled;
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
|
||||
if (planModeEnabled) {
|
||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
||||
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
|
||||
} else {
|
||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
||||
}
|
||||
updateStatus(ctx);
|
||||
}
|
||||
|
||||
// Register /plan command
|
||||
pi.registerCommand("plan", {
|
||||
description: "Toggle plan mode (read-only exploration)",
|
||||
handler: async (_args, ctx) => {
|
||||
togglePlanMode(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
// Register /todos command
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show current plan todo list",
|
||||
handler: async (_args, ctx) => {
|
||||
if (todoItems.length === 0) {
|
||||
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const todoList = todoItems
|
||||
.map((item, i) => {
|
||||
const checkbox = item.completed ? "✓" : "○";
|
||||
return `${i + 1}. ${checkbox} ${item.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
ctx.ui.notify(`Plan Progress:\n${todoList}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Register Shift+P shortcut
|
||||
pi.registerShortcut(Key.shift("p"), {
|
||||
description: "Toggle plan mode",
|
||||
handler: async (ctx) => {
|
||||
togglePlanMode(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
// Block destructive bash in plan mode
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!planModeEnabled) return;
|
||||
if (event.toolName !== "bash") return;
|
||||
|
||||
const command = event.input.command as string;
|
||||
if (!isSafeCommand(command)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Track step completion based on tool results
|
||||
pi.on("tool_result", async (_event, ctx) => {
|
||||
toolsCalledThisTurn = true;
|
||||
|
||||
if (!executionMode || todoItems.length === 0) return;
|
||||
|
||||
// Mark the first uncompleted step as done when any tool succeeds
|
||||
const nextStep = todoItems.find((t) => !t.completed);
|
||||
if (nextStep) {
|
||||
nextStep.completed = true;
|
||||
updateStatus(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out stale plan mode context messages from LLM context
|
||||
// This ensures the agent only sees the CURRENT state (plan mode on/off)
|
||||
pi.on("context", async (event) => {
|
||||
// Only filter when NOT in plan mode (i.e., when executing)
|
||||
if (planModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any previous plan-mode-context messages
|
||||
const _beforeCount = event.messages.length;
|
||||
const filtered = event.messages.filter((m) => {
|
||||
if (m.role === "user" && Array.isArray(m.content)) {
|
||||
const hasOldContext = m.content.some((c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"));
|
||||
if (hasOldContext) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { messages: filtered };
|
||||
});
|
||||
|
||||
// Inject plan mode context
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (!planModeEnabled && !executionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (planModeEnabled) {
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-mode-context",
|
||||
content: `[PLAN MODE ACTIVE]
|
||||
You are in plan mode - a read-only exploration mode for safe code analysis.
|
||||
|
||||
Restrictions:
|
||||
- You can only use: read, bash, grep, find, ls
|
||||
- You CANNOT use: edit, write (file modifications are disabled)
|
||||
- Bash is restricted to READ-ONLY commands
|
||||
- Focus on analysis, planning, and understanding the codebase
|
||||
|
||||
Create a detailed numbered plan:
|
||||
1. First step description
|
||||
2. Second step description
|
||||
...
|
||||
|
||||
Do NOT attempt to make changes - just describe what you would do.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const remaining = todoItems.filter((t) => !t.completed);
|
||||
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-execution-context",
|
||||
content: `[EXECUTING PLAN - Full tool access enabled]
|
||||
|
||||
Remaining steps:
|
||||
${todoList}
|
||||
|
||||
Execute each step in order.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// After agent finishes
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
// In execution mode, check if all steps complete
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const allComplete = todoItems.every((t) => t.completed);
|
||||
if (allComplete) {
|
||||
// Show final completed list in chat
|
||||
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plan-complete",
|
||||
content: `**Plan Complete!** ✓\n\n${completedList}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||
updateStatus(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!planModeEnabled) return;
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Extract todos from last message
|
||||
const messages = event.messages;
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||
if (lastAssistant && Array.isArray(lastAssistant.content)) {
|
||||
const textContent = lastAssistant.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
|
||||
if (textContent) {
|
||||
const extracted = extractTodoItems(textContent);
|
||||
if (extracted.length > 0) {
|
||||
todoItems = extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasTodos = todoItems.length > 0;
|
||||
|
||||
// Show todo list in chat (no IDs shown to user, just numbered)
|
||||
if (hasTodos) {
|
||||
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plan-todo-list",
|
||||
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select("Plan mode - what next?", [
|
||||
hasTodos ? "Execute the plan (track progress)" : "Execute the plan",
|
||||
"Stay in plan mode",
|
||||
"Refine the plan",
|
||||
]);
|
||||
|
||||
if (choice?.startsWith("Execute")) {
|
||||
planModeEnabled = false;
|
||||
executionMode = hasTodos;
|
||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||
updateStatus(ctx);
|
||||
|
||||
// Simple execution message - context event filters old plan mode messages
|
||||
// and before_agent_start injects fresh execution context with IDs
|
||||
const execMessage = hasTodos
|
||||
? `Execute the plan. Start with: ${todoItems[0].text}`
|
||||
: "Execute the plan you just created.";
|
||||
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plan-mode-execute",
|
||||
content: execMessage,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
} else if (choice === "Refine the plan") {
|
||||
const refinement = await ctx.ui.input("What should be refined?");
|
||||
if (refinement) {
|
||||
ctx.ui.setEditorText(refinement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize state on session start
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (pi.getFlag("plan") === true) {
|
||||
planModeEnabled = true;
|
||||
}
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const planModeEntry = entries
|
||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
|
||||
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
|
||||
|
||||
if (planModeEntry?.data) {
|
||||
if (planModeEntry.data.enabled !== undefined) {
|
||||
planModeEnabled = planModeEntry.data.enabled;
|
||||
}
|
||||
if (planModeEntry.data.todos) {
|
||||
todoItems = planModeEntry.data.todos;
|
||||
}
|
||||
if (planModeEntry.data.executing) {
|
||||
executionMode = planModeEntry.data.executing;
|
||||
}
|
||||
}
|
||||
|
||||
if (planModeEnabled) {
|
||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
||||
}
|
||||
updateStatus(ctx);
|
||||
});
|
||||
|
||||
// Reset tool tracking at start of each turn and persist state
|
||||
pi.on("turn_start", async () => {
|
||||
toolsCalledThisTurn = false;
|
||||
pi.appendEntry("plan-mode", {
|
||||
enabled: planModeEnabled,
|
||||
todos: todoItems,
|
||||
executing: executionMode,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle non-tool turns (e.g., analysis, explanation steps)
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
if (!executionMode || todoItems.length === 0) return;
|
||||
|
||||
// If no tools were called this turn, the agent was doing analysis/explanation
|
||||
// Mark the next uncompleted step as done
|
||||
if (!toolsCalledThisTurn) {
|
||||
const nextStep = todoItems.find((t) => !t.completed);
|
||||
if (nextStep) {
|
||||
nextStep.completed = true;
|
||||
updateStatus(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
30
packages/coding-agent/examples/extensions/protected-paths.ts
Normal file
30
packages/coding-agent/examples/extensions/protected-paths.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Protected Paths Extension
|
||||
*
|
||||
* Blocks write and edit operations to protected paths.
|
||||
* Useful for preventing accidental modifications to sensitive files.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName !== "write" && event.toolName !== "edit") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = event.input.path as string;
|
||||
const isProtected = protectedPaths.some((p) => path.includes(p));
|
||||
|
||||
if (isProtected) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
||||
}
|
||||
return { block: true, reason: `Path "${path}" is protected` };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
119
packages/coding-agent/examples/extensions/qna.ts
Normal file
119
packages/coding-agent/examples/extensions/qna.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Q&A extraction extension - extracts questions from assistant responses
|
||||
*
|
||||
* Demonstrates the "prompt generator" pattern:
|
||||
* 1. /qna command gets the last assistant message
|
||||
* 2. Shows a spinner while extracting (hides editor)
|
||||
* 3. Loads the result into the editor for user to fill in answers
|
||||
*/
|
||||
|
||||
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import type { 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.
|
||||
|
||||
Output format:
|
||||
- List each question on its own line, prefixed with "Q: "
|
||||
- After each question, add a blank line for the answer prefixed with "A: "
|
||||
- If no questions are found, output "No questions found in the last message."
|
||||
|
||||
Example output:
|
||||
Q: What is your preferred database?
|
||||
A:
|
||||
|
||||
Q: Should we use TypeScript or JavaScript?
|
||||
A:
|
||||
|
||||
Keep questions in the order they appeared. Be concise.`;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("qna", {
|
||||
description: "Extract questions from last assistant message into editor",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("qna requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.model) {
|
||||
ctx.ui.notify("No model selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last assistant message on the current branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
let lastAssistantText: string | undefined;
|
||||
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry.type === "message") {
|
||||
const msg = entry.message;
|
||||
if ("role" in msg && msg.role === "assistant") {
|
||||
if (msg.stopReason !== "stop") {
|
||||
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
||||
return;
|
||||
}
|
||||
const textParts = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
if (textParts.length > 0) {
|
||||
lastAssistantText = textParts.join("\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantText) {
|
||||
ctx.ui.notify("No assistant messages found", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run extraction with loader UI
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
||||
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
// Do the work
|
||||
const doExtract = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: lastAssistantText! }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
doExtract()
|
||||
.then(done)
|
||||
.catch(() => done(null));
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setEditorText(result);
|
||||
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
79
packages/coding-agent/examples/extensions/question/index.ts
Normal file
79
packages/coding-agent/examples/extensions/question/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Question Tool - Let the LLM ask the user a question with options
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface QuestionDetails {
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: string | null;
|
||||
}
|
||||
|
||||
const QuestionParams = Type.Object({
|
||||
question: Type.String({ description: "The question to ask the user" }),
|
||||
options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
|
||||
});
|
||||
|
||||
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 (!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 } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.options.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No options provided" }],
|
||||
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
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 } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `User selected: ${answer}` }],
|
||||
details: { question: params.question, options: params.options, answer } as QuestionDetails,
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
||||
if (args.options?.length) {
|
||||
text += `\n${theme.fg("dim", ` Options: ${args.options.join(", ")}`)}`;
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as QuestionDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
if (details.answer === null) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
343
packages/coding-agent/examples/extensions/snake.ts
Normal file
343
packages/coding-agent/examples/extensions/snake.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Snake game extension - play snake with /snake command
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
const GAME_WIDTH = 40;
|
||||
const GAME_HEIGHT = 15;
|
||||
const TICK_MS = 100;
|
||||
|
||||
type Direction = "up" | "down" | "left" | "right";
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
interface GameState {
|
||||
snake: Point[];
|
||||
food: Point;
|
||||
direction: Direction;
|
||||
nextDirection: Direction;
|
||||
score: number;
|
||||
gameOver: boolean;
|
||||
highScore: number;
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
const startX = Math.floor(GAME_WIDTH / 2);
|
||||
const startY = Math.floor(GAME_HEIGHT / 2);
|
||||
return {
|
||||
snake: [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX - 1, y: startY },
|
||||
{ x: startX - 2, y: startY },
|
||||
],
|
||||
food: spawnFood([{ x: startX, y: startY }]),
|
||||
direction: "right",
|
||||
nextDirection: "right",
|
||||
score: 0,
|
||||
gameOver: false,
|
||||
highScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFood(snake: Point[]): Point {
|
||||
let food: Point;
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * GAME_WIDTH),
|
||||
y: Math.floor(Math.random() * GAME_HEIGHT),
|
||||
};
|
||||
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
||||
return food;
|
||||
}
|
||||
|
||||
class SnakeComponent {
|
||||
private state: GameState;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onClose: () => void;
|
||||
private onSave: (state: GameState | null) => void;
|
||||
private tui: { requestRender: () => void };
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private version = 0;
|
||||
private cachedVersion = -1;
|
||||
private paused: boolean;
|
||||
|
||||
constructor(
|
||||
tui: { requestRender: () => void },
|
||||
onClose: () => void,
|
||||
onSave: (state: GameState | null) => void,
|
||||
savedState?: GameState,
|
||||
) {
|
||||
this.tui = tui;
|
||||
if (savedState && !savedState.gameOver) {
|
||||
// Resume from saved state, start paused
|
||||
this.state = savedState;
|
||||
this.paused = true;
|
||||
} else {
|
||||
// New game or saved game was over
|
||||
this.state = createInitialState();
|
||||
if (savedState) {
|
||||
this.state.highScore = savedState.highScore;
|
||||
}
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
}
|
||||
this.onClose = onClose;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.interval = setInterval(() => {
|
||||
if (!this.state.gameOver) {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
// Apply queued direction change
|
||||
this.state.direction = this.state.nextDirection;
|
||||
|
||||
// Calculate new head position
|
||||
const head = this.state.snake[0];
|
||||
let newHead: Point;
|
||||
|
||||
switch (this.state.direction) {
|
||||
case "up":
|
||||
newHead = { x: head.x, y: head.y - 1 };
|
||||
break;
|
||||
case "down":
|
||||
newHead = { x: head.x, y: head.y + 1 };
|
||||
break;
|
||||
case "left":
|
||||
newHead = { x: head.x - 1, y: head.y };
|
||||
break;
|
||||
case "right":
|
||||
newHead = { x: head.x + 1, y: head.y };
|
||||
break;
|
||||
}
|
||||
|
||||
// Check wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check self collision
|
||||
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move snake
|
||||
this.state.snake.unshift(newHead);
|
||||
|
||||
// Check food collision
|
||||
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
||||
this.state.score += 10;
|
||||
if (this.state.score > this.state.highScore) {
|
||||
this.state.highScore = this.state.score;
|
||||
}
|
||||
this.state.food = spawnFood(this.state.snake);
|
||||
} else {
|
||||
this.state.snake.pop();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If paused (resuming), wait for any key
|
||||
if (this.paused) {
|
||||
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
||||
// Quit without clearing save
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
// Any other key resumes
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC to pause and save
|
||||
if (matchesKey(data, "escape")) {
|
||||
this.dispose();
|
||||
this.onSave(this.state);
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Q to quit without saving (clears saved state)
|
||||
if (data === "q" || data === "Q") {
|
||||
this.dispose();
|
||||
this.onSave(null); // Clear saved state
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys or WASD
|
||||
if (matchesKey(data, "up") || data === "w" || data === "W") {
|
||||
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
||||
} else if (matchesKey(data, "down") || data === "s" || data === "S") {
|
||||
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
||||
} else if (matchesKey(data, "right") || data === "d" || data === "D") {
|
||||
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
||||
} else if (matchesKey(data, "left") || data === "a" || data === "A") {
|
||||
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
||||
}
|
||||
|
||||
// Restart on game over
|
||||
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
||||
const highScore = this.state.highScore;
|
||||
this.state = createInitialState();
|
||||
this.state.highScore = highScore;
|
||||
this.onSave(null); // Clear saved state on restart
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = 0;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
||||
const cellWidth = 2;
|
||||
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
||||
const effectiveHeight = GAME_HEIGHT;
|
||||
|
||||
// Colors
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const boxWidth = effectiveWidth * cellWidth;
|
||||
|
||||
// Helper to pad content inside box
|
||||
const boxLine = (content: string) => {
|
||||
const contentLen = visibleWidth(content);
|
||||
const padding = Math.max(0, boxWidth - contentLen);
|
||||
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
||||
};
|
||||
|
||||
// Top border
|
||||
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
||||
|
||||
// Header with score
|
||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
||||
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
||||
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
||||
lines.push(this.padLine(boxLine(title), width));
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Game grid
|
||||
for (let y = 0; y < effectiveHeight; y++) {
|
||||
let row = "";
|
||||
for (let x = 0; x < effectiveWidth; x++) {
|
||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
||||
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
||||
const isFood = this.state.food.x === x && this.state.food.y === y;
|
||||
|
||||
if (isHead) {
|
||||
row += green("██"); // Snake head (2 chars)
|
||||
} else if (isBody) {
|
||||
row += green("▓▓"); // Snake body (2 chars)
|
||||
} else if (isFood) {
|
||||
row += red("◆ "); // Food (2 chars)
|
||||
} else {
|
||||
row += " "; // Empty cell (2 spaces)
|
||||
}
|
||||
}
|
||||
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
||||
}
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Footer
|
||||
let footer: string;
|
||||
if (this.paused) {
|
||||
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
||||
} else if (this.state.gameOver) {
|
||||
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
||||
} else {
|
||||
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
||||
}
|
||||
lines.push(this.padLine(boxLine(footer), width));
|
||||
|
||||
// Bottom border
|
||||
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
this.cachedVersion = this.version;
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(line: string, width: number): string {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
const padding = Math.max(0, width - visibleLen);
|
||||
return line + " ".repeat(padding);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SNAKE_SAVE_TYPE = "snake-save";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("snake", {
|
||||
description: "Play Snake!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved state from session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let savedState: GameState | undefined;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
||||
savedState = entry.data as GameState;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, _theme, done) => {
|
||||
return new SnakeComponent(
|
||||
tui,
|
||||
() => done(undefined),
|
||||
(state) => {
|
||||
// Save or clear state
|
||||
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
||||
},
|
||||
savedState,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
40
packages/coding-agent/examples/extensions/status-line.ts
Normal file
40
packages/coding-agent/examples/extensions/status-line.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Status Line Extension
|
||||
*
|
||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||
* Shows turn progress with themed colors.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let turnCount = 0;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (_event, ctx) => {
|
||||
turnCount++;
|
||||
const theme = ctx.ui.theme;
|
||||
const spinner = theme.fg("accent", "●");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||
ctx.ui.setStatus("status-demo", spinner + text);
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
const check = theme.fg("success", "✓");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||
ctx.ui.setStatus("status-demo", check + text);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (event, ctx) => {
|
||||
if (event.reason === "new") {
|
||||
turnCount = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
}
|
||||
});
|
||||
}
|
||||
172
packages/coding-agent/examples/extensions/subagent/README.md
Normal file
172
packages/coding-agent/examples/extensions/subagent/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Subagent Example
|
||||
|
||||
Delegate tasks to specialized subagents with isolated context windows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Isolated context**: Each subagent runs in a separate `pi` process
|
||||
- **Streaming output**: See tool calls and progress as they happen
|
||||
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
||||
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
|
||||
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
||||
- **Abort support**: Ctrl+C propagates to kill subagent processes
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
subagent/
|
||||
├── README.md # This file
|
||||
├── 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)
|
||||
└── 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
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
From the repository root, symlink the files:
|
||||
|
||||
```bash
|
||||
# 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/extensions/subagent/agents/*.md; do
|
||||
ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
|
||||
done
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
This tool executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration.
|
||||
|
||||
**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
|
||||
|
||||
**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
|
||||
|
||||
To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
|
||||
|
||||
When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
|
||||
|
||||
## Usage
|
||||
|
||||
### Single agent
|
||||
```
|
||||
Use scout to find all authentication code
|
||||
```
|
||||
|
||||
### Parallel execution
|
||||
```
|
||||
Run 2 scouts in parallel: one to find models, one to find providers
|
||||
```
|
||||
|
||||
### Chained workflow
|
||||
```
|
||||
Use a chain: first have scout find the read tool, then have planner suggest improvements
|
||||
```
|
||||
|
||||
### Workflow prompts
|
||||
```
|
||||
/implement add Redis caching to the session store
|
||||
/scout-and-plan refactor auth to support OAuth
|
||||
/implement-and-review add input validation to API endpoints
|
||||
```
|
||||
|
||||
## Tool Modes
|
||||
|
||||
| Mode | Parameter | Description |
|
||||
|------|-----------|-------------|
|
||||
| Single | `{ agent, task }` | One agent, one task |
|
||||
| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently (max 8, 4 concurrent) |
|
||||
| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder |
|
||||
|
||||
## Output Display
|
||||
|
||||
**Collapsed view** (default):
|
||||
- Status icon (✓/✗/⏳) and agent name
|
||||
- Last 5-10 items (tool calls and text)
|
||||
- Usage stats: `3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model`
|
||||
|
||||
**Expanded view** (Ctrl+O):
|
||||
- Full task text
|
||||
- All tool calls with formatted arguments
|
||||
- Final output rendered as Markdown
|
||||
- Per-task usage (for chain/parallel)
|
||||
|
||||
**Parallel mode streaming**:
|
||||
- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
|
||||
- Updates as each task makes progress
|
||||
- Shows "2/3 done, 1 running" status
|
||||
|
||||
**Tool call formatting** (mimics built-in tools):
|
||||
- `$ command` for bash
|
||||
- `read ~/path:1-10` for read
|
||||
- `grep /pattern/ in ~/path` for grep
|
||||
- etc.
|
||||
|
||||
## Agent Definitions
|
||||
|
||||
Agents are markdown files with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-agent
|
||||
description: What this agent does
|
||||
tools: read, grep, find, ls
|
||||
model: claude-haiku-4-5
|
||||
---
|
||||
|
||||
System prompt for the agent goes here.
|
||||
```
|
||||
|
||||
**Locations:**
|
||||
- `~/.pi/agent/agents/*.md` - User-level (always loaded)
|
||||
- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`)
|
||||
|
||||
Project agents override user agents with the same name when `agentScope: "both"`.
|
||||
|
||||
## Sample Agents
|
||||
|
||||
| Agent | Purpose | Model | Tools |
|
||||
|-------|---------|-------|-------|
|
||||
| `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash |
|
||||
| `planner` | Implementation plans | Sonnet | read, grep, find, ls |
|
||||
| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash |
|
||||
| `worker` | General-purpose | Sonnet | (all default) |
|
||||
|
||||
## Workflow Prompts
|
||||
|
||||
| Prompt | Flow |
|
||||
|--------|------|
|
||||
| `/implement <query>` | scout → planner → worker |
|
||||
| `/scout-and-plan <query>` | scout → planner |
|
||||
| `/implement-and-review <query>` | worker → reviewer → worker |
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Exit code != 0**: Tool returns error with stderr/output
|
||||
- **stopReason "error"**: LLM error propagated with error message
|
||||
- **stopReason "aborted"**: User abort (Ctrl+C) kills subprocess, throws error
|
||||
- **Chain mode**: Stops at first failing step, reports which step failed
|
||||
|
||||
## Limitations
|
||||
|
||||
- Output truncated to last 10 items in collapsed view (expand to see all)
|
||||
- Agents discovered fresh on each invocation (allows editing mid-session)
|
||||
- Parallel mode limited to 8 tasks, 4 concurrent
|
||||
156
packages/coding-agent/examples/extensions/subagent/agents.ts
Normal file
156
packages/coding-agent/examples/extensions/subagent/agents.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Agent discovery and configuration
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type AgentScope = "user" | "project" | "both";
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
model?: string;
|
||||
systemPrompt: string;
|
||||
source: "user" | "project";
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface AgentDiscoveryResult {
|
||||
agents: AgentConfig[];
|
||||
projectAgentsDir: string | null;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
|
||||
const frontmatter: Record<string, string> = {};
|
||||
const normalized = content.replace(/\r\n/g, "\n");
|
||||
|
||||
if (!normalized.startsWith("---")) {
|
||||
return { frontmatter, body: normalized };
|
||||
}
|
||||
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter, body: normalized };
|
||||
}
|
||||
|
||||
const frontmatterBlock = normalized.slice(4, endIndex);
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
|
||||
for (const line of frontmatterBlock.split("\n")) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (match) {
|
||||
let value = match[2].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
frontmatter[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
||||
const agents: AgentConfig[] = [];
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return agents;
|
||||
}
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return agents;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
|
||||
const filePath = path.join(dir, entry.name);
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter.name || !frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = frontmatter.tools
|
||||
?.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
agents.push({
|
||||
name: frontmatter.name,
|
||||
description: frontmatter.description,
|
||||
tools: tools && tools.length > 0 ? tools : undefined,
|
||||
model: frontmatter.model,
|
||||
systemPrompt: body,
|
||||
source,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
function isDirectory(p: string): boolean {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findNearestProjectAgentsDir(cwd: string): string | null {
|
||||
let currentDir = cwd;
|
||||
while (true) {
|
||||
const candidate = path.join(currentDir, ".pi", "agents");
|
||||
if (isDirectory(candidate)) return candidate;
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) return null;
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
||||
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
||||
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
||||
|
||||
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
||||
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
||||
|
||||
const agentMap = new Map<string, AgentConfig>();
|
||||
|
||||
if (scope === "both") {
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
} else if (scope === "user") {
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
} else {
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
}
|
||||
|
||||
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
||||
}
|
||||
|
||||
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
||||
if (agents.length === 0) return { text: "none", remaining: 0 };
|
||||
const listed = agents.slice(0, maxItems);
|
||||
const remaining = agents.length - listed.length;
|
||||
return {
|
||||
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
name: planner
|
||||
description: Creates implementation plans from context and requirements
|
||||
tools: read, grep, find, ls
|
||||
model: claude-sonnet-4-5
|
||||
---
|
||||
|
||||
You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
|
||||
|
||||
You must NOT make any changes. Only read, analyze, and plan.
|
||||
|
||||
Input format you'll receive:
|
||||
- Context/findings from a scout agent
|
||||
- Original query or requirements
|
||||
|
||||
Output format:
|
||||
|
||||
## Goal
|
||||
One sentence summary of what needs to be done.
|
||||
|
||||
## Plan
|
||||
Numbered steps, each small and actionable:
|
||||
1. Step one - specific file/function to modify
|
||||
2. Step two - what to add/change
|
||||
3. ...
|
||||
|
||||
## Files to Modify
|
||||
- `path/to/file.ts` - what changes
|
||||
- `path/to/other.ts` - what changes
|
||||
|
||||
## New Files (if any)
|
||||
- `path/to/new.ts` - purpose
|
||||
|
||||
## Risks
|
||||
Anything to watch out for.
|
||||
|
||||
Keep the plan concrete. The worker agent will execute it verbatim.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: reviewer
|
||||
description: Code review specialist for quality and security analysis
|
||||
tools: read, grep, find, ls, bash
|
||||
model: claude-sonnet-4-5
|
||||
---
|
||||
|
||||
You are a senior code reviewer. Analyze code for quality, security, and maintainability.
|
||||
|
||||
Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
|
||||
Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
|
||||
|
||||
Strategy:
|
||||
1. Run `git diff` to see recent changes (if applicable)
|
||||
2. Read the modified files
|
||||
3. Check for bugs, security issues, code smells
|
||||
|
||||
Output format:
|
||||
|
||||
## Files Reviewed
|
||||
- `path/to/file.ts` (lines X-Y)
|
||||
|
||||
## Critical (must fix)
|
||||
- `file.ts:42` - Issue description
|
||||
|
||||
## Warnings (should fix)
|
||||
- `file.ts:100` - Issue description
|
||||
|
||||
## Suggestions (consider)
|
||||
- `file.ts:150` - Improvement idea
|
||||
|
||||
## Summary
|
||||
Overall assessment in 2-3 sentences.
|
||||
|
||||
Be specific with file paths and line numbers.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
name: scout
|
||||
description: Fast codebase recon that returns compressed context for handoff to other agents
|
||||
tools: read, grep, find, ls, bash
|
||||
model: claude-haiku-4-5
|
||||
---
|
||||
|
||||
You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
|
||||
|
||||
Your output will be passed to an agent who has NOT seen the files you explored.
|
||||
|
||||
Thoroughness (infer from task, default medium):
|
||||
- Quick: Targeted lookups, key files only
|
||||
- Medium: Follow imports, read critical sections
|
||||
- Thorough: Trace all dependencies, check tests/types
|
||||
|
||||
Strategy:
|
||||
1. grep/find to locate relevant code
|
||||
2. Read key sections (not entire files)
|
||||
3. Identify types, interfaces, key functions
|
||||
4. Note dependencies between files
|
||||
|
||||
Output format:
|
||||
|
||||
## Files Retrieved
|
||||
List with exact line ranges:
|
||||
1. `path/to/file.ts` (lines 10-50) - Description of what's here
|
||||
2. `path/to/other.ts` (lines 100-150) - Description
|
||||
3. ...
|
||||
|
||||
## Key Code
|
||||
Critical types, interfaces, or functions:
|
||||
|
||||
```typescript
|
||||
interface Example {
|
||||
// actual code from the files
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
function keyFunction() {
|
||||
// actual implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
Brief explanation of how the pieces connect.
|
||||
|
||||
## Start Here
|
||||
Which file to look at first and why.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: worker
|
||||
description: General-purpose subagent with full capabilities, isolated context
|
||||
model: claude-sonnet-4-5
|
||||
---
|
||||
|
||||
You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
|
||||
|
||||
Work autonomously to complete the assigned task. Use all available tools as needed.
|
||||
|
||||
Output format when finished:
|
||||
|
||||
## Completed
|
||||
What was done.
|
||||
|
||||
## Files Changed
|
||||
- `path/to/file.ts` - what changed
|
||||
|
||||
## Notes (if any)
|
||||
Anything the main agent should know.
|
||||
|
||||
If handing off to another agent (e.g. reviewer), include:
|
||||
- Exact file paths changed
|
||||
- Key functions/types touched (short list)
|
||||
963
packages/coding-agent/examples/extensions/subagent/index.ts
Normal file
963
packages/coding-agent/examples/extensions/subagent/index.ts
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
/**
|
||||
* Subagent Tool - Delegate tasks to specialized agents
|
||||
*
|
||||
* Spawns a separate `pi` process for each subagent invocation,
|
||||
* giving it an isolated context window.
|
||||
*
|
||||
* Supports three modes:
|
||||
* - Single: { agent: "name", task: "..." }
|
||||
* - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
|
||||
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
|
||||
*
|
||||
* Uses JSON mode to capture structured output from subagents.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
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 } from "./agents.js";
|
||||
|
||||
const MAX_PARALLEL_TASKS = 8;
|
||||
const MAX_CONCURRENCY = 4;
|
||||
const COLLAPSED_ITEM_COUNT = 10;
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function formatUsageStats(
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextTokens?: number;
|
||||
turns?: number;
|
||||
},
|
||||
model?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
||||
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
||||
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
||||
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
||||
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
||||
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
||||
if (usage.contextTokens && usage.contextTokens > 0) {
|
||||
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
||||
}
|
||||
if (model) parts.push(model);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
themeFg: (color: any, text: string) => string,
|
||||
): string {
|
||||
const shortenPath = (p: string) => {
|
||||
const home = os.homedir();
|
||||
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
||||
};
|
||||
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = (args.command as string) || "...";
|
||||
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
||||
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
||||
}
|
||||
case "read": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
const filePath = shortenPath(rawPath);
|
||||
const offset = args.offset as number | undefined;
|
||||
const limit = args.limit as number | undefined;
|
||||
let text = themeFg("accent", filePath);
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = offset ?? 1;
|
||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
||||
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
||||
}
|
||||
return themeFg("muted", "read ") + text;
|
||||
}
|
||||
case "write": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
const filePath = shortenPath(rawPath);
|
||||
const content = (args.content || "") as string;
|
||||
const lines = content.split("\n").length;
|
||||
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
|
||||
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
||||
return text;
|
||||
}
|
||||
case "edit": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
||||
}
|
||||
case "ls": {
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
||||
}
|
||||
case "find": {
|
||||
const pattern = (args.pattern || "*") as string;
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
||||
}
|
||||
case "grep": {
|
||||
const pattern = (args.pattern || "") as string;
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return (
|
||||
themeFg("muted", "grep ") +
|
||||
themeFg("accent", `/${pattern}/`) +
|
||||
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const argsStr = JSON.stringify(args);
|
||||
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
||||
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextTokens: number;
|
||||
turns: number;
|
||||
}
|
||||
|
||||
interface SingleResult {
|
||||
agent: string;
|
||||
agentSource: "user" | "project" | "unknown";
|
||||
task: string;
|
||||
exitCode: number;
|
||||
messages: Message[];
|
||||
stderr: string;
|
||||
usage: UsageStats;
|
||||
model?: string;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface SubagentDetails {
|
||||
mode: "single" | "parallel" | "chain";
|
||||
agentScope: AgentScope;
|
||||
projectAgentsDir: string | null;
|
||||
results: SingleResult[];
|
||||
}
|
||||
|
||||
function getFinalOutput(messages: Message[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant") {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
|
||||
|
||||
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
||||
const items: DisplayItem[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant") {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") items.push({ type: "text", text: part.text });
|
||||
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function mapWithConcurrencyLimit<TIn, TOut>(
|
||||
items: TIn[],
|
||||
concurrency: number,
|
||||
fn: (item: TIn, index: number) => Promise<TOut>,
|
||||
): Promise<TOut[]> {
|
||||
if (items.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(concurrency, items.length));
|
||||
const results: TOut[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workers = new Array(limit).fill(null).map(async () => {
|
||||
while (true) {
|
||||
const current = nextIndex++;
|
||||
if (current >= items.length) return;
|
||||
results[current] = await fn(items[current], current);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
|
||||
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
||||
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
||||
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
||||
return { dir: tmpDir, filePath };
|
||||
}
|
||||
|
||||
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
||||
|
||||
async function runSingleAgent(
|
||||
defaultCwd: string,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
cwd: string | undefined,
|
||||
step: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: OnUpdateCallback | undefined,
|
||||
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
||||
): Promise<SingleResult> {
|
||||
const agent = agents.find((a) => a.name === agentName);
|
||||
|
||||
if (!agent) {
|
||||
return {
|
||||
agent: agentName,
|
||||
agentSource: "unknown",
|
||||
task,
|
||||
exitCode: 1,
|
||||
messages: [],
|
||||
stderr: `Unknown agent: ${agentName}`,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
||||
if (agent.model) args.push("--model", agent.model);
|
||||
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
||||
|
||||
let tmpPromptDir: string | null = null;
|
||||
let tmpPromptPath: string | null = null;
|
||||
|
||||
const currentResult: SingleResult = {
|
||||
agent: agentName,
|
||||
agentSource: agent.source,
|
||||
task,
|
||||
exitCode: 0,
|
||||
messages: [],
|
||||
stderr: "",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
||||
model: agent.model,
|
||||
step,
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (onUpdate) {
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
||||
details: makeDetails([currentResult]),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (agent.systemPrompt.trim()) {
|
||||
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
||||
tmpPromptDir = tmp.dir;
|
||||
tmpPromptPath = tmp.filePath;
|
||||
args.push("--append-system-prompt", tmpPromptPath);
|
||||
}
|
||||
|
||||
args.push(`Task: ${task}`);
|
||||
let wasAborted = false;
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let buffer = "";
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "message_end" && event.message) {
|
||||
const msg = event.message as Message;
|
||||
currentResult.messages.push(msg);
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
currentResult.usage.turns++;
|
||||
const usage = msg.usage;
|
||||
if (usage) {
|
||||
currentResult.usage.input += usage.input || 0;
|
||||
currentResult.usage.output += usage.output || 0;
|
||||
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
||||
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
||||
currentResult.usage.cost += usage.cost?.total || 0;
|
||||
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
||||
}
|
||||
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
||||
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
||||
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
||||
}
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
if (event.type === "tool_result_end" && event.message) {
|
||||
currentResult.messages.push(event.message as Message);
|
||||
emitUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) processLine(line);
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
currentResult.stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (buffer.trim()) processLine(buffer);
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => {
|
||||
resolve(1);
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
const killProc = () => {
|
||||
wasAborted = true;
|
||||
proc.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) proc.kill("SIGKILL");
|
||||
}, 5000);
|
||||
};
|
||||
if (signal.aborted) killProc();
|
||||
else signal.addEventListener("abort", killProc, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
currentResult.exitCode = exitCode;
|
||||
if (wasAborted) throw new Error("Subagent was aborted");
|
||||
return currentResult;
|
||||
} finally {
|
||||
if (tmpPromptPath)
|
||||
try {
|
||||
fs.unlinkSync(tmpPromptPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (tmpPromptDir)
|
||||
try {
|
||||
fs.rmdirSync(tmpPromptDir);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TaskItem = Type.Object({
|
||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
||||
task: Type.String({ description: "Task to delegate to the agent" }),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
||||
});
|
||||
|
||||
const ChainItem = Type.Object({
|
||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
||||
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
||||
});
|
||||
|
||||
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
||||
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
||||
default: "user",
|
||||
});
|
||||
|
||||
const SubagentParams = Type.Object({
|
||||
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
|
||||
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
|
||||
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
||||
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
|
||||
agentScope: Type.Optional(AgentScopeSchema),
|
||||
confirmProjectAgents: Type.Optional(
|
||||
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
|
||||
),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "subagent",
|
||||
label: "Subagent",
|
||||
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) {
|
||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
||||
|
||||
const hasChain = (params.chain?.length ?? 0) > 0;
|
||||
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
||||
const hasSingle = Boolean(params.agent && params.task);
|
||||
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
||||
|
||||
const makeDetails =
|
||||
(mode: "single" | "parallel" | "chain") =>
|
||||
(results: SingleResult[]): SubagentDetails => ({
|
||||
mode,
|
||||
agentScope,
|
||||
projectAgentsDir: discovery.projectAgentsDir,
|
||||
results,
|
||||
});
|
||||
|
||||
if (modeCount !== 1) {
|
||||
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
|
||||
},
|
||||
],
|
||||
details: makeDetails("single")([]),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (params.agent) requestedAgentNames.add(params.agent);
|
||||
|
||||
const projectAgentsRequested = Array.from(requestedAgentNames)
|
||||
.map((name) => agents.find((a) => a.name === name))
|
||||
.filter((a): a is AgentConfig => a?.source === "project");
|
||||
|
||||
if (projectAgentsRequested.length > 0) {
|
||||
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
||||
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
||||
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.`,
|
||||
);
|
||||
if (!ok)
|
||||
return {
|
||||
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
||||
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (params.chain && params.chain.length > 0) {
|
||||
const results: SingleResult[] = [];
|
||||
let previousOutput = "";
|
||||
|
||||
for (let i = 0; i < params.chain.length; i++) {
|
||||
const step = params.chain[i];
|
||||
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
||||
|
||||
// Create update callback that includes all previous results
|
||||
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
||||
? (partial) => {
|
||||
// Combine completed results with current streaming result
|
||||
const currentResult = partial.details?.results[0];
|
||||
if (currentResult) {
|
||||
const allResults = [...results, currentResult];
|
||||
onUpdate({
|
||||
content: partial.content,
|
||||
details: makeDetails("chain")(allResults),
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
step.agent,
|
||||
taskWithContext,
|
||||
step.cwd,
|
||||
i + 1,
|
||||
signal,
|
||||
chainUpdate,
|
||||
makeDetails("chain"),
|
||||
);
|
||||
results.push(result);
|
||||
|
||||
const isError =
|
||||
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
||||
if (isError) {
|
||||
const errorMsg =
|
||||
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
||||
return {
|
||||
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
||||
details: makeDetails("chain")(results),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
previousOutput = getFinalOutput(result.messages);
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
|
||||
details: makeDetails("chain")(results),
|
||||
};
|
||||
}
|
||||
|
||||
if (params.tasks && params.tasks.length > 0) {
|
||||
if (params.tasks.length > MAX_PARALLEL_TASKS)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
||||
},
|
||||
],
|
||||
details: makeDetails("parallel")([]),
|
||||
};
|
||||
|
||||
// Track all results for streaming updates
|
||||
const allResults: SingleResult[] = new Array(params.tasks.length);
|
||||
|
||||
// Initialize placeholder results
|
||||
for (let i = 0; i < params.tasks.length; i++) {
|
||||
allResults[i] = {
|
||||
agent: params.tasks[i].agent,
|
||||
agentSource: "unknown",
|
||||
task: params.tasks[i].task,
|
||||
exitCode: -1, // -1 = still running
|
||||
messages: [],
|
||||
stderr: "",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const emitParallelUpdate = () => {
|
||||
if (onUpdate) {
|
||||
const running = allResults.filter((r) => r.exitCode === -1).length;
|
||||
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
||||
onUpdate({
|
||||
content: [
|
||||
{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
|
||||
],
|
||||
details: makeDetails("parallel")([...allResults]),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
||||
const result = await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
t.agent,
|
||||
t.task,
|
||||
t.cwd,
|
||||
undefined,
|
||||
signal,
|
||||
// Per-task update callback
|
||||
(partial) => {
|
||||
if (partial.details?.results[0]) {
|
||||
allResults[index] = partial.details.results[0];
|
||||
emitParallelUpdate();
|
||||
}
|
||||
},
|
||||
makeDetails("parallel"),
|
||||
);
|
||||
allResults[index] = result;
|
||||
emitParallelUpdate();
|
||||
return result;
|
||||
});
|
||||
|
||||
const successCount = results.filter((r) => r.exitCode === 0).length;
|
||||
const summaries = results.map((r) => {
|
||||
const output = getFinalOutput(r.messages);
|
||||
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
|
||||
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
|
||||
},
|
||||
],
|
||||
details: makeDetails("parallel")(results),
|
||||
};
|
||||
}
|
||||
|
||||
if (params.agent && params.task) {
|
||||
const result = await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
params.agent,
|
||||
params.task,
|
||||
params.cwd,
|
||||
undefined,
|
||||
signal,
|
||||
onUpdate,
|
||||
makeDetails("single"),
|
||||
);
|
||||
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
||||
if (isError) {
|
||||
const errorMsg =
|
||||
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
||||
return {
|
||||
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
|
||||
details: makeDetails("single")([result]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
||||
details: makeDetails("single")([result]),
|
||||
};
|
||||
}
|
||||
|
||||
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
||||
return {
|
||||
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
|
||||
details: makeDetails("single")([]),
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const scope: AgentScope = args.agentScope ?? "user";
|
||||
if (args.chain && args.chain.length > 0) {
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
||||
theme.fg("accent", `chain (${args.chain.length} steps)`) +
|
||||
theme.fg("muted", ` [${scope}]`);
|
||||
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
||||
const step = args.chain[i];
|
||||
// Clean up {previous} placeholder for display
|
||||
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
||||
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
||||
text +=
|
||||
"\n " +
|
||||
theme.fg("muted", `${i + 1}.`) +
|
||||
" " +
|
||||
theme.fg("accent", step.agent) +
|
||||
theme.fg("dim", ` ${preview}`);
|
||||
}
|
||||
if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
if (args.tasks && args.tasks.length > 0) {
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
||||
theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
||||
theme.fg("muted", ` [${scope}]`);
|
||||
for (const t of args.tasks.slice(0, 3)) {
|
||||
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
|
||||
text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
|
||||
}
|
||||
if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
const agentName = args.agent || "...";
|
||||
const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
||||
theme.fg("accent", agentName) +
|
||||
theme.fg("muted", ` [${scope}]`);
|
||||
text += `\n ${theme.fg("dim", preview)}`;
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
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);
|
||||
}
|
||||
|
||||
const mdTheme = getMarkdownTheme();
|
||||
|
||||
const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
|
||||
const toShow = limit ? items.slice(-limit) : items;
|
||||
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
||||
let text = "";
|
||||
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
||||
for (const item of toShow) {
|
||||
if (item.type === "text") {
|
||||
const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
|
||||
text += `${theme.fg("toolOutput", preview)}\n`;
|
||||
} else {
|
||||
text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
||||
}
|
||||
}
|
||||
return text.trimEnd();
|
||||
};
|
||||
|
||||
if (details.mode === "single" && details.results.length === 1) {
|
||||
const r = details.results[0];
|
||||
const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
||||
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
||||
const displayItems = getDisplayItems(r.messages);
|
||||
const finalOutput = getFinalOutput(r.messages);
|
||||
|
||||
if (expanded) {
|
||||
const container = new Container();
|
||||
let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
|
||||
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
||||
container.addChild(new Text(header, 0, 0));
|
||||
if (isError && r.errorMessage)
|
||||
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
||||
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
||||
if (displayItems.length === 0 && !finalOutput) {
|
||||
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
||||
} else {
|
||||
for (const item of displayItems) {
|
||||
if (item.type === "toolCall")
|
||||
container.addChild(
|
||||
new Text(
|
||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
}
|
||||
const usageStr = formatUsageStats(r.usage, r.model);
|
||||
if (usageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
|
||||
if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
||||
if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
|
||||
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
||||
else {
|
||||
text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
|
||||
if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
}
|
||||
const usageStr = formatUsageStats(r.usage, r.model);
|
||||
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
const aggregateUsage = (results: SingleResult[]) => {
|
||||
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
||||
for (const r of results) {
|
||||
total.input += r.usage.input;
|
||||
total.output += r.usage.output;
|
||||
total.cacheRead += r.usage.cacheRead;
|
||||
total.cacheWrite += r.usage.cacheWrite;
|
||||
total.cost += r.usage.cost;
|
||||
total.turns += r.usage.turns;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
if (details.mode === "chain") {
|
||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
||||
const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
|
||||
if (expanded) {
|
||||
const container = new Container();
|
||||
container.addChild(
|
||||
new Text(
|
||||
icon +
|
||||
" " +
|
||||
theme.fg("toolTitle", theme.bold("chain ")) +
|
||||
theme.fg("accent", `${successCount}/${details.results.length} steps`),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const r of details.results) {
|
||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(r.messages);
|
||||
const finalOutput = getFinalOutput(r.messages);
|
||||
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(
|
||||
new Text(
|
||||
`${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
||||
|
||||
// Show tool calls
|
||||
for (const item of displayItems) {
|
||||
if (item.type === "toolCall") {
|
||||
container.addChild(
|
||||
new Text(
|
||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show final output as markdown
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
|
||||
const stepUsage = formatUsageStats(r.usage, r.model);
|
||||
if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
|
||||
}
|
||||
|
||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (usageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
// Collapsed view
|
||||
let text =
|
||||
icon +
|
||||
" " +
|
||||
theme.fg("toolTitle", theme.bold("chain ")) +
|
||||
theme.fg("accent", `${successCount}/${details.results.length} steps`);
|
||||
for (const r of details.results) {
|
||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(r.messages);
|
||||
text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
|
||||
if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
||||
}
|
||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
|
||||
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (details.mode === "parallel") {
|
||||
const running = details.results.filter((r) => r.exitCode === -1).length;
|
||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
||||
const failCount = details.results.filter((r) => r.exitCode > 0).length;
|
||||
const isRunning = running > 0;
|
||||
const icon = isRunning
|
||||
? theme.fg("warning", "⏳")
|
||||
: failCount > 0
|
||||
? theme.fg("warning", "◐")
|
||||
: theme.fg("success", "✓");
|
||||
const status = isRunning
|
||||
? `${successCount + failCount}/${details.results.length} done, ${running} running`
|
||||
: `${successCount}/${details.results.length} tasks`;
|
||||
|
||||
if (expanded && !isRunning) {
|
||||
const container = new Container();
|
||||
container.addChild(
|
||||
new Text(
|
||||
`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const r of details.results) {
|
||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(r.messages);
|
||||
const finalOutput = getFinalOutput(r.messages);
|
||||
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(
|
||||
new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
|
||||
);
|
||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
||||
|
||||
// Show tool calls
|
||||
for (const item of displayItems) {
|
||||
if (item.type === "toolCall") {
|
||||
container.addChild(
|
||||
new Text(
|
||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show final output as markdown
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
|
||||
const taskUsage = formatUsageStats(r.usage, r.model);
|
||||
if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
|
||||
}
|
||||
|
||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (usageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
// Collapsed view (or still running)
|
||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
|
||||
for (const r of details.results) {
|
||||
const rIcon =
|
||||
r.exitCode === -1
|
||||
? theme.fg("warning", "⏳")
|
||||
: r.exitCode === 0
|
||||
? theme.fg("success", "✓")
|
||||
: theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(r.messages);
|
||||
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
|
||||
if (displayItems.length === 0)
|
||||
text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
|
||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
||||
}
|
||||
if (!isRunning) {
|
||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
|
||||
}
|
||||
if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Worker implements, reviewer reviews, worker applies feedback
|
||||
---
|
||||
Use the subagent tool with the chain parameter to execute this workflow:
|
||||
|
||||
1. First, use the "worker" agent to implement: $@
|
||||
2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
|
||||
3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
|
||||
|
||||
Execute this as a chain, passing output between steps via {previous}.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
|
||||
---
|
||||
Use the subagent tool with the chain parameter to execute this workflow:
|
||||
|
||||
1. First, use the "scout" agent to find all code relevant to: $@
|
||||
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
||||
3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
|
||||
|
||||
Execute this as a chain, passing output between steps via {previous}.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Scout gathers context, planner creates implementation plan (no implementation)
|
||||
---
|
||||
Use the subagent tool with the chain parameter to execute this workflow:
|
||||
|
||||
1. First, use the "scout" agent to find all code relevant to: $@
|
||||
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
||||
|
||||
Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.
|
||||
299
packages/coding-agent/examples/extensions/todo.ts
Normal file
299
packages/coding-agent/examples/extensions/todo.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* Todo Extension - Demonstrates state management via session entries
|
||||
*
|
||||
* This extension:
|
||||
* - Registers a `todo` tool for the LLM to manage todos
|
||||
* - Registers a `/todos` command for users to view the list
|
||||
*
|
||||
* 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 { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface Todo {
|
||||
id: number;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
interface TodoDetails {
|
||||
action: "list" | "add" | "toggle" | "clear";
|
||||
todos: Todo[];
|
||||
nextId: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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)" })),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Reconstruct state from session entries.
|
||||
* Scans tool results for this tool and applies them in order.
|
||||
*/
|
||||
const reconstructState = (ctx: ExtensionContext) => {
|
||||
todos = [];
|
||||
nextId = 1;
|
||||
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
||||
|
||||
const details = msg.details as TodoDetails | undefined;
|
||||
if (details) {
|
||||
todos = details.todos;
|
||||
nextId = details.nextId;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: todos.length
|
||||
? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
|
||||
: "No todos",
|
||||
},
|
||||
],
|
||||
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" } 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 } as TodoDetails,
|
||||
};
|
||||
}
|
||||
|
||||
case "toggle": {
|
||||
if (params.id === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: id required for toggle" }],
|
||||
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`,
|
||||
} as TodoDetails,
|
||||
};
|
||||
}
|
||||
todo.done = !todo.done;
|
||||
return {
|
||||
content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
|
||||
details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
|
||||
};
|
||||
}
|
||||
|
||||
case "clear": {
|
||||
const count = todos.length;
|
||||
todos = [];
|
||||
nextId = 1;
|
||||
return {
|
||||
content: [{ type: "text", text: `Cleared ${count} todos` }],
|
||||
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}`,
|
||||
} as TodoDetails,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
||||
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
||||
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
const details = result.details as TodoDetails | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
if (details.error) {
|
||||
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
||||
}
|
||||
|
||||
const todoList = details.todos;
|
||||
|
||||
switch (details.action) {
|
||||
case "list": {
|
||||
if (todoList.length === 0) {
|
||||
return new Text(theme.fg("dim", "No todos"), 0, 0);
|
||||
}
|
||||
let listText = theme.fg("muted", `${todoList.length} todo(s):`);
|
||||
const display = expanded ? todoList : todoList.slice(0, 5);
|
||||
for (const t of display) {
|
||||
const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
|
||||
const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
||||
listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
||||
}
|
||||
if (!expanded && todoList.length > 5) {
|
||||
listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
|
||||
}
|
||||
return new Text(listText, 0, 0);
|
||||
}
|
||||
|
||||
case "add": {
|
||||
const added = todoList[todoList.length - 1];
|
||||
return new Text(
|
||||
theme.fg("success", "✓ Added ") +
|
||||
theme.fg("accent", `#${added.id}`) +
|
||||
" " +
|
||||
theme.fg("muted", added.text),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle": {
|
||||
const text = result.content[0];
|
||||
const msg = text?.type === "text" ? text.text : "";
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
||||
}
|
||||
|
||||
case "clear":
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
await ctx.ui.custom<void>((_tui, theme, done) => {
|
||||
return new TodoListComponent(todos, theme, () => done());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
145
packages/coding-agent/examples/extensions/tools.ts
Normal file
145
packages/coding-agent/examples/extensions/tools.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* 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/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 { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
|
||||
|
||||
// State persisted to session
|
||||
interface ToolsState {
|
||||
enabledTools: string[];
|
||||
}
|
||||
|
||||
export default function toolsExtension(pi: ExtensionAPI) {
|
||||
// Track enabled tools
|
||||
let enabledTools: Set<string> = new Set();
|
||||
let allTools: string[] = [];
|
||||
|
||||
// Persist current state
|
||||
function persistState() {
|
||||
pi.appendEntry<ToolsState>("tools-config", {
|
||||
enabledTools: Array.from(enabledTools),
|
||||
});
|
||||
}
|
||||
|
||||
// Apply current tool selection
|
||||
function applyTools() {
|
||||
pi.setActiveTools(Array.from(enabledTools));
|
||||
}
|
||||
|
||||
// Find the last tools-config entry in the current branch
|
||||
function restoreFromBranch(ctx: ExtensionContext) {
|
||||
allTools = pi.getAllTools();
|
||||
|
||||
// Get entries in current branch only
|
||||
const branchEntries = ctx.sessionManager.getBranch();
|
||||
let savedTools: string[] | undefined;
|
||||
|
||||
for (const entry of branchEntries) {
|
||||
if (entry.type === "custom" && entry.customType === "tools-config") {
|
||||
const data = entry.data as ToolsState | undefined;
|
||||
if (data?.enabledTools) {
|
||||
savedTools = data.enabledTools;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedTools) {
|
||||
// Restore saved tool selection (filter to only tools that still exist)
|
||||
enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t)));
|
||||
applyTools();
|
||||
} else {
|
||||
// No saved state - sync with currently active tools
|
||||
enabledTools = new Set(pi.getActiveTools());
|
||||
}
|
||||
}
|
||||
|
||||
// Register /tools command
|
||||
pi.registerCommand("tools", {
|
||||
description: "Enable/disable tools",
|
||||
handler: async (_args, ctx) => {
|
||||
// Refresh tool list
|
||||
allTools = pi.getAllTools();
|
||||
|
||||
await ctx.ui.custom((tui, theme, done) => {
|
||||
// Build settings items for each tool
|
||||
const items: SettingItem[] = allTools.map((tool) => ({
|
||||
id: tool,
|
||||
label: tool,
|
||||
currentValue: enabledTools.has(tool) ? "enabled" : "disabled",
|
||||
values: ["enabled", "disabled"],
|
||||
}));
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(
|
||||
new (class {
|
||||
render(_width: number) {
|
||||
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
|
||||
}
|
||||
invalidate() {}
|
||||
})(),
|
||||
);
|
||||
|
||||
const settingsList = new SettingsList(
|
||||
items,
|
||||
Math.min(items.length + 2, 15),
|
||||
getSettingsListTheme(),
|
||||
(id, newValue) => {
|
||||
// Update enabled state and apply immediately
|
||||
if (newValue === "enabled") {
|
||||
enabledTools.add(id);
|
||||
} else {
|
||||
enabledTools.delete(id);
|
||||
}
|
||||
applyTools();
|
||||
persistState();
|
||||
},
|
||||
() => {
|
||||
// Close dialog
|
||||
done(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
container.addChild(settingsList);
|
||||
|
||||
const component = {
|
||||
render(width: number) {
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
handleInput(data: string) {
|
||||
settingsList.handleInput?.(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
|
||||
return component;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Restore state on session start
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
restoreFromBranch(ctx);
|
||||
});
|
||||
|
||||
// Restore state when navigating the session tree
|
||||
pi.on("session_tree", async (_event, ctx) => {
|
||||
restoreFromBranch(ctx);
|
||||
});
|
||||
|
||||
// Restore state after branching
|
||||
pi.on("session_branch", async (_event, ctx) => {
|
||||
restoreFromBranch(ctx);
|
||||
});
|
||||
}
|
||||
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/
|
||||
40
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal file
40
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 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) {
|
||||
// Use the ms package to prove it loaded
|
||||
const uptime = ms(process.uptime() * 1000, { long: true });
|
||||
console.log(`[with-deps] Extension loaded. Process uptime: ${uptime}`);
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue