/** * Session-scoped state and tool gating for plan mode. * * Enabling plan mode removes every tool the policy does not consider read-only * from the active set and activates the plan-mode control tools: `exit_plan_mode` * (present the plan for approval) and `write_plan` (save the plan artifact under * `.pi/plans`). Disabling restores exactly what it hid. The enabled flag is * persisted as a custom session entry so `/resume` and `/tree` follow the * branch, and it is also mirrored into the footer status. * * `write_plan` is the one sanctioned write while planning. The read-only guard * in `index.ts` permits a control tool only when the registered tool is this * extension's own (matched by source path), so a same-named tool from another * extension cannot borrow the exemption. */ import { basename, resolve } from "node:path"; import { realpathSync } from "node:fs"; import { truncateToWidth } from "@earendil-works/pi-tui"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { sanitizeLine } from "../../lib/format.ts"; import type { ReadOnlyPolicy } from "../../lib/policy.ts"; import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts"; import { paint } from "../../lib/tui/tokens.ts"; import { createPlanStore, type PlanStore } from "./plans.ts"; import type { PlanModeEntry } from "./types.ts"; /** Tool the model calls to leave plan mode after approval. */ export const EXIT_TOOL = "exit_plan_mode"; /** Tool the model calls to ask to enter plan mode. */ export const ENTER_TOOL = "enter_plan_mode"; /** Tool the model calls to save the plan artifact. */ export const WRITE_PLAN_TOOL = "write_plan"; /** Tool the model calls to keep an approved plan's steps current. */ export const SET_PLAN_STEPS_TOOL = "set_plan_steps"; /** Custom-entry type used for persistence. */ export const STATE_TYPE = "plan-mode"; /** Longest plan-file basename shown in the footer chip. */ const STATUS_NAME_LENGTH = 28; /** Tools plan mode activates itself; the guard permits only this extension's own. */ const CONTROL_TOOLS = [EXIT_TOOL, WRITE_PLAN_TOOL]; export interface PlanRuntime { /** Whether plan mode is currently on. */ isEnabled(): boolean; enable(ctx: ExtensionContext): void; disable(ctx: ExtensionContext): void; toggle(ctx: ExtensionContext): void; /** Restore state from the branch and the `--plan` flag. */ restore(ctx: ExtensionContext): void; /** Plan-file storage for `write_plan` and `exit_plan_mode`. */ plans: PlanStore; /** Whether `toolName` is a plan-owned control tool (not a same-named impostor). */ isControlTool(toolName: string): boolean; /** Path of the most recently written plan, for the footer chip. */ lastPlanPath(): string | undefined; /** Remember a written plan and repaint the footer. */ setLastPlan(ctx: ExtensionContext, path: string): void; /** The content a review last presented for `path`, for the changed markers. */ reviewed(path: string): string | undefined; /** Remember what a review just presented; memory only, keyed by resolved path. */ markReviewed(path: string, content: string): void; } /** The last plan-mode entry on the branch, with the tools it hid. */ function readPersistedState(ctx: ExtensionContext): PlanModeEntry | undefined { let state: PlanModeEntry | undefined; for (const raw of ctx.sessionManager.getBranch()) { const entry = raw as { type?: string; customType?: string; data?: unknown }; if (entry.type !== "custom" || entry.customType !== STATE_TYPE) continue; const data = entry.data as { enabled?: unknown; removed?: unknown } | null | undefined; if (typeof data?.enabled !== "boolean") continue; // Branch data is untrusted; keep only string tool names. const removed = Array.isArray(data.removed) ? data.removed.filter((name): name is string => typeof name === "string") : undefined; state = removed ? { enabled: data.enabled, removed } : { enabled: data.enabled }; } return state; } /** Compare two paths, resolving symlinks when possible so `/tmp` and `/private/tmp` match. */ function samePath(a: string, b: string): boolean { const normalize = (path: string): string => { try { return realpathSync(path); } catch { return resolve(path); } }; return normalize(a) === normalize(b); } /** One terminal-safe line for the footer chip. */ function statusName(path: string): string { const name = sanitizeLine(basename(path).replace(/\.md$/i, "")); if (!name) return "plan"; return truncateToWidth(name, STATUS_NAME_LENGTH, "…"); } export function createPlanRuntime( pi: ExtensionAPI, policy: ReadOnlyPolicy, options: { entryPath?: string } = {}, ): PlanRuntime { let enabled = false; let lastPlanPath: string | undefined; // The content each plan path last showed in a review, so the next review can // mark what changed. Memory only: a resumed session has no baseline and shows // no markers, which is better than claiming everything changed. const reviewedPlans = new Map(); // Tools plan mode deactivated, so disabling restores exactly those and does // not re-activate a tool another extension intentionally hid. let removedForPlan: string[] = []; const plans = createPlanStore(pi); const isControlTool = (toolName: string): boolean => { if (!CONTROL_TOOLS.includes(toolName)) return false; const info = pi.getAllTools().find((tool) => tool.name === toolName); const source = info?.sourceInfo?.path; // Older hosts may not record a source; only reject a recorded mismatch. return source === undefined || options.entryPath === undefined || samePath(source, options.entryPath); }; /** Tools plan mode would hide from the current active set. */ const captureHidden = (): string[] => [ ...new Set(pi.getActiveTools().filter((name) => !policy.isAllowed(name) && !CONTROL_TOOLS.includes(name))), ]; /** * Apply the current mode to the active set. `addHidden` re-adds the tools a * user disable hid; a branch restore passes false, because the host has * already put the target branch's base tools back. */ const applyTools = (addHidden: boolean): void => { const active = pi.getActiveTools(); if (enabled) { // Keep only tools the policy considers safe to expose while read-only // (structured readers and the plan/goal trackers) plus the control tools. pi.setActiveTools([...new Set([...active.filter((name) => policy.isAllowed(name)), ...CONTROL_TOOLS])]); return; } const restored = addHidden ? removedForPlan : []; pi.setActiveTools([...new Set([...active.filter((name) => !CONTROL_TOOLS.includes(name)), ...restored])]); removedForPlan = []; }; const statusText = (): string => lastPlanPath ? `${GLYPHS.plan} plan · ${statusName(lastPlanPath)}` : `${GLYPHS.plan} plan`; const setStatus = (ctx: ExtensionContext): void => { ctx.ui.setStatus(STATUS_KEYS.planMode, enabled ? paint(ctx.ui.theme, "warn", statusText()) : undefined); }; const setEnabled = (next: boolean, ctx: ExtensionContext | undefined, persist: boolean): void => { const changed = next !== enabled; // Capture what we hide only when entering plan mode from a non-plan set. // A redundant enable (for example a `session_tree` restore while already // enabled) must not recompute this from the already-filtered active set, or // disabling would lose them. if (changed && next) removedForPlan = captureHidden(); enabled = next; applyTools(!next); if (persist && changed) { pi.appendEntry(STATE_TYPE, (next ? { enabled, removed: removedForPlan } : { enabled }) satisfies PlanModeEntry); } if (ctx) setStatus(ctx); }; return { isEnabled: () => enabled, enable: (ctx) => setEnabled(true, ctx, true), disable: (ctx) => setEnabled(false, ctx, true), toggle: (ctx) => setEnabled(!enabled, ctx, true), restore: (ctx) => { lastPlanPath = undefined; const persisted = readPersistedState(ctx); // `--plan` starts a fresh session in plan mode; an explicit persisted // choice (including a later disable) wins over the launch flag. const fromFlag = persisted === undefined && pi.getFlag("plan") === true; const next = fromFlag || persisted?.enabled === true; // A branch carries the tools it hid, so adopt them for a later disable; // a fresh `--plan` captures the current set instead. if (next && persisted?.removed !== undefined) removedForPlan = [...persisted.removed]; else if (next) removedForPlan = captureHidden(); else removedForPlan = []; enabled = next; // Never re-add hidden tools on restore: the host has already restored the // target branch's base set, and only a user disable should undo our hide. applyTools(false); if (ctx) setStatus(ctx); }, plans, isControlTool, lastPlanPath: () => lastPlanPath, setLastPlan: (ctx, path) => { lastPlanPath = path; setStatus(ctx); }, reviewed: (path) => reviewedPlans.get(resolve(path)), markReviewed: (path, content) => { reviewedPlans.set(resolve(path), content); }, }; }