mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
Merge branch 'main' into feat/tui-overlay-options
This commit is contained in:
commit
7d45e434de
90 changed files with 10277 additions and 1700 deletions
|
|
@ -22,6 +22,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
|
||||
|
||||
### Custom Tools
|
||||
|
||||
|
|
@ -29,8 +30,10 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
|-----------|-------------|
|
||||
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
|
||||
| `hello.ts` | Minimal custom tool example |
|
||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
|
||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions with custom UI |
|
||||
| `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
|
||||
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
|
||||
| `truncated-tool.ts` | Wraps ripgrep with proper output truncation (50KB/2000 lines) |
|
||||
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
|
||||
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
||||
|
||||
|
|
@ -39,19 +42,26 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
|
||||
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
|
||||
| `plan-mode/` | Claude Code-style plan mode for read-only exploration with `/plan` command and step tracking |
|
||||
| `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 |
|
||||
| `model-status.ts` | Shows model changes in status bar via `model_select` hook |
|
||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
|
||||
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
|
||||
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
||||
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
|
||||
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
||||
| `summarize.ts` | Summarize conversation with GPT-5.2 and show in transient UI |
|
||||
| `custom-footer.ts` | Custom footer with git branch and token stats via `ctx.ui.setFooter()` |
|
||||
| `custom-header.ts` | Custom header via `ctx.ui.setHeader()` |
|
||||
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
|
||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
|
||||
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
|
||||
| `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
|
||||
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
|
||||
|
||||
### Git Integration
|
||||
|
||||
|
|
@ -65,8 +75,15 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
||||
| `claude-rules.ts` | Scans `.claude/rules/` folder and lists rules in system prompt |
|
||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
||||
|
||||
### System Integration
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `mac-system-theme.ts` | Syncs pi theme with macOS dark/light mode |
|
||||
|
||||
### External Dependencies
|
||||
|
||||
| Extension | Description |
|
||||
|
|
|
|||
|
|
@ -1,548 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Plan Mode Extension
|
||||
|
||||
Read-only exploration mode for safe code analysis.
|
||||
|
||||
## Features
|
||||
|
||||
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
|
||||
- **Bash allowlist**: Only read-only bash commands are allowed
|
||||
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
|
||||
- **Progress tracking**: Widget shows completion status during execution
|
||||
- **[DONE:n] markers**: Explicit step completion tracking
|
||||
- **Session persistence**: State survives session resume
|
||||
|
||||
## Commands
|
||||
|
||||
- `/plan` - Toggle plan mode
|
||||
- `/todos` - Show current plan progress
|
||||
- `Shift+P` - Toggle plan mode (shortcut)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enable plan mode with `/plan` or `--plan` flag
|
||||
2. Ask the agent to analyze code and create a plan
|
||||
3. The agent should output a numbered plan under a `Plan:` header:
|
||||
|
||||
```
|
||||
Plan:
|
||||
1. First step description
|
||||
2. Second step description
|
||||
3. Third step description
|
||||
```
|
||||
|
||||
4. Choose "Execute the plan" when prompted
|
||||
5. During execution, the agent marks steps complete with `[DONE:n]` tags
|
||||
6. Progress widget shows completion status
|
||||
|
||||
## How It Works
|
||||
|
||||
### Plan Mode (Read-Only)
|
||||
- Only read-only tools available
|
||||
- Bash commands filtered through allowlist
|
||||
- Agent creates a plan without making changes
|
||||
|
||||
### Execution Mode
|
||||
- Full tool access restored
|
||||
- Agent executes steps in order
|
||||
- `[DONE:n]` markers track completion
|
||||
- Widget shows progress
|
||||
|
||||
### Command Allowlist
|
||||
|
||||
Safe commands (allowed):
|
||||
- File inspection: `cat`, `head`, `tail`, `less`, `more`
|
||||
- Search: `grep`, `find`, `rg`, `fd`
|
||||
- Directory: `ls`, `pwd`, `tree`
|
||||
- Git read: `git status`, `git log`, `git diff`, `git branch`
|
||||
- Package info: `npm list`, `npm outdated`, `yarn info`
|
||||
- System info: `uname`, `whoami`, `date`, `uptime`
|
||||
|
||||
Blocked commands:
|
||||
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
|
||||
- Git write: `git add`, `git commit`, `git push`
|
||||
- Package install: `npm install`, `yarn add`, `pip install`
|
||||
- System: `sudo`, `kill`, `reboot`
|
||||
- Editors: `vim`, `nano`, `code`
|
||||
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal file
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* Plan Mode Extension
|
||||
*
|
||||
* Read-only exploration mode for safe code analysis.
|
||||
* When enabled, only read-only tools are available.
|
||||
*
|
||||
* Features:
|
||||
* - /plan command or Shift+P to toggle
|
||||
* - Bash restricted to allowlisted read-only commands
|
||||
* - Extracts numbered plan steps from "Plan:" sections
|
||||
* - [DONE:n] markers to complete steps during execution
|
||||
* - Progress tracking widget during execution
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Key } from "@mariozechner/pi-tui";
|
||||
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
|
||||
|
||||
// Tools
|
||||
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
|
||||
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
||||
|
||||
// Type guard for assistant messages
|
||||
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
||||
return m.role === "assistant" && Array.isArray(m.content);
|
||||
}
|
||||
|
||||
// Extract text content from an assistant message
|
||||
function getTextContent(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function planModeExtension(pi: ExtensionAPI): void {
|
||||
let planModeEnabled = false;
|
||||
let executionMode = false;
|
||||
let todoItems: TodoItem[] = [];
|
||||
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in plan mode (read-only exploration)",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
function updateStatus(ctx: ExtensionContext): void {
|
||||
// Footer status
|
||||
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);
|
||||
}
|
||||
|
||||
// Widget showing todo list
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const lines = todoItems.map((item) => {
|
||||
if (item.completed) {
|
||||
return (
|
||||
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
||||
);
|
||||
}
|
||||
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
|
||||
});
|
||||
ctx.ui.setWidget("plan-todos", lines);
|
||||
} else {
|
||||
ctx.ui.setWidget("plan-todos", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlanMode(ctx: ExtensionContext): void {
|
||||
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);
|
||||
}
|
||||
|
||||
function persistState(): void {
|
||||
pi.appendEntry("plan-mode", {
|
||||
enabled: planModeEnabled,
|
||||
todos: todoItems,
|
||||
executing: executionMode,
|
||||
});
|
||||
}
|
||||
|
||||
pi.registerCommand("plan", {
|
||||
description: "Toggle plan mode (read-only exploration)",
|
||||
handler: async (_args, ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
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 list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
|
||||
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(Key.shift("p"), {
|
||||
description: "Toggle plan mode",
|
||||
handler: async (ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
// Block destructive bash commands in plan mode
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!planModeEnabled || event.toolName !== "bash") return;
|
||||
|
||||
const command = event.input.command as string;
|
||||
if (!isSafeCommand(command)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out stale plan mode context when not in plan mode
|
||||
pi.on("context", async (event) => {
|
||||
if (planModeEnabled) return;
|
||||
|
||||
return {
|
||||
messages: event.messages.filter((m) => {
|
||||
const msg = m as AgentMessage & { customType?: string };
|
||||
if (msg.customType === "plan-mode-context") return false;
|
||||
if (msg.role !== "user") return true;
|
||||
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") {
|
||||
return !content.includes("[PLAN MODE ACTIVE]");
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return !content.some(
|
||||
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Inject plan/execution context before agent starts
|
||||
pi.on("before_agent_start", async () => {
|
||||
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, questionnaire
|
||||
- You CANNOT use: edit, write (file modifications are disabled)
|
||||
- Bash is restricted to an allowlist of read-only commands
|
||||
|
||||
Ask clarifying questions using the questionnaire tool.
|
||||
Use brave-search skill via bash for web research.
|
||||
|
||||
Create a detailed numbered plan under a "Plan:" header:
|
||||
|
||||
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.
|
||||
After completing a step, include a [DONE:n] tag in your response.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Track progress after each turn
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
if (!executionMode || todoItems.length === 0) return;
|
||||
if (!isAssistantMessage(event.message)) return;
|
||||
|
||||
const text = getTextContent(event.message);
|
||||
if (markCompletedSteps(text, todoItems) > 0) {
|
||||
updateStatus(ctx);
|
||||
}
|
||||
persistState();
|
||||
});
|
||||
|
||||
// Handle plan completion and plan mode UI
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
// Check if execution is complete
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
if (todoItems.every((t) => t.completed)) {
|
||||
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);
|
||||
persistState(); // Save cleared state so resume doesn't restore old execution mode
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!planModeEnabled || !ctx.hasUI) return;
|
||||
|
||||
// Extract todos from last assistant message
|
||||
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
||||
if (lastAssistant) {
|
||||
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
||||
if (extracted.length > 0) {
|
||||
todoItems = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
// Show plan steps and prompt for next action
|
||||
if (todoItems.length > 0) {
|
||||
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?", [
|
||||
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
|
||||
"Stay in plan mode",
|
||||
"Refine the plan",
|
||||
]);
|
||||
|
||||
if (choice?.startsWith("Execute")) {
|
||||
planModeEnabled = false;
|
||||
executionMode = todoItems.length > 0;
|
||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||
updateStatus(ctx);
|
||||
|
||||
const execMessage =
|
||||
todoItems.length > 0
|
||||
? `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.editor("Refine the plan:", "");
|
||||
if (refinement?.trim()) {
|
||||
pi.sendUserMessage(refinement.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Restore state on session start/resume
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (pi.getFlag("plan") === true) {
|
||||
planModeEnabled = true;
|
||||
}
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
|
||||
// Restore persisted state
|
||||
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) {
|
||||
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
|
||||
todoItems = planModeEntry.data.todos ?? todoItems;
|
||||
executionMode = planModeEntry.data.executing ?? executionMode;
|
||||
}
|
||||
|
||||
// On resume: re-scan messages to rebuild completion state
|
||||
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
|
||||
const isResume = planModeEntry !== undefined;
|
||||
if (isResume && executionMode && todoItems.length > 0) {
|
||||
// Find the index of the last plan-mode-execute entry (marks when current execution started)
|
||||
let executeIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as { type: string; customType?: string };
|
||||
if (entry.customType === "plan-mode-execute") {
|
||||
executeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only scan messages after the execute marker
|
||||
const messages: AssistantMessage[] = [];
|
||||
for (let i = executeIndex + 1; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
||||
messages.push(entry.message as AssistantMessage);
|
||||
}
|
||||
}
|
||||
const allText = messages.map(getTextContent).join("\n");
|
||||
markCompletedSteps(allText, todoItems);
|
||||
}
|
||||
|
||||
if (planModeEnabled) {
|
||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
||||
}
|
||||
updateStatus(ctx);
|
||||
});
|
||||
}
|
||||
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal file
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Pure utility functions for plan mode.
|
||||
* Extracted for testability.
|
||||
*/
|
||||
|
||||
// Destructive commands 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|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,
|
||||
];
|
||||
|
||||
// Safe read-only commands allowed in plan mode
|
||||
const SAFE_PATTERNS = [
|
||||
/^\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/,
|
||||
];
|
||||
|
||||
export function isSafeCommand(command: string): boolean {
|
||||
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
||||
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
||||
return !isDestructive && isSafe;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function cleanStepText(text: string): string {
|
||||
let cleaned = text
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
|
||||
.replace(/`([^`]+)`/g, "$1") // Remove code
|
||||
.replace(
|
||||
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (cleaned.length > 0) {
|
||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
if (cleaned.length > 50) {
|
||||
cleaned = `${cleaned.slice(0, 47)}...`;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function extractTodoItems(message: string): TodoItem[] {
|
||||
const items: TodoItem[] = [];
|
||||
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
||||
if (!headerMatch) return items;
|
||||
|
||||
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
||||
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
||||
|
||||
for (const match of planSection.matchAll(numberedPattern)) {
|
||||
const text = match[2]
|
||||
.trim()
|
||||
.replace(/\*{1,2}$/, "")
|
||||
.trim();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function extractDoneSteps(message: string): number[] {
|
||||
const steps: number[] = [];
|
||||
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
||||
const step = Number(match[1]);
|
||||
if (Number.isFinite(step)) steps.push(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
||||
const doneSteps = extractDoneSteps(text);
|
||||
for (const step of doneSteps) {
|
||||
const item = items.find((t) => t.step === step);
|
||||
if (item) item.completed = true;
|
||||
}
|
||||
return doneSteps.length;
|
||||
}
|
||||
|
|
@ -1,23 +1,50 @@
|
|||
/**
|
||||
* Question Tool - Let the LLM ask the user a question with options
|
||||
* Question Tool - Single question with options
|
||||
* Full custom UI: options list + inline editor for "Type something..."
|
||||
* Escape in editor returns to options, Escape in options cancels
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface OptionWithDesc {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type DisplayOption = OptionWithDesc & { isOther?: boolean };
|
||||
|
||||
interface QuestionDetails {
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: string | null;
|
||||
wasCustom?: boolean;
|
||||
}
|
||||
|
||||
// Support both simple strings and objects with descriptions
|
||||
const OptionSchema = Type.Union([
|
||||
Type.String(),
|
||||
Type.Object({
|
||||
label: Type.String({ description: "Display label for the option" }),
|
||||
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||
}),
|
||||
]);
|
||||
|
||||
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" }),
|
||||
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Normalize option to { label, description? }
|
||||
function normalizeOption(opt: string | { label: string; description?: string }): OptionWithDesc {
|
||||
if (typeof opt === "string") {
|
||||
return { label: opt };
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
|
||||
export default function question(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "question",
|
||||
label: "Question",
|
||||
|
|
@ -28,7 +55,11 @@ export default function (pi: ExtensionAPI) {
|
|||
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,
|
||||
details: {
|
||||
question: params.question,
|
||||
options: params.options.map((o) => (typeof o === "string" ? o : o.label)),
|
||||
answer: null,
|
||||
} as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -39,25 +70,183 @@ export default function (pi: ExtensionAPI) {
|
|||
};
|
||||
}
|
||||
|
||||
const answer = await ctx.ui.select(params.question, params.options);
|
||||
// Normalize options
|
||||
const normalizedOptions = params.options.map(normalizeOption);
|
||||
const allOptions: DisplayOption[] = [...normalizedOptions, { label: "Type something.", isOther: true }];
|
||||
|
||||
if (answer === undefined) {
|
||||
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
|
||||
(tui, theme, _kb, done) => {
|
||||
let optionIndex = 0;
|
||||
let editMode = false;
|
||||
let cachedLines: string[] | undefined;
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (s) => theme.fg("accent", s),
|
||||
selectList: {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
},
|
||||
};
|
||||
const editor = new Editor(editorTheme);
|
||||
|
||||
editor.onSubmit = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
done({ answer: trimmed, wasCustom: true });
|
||||
} else {
|
||||
editMode = false;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function handleInput(data: string) {
|
||||
if (editMode) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
editMode = false;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
editor.handleInput(data);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.up)) {
|
||||
optionIndex = Math.max(0, optionIndex - 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const selected = allOptions[optionIndex];
|
||||
if (selected.isOther) {
|
||||
editMode = true;
|
||||
refresh();
|
||||
} else {
|
||||
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(null);
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const lines: string[] = [];
|
||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
add(theme.fg("text", ` ${params.question}`));
|
||||
lines.push("");
|
||||
|
||||
for (let i = 0; i < allOptions.length; i++) {
|
||||
const opt = allOptions[i];
|
||||
const selected = i === optionIndex;
|
||||
const isOther = opt.isOther === true;
|
||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||
|
||||
if (isOther && editMode) {
|
||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||
} else if (selected) {
|
||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
|
||||
} else {
|
||||
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
|
||||
}
|
||||
|
||||
// Show description if present
|
||||
if (opt.description) {
|
||||
add(` ${theme.fg("muted", opt.description)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
lines.push("");
|
||||
add(theme.fg("muted", " Your answer:"));
|
||||
for (const line of editor.render(width - 2)) {
|
||||
add(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (editMode) {
|
||||
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
||||
} else {
|
||||
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
|
||||
}
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Build simple options list for details
|
||||
const simpleOptions = normalizedOptions.map((o) => o.label);
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
|
||||
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.wasCustom) {
|
||||
return {
|
||||
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
|
||||
details: {
|
||||
question: params.question,
|
||||
options: simpleOptions,
|
||||
answer: result.answer,
|
||||
wasCustom: true,
|
||||
} as QuestionDetails,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `User selected: ${answer}` }],
|
||||
details: { question: params.question, options: params.options, answer } as QuestionDetails,
|
||||
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
|
||||
details: {
|
||||
question: params.question,
|
||||
options: simpleOptions,
|
||||
answer: result.answer,
|
||||
wasCustom: false,
|
||||
} 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(", ")}`)}`;
|
||||
const opts = Array.isArray(args.options) ? args.options : [];
|
||||
if (opts.length) {
|
||||
const labels = opts.map((o: string | { label: string }) => (typeof o === "string" ? o : o.label));
|
||||
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
|
||||
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
|
@ -73,7 +262,16 @@ export default function (pi: ExtensionAPI) {
|
|||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
|
||||
if (details.wasCustom) {
|
||||
return new Text(
|
||||
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
const idx = details.options.indexOf(details.answer) + 1;
|
||||
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
|
||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal file
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Questionnaire Tool - Unified tool for asking single or multiple questions
|
||||
*
|
||||
* Single question: simple options list
|
||||
* Multiple questions: tab bar navigation between questions
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Types
|
||||
interface QuestionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type RenderOption = QuestionOption & { isOther?: boolean };
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
options: QuestionOption[];
|
||||
allowOther: boolean;
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
wasCustom: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
interface QuestionnaireResult {
|
||||
questions: Question[];
|
||||
answers: Answer[];
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
// Schema
|
||||
const QuestionOptionSchema = Type.Object({
|
||||
value: Type.String({ description: "The value returned when selected" }),
|
||||
label: Type.String({ description: "Display label for the option" }),
|
||||
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||
});
|
||||
|
||||
const QuestionSchema = Type.Object({
|
||||
id: Type.String({ description: "Unique identifier for this question" }),
|
||||
label: Type.Optional(
|
||||
Type.String({
|
||||
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
|
||||
}),
|
||||
),
|
||||
prompt: Type.String({ description: "The full question text to display" }),
|
||||
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
|
||||
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
|
||||
});
|
||||
|
||||
const QuestionnaireParams = Type.Object({
|
||||
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
||||
});
|
||||
|
||||
function errorResult(
|
||||
message: string,
|
||||
questions: Question[] = [],
|
||||
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { questions, answers: [], cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default function questionnaire(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "questionnaire",
|
||||
label: "Questionnaire",
|
||||
description:
|
||||
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
|
||||
parameters: QuestionnaireParams,
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
|
||||
if (!ctx.hasUI) {
|
||||
return errorResult("Error: UI not available (running in non-interactive mode)");
|
||||
}
|
||||
if (params.questions.length === 0) {
|
||||
return errorResult("Error: No questions provided");
|
||||
}
|
||||
|
||||
// Normalize questions with defaults
|
||||
const questions: Question[] = params.questions.map((q, i) => ({
|
||||
...q,
|
||||
label: q.label || `Q${i + 1}`,
|
||||
allowOther: q.allowOther !== false,
|
||||
}));
|
||||
|
||||
const isMulti = questions.length > 1;
|
||||
const totalTabs = questions.length + 1; // questions + Submit
|
||||
|
||||
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
|
||||
// State
|
||||
let currentTab = 0;
|
||||
let optionIndex = 0;
|
||||
let inputMode = false;
|
||||
let inputQuestionId: string | null = null;
|
||||
let cachedLines: string[] | undefined;
|
||||
const answers = new Map<string, Answer>();
|
||||
|
||||
// Editor for "Type something" option
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (s) => theme.fg("accent", s),
|
||||
selectList: {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
},
|
||||
};
|
||||
const editor = new Editor(editorTheme);
|
||||
|
||||
// Helpers
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function submit(cancelled: boolean) {
|
||||
done({ questions, answers: Array.from(answers.values()), cancelled });
|
||||
}
|
||||
|
||||
function currentQuestion(): Question | undefined {
|
||||
return questions[currentTab];
|
||||
}
|
||||
|
||||
function currentOptions(): RenderOption[] {
|
||||
const q = currentQuestion();
|
||||
if (!q) return [];
|
||||
const opts: RenderOption[] = [...q.options];
|
||||
if (q.allowOther) {
|
||||
opts.push({ value: "__other__", label: "Type something.", isOther: true });
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function allAnswered(): boolean {
|
||||
return questions.every((q) => answers.has(q.id));
|
||||
}
|
||||
|
||||
function advanceAfterAnswer() {
|
||||
if (!isMulti) {
|
||||
submit(false);
|
||||
return;
|
||||
}
|
||||
if (currentTab < questions.length - 1) {
|
||||
currentTab++;
|
||||
} else {
|
||||
currentTab = questions.length; // Submit tab
|
||||
}
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
|
||||
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
|
||||
}
|
||||
|
||||
// Editor submit callback
|
||||
editor.onSubmit = (value) => {
|
||||
if (!inputQuestionId) return;
|
||||
const trimmed = value.trim() || "(no response)";
|
||||
saveAnswer(inputQuestionId, trimmed, trimmed, true);
|
||||
inputMode = false;
|
||||
inputQuestionId = null;
|
||||
editor.setText("");
|
||||
advanceAfterAnswer();
|
||||
};
|
||||
|
||||
function handleInput(data: string) {
|
||||
// Input mode: route to editor
|
||||
if (inputMode) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
inputMode = false;
|
||||
inputQuestionId = null;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
editor.handleInput(data);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const q = currentQuestion();
|
||||
const opts = currentOptions();
|
||||
|
||||
// Tab navigation (multi-question only)
|
||||
if (isMulti) {
|
||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||
currentTab = (currentTab + 1) % totalTabs;
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit tab
|
||||
if (currentTab === questions.length) {
|
||||
if (matchesKey(data, Key.enter) && allAnswered()) {
|
||||
submit(false);
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
submit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Option navigation
|
||||
if (matchesKey(data, Key.up)) {
|
||||
optionIndex = Math.max(0, optionIndex - 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Select option
|
||||
if (matchesKey(data, Key.enter) && q) {
|
||||
const opt = opts[optionIndex];
|
||||
if (opt.isOther) {
|
||||
inputMode = true;
|
||||
inputQuestionId = q.id;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
|
||||
advanceAfterAnswer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
submit(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const lines: string[] = [];
|
||||
const q = currentQuestion();
|
||||
const opts = currentOptions();
|
||||
|
||||
// Helper to add truncated line
|
||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
|
||||
// Tab bar (multi-question only)
|
||||
if (isMulti) {
|
||||
const tabs: string[] = ["← "];
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const isActive = i === currentTab;
|
||||
const isAnswered = answers.has(questions[i].id);
|
||||
const lbl = questions[i].label;
|
||||
const box = isAnswered ? "■" : "□";
|
||||
const color = isAnswered ? "success" : "muted";
|
||||
const text = ` ${box} ${lbl} `;
|
||||
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
|
||||
tabs.push(`${styled} `);
|
||||
}
|
||||
const canSubmit = allAnswered();
|
||||
const isSubmitTab = currentTab === questions.length;
|
||||
const submitText = " ✓ Submit ";
|
||||
const submitStyled = isSubmitTab
|
||||
? theme.bg("selectedBg", theme.fg("text", submitText))
|
||||
: theme.fg(canSubmit ? "success" : "dim", submitText);
|
||||
tabs.push(`${submitStyled} →`);
|
||||
add(` ${tabs.join("")}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Helper to render options list
|
||||
function renderOptions() {
|
||||
for (let i = 0; i < opts.length; i++) {
|
||||
const opt = opts[i];
|
||||
const selected = i === optionIndex;
|
||||
const isOther = opt.isOther === true;
|
||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||
const color = selected ? "accent" : "text";
|
||||
// Mark "Type something" differently when in input mode
|
||||
if (isOther && inputMode) {
|
||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||
} else {
|
||||
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
|
||||
}
|
||||
if (opt.description) {
|
||||
add(` ${theme.fg("muted", opt.description)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
if (inputMode && q) {
|
||||
add(theme.fg("text", ` ${q.prompt}`));
|
||||
lines.push("");
|
||||
// Show options for reference
|
||||
renderOptions();
|
||||
lines.push("");
|
||||
add(theme.fg("muted", " Your answer:"));
|
||||
for (const line of editor.render(width - 2)) {
|
||||
add(` ${line}`);
|
||||
}
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
||||
} else if (currentTab === questions.length) {
|
||||
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
||||
lines.push("");
|
||||
for (const question of questions) {
|
||||
const answer = answers.get(question.id);
|
||||
if (answer) {
|
||||
const prefix = answer.wasCustom ? "(wrote) " : "";
|
||||
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
if (allAnswered()) {
|
||||
add(theme.fg("success", " Press Enter to submit"));
|
||||
} else {
|
||||
const missing = questions
|
||||
.filter((q) => !answers.has(q.id))
|
||||
.map((q) => q.label)
|
||||
.join(", ");
|
||||
add(theme.fg("warning", ` Unanswered: ${missing}`));
|
||||
}
|
||||
} else if (q) {
|
||||
add(theme.fg("text", ` ${q.prompt}`));
|
||||
lines.push("");
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (!inputMode) {
|
||||
const help = isMulti
|
||||
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
|
||||
: " ↑↓ navigate • Enter select • Esc cancel";
|
||||
add(theme.fg("dim", help));
|
||||
}
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
||||
details: result,
|
||||
};
|
||||
}
|
||||
|
||||
const answerLines = result.answers.map((a) => {
|
||||
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
|
||||
if (a.wasCustom) {
|
||||
return `${qLabel}: user wrote: ${a.label}`;
|
||||
}
|
||||
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: answerLines.join("\n") }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const qs = (args.questions as Question[]) || [];
|
||||
const count = qs.length;
|
||||
const labels = qs.map((q) => q.label || q.id).join(", ");
|
||||
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
||||
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
||||
if (labels) {
|
||||
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as QuestionnaireResult | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
if (details.cancelled) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
const lines = details.answers.map((a) => {
|
||||
if (a.wasCustom) {
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
|
||||
}
|
||||
const display = a.index ? `${a.index}. ${a.label}` : a.label;
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
|
||||
});
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Sandbox Extension - OS-level sandboxing for bash commands
|
||||
*
|
||||
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
|
||||
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
|
||||
* bubblewrap on Linux).
|
||||
*
|
||||
* Config files (merged, project takes precedence):
|
||||
* - ~/.pi/agent/sandbox.json (global)
|
||||
* - <cwd>/.pi/sandbox.json (project-local)
|
||||
*
|
||||
* Example .pi/sandbox.json:
|
||||
* ```json
|
||||
* {
|
||||
* "enabled": true,
|
||||
* "network": {
|
||||
* "allowedDomains": ["github.com", "*.github.com"],
|
||||
* "deniedDomains": []
|
||||
* },
|
||||
* "filesystem": {
|
||||
* "denyRead": ["~/.ssh", "~/.aws"],
|
||||
* "allowWrite": [".", "/tmp"],
|
||||
* "denyWrite": [".env"]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usage:
|
||||
* - `pi -e ./sandbox` - sandbox enabled with default/config settings
|
||||
* - `pi -e ./sandbox --no-sandbox` - disable sandboxing
|
||||
* - `/sandbox` - show current sandbox configuration
|
||||
*
|
||||
* Setup:
|
||||
* 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
|
||||
* 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/
|
||||
*
|
||||
* Linux also requires: bubblewrap, socat, ripgrep
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
interface SandboxConfig extends SandboxRuntimeConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SandboxConfig = {
|
||||
enabled: true,
|
||||
network: {
|
||||
allowedDomains: [
|
||||
"npmjs.org",
|
||||
"*.npmjs.org",
|
||||
"registry.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"pypi.org",
|
||||
"*.pypi.org",
|
||||
"github.com",
|
||||
"*.github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
],
|
||||
deniedDomains: [],
|
||||
},
|
||||
filesystem: {
|
||||
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
|
||||
allowWrite: [".", "/tmp"],
|
||||
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
|
||||
},
|
||||
};
|
||||
|
||||
function loadConfig(cwd: string): SandboxConfig {
|
||||
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
|
||||
const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
|
||||
|
||||
let globalConfig: Partial<SandboxConfig> = {};
|
||||
let projectConfig: Partial<SandboxConfig> = {};
|
||||
|
||||
if (existsSync(globalConfigPath)) {
|
||||
try {
|
||||
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(projectConfigPath)) {
|
||||
try {
|
||||
projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
||||
}
|
||||
|
||||
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
||||
const result: SandboxConfig = { ...base };
|
||||
|
||||
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
|
||||
if (overrides.network) {
|
||||
result.network = { ...base.network, ...overrides.network };
|
||||
}
|
||||
if (overrides.filesystem) {
|
||||
result.filesystem = { ...base.filesystem, ...overrides.filesystem };
|
||||
}
|
||||
|
||||
const extOverrides = overrides as {
|
||||
ignoreViolations?: Record<string, string[]>;
|
||||
enableWeakerNestedSandbox?: boolean;
|
||||
};
|
||||
const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
|
||||
|
||||
if (extOverrides.ignoreViolations) {
|
||||
extResult.ignoreViolations = extOverrides.ignoreViolations;
|
||||
}
|
||||
if (extOverrides.enableWeakerNestedSandbox !== undefined) {
|
||||
extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function createSandboxedBashOps(): BashOperations {
|
||||
return {
|
||||
async exec(command, cwd, { onData, signal, timeout }) {
|
||||
if (!existsSync(cwd)) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}`);
|
||||
}
|
||||
|
||||
const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("bash", ["-c", wrappedCommand], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
} else if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
} else {
|
||||
resolve({ exitCode: code });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerFlag("no-sandbox", {
|
||||
description: "Disable OS-level sandboxing for bash commands",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const localCwd = process.cwd();
|
||||
const localBash = createBashTool(localCwd);
|
||||
|
||||
let sandboxEnabled = false;
|
||||
let sandboxInitialized = false;
|
||||
|
||||
pi.registerTool({
|
||||
...localBash,
|
||||
label: "bash (sandboxed)",
|
||||
async execute(id, params, onUpdate, _ctx, signal) {
|
||||
if (!sandboxEnabled || !sandboxInitialized) {
|
||||
return localBash.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
|
||||
const sandboxedBash = createBashTool(localCwd, {
|
||||
operations: createSandboxedBashOps(),
|
||||
});
|
||||
return sandboxedBash.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("user_bash", () => {
|
||||
if (!sandboxEnabled || !sandboxInitialized) return;
|
||||
return { operations: createSandboxedBashOps() };
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const noSandbox = pi.getFlag("no-sandbox") as boolean;
|
||||
|
||||
if (noSandbox) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(ctx.cwd);
|
||||
|
||||
if (!config.enabled) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify("Sandbox disabled via config", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
if (platform !== "darwin" && platform !== "linux") {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const configExt = config as unknown as {
|
||||
ignoreViolations?: Record<string, string[]>;
|
||||
enableWeakerNestedSandbox?: boolean;
|
||||
};
|
||||
|
||||
await SandboxManager.initialize({
|
||||
network: config.network,
|
||||
filesystem: config.filesystem,
|
||||
ignoreViolations: configExt.ignoreViolations,
|
||||
enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
|
||||
});
|
||||
|
||||
sandboxEnabled = true;
|
||||
sandboxInitialized = true;
|
||||
|
||||
const networkCount = config.network?.allowedDomains?.length ?? 0;
|
||||
const writeCount = config.filesystem?.allowWrite?.length ?? 0;
|
||||
ctx.ui.setStatus(
|
||||
"sandbox",
|
||||
ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`),
|
||||
);
|
||||
ctx.ui.notify("Sandbox initialized", "info");
|
||||
} catch (err) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (sandboxInitialized) {
|
||||
try {
|
||||
await SandboxManager.reset();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("sandbox", {
|
||||
description: "Show sandbox configuration",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!sandboxEnabled) {
|
||||
ctx.ui.notify("Sandbox is disabled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(ctx.cwd);
|
||||
const lines = [
|
||||
"Sandbox Configuration:",
|
||||
"",
|
||||
"Network:",
|
||||
` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
|
||||
` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
|
||||
"",
|
||||
"Filesystem:",
|
||||
` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
|
||||
` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
|
||||
` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
|
||||
];
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
||||
"version": "0.0.26",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz",
|
||||
"integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@pondwader/socks5-server": "^1.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"commander": "^12.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"shell-quote": "^1.8.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
"srt": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pondwader/socks5-server": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
"build": "echo 'nothing to build'",
|
||||
"check": "echo 'nothing to check'"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
}
|
||||
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui";
|
||||
|
||||
type ContentBlock = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SessionEntry = {
|
||||
type: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const extractTextParts = (content: unknown): string[] => {
|
||||
if (typeof content === "string") {
|
||||
return [content];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = part as ContentBlock;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts;
|
||||
};
|
||||
|
||||
const extractToolCallLines = (content: unknown): string[] => {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const toolCalls: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = part as ContentBlock;
|
||||
if (block.type !== "toolCall" || typeof block.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const args = block.arguments ?? {};
|
||||
toolCalls.push(`Tool ${block.name} was called with args ${JSON.stringify(args)}`);
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
};
|
||||
|
||||
const buildConversationText = (entries: SessionEntry[]): string => {
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message" || !entry.message?.role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = entry.message.role;
|
||||
const isUser = role === "user";
|
||||
const isAssistant = role === "assistant";
|
||||
|
||||
if (!isUser && !isAssistant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryLines: string[] = [];
|
||||
const textParts = extractTextParts(entry.message.content);
|
||||
if (textParts.length > 0) {
|
||||
const roleLabel = isUser ? "User" : "Assistant";
|
||||
const messageText = textParts.join("\n").trim();
|
||||
if (messageText.length > 0) {
|
||||
entryLines.push(`${roleLabel}: ${messageText}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistant) {
|
||||
entryLines.push(...extractToolCallLines(entry.message.content));
|
||||
}
|
||||
|
||||
if (entryLines.length > 0) {
|
||||
sections.push(entryLines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
};
|
||||
|
||||
const buildSummaryPrompt = (conversationText: string): string =>
|
||||
[
|
||||
"Summarize this conversation so I can resume it later.",
|
||||
"Include goals, key decisions, progress, open questions, and next steps.",
|
||||
"Keep it concise and structured with headings.",
|
||||
"",
|
||||
"<conversation>",
|
||||
conversationText,
|
||||
"</conversation>",
|
||||
].join("\n");
|
||||
|
||||
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
|
||||
const mdTheme = getMarkdownTheme();
|
||||
|
||||
container.addChild(border);
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold("Conversation Summary")), 1, 0));
|
||||
container.addChild(new Markdown(summary, 1, 1, mdTheme));
|
||||
container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
|
||||
container.addChild(border);
|
||||
|
||||
return {
|
||||
render: (width: number) => container.render(width),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data: string) => {
|
||||
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
|
||||
done(undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("summarize", {
|
||||
description: "Summarize the current conversation in a custom UI",
|
||||
handler: async (_args, ctx) => {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const conversationText = buildConversationText(branch);
|
||||
|
||||
if (!conversationText.trim()) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify("No conversation text found", "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify("Preparing summary...", "info");
|
||||
}
|
||||
|
||||
const model = getModel("openai", "gpt-5.2");
|
||||
if (!model && ctx.hasUI) {
|
||||
ctx.ui.notify("Model openai/gpt-5.2 not found", "warning");
|
||||
}
|
||||
|
||||
const apiKey = model ? await ctx.modelRegistry.getApiKey(model) : undefined;
|
||||
if (!apiKey && ctx.hasUI) {
|
||||
ctx.ui.notify("No API key for openai/gpt-5.2", "warning");
|
||||
}
|
||||
|
||||
if (!model || !apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: buildSummaryPrompt(conversationText) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summaryMessages }, { apiKey, reasoningEffort: "high" });
|
||||
|
||||
const summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
await showSummaryUi(summary, ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.5",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue