// ─── 只读模式入口 ────────────────────────────────────────────── import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { ModeCallbacks, ModeHooks } from "./shared/mode-types.js"; import { loadModeToolConfig, type ReminderMode, saveModeToolConfig, } from "./shared/tool-config.js"; import { blockedReasonSuffix, isBlockedBuiltinToolName, isBuiltinToolName, isSafeCommand, } from "./shared/tool-safety.js"; import { computeActiveToolNames, deactivateRequiredTool, filterBlockedToolsForDisplay, formatToolSummary, restoreTools, type SelectedToolNamesAccessor, showToolSelector, stripRequiredTool, type ToolSelectorPolicy, } from "./shared/tool-selector.js"; import type { CommandArgumentCompletion } from "./shared/types.js"; import { readCommand } from "./shared/utils.js"; // 只读模式复用 plan-mode 的工具/bash 安全策略,但精简了提示词与 UI const STATE_ENTRY_TYPE = "readonly-mode-state"; const STATUS_KEY = "readonly-mode"; const READONLY_CONTEXT_MARKER = "[READONLY MODE ACTIVE]"; // A stable, deterministic reminder appended to the most recent context on every // LLM call while Read-only mode is active. Keep it static (no timestamps/counts) // so it never perturbs the cached prompt prefix, and always at the tail so the // prefix stays byte-identical across turns. const READONLY_REMINDER_TEXT = `${READONLY_CONTEXT_MARKER} You remain in read-only mode for this turn. Do NOT call edit/write or run any mutating bash command, and do NOT try workarounds (other tools, shell redirects, etc.). If the user requests a change, tell them to run /ro to exit read-only mode first, then stop.`; const READONLY_CONTEXT_MESSAGE_TYPE = "readonly-mode-reminder"; /** Default reminder cadence: system-prompt prompt only, no per-call reminder. */ const DEFAULT_REMINDER_MODE: ReminderMode = "once"; const READONLY_SELECTOR_POLICY: ToolSelectorPolicy = { modeName: "Read-only", }; interface ReadonlyModeState { enabled: boolean; availableTools?: string[]; reminderMode?: ReminderMode; } const READONLY_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [ { value: "exit", label: "exit", description: "Exit Read-only mode" }, { value: "off", label: "off", description: "Exit Read-only mode" }, { value: "tools", label: "tools", description: "Select tools allowed in Read-only mode", }, { value: "reminder", label: "reminder", description: "Set reminder injection: once | always", }, ]; const REMINDER_MODE_COMPLETIONS: readonly CommandArgumentCompletion[] = [ { value: "once", label: "once", description: "Read-only prompt only (default; no per-call reminder)", }, { value: "always", label: "always", description: "Inject the reminder on every LLM call", }, ]; export function completeReadonlyArguments( argumentPrefix: string, ): CommandArgumentCompletion[] | null { const prefix = argumentPrefix.trimStart().toLowerCase(); if (prefix === "") return [...READONLY_COMMAND_COMPLETIONS]; // Two-level completion for `/ro reminder once|always`. // pi replaces the ENTIRE argument text with item.value on selection, so the // value must be the full "reminder " argument — not just the mode. if (prefix === "reminder" || prefix.startsWith("reminder ")) { const rest = prefix.slice("reminder".length).trimStart(); const modes = REMINDER_MODE_COMPLETIONS.filter((item) => item.value.startsWith(rest), ); if (modes.length === 0) return null; return modes.map((item) => ({ value: `reminder ${item.value}`, label: item.value, description: item.description, })); } if (/\s/.test(prefix)) return null; const matches = READONLY_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(prefix), ); return matches.length > 0 ? [...matches] : null; } function buildReadonlyPrompt() { return `${READONLY_CONTEXT_MARKER} # Read-only Mode You are currently in **read-only mode**. It reuses Plan mode's tool-safety policy, but drops planning and the question tool. ## Mode rules - Use only the built-in read-only tools (read/grep/find/ls) and bash commands allowed by the read-only allowlist. - Do NOT use edit/write. Do NOT run any mutating bash command (no file writes, installs, commits, migrations, or formatting that rewrites files). Do NOT call any non-allowlisted extension tool that mutates state. - Do not produce an implementation plan, and do not call any plan_mode_question-style tool. - Read-only mode only helps you understand code and current state safely. It never advances a change. ## Handling change requests - If the user asks you to modify files or make any change, do NOT attempt it. Tell them explicitly to run \`/ro\` to exit read-only mode first, then stop. - A blocked tool call means stop — not "try another way". Do not bypass the restriction by switching tools, writing via bash, redirecting output to disk, or any other workaround. - Even if the user insists, claims authorization, or says it is urgent, still require exiting read-only mode before any mutation.`; } // ═══════════════════════════════════════════════════════════════════ // 扩展入口 // ═══════════════════════════════════════════════════════════════════ export default function setupReadonlyMode( pi: ExtensionAPI, hooks: ModeHooks, ): ModeCallbacks { let state: ReadonlyModeState = { enabled: false }; let previousTools: string[] | undefined; let cwd = process.cwd(); const toolAccessor: SelectedToolNamesAccessor = { get: () => state.availableTools, set: (names) => { state = { ...state, availableTools: names }; }, }; pi.registerFlag("ro", { description: "Start in Read-only mode", type: "boolean", default: false, }); pi.registerCommand("ro", { description: "Enter or exit Read-only mode (toggle)", getArgumentCompletions: completeReadonlyArguments, handler: async (args, ctx) => { const prompt = args.trim(); const command = prompt.toLowerCase(); // /ro exit / off — 显式退出 if (command === "exit" || command === "off") { if (state.enabled) { exitReadonlyMode(ctx); ctx.ui.notify( "Read-only mode disabled. Original tools restored.", "info", ); } else { ctx.ui.notify("Read-only mode is already off.", "info"); } return; } // /ro tools — 配置只读模式允许的工具 if (command === "tools") { if (!state.enabled) enterReadonlyMode(ctx); await openToolSelector(ctx); return; } // /ro reminder once|always — 配置只读提醒注入频率 if (command === "reminder" || command.startsWith("reminder ")) { const sub = prompt.slice("reminder".length).trim().toLowerCase(); if (sub === "once" || sub === "always") { state = { ...state, reminderMode: sub }; persistState(); persistToolConfig(); updateUi(ctx); ctx.ui.notify(`Read-only reminder mode set to '${sub}'.`, "info"); } else { ctx.ui.notify( `Read-only reminder mode is '${state.reminderMode ?? DEFAULT_REMINDER_MODE}'. Usage: /ro reminder once|always.`, "info", ); } return; } // /ro [其他参数] — 切换模式 + 发送消息 + 显示通知 // /ro (无参数) — 智能切换 + 显示通知 if (state.enabled) { exitReadonlyMode(ctx); ctx.ui.notify( "Read-only mode disabled. Original tools restored.", "info", ); } else { enterReadonlyMode(ctx); ctx.ui.notify( `Read-only mode enabled.\n${currentToolSummary()}\nI will read and search, but not modify files.`, "info", ); } if (prompt) { sendReadonlyUserMessage(prompt, ctx); } }, }); pi.on("session_start", (_event, ctx) => { try { cwd = ctx.sessionManager.getCwd(); } catch { // 保留 process.cwd() 默认值 } restoreState(ctx); mergePersistedToolConfig(); if (pi.getFlag("ro") === true) state.enabled = true; if (state.enabled) activateReadonlyModeTools(); else deactivateRequiredTool(pi, READONLY_SELECTOR_POLICY); updateUi(ctx); }); pi.on("session_shutdown", (_event, ctx) => { persistState(); clearUi(ctx); }); pi.on("tool_call", async (event) => { if (!state.enabled) return; if (isBlockedBuiltinToolName(pi, event.toolName)) { return { block: true, reason: `Read-only mode blocked '${event.toolName}'. ${blockedReasonSuffix({ modeName: "Read-only", exitHint: "/ro" })}`, }; } if (event.toolName !== "bash" || !isBuiltinToolName(pi, event.toolName)) return; const command = readCommand(event.input); if (!isSafeCommand(command)) { return { block: true, reason: `Read-only mode blocks this bash command as mutating or non-allowlisted.\nCommand: ${command} ${blockedReasonSuffix({ modeName: "Read-only", exitHint: "/ro" })}`, }; } }); pi.on("before_agent_start", (event) => { if (!state.enabled) return; applyReadonlyModeTools(); return { systemPrompt: `${event.systemPrompt}\n\n${buildReadonlyPrompt()}`, }; }); // 方案 B:每次 LLM 调用前,在上下文末尾追加一条固定的只读提醒。 // 追加在末尾 ⇒ 前缀逐字节稳定,缓存命中;内容完全静态 ⇒ 不会破坏前缀。 // 临时注入(不写回 session),因此不会累积膨胀。 pi.on("context", async (event) => { if (!state.enabled) return; // `once`: rely on the system-prompt prompt only; skip the per-call reminder. // `always` (default): inject the reminder on every LLM call. const reminderMode = state.reminderMode ?? DEFAULT_REMINDER_MODE; if (reminderMode === "once") return; const reminder = { role: "custom" as const, customType: READONLY_CONTEXT_MESSAGE_TYPE, content: READONLY_REMINDER_TEXT, display: false, timestamp: Date.now(), }; // 防御性去重:先剔除已有的同类提醒再追加,保证末尾只有一条、且即使 // 未来注入语义变化也不会让前缀漂移。 const messages = event.messages.filter((message) => { if (message?.role !== "custom") return true; const customType = (message as { customType?: unknown }).customType; return customType !== READONLY_CONTEXT_MESSAGE_TYPE; }); return { messages: [...messages, reminder] }; }); // ─── 内部动作 ──────────────────────────────────────────────── function enterReadonlyMode(ctx: ExtensionContext) { hooks.onEnter(ctx); if (!state.enabled) previousTools = stripRequiredTool( safeGetActiveTools(), READONLY_SELECTOR_POLICY, ); state = { ...state, enabled: true }; activateReadonlyModeTools(); persistState(); updateUi(ctx); } function exitReadonlyMode(ctx: ExtensionContext) { const wasEnabled = state.enabled; state = { ...state, enabled: false }; if (wasEnabled) restoreTools(pi, previousTools, READONLY_SELECTOR_POLICY); persistState(); updateUi(ctx); hooks.onExit(ctx); } function sendReadonlyUserMessage(message: string, ctx: ExtensionContext) { if (ctx.isIdle()) pi.sendUserMessage(message); else pi.sendUserMessage(message, { deliverAs: "followUp" }); } function activateReadonlyModeTools() { previousTools ??= stripRequiredTool( safeGetActiveTools(), READONLY_SELECTOR_POLICY, ); applyReadonlyModeTools(); } function applyReadonlyModeTools() { pi.setActiveTools( computeActiveToolNames(pi, toolAccessor, READONLY_SELECTOR_POLICY), ); } function safeGetActiveTools() { try { return pi.getActiveTools(); } catch { return ["read", "bash"]; } } async function openToolSelector(ctx: ExtensionContext) { await showToolSelector(pi, ctx, toolAccessor, READONLY_SELECTOR_POLICY, { onChange: () => { applyReadonlyModeTools(); persistState(); persistToolConfig(); updateUi(ctx); }, }); } function persistState() { const data: ReadonlyModeState = { ...state, availableTools: toolAccessor.get(), }; pi.appendEntry(STATE_ENTRY_TYPE, data); } function restoreState(ctx: ExtensionContext) { const entries = ctx.sessionManager.getEntries() as Array<{ type?: string; customType?: string; data?: unknown; }>; const entry = entries .filter( (candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE, ) .pop(); if (!entry?.data || typeof entry.data !== "object") return; const data = entry.data as Partial; state = { enabled: data.enabled === true, availableTools: Array.isArray(data.availableTools) ? (data.availableTools.filter( (n: unknown) => typeof n === "string", ) as string[]) : undefined, reminderMode: data.reminderMode === "once" || data.reminderMode === "always" ? data.reminderMode : undefined, }; } function mergePersistedToolConfig() { const persisted = loadModeToolConfig("readonly", cwd); if (!state.availableTools?.length && persisted.availableTools?.length) { state = { ...state, availableTools: persisted.availableTools }; } if (!state.reminderMode && persisted.reminderMode) { state = { ...state, reminderMode: persisted.reminderMode }; } } function persistToolConfig() { const names = toolAccessor.get(); const reminderMode = state.reminderMode; if (!names && !reminderMode) return; saveModeToolConfig("readonly", cwd, { ...(names ? { availableTools: names } : {}), ...(reminderMode ? { reminderMode } : {}), }); } function updateUi(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setStatus(STATUS_KEY, formatStatus()); } function formatStatus() { return state.enabled ? "readonly" : undefined; } function clearUi(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setStatus(STATUS_KEY, undefined); } function currentToolSummary() { const activeTools = computeActiveToolNames(pi, toolAccessor, READONLY_SELECTOR_POLICY); return formatToolSummary(filterBlockedToolsForDisplay(activeTools)); } return { enter: enterReadonlyMode, exit: exitReadonlyMode, clearUi }; }