mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
feat: add Pi coding agent extension (#168)
Add Pi coding agent extension with Node-compatible servers, browser-based plan review, code review, and markdown annotation commands.
This commit is contained in:
@@ -20,6 +20,10 @@ dist-ssr
|
||||
apps/opencode-plugin/plannotator.html
|
||||
apps/opencode-plugin/review-editor.html
|
||||
|
||||
# Pi extension build artifacts (generated from hook/review dist)
|
||||
apps/pi-extension/plannotator.html
|
||||
apps/pi-extension/review-editor.html
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# Plannotator
|
||||
|
||||
Interactive Plan Review for AI Coding Agents. Mark up and refine your plans using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code** and **OpenCode**.
|
||||
Interactive Plan Review for AI Coding Agents. Mark up and refine your plans using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code**, **OpenCode**, and **Pi**.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -32,6 +32,12 @@ Interactive Plan Review for AI Coding Agents. Mark up and refine your plans usin
|
||||
- Attach and annotate images with your feedback (pen, arrow, circle tools)
|
||||
- Auto-save approved plans to [Obsidian](https://obsidian.md/) and [Bear Notes](https://bear.app/)
|
||||
|
||||
## Install
|
||||
|
||||
- [Claude Code](#install-for-claude-code)
|
||||
- [OpenCode](#install-for-opencode)
|
||||
- [Pi](#install-for-pi)
|
||||
|
||||
## Install for Claude Code
|
||||
|
||||
**Install the `plannotator` command:**
|
||||
@@ -86,6 +92,18 @@ This also clears any cached plugin versions. Then restart OpenCode.
|
||||
|
||||
---
|
||||
|
||||
## Install for Pi
|
||||
|
||||
```bash
|
||||
pi install npm:@plannotator/pi-extension
|
||||
```
|
||||
|
||||
Then start Pi with `--plan` to enter plan mode, or toggle it during a session with `/plannotator`.
|
||||
|
||||
See [apps/pi-extension/README.md](apps/pi-extension/README.md) for full usage details, commands, and flags.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When your AI agent finishes planning, Plannotator:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Plannotator for Pi
|
||||
|
||||
Plannotator integration for the [Pi coding agent](https://github.com/mariozechner/pi). Adds file-based plan mode with a visual browser UI for reviewing, annotating, and approving agent plans.
|
||||
|
||||
## Install
|
||||
|
||||
**From npm** (recommended):
|
||||
|
||||
```bash
|
||||
pi install npm:@plannotator/pi-extension
|
||||
```
|
||||
|
||||
**From source:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/backnotprop/plannotator.git
|
||||
pi install ./plannotator/apps/pi-extension
|
||||
```
|
||||
|
||||
**Try without installing:**
|
||||
|
||||
```bash
|
||||
pi -e npm:@plannotator/pi-extension
|
||||
```
|
||||
|
||||
## Build from source
|
||||
|
||||
If installing from a local clone, build the HTML assets first:
|
||||
|
||||
```bash
|
||||
cd plannotator
|
||||
bun install
|
||||
bun run build:pi
|
||||
```
|
||||
|
||||
This builds the plan review and code review UIs and copies them into `apps/pi-extension/`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Plan mode
|
||||
|
||||
Start Pi in plan mode:
|
||||
|
||||
```bash
|
||||
pi --plan
|
||||
```
|
||||
|
||||
Or toggle it during a session with `/plannotator` or `Ctrl+Alt+P`.
|
||||
|
||||
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:
|
||||
|
||||
```markdown
|
||||
- [ ] Add validation to the login form
|
||||
- [ ] Write tests for the new validation logic
|
||||
- [ ] Update error messages in the UI
|
||||
```
|
||||
|
||||
When the agent calls `exit_plan_mode`, the Plannotator UI opens in your browser. You can:
|
||||
|
||||
- **Approve** the plan to begin execution
|
||||
- **Deny with annotations** to send structured feedback back to the agent
|
||||
- **Approve with notes** to proceed but include implementation guidance
|
||||
|
||||
The agent iterates on the plan until you approve, then executes with full tool access.
|
||||
|
||||
### Code review
|
||||
|
||||
Run `/plannotator-review` to open your current git changes in the code review UI. Annotate specific lines, switch between diff views (uncommitted, staged, last commit, branch), and submit feedback that gets sent to the agent.
|
||||
|
||||
### Markdown annotation
|
||||
|
||||
Run `/plannotator-annotate <file.md>` to open any markdown file in the annotation UI. Useful for reviewing documentation or design specs with the agent.
|
||||
|
||||
### Progress tracking
|
||||
|
||||
During execution, the agent marks completed steps with `[DONE:n]` markers. Progress is shown in the status line and as a checklist widget in the terminal.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/plannotator` | Toggle plan mode on/off |
|
||||
| `/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 |
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--plan` | Start in plan mode |
|
||||
| `--plan-file <path>` | Custom plan file path (default: `PLAN.md`) |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Shortcut | Description |
|
||||
|----------|-------------|
|
||||
| `Ctrl+Alt+P` | Toggle plan mode |
|
||||
|
||||
## How it works
|
||||
|
||||
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
|
||||
|
||||
During **executing**:
|
||||
- Full tool access: `read`, `bash`, `edit`, `write`
|
||||
- Progress tracked via `[DONE:n]` markers in agent responses
|
||||
- Plan re-read from disk each turn to stay current
|
||||
|
||||
State persists across session restarts via Pi's `appendEntry` API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Pi](https://github.com/mariozechner/pi) >= 0.53.0
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* Plannotator Pi Extension — File-based plan mode with visual browser review.
|
||||
*
|
||||
* Plans are written to PLAN.md on disk (git-trackable, editor-visible).
|
||||
* The agent calls exit_plan_mode to request approval; the user reviews
|
||||
* the plan in the Plannotator browser UI and can approve, deny with
|
||||
* annotations, or request changes.
|
||||
*
|
||||
* Features:
|
||||
* - /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
|
||||
* - Write restricted to plan file only during planning
|
||||
* - exit_plan_mode tool with browser-based visual approval
|
||||
* - [DONE:n] markers for execution progress tracking
|
||||
* - /plannotator-review command for code review
|
||||
* - /plannotator-annotate command for markdown annotation
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
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 {
|
||||
startPlanReviewServer,
|
||||
startReviewServer,
|
||||
startAnnotateServer,
|
||||
getGitContext,
|
||||
runGitDiff,
|
||||
openBrowser,
|
||||
} from "./server.js";
|
||||
|
||||
// Load HTML at runtime (jiti doesn't support import attributes)
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let planHtmlContent = "";
|
||||
let reviewHtmlContent = "";
|
||||
try {
|
||||
planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8");
|
||||
} catch {
|
||||
// HTML not built yet — browser features will be unavailable
|
||||
}
|
||||
try {
|
||||
reviewHtmlContent = readFileSync(resolve(__dirname, "review-editor.html"), "utf-8");
|
||||
} catch {
|
||||
// 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"];
|
||||
|
||||
type Phase = "idle" | "planning" | "executing";
|
||||
|
||||
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
||||
return m.role === "assistant" && Array.isArray(m.content);
|
||||
}
|
||||
|
||||
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 plannotator(pi: ExtensionAPI): void {
|
||||
let phase: Phase = "idle";
|
||||
let planFilePath = "PLAN.md";
|
||||
let checklistItems: ChecklistItem[] = [];
|
||||
|
||||
// ── Flags ────────────────────────────────────────────────────────────
|
||||
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in plan mode (read-only exploration)",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
pi.registerFlag("plan-file", {
|
||||
description: "Plan file path (default: PLAN.md)",
|
||||
type: "string",
|
||||
default: "PLAN.md",
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function resolvePlanPath(cwd: string): string {
|
||||
return resolve(cwd, planFilePath);
|
||||
}
|
||||
|
||||
function updateStatus(ctx: ExtensionContext): void {
|
||||
if (phase === "executing" && checklistItems.length > 0) {
|
||||
const completed = checklistItems.filter((t) => t.completed).length;
|
||||
ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`));
|
||||
} else if (phase === "planning") {
|
||||
ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", "⏸ plan"));
|
||||
} else {
|
||||
ctx.ui.setStatus("plannotator", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
if (phase === "executing" && checklistItems.length > 0) {
|
||||
const lines = checklistItems.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("plannotator-progress", lines);
|
||||
} else {
|
||||
ctx.ui.setWidget("plannotator-progress", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function persistState(): void {
|
||||
pi.appendEntry("plannotator", { phase, planFilePath });
|
||||
}
|
||||
|
||||
function enterPlanning(ctx: ExtensionContext): void {
|
||||
phase = "planning";
|
||||
checklistItems = [];
|
||||
pi.setActiveTools(PLANNING_TOOLS);
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify(`Plannotator: planning mode enabled. Write your plan to ${planFilePath}.`);
|
||||
}
|
||||
|
||||
function exitToIdle(ctx: ExtensionContext): void {
|
||||
phase = "idle";
|
||||
checklistItems = [];
|
||||
pi.setActiveTools(NORMAL_TOOLS);
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify("Plannotator: disabled. Full access restored.");
|
||||
}
|
||||
|
||||
function togglePlanMode(ctx: ExtensionContext): void {
|
||||
if (phase === "idle") {
|
||||
enterPlanning(ctx);
|
||||
} else {
|
||||
exitToIdle(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands & Shortcuts ─────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("plannotator", {
|
||||
description: "Toggle plannotator (file-based plan mode)",
|
||||
handler: async (_args, ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
pi.registerCommand("plannotator-status", {
|
||||
description: "Show plannotator status",
|
||||
handler: async (_args, ctx) => {
|
||||
const parts = [`Phase: ${phase}`, `Plan file: ${planFilePath}`];
|
||||
if (checklistItems.length > 0) {
|
||||
const done = checklistItems.filter((t) => t.completed).length;
|
||||
parts.push(`Progress: ${done}/${checklistItems.length}`);
|
||||
}
|
||||
ctx.ui.notify(parts.join("\n"), "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("plannotator-review", {
|
||||
description: "Open interactive code review for current changes",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!reviewHtmlContent) {
|
||||
ctx.ui.notify("Review UI not available. Run 'bun run build' in the pi-extension directory.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify("Opening code review UI...", "info");
|
||||
|
||||
const gitCtx = getGitContext();
|
||||
const { patch: rawPatch, label: gitRef } = runGitDiff("uncommitted", gitCtx.defaultBranch);
|
||||
|
||||
const server = startReviewServer({
|
||||
rawPatch,
|
||||
gitRef,
|
||||
origin: "pi",
|
||||
diffType: "uncommitted",
|
||||
gitContext: gitCtx,
|
||||
htmlContent: reviewHtmlContent,
|
||||
});
|
||||
|
||||
openBrowser(server.url);
|
||||
|
||||
const result = await server.waitForDecision();
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
server.stop();
|
||||
|
||||
if (result.feedback) {
|
||||
pi.sendUserMessage(`# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`);
|
||||
} else {
|
||||
ctx.ui.notify("Code review closed (no feedback).", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("plannotator-annotate", {
|
||||
description: "Open markdown file in annotation UI",
|
||||
handler: async (args, ctx) => {
|
||||
const filePath = args?.trim();
|
||||
if (!filePath) {
|
||||
ctx.ui.notify("Usage: /plannotator-annotate <file.md>", "error");
|
||||
return;
|
||||
}
|
||||
if (!planHtmlContent) {
|
||||
ctx.ui.notify("Annotation UI not available. Run 'bun run build' in the pi-extension directory.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = resolve(ctx.cwd, filePath);
|
||||
if (!existsSync(absolutePath)) {
|
||||
ctx.ui.notify(`File not found: ${absolutePath}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
|
||||
|
||||
const markdown = readFileSync(absolutePath, "utf-8");
|
||||
const server = startAnnotateServer({
|
||||
markdown,
|
||||
filePath: absolutePath,
|
||||
origin: "pi",
|
||||
htmlContent: planHtmlContent,
|
||||
});
|
||||
|
||||
openBrowser(server.url);
|
||||
|
||||
const result = await server.waitForDecision();
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
server.stop();
|
||||
|
||||
if (result.feedback) {
|
||||
pi.sendUserMessage(
|
||||
`# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`,
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify("Annotation closed (no feedback).", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(Key.ctrlAlt("p"), {
|
||||
description: "Toggle plannotator",
|
||||
handler: async (ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
// ── exit_plan_mode Tool ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "exit_plan_mode",
|
||||
label: "Exit Plan Mode",
|
||||
description:
|
||||
"Submit your plan for user review. " +
|
||||
"Call this after drafting or revising your plan in PLAN.md. " +
|
||||
"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({
|
||||
summary: Type.Optional(
|
||||
Type.String({ description: "Brief summary of the plan for the user's review" }),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
// Guard: must be in planning phase
|
||||
if (phase !== "planning") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: Not in plan mode. Use /plannotator to enter planning mode first." }],
|
||||
details: { approved: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Read plan file
|
||||
const fullPath = resolvePlanPath(ctx.cwd);
|
||||
let planContent: string;
|
||||
try {
|
||||
planContent = readFileSync(fullPath, "utf-8");
|
||||
} catch {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${planFilePath} does not exist. Write your plan using the write tool first, then call exit_plan_mode again.`,
|
||||
},
|
||||
],
|
||||
details: { approved: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (planContent.trim().length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${planFilePath} is empty. Write your plan first, then call exit_plan_mode again.`,
|
||||
},
|
||||
],
|
||||
details: { approved: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Parse checklist items
|
||||
checklistItems = parseChecklist(planContent);
|
||||
|
||||
// Non-interactive or no HTML: auto-approve
|
||||
if (!ctx.hasUI || !planHtmlContent) {
|
||||
phase = "executing";
|
||||
pi.setActiveTools(EXECUTION_TOOLS);
|
||||
persistState();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Plan auto-approved (non-interactive mode). Execute the plan now.",
|
||||
},
|
||||
],
|
||||
details: { approved: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Start browser-based plan review server
|
||||
const server = startPlanReviewServer({
|
||||
plan: planContent,
|
||||
htmlContent: planHtmlContent,
|
||||
origin: "pi",
|
||||
});
|
||||
|
||||
openBrowser(server.url);
|
||||
|
||||
// Wait for user decision in the browser
|
||||
const result = await server.waitForDecision();
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
server.stop();
|
||||
|
||||
if (result.approved) {
|
||||
phase = "executing";
|
||||
pi.setActiveTools(EXECUTION_TOOLS);
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
persistState();
|
||||
|
||||
pi.appendEntry("plannotator-execute", { planFilePath });
|
||||
|
||||
const doneMsg = checklistItems.length > 0
|
||||
? `After completing each step, include [DONE:n] in your response where n is the step number.`
|
||||
: "";
|
||||
|
||||
if (result.feedback) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`,
|
||||
},
|
||||
],
|
||||
details: { approved: true, feedback: result.feedback },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}`,
|
||||
},
|
||||
],
|
||||
details: { approved: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Denied
|
||||
const feedbackText = result.feedback || "Plan rejected. Please revise.";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Plan not approved.\n\nUser feedback: ${feedbackText}\n\nRevise the plan:\n1. Read ${planFilePath} to see the current plan.\n2. Use the edit tool to make targeted changes addressing the feedback above — do not rewrite the entire file.\n3. Call exit_plan_mode again when ready.`,
|
||||
},
|
||||
],
|
||||
details: { approved: false, feedback: feedbackText },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Event Handlers ───────────────────────────────────────────────────
|
||||
|
||||
// Gate writes and bash 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);
|
||||
if (targetPath !== allowedPath) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plannotator: writes are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (event.toolName === "edit") {
|
||||
const targetPath = resolve(ctx.cwd, event.input.path as string);
|
||||
const allowedPath = resolvePlanPath(ctx.cwd);
|
||||
if (targetPath !== allowedPath) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plannotator: edits are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Inject phase-specific context
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
if (phase === "planning") {
|
||||
return {
|
||||
message: {
|
||||
customType: "plannotator-context",
|
||||
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
|
||||
|
||||
## Iterative Planning Workflow
|
||||
|
||||
You are pair-planning with the user. Explore the code to build context, then write your findings into ${planFilePath} as you go. The plan starts as a rough skeleton and gradually becomes the final plan.
|
||||
|
||||
### The Loop
|
||||
|
||||
Repeat this cycle until the plan is complete:
|
||||
|
||||
1. **Explore** — Use read, grep, find, ls, and bash to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.
|
||||
2. **Update the plan file** — After each discovery, immediately capture what you learned in ${planFilePath}. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates.
|
||||
3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, ask. Then go back to step 1.
|
||||
|
||||
### First Turn
|
||||
|
||||
Start by quickly scanning key files to form an initial understanding of the task scope. Then write a skeleton plan (headers and rough notes) and ask the user your first round of questions. Don't explore exhaustively before engaging the user.
|
||||
|
||||
### Asking Good Questions
|
||||
|
||||
- Never ask what you could find out by reading the code.
|
||||
- Batch related questions together.
|
||||
- Focus on things only the user can answer: requirements, preferences, tradeoffs, edge-case priorities.
|
||||
- Scale depth to the task — a vague feature request needs many rounds; a focused bug fix may need one or none.
|
||||
|
||||
### Plan File Structure
|
||||
|
||||
Your plan file should use markdown with clear sections:
|
||||
- **Context** — Why this change is being made: the problem, what prompted it, the intended outcome.
|
||||
- **Approach** — Your recommended approach only, not all alternatives considered.
|
||||
- **Files to modify** — List the critical file paths that will be changed.
|
||||
- **Reuse** — Reference existing functions and utilities you found, with their file paths.
|
||||
- **Steps** — Implementation checklist:
|
||||
- [ ] Step 1 description
|
||||
- [ ] Step 2 description
|
||||
- **Verification** — How to test the changes end-to-end (run the code, run tests, manual checks).
|
||||
|
||||
Keep the plan concise enough to scan quickly, but detailed enough to execute effectively.
|
||||
|
||||
### When to Submit
|
||||
|
||||
Your plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call exit_plan_mode to submit for review.
|
||||
|
||||
### Revising After Feedback
|
||||
|
||||
When the user denies a plan with feedback:
|
||||
1. Read ${planFilePath} to see the current plan.
|
||||
2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file.
|
||||
3. Call exit_plan_mode again to resubmit.
|
||||
|
||||
### Ending Your Turn
|
||||
|
||||
Your turn should only end by either:
|
||||
- Asking the user a question to gather more information.
|
||||
- Calling exit_plan_mode when the plan is ready for review.
|
||||
|
||||
Do not end your turn without doing one of these two things.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === "executing" && checklistItems.length > 0) {
|
||||
// Re-read from disk each turn to stay current
|
||||
const fullPath = resolvePlanPath(ctx.cwd);
|
||||
let planContent = "";
|
||||
try {
|
||||
planContent = readFileSync(fullPath, "utf-8");
|
||||
checklistItems = parseChecklist(planContent);
|
||||
} catch {
|
||||
// File deleted during execution — degrade gracefully
|
||||
}
|
||||
|
||||
const remaining = checklistItems.filter((t) => !t.completed);
|
||||
if (remaining.length > 0) {
|
||||
const todoList = remaining.map((t) => `- [ ] ${t.step}. ${t.text}`).join("\n");
|
||||
return {
|
||||
message: {
|
||||
customType: "plannotator-context",
|
||||
content: `[PLANNOTATOR - EXECUTING PLAN]
|
||||
Full tool access is enabled. Execute the plan from ${planFilePath}.
|
||||
|
||||
Remaining steps:
|
||||
${todoList}
|
||||
|
||||
Execute each step in order. After completing a step, include [DONE:n] in your response where n is the step number.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter stale context when idle
|
||||
pi.on("context", async (event) => {
|
||||
if (phase !== "idle") return;
|
||||
|
||||
return {
|
||||
messages: event.messages.filter((m) => {
|
||||
const msg = m as AgentMessage & { customType?: string };
|
||||
if (msg.customType === "plannotator-context") return false;
|
||||
if (msg.role !== "user") return true;
|
||||
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") {
|
||||
return !content.includes("[PLANNOTATOR -");
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return !content.some(
|
||||
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLANNOTATOR -"),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Track execution progress
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
if (phase !== "executing" || checklistItems.length === 0) return;
|
||||
if (!isAssistantMessage(event.message)) return;
|
||||
|
||||
const text = getTextContent(event.message);
|
||||
if (markCompletedSteps(text, checklistItems) > 0) {
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
}
|
||||
persistState();
|
||||
});
|
||||
|
||||
// Detect execution completion
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
if (phase !== "executing" || checklistItems.length === 0) return;
|
||||
|
||||
if (checklistItems.every((t) => t.completed)) {
|
||||
const completedList = checklistItems.map((t) => `- [x] ~~${t.text}~~`).join("\n");
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plannotator-complete",
|
||||
content: `**Plan Complete!** ✓\n\n${completedList}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
phase = "idle";
|
||||
checklistItems = [];
|
||||
pi.setActiveTools(NORMAL_TOOLS);
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
persistState();
|
||||
}
|
||||
});
|
||||
|
||||
// Restore state on session start/resume
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Resolve plan file path from flag
|
||||
const flagPlanFile = pi.getFlag("plan-file") as string;
|
||||
if (flagPlanFile) {
|
||||
planFilePath = flagPlanFile;
|
||||
}
|
||||
|
||||
// Check --plan flag
|
||||
if (pi.getFlag("plan") === true) {
|
||||
phase = "planning";
|
||||
}
|
||||
|
||||
// Restore persisted state
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const stateEntry = entries
|
||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plannotator")
|
||||
.pop() as { data?: { phase: Phase; planFilePath?: string } } | undefined;
|
||||
|
||||
if (stateEntry?.data) {
|
||||
phase = stateEntry.data.phase ?? phase;
|
||||
planFilePath = stateEntry.data.planFilePath ?? planFilePath;
|
||||
}
|
||||
|
||||
// Rebuild execution state from disk + session messages
|
||||
if (phase === "executing") {
|
||||
const fullPath = resolvePlanPath(ctx.cwd);
|
||||
if (existsSync(fullPath)) {
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
checklistItems = parseChecklist(content);
|
||||
|
||||
// Find last execution marker and scan messages after it for [DONE:n]
|
||||
let executeIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as { type: string; customType?: string };
|
||||
if (entry.customType === "plannotator-execute") {
|
||||
executeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
const text = getTextContent(entry.message as AssistantMessage);
|
||||
markCompletedSteps(text, checklistItems);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plan file gone — fall back to idle
|
||||
phase = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tool restrictions for current phase
|
||||
if (phase === "planning") {
|
||||
pi.setActiveTools(PLANNING_TOOLS);
|
||||
} else if (phase === "executing") {
|
||||
pi.setActiveTools(EXECUTION_TOOLS);
|
||||
}
|
||||
|
||||
updateStatus(ctx);
|
||||
updateWidget(ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@plannotator/pi-extension",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
|
||||
"author": "backnotprop",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/backnotprop/plannotator.git",
|
||||
"directory": "apps/pi-extension"
|
||||
},
|
||||
"homepage": "https://github.com/backnotprop/plannotator",
|
||||
"bugs": {
|
||||
"url": "https://github.com/backnotprop/plannotator/issues"
|
||||
},
|
||||
"keywords": ["pi-package", "plannotator", "plan-review", "ai-agent", "coding-agent"],
|
||||
"pi": {
|
||||
"extensions": ["./"]
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"server.ts",
|
||||
"utils.ts",
|
||||
"README.md",
|
||||
"plannotator.html",
|
||||
"review-editor.html"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html",
|
||||
"prepublishOnly": "cd ../.. && bun run build:pi"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": ">=0.53.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Node-compatible servers for Plannotator Pi extension.
|
||||
*
|
||||
* Pi loads extensions via jiti (Node.js), so we can't use Bun.serve().
|
||||
* These are lightweight node:http servers implementing just the routes
|
||||
* each UI needs — plan review, code review, and markdown annotation.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import { execSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk: string) => (data += chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function json(res: import("node:http").ServerResponse, data: unknown, status = 200): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function html(res: import("node:http").ServerResponse, content: string): void {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
function listenOnRandomPort(server: Server): number {
|
||||
server.listen(0);
|
||||
const addr = server.address() as { port: number };
|
||||
return addr.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open URL in system browser (Node-compatible, no Bun $ dependency).
|
||||
* Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts.
|
||||
*/
|
||||
export function openBrowser(url: string): void {
|
||||
try {
|
||||
const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
|
||||
const platform = process.platform;
|
||||
const wsl = platform === "linux" && os.release().toLowerCase().includes("microsoft");
|
||||
|
||||
if (browser) {
|
||||
if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") {
|
||||
execSync(`open -a ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
} else if (platform === "win32" || wsl) {
|
||||
execSync(`cmd.exe /c start "" ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
}
|
||||
} else if (platform === "win32" || wsl) {
|
||||
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
} else if (platform === "darwin") {
|
||||
execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" });
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan Review Server ──────────────────────────────────────────────────
|
||||
|
||||
export interface PlanServerResult {
|
||||
port: number;
|
||||
url: string;
|
||||
waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function startPlanReviewServer(options: {
|
||||
plan: string;
|
||||
htmlContent: string;
|
||||
origin?: string;
|
||||
}): PlanServerResult {
|
||||
let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void;
|
||||
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => {
|
||||
resolveDecision = r;
|
||||
});
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost`);
|
||||
|
||||
if (url.pathname === "/api/plan") {
|
||||
json(res, { plan: options.plan, origin: options.origin ?? "pi" });
|
||||
} else if (url.pathname === "/api/approve" && req.method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
resolveDecision({ approved: true, feedback: body.feedback as string | undefined });
|
||||
json(res, { ok: true });
|
||||
} else if (url.pathname === "/api/deny" && req.method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" });
|
||||
json(res, { ok: true });
|
||||
} else {
|
||||
html(res, options.htmlContent);
|
||||
}
|
||||
});
|
||||
|
||||
const port = listenOnRandomPort(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
waitForDecision: () => decisionPromise,
|
||||
stop: () => server.close(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Code Review Server ──────────────────────────────────────────────────
|
||||
|
||||
export type DiffType = "uncommitted" | "staged" | "unstaged" | "last-commit" | "branch";
|
||||
|
||||
export interface DiffOption {
|
||||
id: DiffType | "separator";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GitContext {
|
||||
currentBranch: string;
|
||||
defaultBranch: string;
|
||||
diffOptions: DiffOption[];
|
||||
}
|
||||
|
||||
export interface ReviewServerResult {
|
||||
port: number;
|
||||
url: string;
|
||||
waitForDecision: () => Promise<{ feedback: string }>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/** Run a git command and return stdout (empty string on error). */
|
||||
function git(cmd: string): string {
|
||||
try {
|
||||
return execSync(`git ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitContext(): GitContext {
|
||||
const currentBranch = git("rev-parse --abbrev-ref HEAD") || "HEAD";
|
||||
|
||||
let defaultBranch = "";
|
||||
const symRef = git("symbolic-ref refs/remotes/origin/HEAD");
|
||||
if (symRef) {
|
||||
defaultBranch = symRef.replace("refs/remotes/origin/", "");
|
||||
}
|
||||
if (!defaultBranch) {
|
||||
const hasMain = git("show-ref --verify refs/heads/main");
|
||||
defaultBranch = hasMain ? "main" : "master";
|
||||
}
|
||||
|
||||
const diffOptions: DiffOption[] = [
|
||||
{ id: "uncommitted", label: "Uncommitted changes" },
|
||||
{ id: "last-commit", label: "Last commit" },
|
||||
];
|
||||
if (currentBranch !== defaultBranch) {
|
||||
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
|
||||
}
|
||||
|
||||
return { currentBranch, defaultBranch, diffOptions };
|
||||
}
|
||||
|
||||
export function runGitDiff(diffType: DiffType, defaultBranch = "main"): { patch: string; label: string } {
|
||||
switch (diffType) {
|
||||
case "uncommitted":
|
||||
return { patch: git("diff HEAD"), label: "Uncommitted changes" };
|
||||
case "staged":
|
||||
return { patch: git("diff --staged"), label: "Staged changes" };
|
||||
case "unstaged":
|
||||
return { patch: git("diff"), label: "Unstaged changes" };
|
||||
case "last-commit":
|
||||
return { patch: git("diff HEAD~1..HEAD"), label: "Last commit" };
|
||||
case "branch":
|
||||
return { patch: git(`diff ${defaultBranch}..HEAD`), label: `Changes vs ${defaultBranch}` };
|
||||
default:
|
||||
return { patch: "", label: "Unknown diff type" };
|
||||
}
|
||||
}
|
||||
|
||||
export function startReviewServer(options: {
|
||||
rawPatch: string;
|
||||
gitRef: string;
|
||||
htmlContent: string;
|
||||
origin?: string;
|
||||
diffType?: DiffType;
|
||||
gitContext?: GitContext;
|
||||
}): ReviewServerResult {
|
||||
let currentPatch = options.rawPatch;
|
||||
let currentGitRef = options.gitRef;
|
||||
let currentDiffType: DiffType = options.diffType || "uncommitted";
|
||||
|
||||
let resolveDecision!: (result: { feedback: string }) => void;
|
||||
const decisionPromise = new Promise<{ feedback: string }>((r) => {
|
||||
resolveDecision = r;
|
||||
});
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost`);
|
||||
|
||||
if (url.pathname === "/api/diff" && req.method === "GET") {
|
||||
json(res, {
|
||||
rawPatch: currentPatch,
|
||||
gitRef: currentGitRef,
|
||||
origin: options.origin ?? "pi",
|
||||
diffType: currentDiffType,
|
||||
gitContext: options.gitContext,
|
||||
});
|
||||
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
const newType = body.diffType as DiffType;
|
||||
if (!newType) {
|
||||
json(res, { error: "Missing diffType" }, 400);
|
||||
return;
|
||||
}
|
||||
const defaultBranch = options.gitContext?.defaultBranch || "main";
|
||||
const result = runGitDiff(newType, defaultBranch);
|
||||
currentPatch = result.patch;
|
||||
currentGitRef = result.label;
|
||||
currentDiffType = newType;
|
||||
json(res, { rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType });
|
||||
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
resolveDecision({ feedback: (body.feedback as string) || "" });
|
||||
json(res, { ok: true });
|
||||
} else {
|
||||
html(res, options.htmlContent);
|
||||
}
|
||||
});
|
||||
|
||||
const port = listenOnRandomPort(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
waitForDecision: () => decisionPromise,
|
||||
stop: () => server.close(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Annotate Server ─────────────────────────────────────────────────────
|
||||
|
||||
export interface AnnotateServerResult {
|
||||
port: number;
|
||||
url: string;
|
||||
waitForDecision: () => Promise<{ feedback: string }>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function startAnnotateServer(options: {
|
||||
markdown: string;
|
||||
filePath: string;
|
||||
htmlContent: string;
|
||||
origin?: string;
|
||||
}): AnnotateServerResult {
|
||||
let resolveDecision!: (result: { feedback: string }) => void;
|
||||
const decisionPromise = new Promise<{ feedback: string }>((r) => {
|
||||
resolveDecision = r;
|
||||
});
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost`);
|
||||
|
||||
if (url.pathname === "/api/plan" && req.method === "GET") {
|
||||
json(res, {
|
||||
plan: options.markdown,
|
||||
origin: options.origin ?? "pi",
|
||||
mode: "annotate",
|
||||
filePath: options.filePath,
|
||||
});
|
||||
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
resolveDecision({ feedback: (body.feedback as string) || "" });
|
||||
json(res, { ok: true });
|
||||
} else {
|
||||
html(res, options.htmlContent);
|
||||
}
|
||||
});
|
||||
|
||||
const port = listenOnRandomPort(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
waitForDecision: () => decisionPromise,
|
||||
stop: () => server.close(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Plannotator Pi extension utilities.
|
||||
*
|
||||
* Inlined versions of bash safety checks and checklist parsing.
|
||||
* (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 {
|
||||
/** 1-based step number, compatible with markCompletedSteps/extractDoneSteps. */
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse standard markdown checkboxes from file content.
|
||||
*
|
||||
* Matches lines like:
|
||||
* - [ ] Step description
|
||||
* - [x] Completed step
|
||||
* * [ ] Alternative bullet
|
||||
*/
|
||||
export function parseChecklist(content: string): ChecklistItem[] {
|
||||
const items: ChecklistItem[] = [];
|
||||
const pattern = /^[-*]\s*\[([ xX])\]\s+(.+)$/gm;
|
||||
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const completed = match[1] !== " ";
|
||||
const text = match[2].trim();
|
||||
if (text.length > 0) {
|
||||
items.push({ step: items.length + 1, text, completed });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── Progress Tracking ────────────────────────────────────────────────────
|
||||
|
||||
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: ChecklistItem[]): 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,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "plannotator",
|
||||
@@ -53,7 +52,7 @@
|
||||
},
|
||||
"apps/opencode-plugin": {
|
||||
"name": "@plannotator/opencode",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "^1.1.10",
|
||||
"@plannotator/server": "workspace:*",
|
||||
@@ -124,7 +123,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@plannotator/server",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"build:marketing": "bun run --cwd apps/marketing build",
|
||||
"build:opencode": "bun run --cwd apps/opencode-plugin build",
|
||||
"build:review": "bun run --cwd apps/review build",
|
||||
"build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build",
|
||||
"build": "bun run build:hook && bun run build:opencode",
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user