/** * Mode Manager — переключение режимов как в Claude Code. * * Режимы: * auto — всё разрешено без подтверждений (по умолчанию) * manual — подтверждение перед каждым write / edit / опасным bash * plan — read-only: write/edit заблокированы, bash ограничен безопасными командами * * Команда: /mode * Совместим с @narumitw/pi-plan-mode: если plan-mode активен, не дублируем блокировки. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // ── Типы ──────────────────────────────────────────────────────────────────── type Mode = "auto" | "manual" | "plan"; interface ModeState { mode: Mode; } const STATE_TYPE = "mode-manager-state"; const STATUS_KEY = "mode-manager"; // ── Безопасные bash-паттерны для plan-режима ───────────────────────────────── const SAFE_BASH_PATTERNS = [ /^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|jq|awk|rg|fd|bat|eza)\b/i, /^\s*sed\s+-n\b/i, /^\s*git\s+(status|log|diff|show|branch|remote|config|ls-files|grep)\b/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i, /^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i, // ── curl: только GET/HEAD/OPTIONS, без данных ─────────────────────────── /^\s*curl\s+/i, // ── PostgreSQL read-only queries (через docker-compose exec) ───────────── // docker compose exec postgres psql -c "SELECT ..." /^\s*docker\s+compose\s+.*\bexec\b.*\bpostgres\b.*\bpsql\b/i, // docker compose exec postgres pg_dump --schema-only /^\s*docker\s+compose\s+.*\bexec\b.*\bpostgres\b.*\bpg_dump\s+--schema-only\b/i, // docker compose exec php84 php artisan db:show / db:table / db:monitor /^\s*docker\s+compose\s+.*\bexec\b.*\bphp84\b.*\bphp\s+artisan\s+db:(show|table|monitor)\b/i, // ./scripts/pg-query (read-only wrapper) /^\s*\.?\/?scripts\/pg-query\b/i, // cd docker && docker-compose exec ... (то же через cd) /^\s*cd\s+.*docker.*&&.*docker.compose.*exec.*(postgres|php84)/i, ]; const DANGEROUS_BASH_PATTERNS = [ /\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i, /\bgit\s+push\s+.*(--force|--delete)/i, /\bDROP\b/i, /\bTRUNCATE\b/i, // ── curl: мутирующие методы и передача данных ──────────────────────────── /^\s*curl\s+.*\s(?:-d|--data|--data-binary|--data-raw|--data-urlencode|-F|--form|-T|--upload-file)(?:\s|$)/i, /^\s*curl\s+.*\s(?:-X|--request)\s+(?:POST|PUT|DELETE|PAT?CH)\b/i, ]; // ── Системный промпт для plan-режима (лаконичный) ──────────────────────────── const PLAN_SYSTEM_PROMPT = ` [PLAN MODE ACTIVE] You are in Plan Mode. You CANNOT modify files or run mutating commands. - Write and edit tools are blocked. - Bash is limited to read-only commands (cat, ls, grep, find, git status/log/diff, etc.). - curl is allowed for HTTP GET/HEAD/OPTIONS (no -d/-F/-T or -X POST/PUT/DELETE/PATCH). Use it to read API documentation or query external APIs. - Read-only PostgreSQL queries are allowed via: docker compose exec postgres psql, docker compose exec php84 php artisan db:show/db:table, or ./scripts/pg-query. - Explore, analyze the codebase and database, and produce a detailed implementation plan. - When ready, present the plan clearly so the user can switch to auto or manual mode to implement it. `; // ── Расширение ─────────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let state: ModeState = { mode: "auto" }; // ──── Команда /mode ────────────────────────────────────────────────────── pi.registerCommand("mode", { description: "Switch editing mode: auto, manual, or plan", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify(`Current mode: ${state.mode}`, "info"); return; } const currentLabel = modeLabel(state.mode); const choice = await ctx.ui.select( `Current mode: ${currentLabel}\n\nSelect mode:`, [ "🟢 Auto — no confirmations, full access", "🟡 Manual — confirm every write/edit/dangerous bash", "🔵 Plan — read-only, plan before implementing", ], ); if (!choice) return; if (choice.startsWith("🟢")) setMode("auto", ctx); else if (choice.startsWith("🟡")) setMode("manual", ctx); else if (choice.startsWith("🔵")) setMode("plan", ctx); }, }); // ──── Восстановление состояния при старте ──────────────────────────────── pi.on("session_start", (_event, ctx) => { restoreState(ctx); updateUi(ctx); }); pi.on("session_shutdown", (_event, ctx) => { persistState(); clearUi(ctx); }); // ──── Основная логика: перехват tool_call ──────────────────────────────── pi.on("tool_call", async (event, ctx) => { // Если активен @narumitw/pi-plan-mode — не дублируем блокировки if (isExternalPlanModeActive(ctx)) return undefined; // ── Plan mode: блокируем write/edit, ограничиваем bash ─────────────── if (state.mode === "plan") { if (event.toolName === "write" || event.toolName === "edit") { return { block: true, reason: `Plan mode: ${event.toolName} is blocked. Use /mode auto or /mode manual to edit files.`, }; } if (event.toolName === "bash") { const command = (event.input as { command?: string }).command ?? ""; // Сначала проверяем опасные паттерны (sudo, rm -rf, curl -X POST и т.д.) if (DANGEROUS_BASH_PATTERNS.some((p) => p.test(command))) { return { block: true, reason: `Plan mode: dangerous or mutating bash commands are blocked.\nCommand: ${command}`, }; } if (!isSafeCommand(command)) { return { block: true, reason: `Plan mode: mutating bash commands are blocked.\nCommand: ${command}`, }; } } } // ── Manual mode: подтверждение write/edit ──────────────────────────── if (state.mode === "manual") { if (event.toolName === "write" || event.toolName === "edit") { if (!ctx.hasUI) { return { block: true, reason: `${event.toolName} blocked: no UI for confirmation` }; } const input = event.input as { path?: string; content?: string; edits?: Array<{ oldText: string; newText: string }>; }; const path = input.path ?? "unknown"; const changesPreview = formatChanges(event.toolName, input); const confirmed = await ctx.ui.confirm( `Manual mode: allow ${event.toolName}?`, `File: ${path}\n\n${changesPreview}`, ); if (!confirmed) { return { block: true, reason: `${event.toolName} blocked by user` }; } } // Опасный bash if (event.toolName === "bash") { const command = (event.input as { command?: string }).command ?? ""; if (DANGEROUS_BASH_PATTERNS.some((p) => p.test(command))) { if (!ctx.hasUI) { return { block: true, reason: "Dangerous bash blocked: no UI for confirmation" }; } const confirmed = await ctx.ui.confirm( "⚠️ Manual mode: dangerous command detected", command, ); if (!confirmed) { return { block: true, reason: "Dangerous bash blocked by user" }; } } } } return undefined; }); // ──── Plan mode: добавляем системный промпт ───────────────────────────── pi.on("before_agent_start", (event, ctx) => { if (state.mode !== "plan") return; if (isExternalPlanModeActive(ctx)) return; // plan-mode пакет сам добавит return { systemPrompt: `${event.systemPrompt}\n\n${PLAN_SYSTEM_PROMPT}`, }; }); // ──── Вспомогательные функции ──────────────────────────────────────────── function setMode(mode: Mode, ctx: Parameters[0]) { const oldMode = state.mode; state = { ...state, mode }; persistState(); updateUi(ctx); const label = modeLabel(mode); if (mode === "plan" && oldMode !== "plan") { ctx.ui.notify(`Switched to ${label}. Write/edit blocked, bash limited. /mode to change.`, "info"); } else if (mode !== "plan" && oldMode === "plan") { ctx.ui.notify(`Switched to ${label}. Full access restored.`, "info"); } else { ctx.ui.notify(`Switched to ${label}.`, "info"); } } function modeLabel(mode: Mode): string { switch (mode) { case "auto": return "🟢 Auto"; case "manual": return "🟡 Manual"; case "plan": return "🔵 Plan"; } } function updateUi(ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } }) { ctx.ui.setStatus(STATUS_KEY, modeLabel(state.mode)); } function clearUi(ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } }) { ctx.ui.setStatus(STATUS_KEY, undefined); } function persistState() { pi.appendEntry(STATE_TYPE, state); } function restoreState(ctx: { sessionManager: { getEntries: () => Array<{ type?: string; customType?: string; data?: ModeState }> } }) { const entries = ctx.sessionManager.getEntries(); const entry = [...entries] .reverse() .find((e) => e.type === "custom" && e.customType === STATE_TYPE); if (entry?.data?.mode) { state = { mode: entry.data.mode }; } } function isSafeCommand(command: string): boolean { const trimmed = command.trim(); if (!trimmed) return false; return SAFE_BASH_PATTERNS.some((p) => p.test(trimmed)); } /** * Формирует предпросмотр изменений для подтверждения в manual-режиме. * Для write — первые 20 строк контента. * Для edit — oldText → newText для каждого блока (с truncation). */ function formatChanges( toolName: string, input: { path?: string; content?: string; edits?: Array<{ oldText: string; newText: string }> }, ): string { if (toolName === "write") { const content = input.content ?? ""; const lines = content.split("\n"); if (lines.length <= 20) return content; const preview = lines.slice(0, 20).join("\n"); return `${preview}\n... (${lines.length - 20} more lines)`; } if (toolName === "edit") { const edits = input.edits ?? []; return edits .map((ed, i) => { const oldPreview = truncateText(ed.oldText, 200); const newPreview = truncateText(ed.newText, 200); return `[Edit #${i + 1}]\n − ${oldPreview}\n + ${newPreview}`; }) .join("\n"); } return ""; } /** Truncate text to maxLen chars, appending "…" if cut. */ function truncateText(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen) + "…"; } /** * Проверяет, активен ли внешний plan-mode (@narumitw/pi-plan-mode). * Ищем custom-запись с типом "plan-mode-state" и enabled: true. */ function isExternalPlanModeActive(ctx: { sessionManager: { getEntries: () => Array<{ type?: string; customType?: string; data?: { enabled?: boolean }; }>; }; }): boolean { const entries = ctx.sessionManager.getEntries(); const planStateEntry = [...entries] .reverse() .find((e) => e.type === "custom" && e.customType === "plan-mode-state"); return planStateEntry?.data?.enabled === true; } }