/** * plan — a file-backed planning mode for Pi. * * While plan mode is on, tools that are not read-only are removed from the * active set or blocked: write and edit are hidden, bash is limited to a single * read-only git command, powershell is blocked, and everything else that mutates * (worktree, MCP tools) is blocked. `subagent` stays active but is forced * read-only. The one permitted write is the `plan` tool's `write` action, which * saves the plan in the agent directory. The model investigates, saves the plan, * and calls `plan` action `exit` to read the file back and ask for approval. * * Toggle with `/plan` (or Shift+Tab, which bytefly's editor binds), or start with * `--plan`. The state is * persisted as a custom session entry, so it follows the active branch. * * Load with: pi --extension ./extensions/plan */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { fileURLToPath } from "node:url"; import { isExtensionEnabled } from "../../lib/env.ts"; import { requestMount } from "../../lib/http/mount.ts"; import { sanitizeLine } from "../../lib/format.ts"; import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts"; import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, PLAN_TOGGLE_REQUEST, type PlanModeRequest, } from "../../lib/plan-entry.ts"; import { isBoardRead } from "../../lib/policy.ts"; import { boardWriteRefusal } from "../../lib/board/actions.ts"; import { BOARD_TOOL, QUESTION_TOOL, SUBAGENT_READ_ONLY_PARAM, SUBAGENT_TOOL } from "../../lib/tool-names.ts"; import { clip } from "../../lib/tui/columns.ts"; import { STATUS_KEYS } from "../../lib/tui/glyphs.ts"; import { planModeNotice, registerCommands } from "./commands.ts"; import { createPlanPolicy, BASH_BLOCKED_GUIDANCE, BLOCKED_GUIDANCE, PLAN_SAFE_TOOLS } from "./policy.ts"; import { createPlanRuntime } from "./runtime.ts"; import { registerTools } from "./tools.ts"; import { createLazyPlanWebReview } from "./web-lazy.ts"; /** Marker embedded in the injected prompt and used to filter stale context. */ export const PLAN_MODE_MARKER = "[PLAN MODE ACTIVE]"; /** * Longest goal text restated here. `goal` injects the full objective every turn * regardless of plan mode, so this line is a short pointer; the clip is what keeps * the whole context inside the prompt-budget ceiling. */ const GOAL_CONTEXT_CHARS = 64; /** * Injected each turn while planning: the facts and the next action, not the tool * contracts. A pursued session goal — being planned or already final — becomes one * extra bullet, so a plan is designed against the objective instead of beside it. */ export function planModeContext(goal?: PublishedGoal | null): string { const lines = [ PLAN_MODE_MARKER, "", `- Do not modify anything except the plan file: while planning, ${READ_ONLY_SUMMARY}.`, "- bash is git-only: read-only git commands chain with && or ; — no pipes, redirection, or substitution, and no powershell. The cwd is already the project root, so skip cd, and read files with read, grep, find, and ls.", "- Investigate and design a concrete plan, then save it with `plan` action `write` and call `plan` action `exit` with the returned plan_path for approval.", "- Delegate broad exploration with subagent.", `- To choose between approaches, ask with ${QUESTION_TOOL} before finalizing.`, ]; if (goal && isPursuedGoal(goal)) { lines.push(`- Current session goal: ${clip(sanitizeLine(goal.objective), GOAL_CONTEXT_CHARS)}`); } return lines.join("\n"); } /** This extension entry file, as Pi records it for our tool registrations. */ function extensionEntryPath(): string | undefined { try { return fileURLToPath(import.meta.url); } catch { return undefined; } } function isPlanModeContext(message: AgentMessage): boolean { // Only the injected message carries this custom type. Matching on the marker // text would also capture (and drop) a real user message that contains it. return (message as AgentMessage & { customType?: string }).customType === "plan-mode-context"; } export default function planMode(pi: ExtensionAPI): void { if (!isExtensionEnabled("plan")) return; const policy = createPlanPolicy(pi); const runtime = createPlanRuntime(pi, policy, { entryPath: extensionEntryPath() }); /** The session this review serves as; its prefix lands on that session's one server. */ let sessionId = ""; const web = createLazyPlanWebReview(runtime.plans, { mount: (spec) => requestMount(pi, spec), session: () => sessionId, }); registerTools(pi, runtime, web); registerCommands(pi, runtime, web); pi.registerFlag("plan", { description: "Start in plan mode (read-only exploration)", type: "boolean", default: false, }); // Another extension asks on these channels instead of calling a tool it cannot // reach from a command or editor context: `goal` offers plan mode as a phase, and // bytefly's editor binds Shift+Tab to the toggle. The asker owns the user's answer, // so enabling here must not ask again, and a toggle answers with the state it left. const unsubscribes: Array<() => void> = []; const listenForRequests = (): void => { for (const unsubscribe of unsubscribes) unsubscribe(); unsubscribes.length = 0; const listen = (channel: string, handle: (request: PlanModeRequest) => void): void => { unsubscribes.push( pi.events.on(channel, (data) => { const request = data as Partial | undefined; if (!request?.ctx || typeof request.respond !== "function") return; handle(request as PlanModeRequest); }), ); }; listen(PLAN_ENTER_REQUEST, (request) => { if (!runtime.isEnabled()) runtime.enable(request.ctx); request.respond(true); }); listen(PLAN_TOGGLE_REQUEST, (request) => { runtime.toggle(request.ctx); request.ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info"); request.respond(runtime.isEnabled()); }); }; pi.on("session_start", (_event, ctx) => { listenForRequests(); runtime.restore(ctx); }); pi.on("session_tree", (_event, ctx) => runtime.restore(ctx)); pi.on("session_shutdown", async (_event, ctx) => { await web.closeAll(); for (const unsubscribe of unsubscribes) unsubscribe(); unsubscribes.length = 0; ctx.ui.setStatus(STATUS_KEYS.planMode, undefined); }); /** * Whether the registered `subagent` tool declares the read-only parameter * plan mode forces. A `subagent` that cannot honor it is refused rather than * run read-write. */ const subagentSupportsReadOnly = (): boolean => { const info = pi.getAllTools().find((tool) => tool.name === SUBAGENT_TOOL); const properties = (info?.parameters as { properties?: Record } | undefined)?.properties; return properties !== undefined && Object.prototype.hasOwnProperty.call(properties, SUBAGENT_READ_ONLY_PARAM); }; /** * Why a tool call is blocked while planning, or undefined when it may * proceed. Two layers: the policy (readers, `readOnlyHint`, deny list), then * the path backstop (a hint on a path-carrying tool is only a claim). */ const blockedReason = async (toolName: string, input: unknown): Promise => { // Plan's own tool is the sanctioned exception: it is the mode's vocabulary — // `write` saves the artifact, `exit` presents it, `cancel` restores access, and // `enter` answers "already" — so its actions decide for themselves whether they // apply, and each refuses outside its mode. The source-path check keeps a // same-named tool from another extension from borrowing the exemption. if (runtime.isOwnTool(toolName)) return undefined; // `subagent` stays active, but only as read-only delegation. The flag is // forced in place (the child enforces it through `--tools`), so the model // cannot forget it; a `subagent` that does not declare the parameter is // refused rather than run read-write. if (toolName === SUBAGENT_TOOL) { if (!subagentSupportsReadOnly()) { return `Plan mode: the registered "${SUBAGENT_TOOL}" tool does not support read-only delegation; explore with the structured tools instead. ${BLOCKED_GUIDANCE}`; } if (typeof input === "object" && input !== null) { // A background run outlives the call and is reached only through `/agents`, which // refuses while planning: starting one here would spend a child's budget on a // result nobody can collect until the plan is approved. if ((input as Record).background === true) { return `Plan mode: a background run cannot be inspected or collected while planning; run it in the foreground and wait for it. ${BLOCKED_GUIDANCE}`; } (input as Record)[SUBAGENT_READ_ONLY_PARAM] = true; } return undefined; } // `board` stays active so planning can read the board it is about to add cards // to. The action is the truth here, not the annotation: the same tool writes, so // `readOnlyHint` cannot say it, and every verb but the reads are refused by name. if (toolName === BOARD_TOOL) { if (isBoardRead(input)) return undefined; return boardWriteRefusal((input as { action?: unknown } | undefined)?.action, BLOCKED_GUIDANCE); } const blocked = policy.check(toolName); if (blocked) return `Plan mode: ${blocked.reason}`; // `bash` is allowed by the policy so read-only git stays possible; this is // what actually restricts it to chained read-only git commands. The // Windows shell (`powershell`) is not in the policy allow list, so it is // blocked above, not here. if (toolName === "bash") { const command = (input as { command?: unknown } | undefined)?.command; if (typeof command !== "string" || command.trim() === "") { return `Plan mode: an empty bash command is not allowed. ${BASH_BLOCKED_GUIDANCE}`; } const { readOnlyGitCommand } = await import("./git-bash.ts"); const decision = readOnlyGitCommand(command); if (!decision.ok) return `Plan mode: ${decision.reason} ${BASH_BLOCKED_GUIDANCE}`; // The guard hands back the command it approved, with the switches that keep a // repository's own diff program out of it: the call runs what the guard decided, the // same way the subagent branch above writes its forced parameter onto the input. (input as { command: string }).command = decision.command; } const { hasPathInput } = await import("./path-guard.ts"); if (hasPathInput(input) && !PLAN_SAFE_TOOLS.includes(toolName)) { return `Plan mode: "${toolName}" takes a file path and is not a known reader. ${BLOCKED_GUIDANCE}`; } return undefined; }; // Block anything that is not read-only while planning. The active-set gating // above hides most of these; this is the second layer for tools already // declared in an in-flight request, and the guard for tools we did not know // about when the extension loaded (MCP servers, future extensions). pi.on("tool_call", async (event) => { if (!runtime.isEnabled()) return undefined; const reason = await blockedReason(event.toolName, event.input); return reason ? { block: true, reason } : undefined; }); // Tell the model it is planning, and restate an active goal so the plan is // designed against it. Reads the branch, so no extra model call is involved. pi.on("before_agent_start", (_event, ctx) => { if (!runtime.isEnabled()) return undefined; const goal = lastGoal(ctx.sessionManager.getBranch()); return { message: { customType: "plan-mode-context", content: planModeContext(goal), display: false } }; }); // Keep stale plan-mode context out of later, non-plan turns (for example after /resume). pi.on("context", (event) => { const planContexts = event.messages.filter(isPlanModeContext); if (runtime.isEnabled()) { // `before_agent_start` injects one each turn; keep only the newest so // repeated instructions do not accumulate during a long plan. if (planContexts.length <= 1) return undefined; const last = planContexts[planContexts.length - 1]; return { messages: event.messages.filter((message) => !isPlanModeContext(message) || message === last) }; } return { messages: event.messages.filter((message) => !isPlanModeContext(message)) }; }); }