/** * Mode Switch Extension * * Use the configured crouter mode-cycle binding to cycle between operating * modes, the way Claude's Shift+Tab cycles plan / accept-edits. Modes here: * * normal → full access (default) * spec → write a specification before any code * plan → produce an implementation plan, no changes * * The only behaviour is context injection: whenever the active mode differs * from the mode whose guidance was last injected, the mode's operating * instructions are prepended (invisibly) to the next agent turn. Stay in the * same mode across turns and nothing extra is injected. * * - The configured shortcut or /mode cycles; /mode jumps to a mode * - The agent can switch its own mode by running the bundled `bin/mode` CLI via * its bash tool; the command prints the new mode's guidance to stdout, so that * text only enters context at the moment of the switch (no always-on tool) * - The active mode is mirrored to a tiny `current` file the attach viewer * reads for its footer indicator; when a real UI exists, it still also renders * as a badge on the bottom-right of the input box by wrapping the previous * editor instead of replacing it. * - State persists across session resume */ import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { KeyId, TUI } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { type FSWatcher, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { nodeDir } from "../../../core/canvas/paths.js"; import { resolveUserKeybindings } from "../../../core/keybindings/index.js"; type Mode = "normal" | "spec" | "plan"; // Absolute path to the bundled mode-switch CLI (extensions/../bin/mode). The // extension runs from its package source dir, so we resolve it relative to this // file. The agent runs it through its normal bash tool, so it costs zero context // until the moment it's used. const CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "mode"); const MODE_CLI = `bash "${CLI_PATH}"`; // Per-node channel the CLI, attach viewer, and this extension share: // /nodes//mode/{request.json,current,guidance/*}. function modeSwitchDir(nodeId: string = requiredNodeId()): string { return join(nodeDir(nodeId), "mode"); } function requiredNodeId(): string { const nodeId = process.env["CRTR_NODE_ID"]; if (nodeId === undefined || nodeId === "") throw new Error("pi-mode-switch requires CRTR_NODE_ID"); return nodeId; } function ensureArtifactDirs(): void { try { mkdirSync(join(modeSwitchDir(), "guidance"), { recursive: true }); } catch { // Best-effort: never block session start on a filesystem hiccup. } } // The order used by the shortcut and /mode command. const CYCLE: Mode[] = ["normal", "spec", "plan"]; interface ModeDef { /** Badge label shown in the input box. */ label: string; /** Guidance injected before the next turn when this mode becomes active. */ context: string; } const MODES: Record = { normal: { label: "normal", context: `[NORMAL MODE] Mode restrictions are lifted — you have full tool access again. Proceed with the work as discussed. If a spec or plan was produced earlier in this session, follow it. You may read, edit, write, and run commands freely. If a request is large enough to need a written spec or plan first, switch modes by running \`${MODE_CLI} spec\` (or \`plan\`); the command prints that mode's operating instructions to follow.`, }, spec: { label: "spec", context: `[SPEC MODE] You are in spec mode. Your job is to turn a rough request into a clear, written specification BEFORE any code is written. How to operate: - Do NOT implement, edit, or write production code. Hold off on changes entirely. - Resolve open questions as you go. Whenever something is ambiguous — scope, inputs and outputs, edge cases, constraints, or what "done" means — put the question to the user with the \`crtr human ask\` command rather than asking inline, then fold the answer into the spec. Do this before drafting for the unknowns you can see up front, and again whenever a new question surfaces while you write. The finished spec records decisions, not open questions. - Explore the codebase read-only to ground the spec in how things actually work. - Then write the specification to your node context directory as \`.md\`. In bash, \`$CRTR_CONTEXT_DIR\` expands to that directory; before using a non-shell write tool, run \`printf '%s\\n' "$CRTR_CONTEXT_DIR"\` and use the printed absolute path rather than pasting the literal variable token. Cover: problem statement, goals and non-goals, requirements, a high-level approach, and acceptance criteria. Keep it tight and reviewable. - Then validate the spec with a reviewer node first: spawn one with \`crtr node new "Review the spec at " --kind review\` — you auto-subscribe to it and are woken when it finishes — the wake message already carries the digest: short reports include their content and file path, while larger reports remain refs to dereference. It flags completeness gaps, contradictions, ambiguity, scope creep, and unspecified failure modes on critical paths. Resolve every Issue it raises before showing the spec to anyone; Recommendations are advisory. - Finally, ALWAYS open the finished spec for the human with \`crtr human review \` — this is required, not optional. Do NOT just tell the user the spec is done or point them at the file path: the user cannot easily open the spec file themselves, so \`crtr human review\` is the way to put it in front of them for line-by-line comments. Run it the moment the spec is complete (after the reviewer node's Issues are resolved), then incorporate the returned comments. Do not move on until the user has reviewed it. Once they approve it, run \`${MODE_CLI} normal\` to lift the restrictions and begin implementing — the command prints your normal-mode operating instructions.`, }, plan: { label: "plan", context: `[PLAN MODE] You are in plan mode. Your job is to produce an actionable implementation plan — no changes yet. How to operate: - Do NOT edit production code. Read-only exploration only (writing the plan file itself is allowed). - Resolve open questions as you go. Whenever something is ambiguous or a decision needs a call, put it to the user with the \`crtr human ask\` command rather than asking inline, then fold the answer into the plan. Do this before drafting for the unknowns you can see up front, and again whenever a new question surfaces while you write. The finished plan records decisions, not open questions. - Investigate the relevant code to understand how the change fits in. - Then write the plan to your node context directory as \`.md\`. In bash, \`$CRTR_CONTEXT_DIR\` expands to that directory; before using a non-shell write tool, run \`printf '%s\\n' "$CRTR_CONTEXT_DIR"\` and use the printed absolute path rather than pasting the literal variable token. The plan is a numbered, ordered list of concrete steps: which files to touch, which functions to add or modify, which tests to write, plus risks and dependencies. - Then validate the plan with a reviewer node first: spawn one with \`crtr node new "Review the plan at " --kind review\` — you auto-subscribe to it and are woken when it finishes — the wake message already carries the digest: short reports include their content and file path, while larger reports remain refs to dereference. It flags completeness gaps, spec misalignment, unresolved decisions, buildability problems, quality smells (timelines, "for now" shortcuts, magic values, fallbacks, missing type contracts), and overlapping file ownership across parallel tasks. Resolve every Issue before moving on; Recommendations are advisory. - Finally, present the finished plan for review with \`crtr human review \` and incorporate the returned comments. Wait for approval before moving on. Once the user approves, run \`${MODE_CLI} normal\` to begin implementation — the command prints your normal-mode operating instructions.`, }, }; export default function modeSwitchExtension(pi: ExtensionAPI): void { let currentMode: Mode = "normal"; // The mode whose guidance was last injected. Injection only fires when the // active mode differs from this. let lastInjectedMode: Mode | null = "normal"; // TUI handle, captured by the editor factory, so mode changes can repaint the // input badge. let activeTui: TUI | undefined; // Watches this node's mode/request.json for agent-initiated CLI switches. let modeWatcher: FSWatcher | undefined; // UI toast, available only when the session has a UI. let uiNotify: ((message: string, type?: "info" | "warning" | "error") => void) | undefined; // Token of the most recent CLI request this instance has applied. Lets us // reconcile to the request file WITHOUT deleting it: deleting was racy because // /reload can leave extra extension instances behind, and a stale one could // consume the request before the on-screen instance ever observed it. let lastAppliedToken: string | undefined; /** Append the mode badge to the bottom-right of the input box border. */ function decorateInput( lines: string[], width: number, theme: { fg(color: string, text: string): string; bg(color: string, text: string): string }, ): string[] { if (lines.length === 0) return lines; const def = MODES[currentMode]; const badge = theme.bg("selectedBg", theme.fg("text", ` ${def.label} `)); const badgeWidth = visibleWidth(badge); if (badgeWidth + 2 >= width) return lines; // Replace the rightmost cells of the bottom border line with the badge. const last = lines.length - 1; lines[last] = truncateToWidth(lines[last]!, width - badgeWidth, "") + badge; return lines; } function repaint(): void { activeTui?.requestRender(); } function persist(): void { pi.appendEntry("mode-switch", { mode: currentMode }); } function persistCurrent(): void { try { writeFileSync(join(modeSwitchDir(), "current"), `${currentMode}\n`); } catch { // Best-effort: the viewer treats the file as a hint, not authority. } } // Apply a mode change to state and persistence. Shared by every path: the // Shortcut/command changes add a UI toast and inject guidance on the next // turn; the CLI path has already handed the agent its guidance via stdout. function applyMode(next: Mode): void { currentMode = next; repaint(); persist(); persistCurrent(); } function setMode(next: Mode, ctx: ExtensionContext): void { if (next === currentMode) { ctx.ui.notify(`Already in ${next} mode.`, "info"); return; } applyMode(next); ctx.ui.notify( currentMode === "normal" ? "Normal mode — full access restored." : `${currentMode[0].toUpperCase()}${currentMode.slice(1)} mode — guidance will be applied on your next message.`, ); } // The agent switches its own mode by running the bundled CLI, which writes the // requested mode to this node's mode/request.json. We pick that up live via // fs.watch (with a before_agent_start reconcile as a backstop) only to sync the // badge and persisted state — never to inject guidance, since the CLI already // printed it to the agent's bash output. Token gating ensures a later // shortcut-driven change can't be clobbered by a stale request. function applyFromRequest(next: Mode, source: "cli" | "attach"): void { if (next === currentMode) return; applyMode(next); if (source === "cli") lastInjectedMode = next; uiNotify?.(source === "cli" ? `Mode → ${next} (agent)` : `Mode → ${next} (viewer)`, "info"); } function reconcileFromCli(): void { let raw: string; try { raw = readFileSync(join(modeSwitchDir(), "request.json"), "utf8"); } catch { return; // no pending request } let parsed: { mode?: string; token?: string; source?: "cli" | "attach" }; try { parsed = JSON.parse(raw) as { mode?: string; token?: string; source?: "cli" | "attach" }; } catch { return; // half-written; a later watch event or turn catches the final write } // Apply each CLI request at most once (token-gated) and never delete it, so // every live instance — including the one actually on screen — converges to the // requested mode regardless of /reload leftovers. A lingering request whose // token we already applied can't clobber a later shortcut-driven change. if (parsed.token && parsed.token === lastAppliedToken) return; lastAppliedToken = parsed.token; if (parsed.mode && (CYCLE as string[]).includes(parsed.mode)) { applyFromRequest(parsed.mode as Mode, parsed.source ?? "cli"); } } function startModeWatcher(dir: string): void { modeWatcher?.close(); try { modeWatcher = watch(dir, (_event, filename) => { if (filename && filename !== "request.json") return; reconcileFromCli(); }); } catch { // fs.watch unsupported here — the before_agent_start reconcile still covers it. } } function cycleMode(ctx: ExtensionContext): void { const idx = CYCLE.indexOf(currentMode); setMode(CYCLE[(idx + 1) % CYCLE.length], ctx); } const piNamedKeys = new Set(["escape", "enter", "tab", "backspace", "delete", "space", "up", "down", "left", "right", "pageUp", "pageDown", "home", "end"]); const piSymbolKeys = new Set(["`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+", "|", "~", "{", "}", ":", "<", ">", "?"]); function isPiKeyId(value: string): value is KeyId { const parts = value === "+" ? ["+"] : value.endsWith("++") ? [...value.slice(0, -2).split("+"), "+"] : value.split("+"); const key = parts.pop(); if (key === undefined) return false; const validKey = /^[a-z0-9]$/.test(key) || piSymbolKeys.has(key) || piNamedKeys.has(key); return validKey && parts.every((modifier) => ["ctrl", "alt", "shift", "super"].includes(modifier)); } function piKeyId(gesture: string): KeyId { const keyId = gesture.replace(/pageup|pagedown/, (key) => key === "pageup" ? "pageUp" : "pageDown"); if (!isPiKeyId(keyId)) throw new Error(`Invalid mode-cycle keybinding: ${gesture}`); return keyId; } // Resolve once when the extension loads. Crouter's sparse user config owns // this semantic action; pi's stock action bindings remain independently owned // by pi and are neither read nor rewritten here. const bindings = resolveUserKeybindings(); for (const gesture of bindings.gestures("crtr.mode.cycle")) { pi.registerShortcut(piKeyId(gesture), { description: "Cycle mode (normal → spec → plan)", handler: async (ctx) => cycleMode(ctx), }); } // /mode cycles; /mode jumps to a specific mode. pi.registerCommand("mode", { description: "Cycle operating mode, or /mode to set one", getArgumentCompletions: (prefix: string) => { const items = CYCLE.map((m) => ({ value: m, label: m })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { const arg = args.trim().toLowerCase(); if (!arg) { cycleMode(ctx); return; } if ((CYCLE as string[]).includes(arg)) { setMode(arg as Mode, ctx); } else { ctx.ui.notify(`Unknown mode "${arg}". Use: ${CYCLE.join(", ")}.`, "error"); } }, }); // The core behaviour: inject the active mode's guidance before the turn, but // only when the mode has changed since the last injection. We first reconcile // any agent-initiated CLI switch the fs.watch may have missed. pi.on("before_agent_start", async () => { reconcileFromCli(); if (currentMode === lastInjectedMode) return; lastInjectedMode = currentMode; return { message: { customType: "mode-switch-context", content: MODES[currentMode].context, display: false, }, }; }); // Keep only the most recent mode-guidance message and drop the older ones, so // the LLM always sees the current mode's instructions (including the // "restrictions lifted" note when returning to normal) and never stale // guidance from a previous mode. pi.on("context", async (event) => { const messages = event.messages; let lastIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { if ((messages[i] as { customType?: string }).customType === "mode-switch-context") { lastIdx = i; break; } } if (lastIdx === -1) return; return { messages: messages.filter((m, i) => { return (m as { customType?: string }).customType !== "mode-switch-context" || i === lastIdx; }), }; }); // Restore persisted mode on session start / resume, and install the input // badge. pi.on("session_start", async (_event, ctx) => { // Guarantee the node-local mode channel exists before reading or watching it. ensureArtifactDirs(); const entries = ctx.sessionManager.getEntries(); const last = entries .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "mode-switch") .pop() as { data?: { mode?: Mode } } | undefined; if (last?.data?.mode && (CYCLE as string[]).includes(last.data.mode)) { currentMode = last.data.mode; } // Re-establish non-normal mode guidance on the next turn after a resume; // normal mode needs no re-injection. lastInjectedMode = currentMode === "normal" ? "normal" : null; // Mirror each mode's guidance to disk so the CLI can hand the right one to the // agent on stdout, clear any stale request from a previous session, write the // current mode hint for the viewer, and start watching for mode requests. const dir = modeSwitchDir(); try { for (const mode of CYCLE) { writeFileSync(join(dir, "guidance", `${mode}.txt`), MODES[mode].context); } writeFileSync(join(dir, "current"), `${currentMode}\n`); rmSync(join(dir, "request.json"), { force: true }); } catch { // Best-effort: the before_agent_start reconcile still covers switches. } lastAppliedToken = undefined; startModeWatcher(dir); if (!ctx.hasUI) return; uiNotify = (message, type) => ctx.ui.notify(message, type); // Wrap whatever editor is already installed (e.g. a session-name badge on // the top border) so both decorations coexist. A transparent Proxy forwards // every method/property to the base editor and overrides only render() to // append the mode badge on the bottom-right border line. const previousFactory = ctx.ui.getEditorComponent(); const uiTheme = ctx.ui.theme; ctx.ui.setEditorComponent((tui, theme, keybindings) => { activeTui = tui; const base = previousFactory ? previousFactory(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings); return new Proxy(base, { get(target, prop, receiver) { if (prop === "render") { return (width: number): string[] => decorateInput(target.render(width), width, uiTheme); } const value = Reflect.get(target, prop, target); return typeof value === "function" ? value.bind(target) : value; }, }); }); repaint(); }); // Stop watching the request file when the session ends. pi.on("session_shutdown", async () => { modeWatcher?.close(); modeWatcher = undefined; }); }