Files
open-harness/.pi/extensions/plan-mode/index.ts
T
OpenHarness BotandClaude Opus 4.7 00078a7e8b feat: pi project config — mifune banner, path-guard, harness-context skill, CI wiring
Stand up .pi/ as a real project workspace for the pi-coding-agent CLI:
- settings.json: OpenAI defaults (gpt-4o), model cycling list, declared resource paths
- mifune-banner extension: flush-left MIFUNE wordmark + pi harness · ryaneggz/mifune subtitle
- path-guard extension: tool_call interceptor with case-insensitive risky-bash regex,
  hasUI headless guard, /dev/sd*|nvme*|hd*|disk* bare-redirection match, defensive
  toolName.toLowerCase() (verified lowercase in pi source), canonical input key `path`
- APPEND_SYSTEM.md: generic, consumer-agnostic
- prompts/review.md: /review template
- skills + rules: directory symlinks to ../.claude/{skills,rules} (single source of
  truth, mirrors .codex/ pattern)
- new canonical .claude/skills/harness-context/SKILL.md reachable from pi via symlink

Tests at .pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts (32 + 7 cases,
hand-rolled mock ExtensionAPI, no pi devDep). vitest.config.ts include glob extended
to .pi/**/__tests__/**/*.test.ts. ci-harness.yml paths filter extended and a new
"Root tests" step runs pnpm test:scripts (also lifts cron-runtime.test.ts into CI).

86 tests passing across 6 files.

Closes #255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:22:28 -06:00

335 lines
10 KiB
TypeScript

import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
extractPlanItems,
formatPlanItems,
isMutatingTool,
isReadOnlyTool,
isSafeReadOnlyCommand,
markCompletedSteps,
normalizeToolNames,
pickPlanTools,
type PlanItem,
} from "./utils";
type AssistantTextMessage = {
role: "assistant";
content: Array<{ type: string; text?: string }>;
};
type PlanModeEntry = {
type: string;
customType?: string;
data?: {
planning?: boolean;
executing?: boolean;
items?: PlanItem[];
previousTools?: string[];
};
};
const STATE_ENTRY = "plan-mode-state";
const SHORTCUT = "ctrl+alt+p";
function isAssistantTextMessage(message: unknown): message is AssistantTextMessage {
return (
!!message &&
typeof message === "object" &&
(message as { role?: unknown }).role === "assistant" &&
Array.isArray((message as { content?: unknown }).content)
);
}
function getAssistantText(message: AssistantTextMessage): string {
return message.content
.filter((block) => block.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("\n");
}
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
if (ctx.hasUI === false) return;
ctx.ui.notify(message, level);
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planning = false;
let executing = false;
let items: PlanItem[] = [];
let previousTools: string[] | undefined;
pi.registerFlag("plan", {
description: "Start in Claude Code-style plan mode (read-only until approval)",
type: "boolean",
default: false,
});
function persistState(): void {
pi.appendEntry(STATE_ENTRY, {
planning,
executing,
items,
previousTools,
});
}
function updateUi(ctx: ExtensionContext): void {
if (ctx.hasUI === false) return;
if (planning) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "plan"));
} else if (executing && items.length > 0) {
const done = items.filter((item) => item.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `plan ${done}/${items.length}`));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
if (executing && items.length > 0) {
ctx.ui.setWidget(
"plan-mode-items",
items.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}`;
}),
);
} else {
ctx.ui.setWidget("plan-mode-items", undefined);
}
}
function restoreTools(): void {
const restored = previousTools?.length ? previousTools : normalizeToolNames(pi.getAllTools());
if (restored.length > 0) pi.setActiveTools(restored);
previousTools = undefined;
}
function enablePlanMode(ctx: ExtensionContext): void {
if (!previousTools) previousTools = normalizeToolNames(pi.getActiveTools());
planning = true;
executing = false;
items = [];
pi.setActiveTools(pickPlanTools(pi.getAllTools()));
updateUi(ctx);
persistState();
notify(ctx, "Plan mode enabled. Pi is read-only until you approve a plan.", "info");
}
function disablePlanMode(ctx: ExtensionContext): void {
planning = false;
executing = false;
items = [];
restoreTools();
updateUi(ctx);
persistState();
notify(ctx, "Plan mode disabled. Tool access restored.", "info");
}
function approvePlan(ctx: ExtensionContext): void {
planning = false;
executing = items.length > 0;
restoreTools();
updateUi(ctx);
persistState();
const kickoff =
items.length > 0
? `Plan approved. Implement it now, one step at a time. Mark each completed step with [DONE:n].\n\n${formatPlanItems(items)}`
: "Plan approved. Implement the plan now.";
pi.sendUserMessage(kickoff);
}
pi.registerCommand("plan", {
description: "Toggle/approve Claude Code-style plan mode: /plan, /plan on, /plan off, /plan approve",
handler: async (args, ctx) => {
const action = args.trim().toLowerCase();
if (action === "on" || action === "start") return enablePlanMode(ctx);
if (action === "off" || action === "stop" || action === "cancel") return disablePlanMode(ctx);
if (action === "approve" || action === "accept" || action === "execute") return approvePlan(ctx);
if (action === "status") {
const status = planning ? "planning" : executing ? "executing" : "off";
notify(ctx, items.length > 0 ? `Plan mode: ${status}\n${formatPlanItems(items)}` : `Plan mode: ${status}`, "info");
return;
}
if (planning || executing) return disablePlanMode(ctx);
return enablePlanMode(ctx);
},
});
pi.registerCommand("todos", {
description: "Show current plan-mode progress",
handler: async (_args, ctx) => {
if (items.length === 0) {
notify(ctx, "No plan steps captured yet.", "info");
return;
}
notify(ctx, formatPlanItems(items), "info");
},
});
pi.registerShortcut(SHORTCUT, {
description: "Toggle plan mode",
handler: async (ctx) => {
if (planning || executing) disablePlanMode(ctx);
else enablePlanMode(ctx);
},
});
pi.on("tool_call", async (event) => {
if (!planning) return;
if (isMutatingTool(event.toolName) || !isReadOnlyTool(event.toolName)) {
return {
block: true,
reason: `Plan mode is read-only. Approve the plan or run /plan off before using ${event.toolName}.`,
};
}
if (event.toolName === "bash") {
const command = (event.input as { command?: unknown } | undefined)?.command;
if (typeof command !== "string" || !isSafeReadOnlyCommand(command)) {
return {
block: true,
reason: `Plan mode blocked a non-read-only bash command. Approve the plan or run /plan off first.`,
};
}
}
});
pi.on("context", async (event) => {
if (planning || executing) return;
return {
messages: event.messages.filter((message: unknown) => {
const customType = (message as { customType?: unknown } | undefined)?.customType;
return customType !== "plan-mode-context" && customType !== "plan-execution-context";
}),
};
});
pi.on("before_agent_start", async (event) => {
if (planning) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in Claude Code-style plan mode.
Rules:
- Explore and reason only. Do not modify files, run mutating shell commands, install packages, commit, or push.
- Use read/search/list tools and read-only bash commands to understand the request.
- Ask clarifying questions if the requested change is ambiguous.
- End with a concise numbered plan under a "Plan:" heading.
- Do not implement until the user approves the plan.`,
display: false,
},
systemPrompt: `${event.systemPrompt}\n\nPlan mode is active: produce a plan only; do not implement until approval.`,
};
}
if (executing && items.length > 0) {
const remaining = items.filter((item) => !item.completed);
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING APPROVED PLAN]
Remaining steps:
${formatPlanItems(remaining)}
Execute steps in order. After completing step n, include [DONE:n] in your response.`,
display: false,
},
};
}
});
pi.on("turn_end", async (event, ctx) => {
if (!executing || items.length === 0 || !isAssistantTextMessage(event.message)) return;
if (markCompletedSteps(getAssistantText(event.message), items) > 0) {
updateUi(ctx);
persistState();
}
});
pi.on("agent_end", async (event, ctx) => {
if (executing && items.length > 0 && items.every((item) => item.completed)) {
pi.sendMessage(
{
customType: "plan-mode-complete",
content: `Plan complete.\n\n${formatPlanItems(items)}`,
display: true,
},
{ triggerTurn: false },
);
executing = false;
items = [];
updateUi(ctx);
persistState();
return;
}
if (!planning) return;
const lastAssistant = [...event.messages].reverse().find(isAssistantTextMessage);
if (lastAssistant) {
const extracted = extractPlanItems(getAssistantText(lastAssistant));
if (extracted.length > 0) items = extracted;
}
if (items.length > 0) {
pi.sendMessage(
{
customType: "plan-mode-plan",
content: `Plan captured.\n\n${formatPlanItems(items)}`,
display: true,
},
{ triggerTurn: false },
);
persistState();
}
if (ctx.hasUI === false) return;
const choice = await ctx.ui.select("Plan mode", [
items.length > 0 ? "Approve plan and implement" : "Approve and implement",
"Stay in plan mode",
"Refine plan",
"Cancel plan mode",
]);
if (choice?.startsWith("Approve")) {
approvePlan(ctx);
} else if (choice === "Refine plan") {
const refinement = await ctx.ui.editor("Refine the plan", "");
if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
} else if (choice === "Cancel plan mode") {
disablePlanMode(ctx);
} else {
updateUi(ctx);
persistState();
}
});
pi.on("session_start", async (_event, ctx) => {
planning = pi.getFlag("plan") === true;
const state = ctx.sessionManager
.getEntries()
.filter((entry: PlanModeEntry) => entry.type === "custom" && entry.customType === STATE_ENTRY)
.pop() as PlanModeEntry | undefined;
if (state?.data) {
planning = state.data.planning ?? planning;
executing = state.data.executing ?? executing;
items = state.data.items ?? items;
previousTools = state.data.previousTools ?? previousTools;
}
if (planning) pi.setActiveTools(pickPlanTools(pi.getAllTools()));
if (!planning && !executing) previousTools = undefined;
updateUi(ctx);
});
}