// ─── 入口常量与导入 ───────────────────────────────────────────── import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { extractProposedPlan, latestAssistantText, stripProposedPlanBlocks, stripProposedPlanBlocksFromMessage, unwrapSessionMessage, } from "./shared/messages.js"; import type { ModeCallbacks, ModeHooks } from "./shared/mode-types.js"; import { loadModeToolConfig, saveModeToolConfig, } from "./shared/tool-config.js"; import { blockedReasonSuffix, isBlockedBuiltinToolName, isBuiltinToolName, isSafeCommand, } from "./shared/tool-safety.js"; import { buildActiveToolNames, computeActiveToolNames, deactivateRequiredTool, filterBlockedToolsForDisplay, formatToolSummary, restoreTools, type SelectedToolNamesAccessor, selectableTools, showToolSelector, stripRequiredTool, type ToolSelectorPolicy, toolNameFromLegacyKey, } from "./shared/tool-selector.js"; import type { CommandArgumentCompletion, SessionEntry, } from "./shared/types.js"; import { isRecord, readCommand, scheduleAfterCurrentAgentRun, stringField, } from "./shared/utils.js"; const STATE_ENTRY_TYPE = "plan-mode-state"; const STATUS_KEY = "plan-mode"; const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context"; const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan"; const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question"; const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]"; const PLAN_SELECTOR_POLICY: ToolSelectorPolicy = { modeName: "Plan", requiredToolName: PLAN_MODE_QUESTION_TOOL_NAME, }; // ─── 兼容旧测试导出 ───────────────────────────────────────────── export function withRequiredPlanModeTools(toolNames: string[]) { return buildActiveToolNames(toolNames, PLAN_SELECTOR_POLICY); } export function withoutPlanModeQuestionTool(toolNames: string[]) { return stripRequiredTool(toolNames, PLAN_SELECTOR_POLICY); } export { canSelectToolInRestrictedMode as canSelectToolInPlanMode } from "./shared/tool-safety.js"; export { extractProposedPlan, isSafeCommand, latestAssistantText, stripProposedPlanBlocks, stripProposedPlanBlocksFromMessage, }; // ─── 类型定义 ──────────────────────────────────────────────────── interface PlanModeState { enabled: boolean; latestPlan?: string; awaitingAction: boolean; availableTools?: string[]; selectedToolKeys?: string[]; } type PlanModeQuestionOption = { label: string; description?: string; }; type PlanModeQuestion = { id: string; header: string; question: string; options: PlanModeQuestionOption[]; }; type PlanModeQuestionAnswer = { id: string; header: string; question: string; answer: string; wasCustom: boolean; optionIndex?: number; }; type PlanModeQuestionReason = | "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input"; type PlanModeQuestionDetails = { cancelled: boolean; reason?: PlanModeQuestionReason; questions: PlanModeQuestion[]; answers?: PlanModeQuestionAnswer[]; }; const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [ { value: "exit", label: "exit", description: "Exit Plan mode" }, { value: "off", label: "off", description: "Exit Plan mode" }, { value: "tools", label: "tools", description: "Select tools allowed in Plan mode", }, ]; const PLAN_MODE_QUESTION_PARAMS = { type: "object", additionalProperties: false, required: ["questions"], properties: { questions: { type: "array", minItems: 1, maxItems: 3, description: "Questions to show the user. Prefer 1, max 3.", items: { type: "object", additionalProperties: false, required: ["id", "header", "question", "options"], properties: { id: { type: "string", description: "Stable identifier for mapping answers (snake_case).", }, header: { type: "string", description: "Short header label shown in the UI (12 or fewer chars).", }, question: { type: "string", description: "Single-sentence prompt shown to the user.", }, options: { type: "array", minItems: 2, maxItems: 4, description: "Provide 2-4 mutually exclusive choices. Put the recommended option first.", items: { type: "object", additionalProperties: false, required: ["label", "description"], properties: { label: { type: "string", description: "User-facing label (1-5 words).", }, description: { type: "string", description: "One short sentence explaining impact/tradeoff if selected.", }, }, }, }, }, }, }, }, } as const; // ─── 旧测试期望的辅助函数(在此集中定义以兼容旧导入) ────────── export function completePlanArguments( argumentPrefix: string, ): CommandArgumentCompletion[] | null { const prefix = argumentPrefix.trimStart().toLowerCase(); if (prefix === "") return [...PLAN_COMMAND_COMPLETIONS]; if (/\s/.test(prefix)) return null; const matches = PLAN_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(prefix), ); return matches.length > 0 ? [...matches] : null; } export function normalizePlanModeQuestionParams(input: unknown) { if (!isRecord(input) || !Array.isArray(input.questions)) { return { ok: false as const, error: "questions must be an array" }; } if (input.questions.length < 1 || input.questions.length > 3) { return { ok: false as const, error: "questions must contain 1-3 items" }; } const questions: PlanModeQuestion[] = []; for (const [questionIndex, rawQuestion] of input.questions.entries()) { if (!isRecord(rawQuestion)) { return { ok: false as const, error: `question ${questionIndex + 1} must be an object`, }; } const id = stringField(rawQuestion.id); const header = stringField(rawQuestion.header); const question = stringField(rawQuestion.question); if (!id || !header || !question) { return { ok: false as const, error: `question ${questionIndex + 1} requires non-empty id, header, and question`, }; } if (!Array.isArray(rawQuestion.options)) { return { ok: false as const, error: `question ${questionIndex + 1} options must be an array`, }; } if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) { return { ok: false as const, error: `question ${questionIndex + 1} options must contain 2-4 items`, }; } const options: PlanModeQuestionOption[] = []; for (const [optionIndex, rawOption] of rawQuestion.options.entries()) { if (!isRecord(rawOption)) { return { ok: false as const, error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`, }; } const label = stringField(rawOption.label); if (!label) { return { ok: false as const, error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label`, }; } const description = stringField(rawOption.description); if (!description) { return { ok: false as const, error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`, }; } options.push({ label, description }); } questions.push({ id, header, question, options }); } return { ok: true as const, questions }; } // ─── 提示词(中文) ───────────────────────────────────────────── function buildPlanModePrompt() { return `${PLAN_CONTEXT_MARKER} # Plan Mode (Conversational) You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved. ## Mode rules - Stay in Plan Mode until a developer or extension explicitly exits it. - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan. - Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking. - Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk. - Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations. ## Phase 1 — Ground in the environment - Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts. - Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available. - Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent. ## Phase 2 — Intent chat - Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs. - Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet. ## Phase 3 — Implementation chat - Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints. - Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options. - If plan_mode_question returns cancelled or ui_unavailable, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption. ## Finalization rule Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown: # Title ## Summary ... ## Key Changes ... ## Test Plan ... ## Assumptions ... Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.`; } // ─── 工具栏内的提问工具:回复载荷 ─────────────────────────────── function planModeQuestionAnswered( questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[], ) { return { content: [ { type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }), }, ], details: { cancelled: false, questions, answers, } satisfies PlanModeQuestionDetails, }; } function planModeQuestionCancelled( questions: PlanModeQuestion[], reason: PlanModeQuestionReason, message: string, ) { return { content: [ { type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message, }), }, ], details: { cancelled: true, reason, questions, } satisfies PlanModeQuestionDetails, }; } function formatPlanModeQuestionPayload(payload: { cancelled: boolean; reason?: PlanModeQuestionReason; message?: string; answers?: PlanModeQuestionAnswer[]; }) { return JSON.stringify(payload, null, 2); } async function askPlanModeQuestions( questions: PlanModeQuestion[], ctx: ExtensionContext, ): Promise { const answers: PlanModeQuestionAnswer[] = []; const total = questions.length; for (const [index, question] of questions.entries()) { const choices = question.options.map(formatPlanModeQuestionChoice); const otherChoice = `${question.options.length + 1}. 其它(自由输入)`; const header = total > 1 ? `[${index + 1}/${total}] ${question.header}` : question.header; const choice = await ctx.ui.select(`${header}: ${question.question}`, [ ...choices, otherChoice, ]); if (!choice) return undefined; if (choice === otherChoice) { const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim(); if (!customAnswer) return undefined; answers.push({ id: question.id, header: question.header, question: question.question, answer: customAnswer, wasCustom: true, }); continue; } const optionIndex = choices.indexOf(choice); const option = question.options[optionIndex]; if (!option) return undefined; answers.push({ id: question.id, header: question.header, question: question.question, answer: option.label, wasCustom: false, optionIndex: optionIndex + 1, }); } return answers; } function formatPlanModeQuestionChoice( option: PlanModeQuestionOption, index: number, ) { return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`; } // ─── 历史消息中的 plan-mode 痕迹识别 ─────────────────────────── function messageContainsLegacyPlanModeContextArtifact(message: unknown) { const candidate = unwrapSessionMessage(message); return candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE; } function messageContainsInactivePlanModeArtifact(message: unknown) { const candidate = unwrapSessionMessage(message); return candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE; } // ═══════════════════════════════════════════════════════════════════ // 扩展入口 // ═══════════════════════════════════════════════════════════════════ export default function setupPlanMode( pi: ExtensionAPI, hooks: ModeHooks, ): ModeCallbacks { let state: PlanModeState = { enabled: false, awaitingAction: false }; let previousTools: string[] | undefined; let cwd = process.cwd(); const toolAccessor: SelectedToolNamesAccessor = { get: () => { if (state.availableTools !== undefined) return state.availableTools; if (state.selectedToolKeys !== undefined) { const tools = selectableTools(pi, PLAN_SELECTOR_POLICY); const migrated = state.selectedToolKeys .map((key) => toolNameFromLegacyKey(key, tools)) .filter((name): name is string => name !== undefined); state = { ...state, availableTools: migrated, selectedToolKeys: undefined, }; return migrated; } return undefined; }, set: (names) => { state = { ...state, availableTools: names, selectedToolKeys: undefined, }; }, }; pi.registerFlag("plan", { description: "Start in Codex-like Plan mode", type: "boolean", default: false, }); pi.registerTool({ name: PLAN_MODE_QUESTION_TOOL_NAME, label: "Plan question", description: "Ask the user one to three Plan-mode clarification questions with meaningful options, then wait for the answer. Only available while Plan mode is active.", promptSnippet: "Ask user decision questions while Plan mode is active", promptGuidelines: [ "In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.", ], parameters: PLAN_MODE_QUESTION_PARAMS, async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) { if (!state.enabled) { return planModeQuestionCancelled( [], "plan_mode_inactive", "Error: plan_mode_question is only available while Plan mode is active.", ); } const parsed = normalizePlanModeQuestionParams(params); if (!parsed.ok) { return planModeQuestionCancelled( [], "invalid_input", `Error: ${parsed.error}`, ); } if (!ctx.hasUI) { return planModeQuestionCancelled( parsed.questions, "ui_unavailable", "Unable to ask Plan-mode questions because interactive UI is not available.", ); } const answers = await askPlanModeQuestions(parsed.questions, ctx); if (!answers) { return planModeQuestionCancelled( parsed.questions, "cancelled", "User cancelled the Plan-mode question prompt.", ); } return planModeQuestionAnswered(parsed.questions, answers); }, }); pi.registerCommand("plan", { description: "Enter or manage Codex-like Plan mode", getArgumentCompletions: completePlanArguments, handler: async (args, ctx) => { const prompt = args.trim(); const command = prompt.toLowerCase(); if (command === "exit" || command === "off") { exitPlanMode(ctx); ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info"); return; } if (command === "tools") { if (!state.enabled) enterPlanMode(ctx); await openToolSelector(ctx); return; } if (prompt) { enterPlanModeWithPrompt(prompt, ctx); return; } if (!state.enabled) { enterPlanMode(ctx); ctx.ui.notify( `Plan mode enabled.\n${currentToolSummary()}\nI will explore and plan, but not modify files.`, "info", ); return; } await showPlanMenu(ctx); }, }); pi.on("session_start", (_event, ctx) => { try { cwd = ctx.sessionManager.getCwd(); } catch { // 忽略:保留默认值 } restoreState(ctx); mergePersistedToolConfig(); if (pi.getFlag("plan") === true) state.enabled = true; if (state.enabled) activatePlanModeTools(); else deactivateRequiredTool(pi, PLAN_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: `Plan mode blocked '${event.toolName}'. ${blockedReasonSuffix({ modeName: "Plan", exitHint: "/plan, then choose implementation" })}`, }; } if (event.toolName !== "bash" || !isBuiltinToolName(pi, event.toolName)) return; const command = readCommand(event.input); if (!isSafeCommand(command)) { return { block: true, reason: `Plan mode blocks this bash command as mutating or non-allowlisted.\nCommand: ${command} ${blockedReasonSuffix({ modeName: "Plan", exitHint: "/plan, then choose implementation" })}`, }; } }); pi.on("context", async (event) => { const messagesWithoutLegacyPlanContext = event.messages.filter( (message: unknown) => !messageContainsLegacyPlanModeContextArtifact(message), ); if (state.enabled) return { messages: messagesWithoutLegacyPlanContext }; return { messages: messagesWithoutLegacyPlanContext .filter( (message: unknown) => !messageContainsInactivePlanModeArtifact(message), ) .map(stripProposedPlanBlocksFromMessage), }; }); pi.on("before_agent_start", (event, ctx) => { if (!state.enabled) return; if (state.latestPlan || state.awaitingAction) { state = { ...state, latestPlan: undefined, awaitingAction: false }; persistState(); updateUi(ctx); } applyPlanModeTools(); return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}`, }; }); pi.on("agent_end", async (event, ctx) => { if (!state.enabled) return; const text = latestAssistantText(event.messages); const proposedPlan = extractProposedPlan(text); if (!proposedPlan) { persistState(); updateUi(ctx); return; } state = { ...state, latestPlan: proposedPlan, awaitingAction: true }; persistState(); updateUi(ctx); scheduleAfterCurrentAgentRun(async () => { if (!state.enabled || state.latestPlan !== proposedPlan) return; if (ctx.hasUI) await showPlanReadyMenu(ctx); if (!state.enabled || state.latestPlan !== proposedPlan) return; pi.sendMessage( { customType: PROPOSED_PLAN_MESSAGE_TYPE, content: `**Proposed Plan**\n\n${proposedPlan}`, display: true, }, { triggerTurn: false }, ); }, "Plan mode follow-up failed"); }); // ─── 内部动作函数 ────────────────────────────────────────────── function enterPlanMode(ctx: ExtensionContext) { hooks.onEnter(ctx); if (!state.enabled) previousTools = stripRequiredTool( safeGetActiveTools(), PLAN_SELECTOR_POLICY, ); state = { ...state, enabled: true, awaitingAction: false }; activatePlanModeTools(); persistState(); updateUi(ctx); } function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) { enterPlanMode(ctx); sendPlanModeUserMessage(prompt, ctx); } function exitPlanMode(ctx: ExtensionContext) { const wasEnabled = state.enabled; state = { ...state, enabled: false, latestPlan: undefined, awaitingAction: false, }; if (wasEnabled) restoreTools(pi, previousTools, PLAN_SELECTOR_POLICY); persistState(); updateUi(ctx); hooks.onExit(ctx); } function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) { if (ctx.isIdle()) pi.sendUserMessage(message); else pi.sendUserMessage(message, { deliverAs: "followUp" }); } function startImplementation(ctx: ExtensionContext) { const plan = state.latestPlan?.trim(); exitPlanMode(ctx); if (!plan) { ctx.ui.notify( "Plan mode disabled. No proposed plan is available to implement.", "warning", ); return; } sendPlanModeUserMessage( `Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:\n\n${plan}`, ctx, ); } async function showPlanMenu(ctx: ExtensionContext) { if (!ctx.hasUI) { ctx.ui.notify(planStatusText(), "info"); return; } const choices = state.latestPlan ? [ "Show latest proposed plan", "Implement this plan", "Configure Plan-mode tools", "Stay in Plan mode", "Exit Plan mode", ] : [ "Produce plan", "Configure Plan-mode tools", "Stay in Plan mode", "Exit Plan mode", ]; const choice = await ctx.ui.select(planStatusText(), choices); if (choice === "Produce plan") { sendPlanModeUserMessage( "Please produce a block based on the above discussion. Put the complete implementation plan inside this block.", ctx, ); return; } if (choice === "Show latest proposed plan") { ctx.ui.notify(state.latestPlan ?? "No proposed plan yet.", "info"); return; } if (choice === "Implement this plan") { startImplementation(ctx); return; } if (choice === "Configure Plan-mode tools") { await openToolSelector(ctx); return; } if (choice === "Exit Plan mode") { exitPlanMode(ctx); ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info"); return; } updateUi(ctx); } async function showPlanReadyMenu(ctx: ExtensionContext) { const choice = await ctx.ui.select("Proposed plan ready. What next?", [ "Implement this plan", "Stay in Plan mode", "Exit Plan mode", ]); if (choice === "Implement this plan") { startImplementation(ctx); return; } if (choice === "Exit Plan mode") { exitPlanMode(ctx); ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info"); } } async function openToolSelector(ctx: ExtensionContext) { await showToolSelector(pi, ctx, toolAccessor, PLAN_SELECTOR_POLICY, { onChange: () => { applyPlanModeTools(); persistState(); persistToolConfig(); updateUi(ctx); }, }); } function activatePlanModeTools() { previousTools ??= stripRequiredTool( safeGetActiveTools(), PLAN_SELECTOR_POLICY, ); applyPlanModeTools(); } function applyPlanModeTools() { pi.setActiveTools( computeActiveToolNames(pi, toolAccessor, PLAN_SELECTOR_POLICY), ); } function safeGetActiveTools() { try { return pi.getActiveTools(); } catch { return ["read", "bash"]; } } function persistState() { const data: PlanModeState = { ...state, availableTools: toolAccessor.get(), }; pi.appendEntry(STATE_ENTRY_TYPE, data); } function restoreState(ctx: ExtensionContext) { const entries = ctx.sessionManager.getEntries() as SessionEntry[]; const entry = entries .filter( (candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE, ) .pop(); if (!entry?.data || !isRecord(entry.data)) return; const data = entry.data as Partial; const enabled = data.enabled === true; state = { enabled, latestPlan: enabled ? (data.latestPlan as string | undefined) : undefined, awaitingAction: enabled ? data.awaitingAction === true : false, availableTools: Array.isArray(data.availableTools) ? (data.availableTools.filter( (n: unknown) => typeof n === "string", ) as string[]) : undefined, selectedToolKeys: Array.isArray(data.selectedToolKeys) ? (data.selectedToolKeys.filter( (n) => typeof n === "string", ) as string[]) : undefined, }; } /** 将本地状态中没有持久化配置时,从磁盘配置中加载并合并。 */ function mergePersistedToolConfig() { if (state.availableTools && state.availableTools.length > 0) return; const persisted = loadModeToolConfig("plan", cwd); if (persisted.availableTools && persisted.availableTools.length > 0) { state = { ...state, availableTools: persisted.availableTools }; } } function updateUi(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setStatus(STATUS_KEY, formatStatus()); } function formatStatus() { if (!state.enabled) return undefined; if (state.awaitingAction || state.latestPlan) return "plan ready"; return "plan"; } function clearUi(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setStatus(STATUS_KEY, undefined); } function planStatusText() { if (!state.enabled) return "Plan mode is off."; if (state.latestPlan) return `Plan mode is active and a proposed plan is ready. ${currentToolSummary()}`; return `Plan mode is active. ${currentToolSummary()} Explore, ask, and produce a block.`; } function currentToolSummary() { const activeTools = computeActiveToolNames(pi, toolAccessor, PLAN_SELECTOR_POLICY); return formatToolSummary(filterBlockedToolsForDisplay(activeTools)); } // 每次切换工具集合后落盘 function persistToolConfig() { const names = toolAccessor.get(); if (!names) return; saveModeToolConfig("plan", cwd, { availableTools: names }); } return { enter: enterPlanMode, exit: exitPlanMode, clearUi }; }