import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { DynamicBorder, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, matchesKey, Text, type KeyId } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { ensurePlanStore, resolvePlanContext, WORKSPACE_CONTEXT_EVENT, type ProjectContextInfo, type WorkspaceContextRequest, } from "./context.ts"; import { getNormalModeTools, getPlanModeTools, mutatingBashReason, PLAN_MODE_DISABLED_TOOLS } from "./mode.ts"; import { archivePlan, createPlan, currentPlanRef, formatPlanList, listPlans, planWorkflowContext, resolveCurrentPlan, resolvePlan, setCurrentPlan, updatePlan, type CurrentPlanRef, } from "./plans.ts"; export const STATE_ENTRY_TYPE = "pi-plan-mode"; export const LEGACY_STATE_ENTRY_TYPE = "project-workspaces-plan-mode"; const APPROVAL_OPTIONS = ["Approve and select", "Approve", "Discuss further"]; const DEFAULT_TOGGLE_SHORTCUT = "ctrl+alt+p"; type PlanApproval = { action: "select" | "approve" | "discuss"; body: string }; const CONFIG_PATH = path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "pi", "agent", "plan-mode.json"); function configuredToggleShortcut(): KeyId { const envShortcut = process.env.PI_PLAN_MODE_SHORTCUT?.trim(); if (envShortcut) return envShortcut as KeyId; try { const raw = readFileSync(CONFIG_PATH, "utf8"); const config = JSON.parse(raw) as { toggleShortcut?: unknown; shortcut?: unknown }; const shortcut = typeof config.toggleShortcut === "string" ? config.toggleShortcut.trim() : typeof config.shortcut === "string" ? config.shortcut.trim() : ""; return (shortcut || DEFAULT_TOGGLE_SHORTCUT) as KeyId; } catch { return DEFAULT_TOGGLE_SHORTCUT; } } interface PlanModeState { enabled: boolean; toolsBeforePlanMode?: string[]; selectedPlanRef?: CurrentPlanRef; } function textResult(text: string, details: Record = {}) { return { content: [{ type: "text" as const, text }], details }; } function planApprovalAction(value: unknown): PlanApproval["action"] | undefined { const choice = String(value || ""); if (choice === APPROVAL_OPTIONS[0]) return "select"; if (choice === APPROVAL_OPTIONS[1]) return "approve"; if (choice === APPROVAL_OPTIONS[2]) return "discuss"; return undefined; } function latestPlanApproval(ctx: ExtensionContext): PlanApproval | undefined { for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { if (entry.type !== "message") continue; const message = entry.message as any; if (message.role !== "toolResult") continue; if (message.toolName === "pi_plan_create") return undefined; if (message.toolName !== "pi_plan_approve") continue; const details = message.details; if (details?.cancelled) return undefined; const body = typeof details?.body === "string" && details.body.trim() ? details.body.trim() : ""; const action = planApprovalAction(details?.value || details?.answer); if (!body || !action) return undefined; return { action, body }; } return undefined; } function planModePrompt(info: ProjectContextInfo, selectedPlanRef?: CurrentPlanRef): string { return `You are in Pi plan mode. You may inspect, search, and reason, but you must not create, edit, delete, move, format, generate, or otherwise mutate project files. The only write-like exception is Pi's internal scoped plan storage through pi_plan_* tools after explicit user approval. Use plan mode to separate research from implementation: 1. Understand the user's goal and constraints. 2. Explore relevant files, commands, docs, and project patterns. 3. Identify the implementation path, risks, edge cases, and verification commands. 4. Present a concrete plan that the user can approve or revise. Question tool rules: - Use the question tool for multiple-choice clarification and approval gates. Do not write numbered options in normal chat and wait for a typed reply. - Ask clarification questions only when the answer materially changes scope, approach, sequencing, risk, or verification. - If no important ambiguity exists, say you found no blockers and proceed. - Keep options concise and high-signal, usually 2-4 options plus an option to continue with assumptions when acceptable. - Never use the question tool as the first user-visible presentation of a final plan. The user must be able to read the draft plan before seeing approval choices. Plan content rules: - Build a detailed, practical plan, not a checkbox checklist. - Include: Goal, Context, Recommended approach, Phases, Risks/assumptions/open questions, Verification commands, Files inspected, and Files likely to change. - Do not include checkbox-style progress tracking in the durable plan. - Use a session todo tool, when available, for live execution tracking. Do not use plan files for checklist progress. Final approval flow is mandatory before saving: 1. Present the final draft plan in a normal assistant chat message. This message must contain the full readable plan body, including the title and all major sections. 2. The approval question must come only after that visible draft plan. Ideally, the immediately preceding assistant message should be the full draft plan plus a short sentence like "If this looks right, choose an approval option." 3. Never call question for final approval if the draft plan has not already been shown in chat. If you notice the plan is only in your reasoning or tool arguments, stop and show the full plan in chat first. 4. Never make the approval question itself the plan presentation; the question text is only the gate, not the plan. 5. Call pi_plan_approve with the plan title and exact full draft plan body. Do not use the generic question tool for final plan approval. 6. Do not call pi_plan_create, pi_plan_current, or pi_plan_update until the user selects one of the approval options through pi_plan_approve. 7. If "${APPROVAL_OPTIONS[0]}": call pi_plan_create with select=true and the exact same full plan body. The tool will save/select the plan and automatically turn plan mode off so normal implementation tools are restored; then continue with implementation if the user asked you to work from the approved plan. 8. If "${APPROVAL_OPTIONS[1]}": call pi_plan_create with select=false and the exact same full plan body. The tool will save the plan and automatically turn plan mode off so normal implementation tools are restored. 9. If "${APPROVAL_OPTIONS[2]}": do not save; continue discussion and refine the draft. If the user asks to resume, continue, execute, or archive a plan, use pi_plan_read first when a current or single active plan exists. If executing a plan and a session todo tool is available, convert the plan phases into a concise live checklist before making changes. ${planWorkflowContext(info, selectedPlanRef)}`; } export default function planModeExtension(pi: ExtensionAPI) { function contextInfo(ctx: ExtensionContext): ProjectContextInfo { const request: WorkspaceContextRequest = { cwd: ctx.cwd, sessionID: ctx.sessionManager.getSessionId(), }; pi.events.emit(WORKSPACE_CONTEXT_EVENT, request); return ensurePlanStore(resolvePlanContext(request.cwd, request.sessionID, request.result)); } let planModeEnabled = false; let toolsBeforePlanMode: string[] | undefined; let selectedPlanRef: CurrentPlanRef | undefined; function persistState(): void { pi.appendEntry(STATE_ENTRY_TYPE, { enabled: planModeEnabled, toolsBeforePlanMode, selectedPlanRef } satisfies PlanModeState); } function updateStatus(ctx: ExtensionContext): void { ctx.ui.setStatus("plan-mode", undefined); ctx.ui.setWidget("plan-mode", planModeEnabled ? [ctx.ui.theme.fg("warning", "PLAN")] : undefined); const selected = resolveCurrentPlan(contextInfo(ctx), "all", selectedPlanRef); if (!selected) selectedPlanRef = undefined; ctx.ui.setStatus("plan-selected", selected ? ctx.ui.theme.fg("dim", "Plan Selected") : undefined); } async function showSelectedPlan(ctx: ExtensionContext): Promise { const selected = resolveCurrentPlan(contextInfo(ctx), "all", selectedPlanRef); if (!selected) { ctx.ui.notify("No plan is selected.", "warning"); return; } if (ctx.mode !== "tui") { ctx.ui.notify(selected.content, "info"); return; } await ctx.ui.custom((_tui, theme, _keybindings, done) => { const container = new Container(); const border = new DynamicBorder((text: string) => theme.fg("accent", text)); container.addChild(border); container.addChild(new Text(theme.fg("accent", theme.bold(`Selected Plan · ${selected.title}`)), 1, 0)); container.addChild(new Markdown(selected.content, 1, 1, getMarkdownTheme())); 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(); }, }; }); } function enablePlanMode(ctx: ExtensionContext): void { if (toolsBeforePlanMode === undefined) toolsBeforePlanMode = pi.getActiveTools(); const allToolNames = pi.getAllTools().map((tool) => tool.name); pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode, allToolNames)); planModeEnabled = true; updateStatus(ctx); persistState(); } function disablePlanMode(ctx: ExtensionContext): void { if (!planModeEnabled) { updateStatus(ctx); return; } pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools())); toolsBeforePlanMode = undefined; planModeEnabled = false; updateStatus(ctx); persistState(); } function togglePlanMode(ctx: ExtensionContext): void { if (planModeEnabled) { disablePlanMode(ctx); ctx.ui.notify("Plan mode disabled. Normal tools restored.", "info"); } else { enablePlanMode(ctx); ctx.ui.notify("Plan mode enabled. Repo write tools disabled; mutating bash is blocked.", "info"); } } pi.registerShortcut(configuredToggleShortcut(), { description: "Toggle plan mode", handler: async (ctx) => togglePlanMode(ctx), }); pi.registerCommand("plan", { description: "Toggle Pi plan mode, or use /plan to enable plan mode and ask the agent to draft a durable plan", handler: async (args, ctx) => { const raw = args.trim(); const action = raw.toLowerCase(); if (["off", "disable", "disabled", "exit"].includes(action)) { disablePlanMode(ctx); ctx.ui.notify("Plan mode disabled. Normal tools restored.", "info"); return; } if (!raw) { togglePlanMode(ctx); return; } if (["on", "enable", "enabled"].includes(action)) { enablePlanMode(ctx); ctx.ui.notify("Plan mode enabled. Repo write tools disabled; mutating bash is blocked.", "info"); return; } enablePlanMode(ctx); ctx.ui.notify("Plan mode enabled. Starting planning request.", "info"); pi.sendUserMessage(raw); }, }); pi.registerCommand("plans", { description: "List active Pi plans in the current scope", handler: async (args, ctx) => { const info = contextInfo(ctx); const status = ["active", "archive", "all"].includes(args.trim()) ? args.trim() : "active"; ctx.ui.notify(formatPlanList(info, listPlans(info, status), selectedPlanRef), "info"); }, }); pi.registerCommand("plan-show", { description: "Show the currently selected durable plan", handler: async (_args, ctx) => showSelectedPlan(ctx), }); pi.on("session_start", async (_event, ctx) => { const entries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries(); const stateEntry = entries .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && [STATE_ENTRY_TYPE, LEGACY_STATE_ENTRY_TYPE].includes(entry.customType || "")) .pop() as { data?: PlanModeState } | undefined; if (stateEntry?.data) { planModeEnabled = Boolean(stateEntry.data.enabled); toolsBeforePlanMode = stateEntry.data.toolsBeforePlanMode; selectedPlanRef = stateEntry.data.selectedPlanRef; } else { if (planModeEnabled) pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools())); planModeEnabled = false; toolsBeforePlanMode = undefined; selectedPlanRef = undefined; } if (planModeEnabled) { const allToolNames = pi.getAllTools().map((tool) => tool.name); pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode ?? pi.getActiveTools(), allToolNames)); } updateStatus(ctx); }); pi.on("before_agent_start", async (event, ctx) => { const info = contextInfo(ctx); return { systemPrompt: `${event.systemPrompt}\n\n${planModeEnabled ? planModePrompt(info, selectedPlanRef) : planWorkflowContext(info, selectedPlanRef)}` }; }); pi.on("tool_call", async (event) => { if (!planModeEnabled) return; if (PLAN_MODE_DISABLED_TOOLS.has(event.toolName)) { return { block: true, reason: `Plan mode: ${event.toolName} is disabled. Use /plan off after the plan is approved if you want to implement.` }; } if (event.toolName === "bash") { const command = typeof event.input.command === "string" ? event.input.command : ""; const reason = mutatingBashReason(command); if (reason) return { block: true, reason: `Plan mode: blocked mutating bash (${reason}). Command: ${command}` }; } }); pi.registerTool({ name: "pi_plan_approve", label: "Plan Approve", description: "Show the final draft plan and ask the user to approve/select, approve, or discuss further. Use this for plan-mode final approval instead of the generic question tool.", promptSnippet: "Show a draft plan for approval before saving it.", promptGuidelines: [ "Use pi_plan_approve for final plan approval; do not use the generic question tool for plan approval gates.", "Call pi_plan_approve only after presenting the full draft plan in normal chat.", "Pass the exact same plan body to pi_plan_approve and pi_plan_create.", ], parameters: Type.Object({ title: Type.String({ description: "Short human-readable plan title" }), body: Type.String({ description: "Full detailed markdown plan body to display and approve" }), }), executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const title = params.title.trim(); const body = params.body.trim(); if (!title) return textResult("Error: pi_plan_approve requires a non-empty title.", { title, body, cancelled: true }); if (!body) return textResult("Error: pi_plan_approve requires a non-empty plan body.", { title, body, cancelled: true }); if (ctx.mode !== "tui") return textResult("Error: pi_plan_approve requires Pi TUI interactive mode.", { title, body, cancelled: true }); const answer = await ctx.ui.select(`Approve this plan?\n\n# ${title}\n\n${body}`, APPROVAL_OPTIONS); const action = planApprovalAction(answer); if (!answer || !action) { return textResult("User cancelled plan approval.", { title, body, answer: null, value: null, action: null, cancelled: true }); } return textResult(`User selected: ${answer}`, { title, body, answer, value: answer, action, cancelled: false }); }, renderCall(args, theme) { const title = typeof args.title === "string" ? args.title : ""; const body = typeof args.body === "string" ? args.body : ""; return new Text([ theme.fg("toolTitle", theme.bold("pi_plan_approve ")) + theme.fg("muted", title), theme.fg("text", body), theme.fg("dim", ` Options: ${APPROVAL_OPTIONS.join(", ")}`), ].filter(Boolean).join("\n"), 0, 0); }, renderResult(result, _options, theme) { const details = result.details as any; const first = result.content?.[0]; const message = first?.type === "text" ? first.text : ""; if (details?.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0); return new Text(theme.fg("success", "✓ ") + theme.fg("accent", message.replace(/^User selected:\s*/, "")), 0, 0); }, }); pi.registerTool({ name: "pi_plan_create", label: "Plan Create", description: "Create a durable active Pi plan in the current scope after the pi_plan_approve approval flow. This tool verifies the latest approval result; the select parameter is accepted for compatibility but the recorded user answer is authoritative.", promptSnippet: "Create a durable scoped plan after explicit approval.", promptGuidelines: [ "Use pi_plan_create only after pi_plan_approve has shown the final draft plan and the user selected Approve and select or Approve.", "Do not use the generic question tool for final plan approval gates; pi_plan_approve owns the exact approval options.", "Do not use pi_plan_create when the user selected Discuss further.", ], parameters: Type.Object({ title: Type.String({ description: "Short human-readable plan title" }), body: Type.String({ description: "Full detailed markdown plan body" }), select: Type.Optional(Type.Boolean({ description: "Deprecated compatibility hint. Actual selection is determined from the latest approval question result." })), task: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const approval = latestPlanApproval(ctx); if (!approval) return textResult("Refusing to create plan: ask for approval first with pi_plan_approve."); if (approval.action === "discuss") return textResult("Refusing to create plan: user selected Discuss further."); if (params.body.trim() !== approval.body) return textResult("Refusing to create plan: plan body does not match the draft plan text approved by pi_plan_approve."); const shouldSelect = approval.action === "select"; const info = contextInfo(ctx); const plan = createPlan(info, params); if (shouldSelect) { selectedPlanRef = currentPlanRef(setCurrentPlan(info, plan.id)); persistState(); } const disabledAfterApproval = planModeEnabled; if (disabledAfterApproval) disablePlanMode(ctx); else updateStatus(ctx); return textResult([ shouldSelect ? "Plan saved and selected." : "Plan saved as active but not selected.", `Plan: ${plan.title} (${plan.id})`, `Path: ${plan.path}`, disabledAfterApproval ? "Plan mode disabled. Normal implementation tools restored." : undefined, ].filter((line) => line !== undefined).join("\n"), { plan, selected: shouldSelect, planModeDisabled: disabledAfterApproval }); }, renderResult(result, _options, theme) { const first = result.content?.[0]; const message = first?.type === "text" ? first.text : "Plan saved"; return new Text(theme.fg("success", message), 0, 0); }, }); pi.registerTool({ name: "pi_plan_list", label: "Plan List", description: "List durable Pi plans in the current session, project, or stream scope.", parameters: Type.Object({ status: Type.Optional(Type.String({ description: "active, archive, or all" })) }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const info = contextInfo(ctx); const status = ["active", "archive", "all"].includes(params.status || "") ? params.status! : "active"; const plans = listPlans(info, status); return textResult(formatPlanList(info, plans, selectedPlanRef), { plans, status }); }, }); pi.registerTool({ name: "pi_plan_current", label: "Plan Current", description: "Show, set, or clear the current plan pointer for the current Pi project.", parameters: Type.Object({ action: Type.Optional(Type.String({ description: "show, set, or clear" })), id: Type.Optional(Type.String({ description: "Plan id/path/title for action=set" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const info = contextInfo(ctx); const action = (params.action || "show").trim().toLowerCase(); if (action === "clear") { selectedPlanRef = undefined; persistState(); updateStatus(ctx); return textResult("Current plan pointer cleared for this session."); } if (action === "set") { if (!params.id) return textResult("action=set requires id"); const plan = setCurrentPlan(info, params.id); selectedPlanRef = currentPlanRef(plan); persistState(); updateStatus(ctx); return textResult(`Current plan set for this session to ${plan.title} (${plan.id})\nPath: ${plan.path}`, { plan }); } const current = resolveCurrentPlan(info, "all", selectedPlanRef); if (!current) return textResult("No current plan is set."); return textResult([`Path: ${current.path}`, "", current.content].join("\n"), { plan: current }); }, }); pi.registerTool({ name: "pi_plan_read", label: "Plan Read", description: "Read a durable Pi plan from the current scope.", parameters: Type.Object({ id: Type.Optional(Type.String({ description: "Plan id/path/title. If omitted, current or single active plan is used." })), status: Type.Optional(Type.String({ description: "active, archive, or all" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { const info = contextInfo(ctx); const status = ["active", "archive", "all"].includes(params.status || "") ? params.status! : "active"; const plan = params.id ? resolvePlan(info, params.id, status) : (resolveCurrentPlan(info, status, selectedPlanRef) || resolvePlan(info, undefined, status)); return textResult([`Path: ${plan.path}`, "", plan.content].join("\n"), { plan }); } catch (error) { return textResult(`❌ ${error instanceof Error ? error.message : String(error)}`); } }, }); pi.registerTool({ name: "pi_plan_update", label: "Plan Update", description: "Update an active durable Pi plan only when intended approach/scope/risks/completion criteria change. Do not use for checklist progress.", parameters: Type.Object({ id: Type.Optional(Type.String()), title: Type.Optional(Type.String()), body: Type.String(), reason: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const info = contextInfo(ctx); const plan = updatePlan(info, params); return textResult(`Updated active plan: ${plan.title} (${plan.id})\nPath: ${plan.path}`, { plan }); }, }); pi.registerTool({ name: "pi_plan_archive", label: "Plan Archive", description: "Archive a completed active durable Pi plan in the current scope.", parameters: Type.Object({ id: Type.Optional(Type.String()), result: Type.Optional(Type.String()) }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const info = contextInfo(ctx); const selectedBeforeArchive = resolveCurrentPlan(info, "active", selectedPlanRef); const plan = archivePlan(info, params); if (selectedBeforeArchive?.id === plan.id) { selectedPlanRef = undefined; persistState(); } updateStatus(ctx); return textResult(`Archived plan: ${plan.title} (${plan.id})\nPath: ${plan.path}`, { plan }); }, }); } export * from "./context.ts"; export * from "./mode.ts"; export * from "./plans.ts";