Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Step from '../components/Step.astro';
<div class="max-w-2xl">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-6">
<span class="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></span>
For Claude Code & OpenCode
For Claude Code, OpenCode & Pi
</div>

<div class="flex items-center gap-6 mb-4">
Expand All @@ -28,7 +28,7 @@ import Step from '../components/Step.astro';

<p class="text-lg text-muted-foreground mb-8 max-w-lg">
Interactive Plan Review for coding agents. Mark up and refine plans visually,
share for team collaboration. Works with Claude Code and OpenCode.
share for team collaboration. Works with Claude Code, OpenCode, and Pi.
</p>

<div class="flex flex-col gap-3">
Expand Down Expand Up @@ -67,6 +67,15 @@ import Step from '../components/Step.astro';
>
Also watch for OpenCode &rarr;
</a>
<!-- Pi video link -->
<a
href="https://youtu.be/XqFun9XCXPw"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Also watch for Pi &rarr;
</a>
</div>
</div>
</section>
Expand Down
12 changes: 7 additions & 5 deletions apps/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ Start Pi in plan mode:
pi --plan
```

Or toggle it during a session with `/plannotator` or `Ctrl+Alt+P`.
Or toggle it during a session with `/plannotator` or `Ctrl+Alt+P`. The command accepts an optional file path argument (`/plannotator plans/auth.md`) or prompts you to choose one interactively.

In plan mode the agent is restricted to read-only tools. It explores your codebase, then writes a plan to `PLAN.md` using markdown checklists:
In plan mode the agent is restricted — destructive commands are blocked, writes are limited to the plan file. It explores your codebase, then writes a plan using markdown checklists:

```markdown
- [ ] Add validation to the login form
Expand Down Expand Up @@ -79,7 +79,8 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr

| Command | Description |
|---------|-------------|
| `/plannotator` | Toggle plan mode on/off |
| `/plannotator [path]` | Toggle plan mode. Accepts optional file path or prompts interactively |
| `/plannotator-set-file <path>` | Change the plan file path mid-session |
| `/plannotator-status` | Show current phase, plan file, and progress |
| `/plannotator-review` | Open code review UI for current changes |
| `/plannotator-annotate <file>` | Open markdown file in annotation UI |
Expand All @@ -102,8 +103,9 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr
The extension manages a state machine: **idle** → **planning** → **executing** → **idle**.

During **planning**:
- Tools restricted to: `read`, `bash` (read-only commands only), `grep`, `find`, `ls`, `write` (plan file only), `exit_plan_mode`
- `edit` is disabled, bash is gated to a read-only allowlist, writes only allowed to the plan file
- All tools from other extensions remain available
- Bash is unrestricted — the agent is guided by the system prompt not to run destructive commands
- Writes and edits restricted to the plan file only

During **executing**:
- Full tool access: `read`, `bash`, `edit`, `write`
Expand Down
101 changes: 72 additions & 29 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* - /plannotator command or Ctrl+Alt+P to toggle
* - --plan flag to start in planning mode
* - --plan-file flag to customize the plan file path
* - Bash restricted to read-only commands during planning
* - Bash unrestricted during planning (prompt-guided)
* - Write restricted to plan file only during planning
* - exit_plan_mode tool with browser-based visual approval
* - [DONE:n] markers for execution progress tracking
Expand All @@ -26,7 +26,7 @@ import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Key } from "@mariozechner/pi-tui";
import { isSafeCommand, markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js";
import { markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js";
import {
startPlanReviewServer,
startReviewServer,
Expand All @@ -51,10 +51,8 @@ try {
// HTML not built yet — review feature will be unavailable
}

// Tool sets by phase
const PLANNING_TOOLS = ["read", "bash", "grep", "find", "ls", "write", "edit", "exit_plan_mode"];
const EXECUTION_TOOLS = ["read", "bash", "edit", "write"];
const NORMAL_TOOLS = ["read", "bash", "edit", "write"];
/** Extra tools to ensure are available during planning (on top of whatever is already active). */
const PLANNING_EXTRA_TOOLS = ["grep", "find", "ls", "exit_plan_mode"];

type Phase = "idle" | "planning" | "executing";

Expand All @@ -73,11 +71,12 @@ export default function plannotator(pi: ExtensionAPI): void {
let phase: Phase = "idle";
let planFilePath = "PLAN.md";
let checklistItems: ChecklistItem[] = [];
let preplanTools: string[] | null = null;

// ── Flags ────────────────────────────────────────────────────────────

pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
description: "Start in plan mode (restricted exploration and planning)",
type: "boolean",
default: false,
});
Expand Down Expand Up @@ -126,10 +125,26 @@ export default function plannotator(pi: ExtensionAPI): void {
pi.appendEntry("plannotator", { phase, planFilePath });
}

/** Apply tool visibility for the current phase, preserving tools from other extensions. */
function applyToolsForPhase(): void {
if (phase === "planning") {
const base = preplanTools ?? pi.getActiveTools();
const toolSet = new Set(base);
for (const t of PLANNING_EXTRA_TOOLS) toolSet.add(t);
pi.setActiveTools([...toolSet]);
} else if (preplanTools) {
// Restore pre-plan tool set (removes exit_plan_mode, etc.)
pi.setActiveTools(preplanTools);
preplanTools = null;
}
// If no preplanTools (e.g. session restore to executing/idle), leave tools as-is
}

function enterPlanning(ctx: ExtensionContext): void {
phase = "planning";
checklistItems = [];
pi.setActiveTools(PLANNING_TOOLS);
preplanTools = pi.getActiveTools();
applyToolsForPhase();
updateStatus(ctx);
updateWidget(ctx);
persistState();
Expand All @@ -139,7 +154,7 @@ export default function plannotator(pi: ExtensionAPI): void {
function exitToIdle(ctx: ExtensionContext): void {
phase = "idle";
checklistItems = [];
pi.setActiveTools(NORMAL_TOOLS);
applyToolsForPhase();
updateStatus(ctx);
updateWidget(ctx);
persistState();
Expand All @@ -158,7 +173,24 @@ export default function plannotator(pi: ExtensionAPI): void {

pi.registerCommand("plannotator", {
description: "Toggle plannotator (file-based plan mode)",
handler: async (_args, ctx) => togglePlanMode(ctx),
handler: async (args, ctx) => {
if (phase !== "idle") {
exitToIdle(ctx);
return;
}

// Accept path as argument: /plannotator plans/auth.md
let targetPath = args?.trim() || undefined;

// No arg — prompt for file path interactively
if (!targetPath && ctx.hasUI) {
targetPath = await ctx.ui.input("Plan file path", planFilePath);
if (targetPath === undefined) return; // cancelled
}

if (targetPath) planFilePath = targetPath;
enterPlanning(ctx);
},
});

pi.registerCommand("plannotator-status", {
Expand All @@ -173,6 +205,27 @@ export default function plannotator(pi: ExtensionAPI): void {
},
});

pi.registerCommand("plannotator-set-file", {
description: "Change the plan file path",
handler: async (args, ctx) => {
let targetPath = args?.trim() || undefined;

if (!targetPath && ctx.hasUI) {
targetPath = await ctx.ui.input("Plan file path", planFilePath);
if (targetPath === undefined) return; // cancelled
}

if (!targetPath) {
ctx.ui.notify(`Current plan file: ${planFilePath}`, "info");
return;
}

planFilePath = targetPath;
persistState();
ctx.ui.notify(`Plan file changed to: ${planFilePath}`);
},
});

pi.registerCommand("plannotator-review", {
description: "Open interactive code review for current changes",
handler: async (_args, ctx) => {
Expand Down Expand Up @@ -266,7 +319,7 @@ export default function plannotator(pi: ExtensionAPI): void {
label: "Exit Plan Mode",
description:
"Submit your plan for user review. " +
"Call this after drafting or revising your plan in PLAN.md. " +
"Call this after drafting or revising your plan file. " +
"The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " +
"If denied, use the edit tool to make targeted revisions (not write), then call this again.",
parameters: Type.Object({
Expand Down Expand Up @@ -319,7 +372,7 @@ export default function plannotator(pi: ExtensionAPI): void {
// Non-interactive or no HTML: auto-approve
if (!ctx.hasUI || !planHtmlContent) {
phase = "executing";
pi.setActiveTools(EXECUTION_TOOLS);
applyToolsForPhase();
persistState();
return {
content: [
Expand Down Expand Up @@ -348,7 +401,7 @@ export default function plannotator(pi: ExtensionAPI): void {

if (result.approved) {
phase = "executing";
pi.setActiveTools(EXECUTION_TOOLS);
applyToolsForPhase();
updateStatus(ctx);
updateWidget(ctx);
persistState();
Expand Down Expand Up @@ -398,20 +451,10 @@ export default function plannotator(pi: ExtensionAPI): void {

// ── Event Handlers ───────────────────────────────────────────────────

// Gate writes and bash during planning
// Gate writes during planning
pi.on("tool_call", async (event, ctx) => {
if (phase !== "planning") return;

if (event.toolName === "bash") {
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plannotator: command blocked (not in read-only allowlist).\nCommand: ${command}`,
};
}
}

if (event.toolName === "write") {
const targetPath = resolve(ctx.cwd, event.input.path as string);
const allowedPath = resolvePlanPath(ctx.cwd);
Expand Down Expand Up @@ -444,7 +487,9 @@ export default function plannotator(pi: ExtensionAPI): void {
content: `[PLANNOTATOR - PLANNING PHASE]
You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}.

Available tools: read, bash (read-only commands only), grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode
Available tools: read, bash, grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode

Do not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine.

## Iterative Planning Workflow

Expand Down Expand Up @@ -590,7 +635,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re
);
phase = "idle";
checklistItems = [];
pi.setActiveTools(NORMAL_TOOLS);
applyToolsForPhase();
updateStatus(ctx);
updateWidget(ctx);
persistState();
Expand Down Expand Up @@ -657,9 +702,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re

// Apply tool restrictions for current phase
if (phase === "planning") {
pi.setActiveTools(PLANNING_TOOLS);
} else if (phase === "executing") {
pi.setActiveTools(EXECUTION_TOOLS);
applyToolsForPhase();
}

updateStatus(ctx);
Expand Down
48 changes: 1 addition & 47 deletions apps/pi-extension/utils.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,10 @@
/**
* Plannotator Pi extension utilities.
*
* Inlined versions of bash safety checks and checklist parsing.
* Checklist parsing and progress tracking helpers.
* (No access to pi-mono's plan-mode/utils at runtime.)
*/

// ── Bash Safety ──────────────────────────────────────────────────────────

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,
];

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;
}

// ── Checklist Parsing ────────────────────────────────────────────────────

export interface ChecklistItem {
Expand Down