1267 lines
40 KiB
TypeScript
1267 lines
40 KiB
TypeScript
/**
|
|
* Plannotator Pi Extension — File-based plan mode with visual browser review.
|
|
*
|
|
* During planning the agent writes any markdown file anywhere inside cwd and
|
|
* calls plannotator_submit_plan with the path. The user reviews in the
|
|
* browser UI and can approve, deny with annotations, or request changes.
|
|
*
|
|
* Features:
|
|
* - /plannotator command or Ctrl+Alt+P to toggle
|
|
* - --plan flag to start in planning mode
|
|
* - Bash unrestricted during planning (prompt-guided)
|
|
* - Writes restricted to markdown files inside cwd during planning
|
|
* - plannotator_submit_plan tool with browser-based visual approval
|
|
* - [DONE:n] markers for execution progress tracking
|
|
* - /plannotator-review command for code review
|
|
* - /plannotator-annotate command for markdown annotation
|
|
*/
|
|
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { basename, resolve } from "node:path";
|
|
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
import { Type } from "@mariozechner/pi-ai";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionContext,
|
|
} from "@mariozechner/pi-coding-agent";
|
|
import { Key } from "@mariozechner/pi-tui";
|
|
import { buildPromptVariables, formatTodoList, loadPlannotatorConfig, renderTemplate, resolvePhaseProfile } from "./config.js";
|
|
import {
|
|
type ChecklistItem,
|
|
markCompletedSteps,
|
|
parseChecklist,
|
|
} from "./generated/checklist.js";
|
|
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
|
|
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
|
|
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
|
|
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
|
|
import { loadConfig, resolveUseJina } from "./generated/config.js";
|
|
import {
|
|
getReviewApprovedPrompt,
|
|
getReviewDeniedSuffix,
|
|
getPlanDeniedPrompt,
|
|
getPlanApprovedPrompt,
|
|
getPlanApprovedWithNotesPrompt,
|
|
getPlanAutoApprovedPrompt,
|
|
getPlanToolName,
|
|
buildPlanFileRule,
|
|
getAnnotateFileFeedbackPrompt,
|
|
getAnnotateMessageFeedbackPrompt,
|
|
} from "./generated/prompts.js";
|
|
import { parseAnnotateArgs } from "./generated/annotate-args.js";
|
|
import { resolveAtReference } from "./generated/at-reference.js";
|
|
import {
|
|
hasPlanBrowserHtml,
|
|
hasReviewBrowserHtml,
|
|
getStartupErrorMessage,
|
|
openArchiveBrowserAction,
|
|
startCodeReviewBrowserSession,
|
|
startLastMessageAnnotationSession,
|
|
startMarkdownAnnotationSession,
|
|
openPlanReviewBrowser,
|
|
registerPlannotatorEventListeners,
|
|
} from "./plannotator-events.js";
|
|
import {
|
|
getAssistantMessageText,
|
|
getLastAssistantMessageSnapshot,
|
|
hasSessionMovedPastEntry,
|
|
} from "./assistant-message.js";
|
|
import {
|
|
getPiSessionIdentity,
|
|
isCurrentPiSessionDifferentFrom,
|
|
notifyCurrentPiSession,
|
|
type PiSessionIdentity,
|
|
registerCurrentPiSession,
|
|
sendUserMessageToCurrentPiSession,
|
|
withCurrentPiSessionFallbackHeader,
|
|
} from "./current-pi-session.js";
|
|
import {
|
|
getToolsForPhase,
|
|
isPlanWritePathAllowed,
|
|
PLAN_SUBMIT_TOOL,
|
|
type Phase,
|
|
stripPlanningOnlyTools,
|
|
} from "./tool-scope.ts";
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
type SavedPhaseState = {
|
|
activeTools: string[];
|
|
model?: { provider: string; id: string };
|
|
thinkingLevel: ThinkingLevel;
|
|
};
|
|
|
|
type PersistedPlannotatorState = {
|
|
phase: Phase;
|
|
lastSubmittedPath?: string;
|
|
savedState?: SavedPhaseState;
|
|
};
|
|
|
|
function getPlanReviewAvailabilityWarning(options: { hasUI: boolean; hasPlanHtml: boolean }): string | null {
|
|
const { hasUI, hasPlanHtml } = options;
|
|
if (hasUI && hasPlanHtml) return null;
|
|
if (!hasUI && !hasPlanHtml) {
|
|
return "Plannotator: interactive plan review is unavailable in this session (no UI support and missing built assets). Plans will auto-approve on exit_plan_mode.";
|
|
}
|
|
if (!hasUI) {
|
|
return "Plannotator: interactive plan review is unavailable in this session (no UI support). Plans will auto-approve on exit_plan_mode.";
|
|
}
|
|
return "Plannotator: interactive plan review assets are missing. Rebuild the extension to restore the browser UI. Plans will auto-approve on exit_plan_mode.";
|
|
}
|
|
|
|
function safeNotify(
|
|
ctx: ExtensionContext,
|
|
message: string,
|
|
type: "info" | "warning" | "error" = "info",
|
|
origin?: PiSessionIdentity,
|
|
): void {
|
|
try {
|
|
ctx.ui.notify(message, type);
|
|
} catch (err) {
|
|
if (notifyCurrentPiSession(message, type, origin)) return;
|
|
console.error(`Plannotator notification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
function reportBackgroundError(ctx: ExtensionContext, message: string, err: unknown, origin?: PiSessionIdentity): void {
|
|
const detail = getStartupErrorMessage(err);
|
|
console.error(`${message}: ${detail}`);
|
|
safeNotify(ctx, `${message}: ${detail}`, "error", origin);
|
|
}
|
|
|
|
function excerptText(text: string, maxChars = 1000): string {
|
|
const trimmed = text.trim();
|
|
if (trimmed.length <= maxChars) return trimmed;
|
|
return `${trimmed.slice(0, maxChars).trimEnd()}...`;
|
|
}
|
|
|
|
function blockquote(text: string): string {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => `> ${line}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function anchorMessageFeedback(feedback: string, originalMessage: string): string {
|
|
return `This feedback applies to the earlier assistant response excerpted below:
|
|
|
|
${blockquote(excerptText(originalMessage))}
|
|
|
|
User feedback:
|
|
${feedback}`;
|
|
}
|
|
|
|
function shouldAnchorLastMessageFeedback(ctx: ExtensionContext, entryId: string, origin: PiSessionIdentity): boolean {
|
|
if (isCurrentPiSessionDifferentFrom(origin)) return true;
|
|
try {
|
|
return hasSessionMovedPastEntry(ctx, entryId);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function reportCurrentSessionSendFailure(errorMessage: string, err: unknown, origin: PiSessionIdentity): void {
|
|
const detail = getStartupErrorMessage(err);
|
|
console.error(`${errorMessage}: ${detail}`);
|
|
notifyCurrentPiSession(`${errorMessage}: ${detail}`, "error", origin);
|
|
}
|
|
|
|
function trySendUserMessageToDifferentCurrentSession(
|
|
content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
|
|
options: Parameters<ExtensionAPI["sendUserMessage"]>[1],
|
|
errorMessage: string,
|
|
origin: PiSessionIdentity,
|
|
): boolean {
|
|
const result = sendUserMessageToCurrentPiSession(
|
|
withCurrentPiSessionFallbackHeader(content),
|
|
options,
|
|
origin,
|
|
);
|
|
if (result.ok) return true;
|
|
if (result.reason === "send-failed") {
|
|
reportCurrentSessionSendFailure(errorMessage, result.error, origin);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sendUserMessageWithCurrentSessionFallback(
|
|
pi: ExtensionAPI,
|
|
content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
|
|
options: Parameters<ExtensionAPI["sendUserMessage"]>[1],
|
|
errorMessage: string,
|
|
origin: PiSessionIdentity,
|
|
): void {
|
|
if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return;
|
|
|
|
try {
|
|
pi.sendUserMessage(content, options);
|
|
return;
|
|
} catch (err) {
|
|
if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export default function plannotator(pi: ExtensionAPI): void {
|
|
const currentPiSession = registerCurrentPiSession(pi);
|
|
let phase: Phase = "idle";
|
|
void registerPlannotatorEventListeners(pi);
|
|
let lastSubmittedPath: string | null = null;
|
|
let checklistItems: ChecklistItem[] = [];
|
|
let savedState: SavedPhaseState | null = null;
|
|
let plannotatorConfig = {};
|
|
let justApprovedPlan = false;
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
currentPiSession.update(ctx);
|
|
});
|
|
|
|
pi.on("session_shutdown", () => {
|
|
currentPiSession.clear();
|
|
});
|
|
|
|
// ── Flags ────────────────────────────────────────────────────────────
|
|
|
|
pi.registerFlag("plan", {
|
|
description: "Start in plan mode (restricted exploration and planning)",
|
|
type: "boolean",
|
|
default: false,
|
|
});
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function getPhaseProfile(): ReturnType<typeof resolvePhaseProfile> | undefined {
|
|
if (phase === "planning" || phase === "executing") {
|
|
return resolvePhaseProfile(plannotatorConfig, phase);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function updateStatus(ctx: ExtensionContext): void {
|
|
const profile = getPhaseProfile();
|
|
if (phase === "executing" && checklistItems.length > 0) {
|
|
const completed = checklistItems.filter((t) => t.completed).length;
|
|
ctx.ui.setStatus(
|
|
"plannotator",
|
|
ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`),
|
|
);
|
|
} else if (phase === "planning" && profile?.statusLabel) {
|
|
ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", profile.statusLabel));
|
|
} else if (phase === "executing" && profile?.statusLabel) {
|
|
ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", profile.statusLabel));
|
|
} else {
|
|
ctx.ui.setStatus("plannotator", undefined);
|
|
}
|
|
}
|
|
|
|
function updateWidget(ctx: ExtensionContext): void {
|
|
if (phase === "executing" && checklistItems.length > 0) {
|
|
const lines = checklistItems.map((item) => {
|
|
if (item.completed) {
|
|
return (
|
|
ctx.ui.theme.fg("success", "☑ ") +
|
|
ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
|
);
|
|
}
|
|
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
|
|
});
|
|
ctx.ui.setWidget("plannotator-progress", lines);
|
|
} else {
|
|
ctx.ui.setWidget("plannotator-progress", undefined);
|
|
}
|
|
}
|
|
|
|
function captureSavedState(ctx: ExtensionContext): void {
|
|
savedState = {
|
|
activeTools: pi.getActiveTools(),
|
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
thinkingLevel: pi.getThinkingLevel(),
|
|
};
|
|
}
|
|
|
|
function persistState(): void {
|
|
|
|
|
|
pi.appendEntry("plannotator", { phase, lastSubmittedPath, savedState });
|
|
}
|
|
|
|
async function applyModelRef(
|
|
ref: { provider: string; id: string },
|
|
ctx: ExtensionContext,
|
|
reason: string,
|
|
): Promise<void> {
|
|
const model = ctx.modelRegistry.find(ref.provider, ref.id);
|
|
if (!model) {
|
|
ctx.ui.notify(`Plannotator: ${reason} model ${ref.provider}/${ref.id} not found.`, "warning");
|
|
return;
|
|
}
|
|
|
|
const success = await pi.setModel(model);
|
|
if (!success) {
|
|
ctx.ui.notify(`Plannotator: no API key for ${ref.provider}/${ref.id}.`, "warning");
|
|
}
|
|
}
|
|
|
|
async function restoreSavedState(ctx: ExtensionContext): Promise<void> {
|
|
if (!savedState) return;
|
|
|
|
pi.setActiveTools(savedState.activeTools);
|
|
if (savedState.model) {
|
|
await applyModelRef(savedState.model, ctx, "restore");
|
|
}
|
|
pi.setThinkingLevel(savedState.thinkingLevel);
|
|
}
|
|
|
|
async function applyPhaseConfig(ctx: ExtensionContext, opts: { restoreSavedState?: boolean } = {}): Promise<void> {
|
|
const profile = getPhaseProfile();
|
|
if (opts.restoreSavedState !== false && savedState) {
|
|
await restoreSavedState(ctx);
|
|
}
|
|
|
|
if (phase === "planning" || phase === "executing") {
|
|
const baseTools = stripPlanningOnlyTools(savedState?.activeTools ?? pi.getActiveTools());
|
|
const toolSet = new Set(baseTools);
|
|
for (const tool of profile?.activeTools ?? []) toolSet.add(tool);
|
|
if (phase === "planning") {
|
|
pi.setActiveTools(getToolsForPhase([...toolSet], phase));
|
|
} else {
|
|
pi.setActiveTools([...toolSet]);
|
|
}
|
|
}
|
|
|
|
if (profile?.model) {
|
|
await applyModelRef(profile.model, ctx, phase);
|
|
}
|
|
|
|
if (profile?.thinking) {
|
|
pi.setThinkingLevel(profile.thinking);
|
|
}
|
|
|
|
updateStatus(ctx);
|
|
updateWidget(ctx);
|
|
}
|
|
|
|
async function enterPlanning(ctx: ExtensionContext): Promise<void> {
|
|
phase = "planning";
|
|
checklistItems = [];
|
|
captureSavedState(ctx);
|
|
await applyPhaseConfig(ctx, { restoreSavedState: false });
|
|
persistState();
|
|
ctx.ui.notify(
|
|
"Plannotator: planning mode enabled.",
|
|
);
|
|
const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() });
|
|
if (warning) {
|
|
ctx.ui.notify(warning, "warning");
|
|
}
|
|
}
|
|
|
|
async function exitToIdle(ctx: ExtensionContext): Promise<void> {
|
|
phase = "idle";
|
|
checklistItems = [];
|
|
lastSubmittedPath = null;
|
|
|
|
await restoreSavedState(ctx);
|
|
savedState = null;
|
|
updateStatus(ctx);
|
|
updateWidget(ctx);
|
|
persistState();
|
|
ctx.ui.notify("Plannotator: disabled. Full access restored.");
|
|
}
|
|
|
|
async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
|
|
if (phase === "idle") {
|
|
await enterPlanning(ctx);
|
|
} else {
|
|
await exitToIdle(ctx);
|
|
}
|
|
}
|
|
|
|
// ── Commands & Shortcuts ─────────────────────────────────────────────
|
|
|
|
pi.registerCommand("plannotator", {
|
|
description: "Toggle plannotator planning mode",
|
|
handler: async (_args, ctx) => {
|
|
await togglePlanMode(ctx);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("plannotator-status", {
|
|
description: "Show plannotator status",
|
|
handler: async (_args, ctx) => {
|
|
const parts = [`Phase: ${phase}`];
|
|
if (lastSubmittedPath) {
|
|
parts.push(`Plan file: ${lastSubmittedPath}`);
|
|
}
|
|
if (checklistItems.length > 0) {
|
|
const done = checklistItems.filter((t) => t.completed).length;
|
|
parts.push(`Progress: ${done}/${checklistItems.length}`);
|
|
}
|
|
ctx.ui.notify(parts.join("\n"), "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("plannotator-review", {
|
|
description: "Open interactive code review for current changes or a PR URL",
|
|
handler: async (args, ctx) => {
|
|
if (!hasReviewBrowserHtml()) {
|
|
ctx.ui.notify(
|
|
"Code review UI not available. Run 'bun run build' in the pi-extension directory.",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
currentPiSession.update(ctx);
|
|
const origin = getPiSessionIdentity(ctx);
|
|
|
|
try {
|
|
const prUrl = args?.trim() || undefined;
|
|
const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://");
|
|
const session = await startCodeReviewBrowserSession(ctx, { prUrl });
|
|
ctx.ui.notify("Code review opened. You can keep chatting while it runs.", "info");
|
|
void session
|
|
.waitForDecision()
|
|
.then((result) => {
|
|
try {
|
|
if (result.exit) {
|
|
safeNotify(ctx, "Code review session closed.", "info", origin);
|
|
return;
|
|
}
|
|
if (result.approved) {
|
|
sendUserMessageWithCurrentSessionFallback(
|
|
pi,
|
|
getReviewApprovedPrompt("pi", loadConfig()),
|
|
{ deliverAs: "followUp" },
|
|
"Plannotator code review feedback could not be sent",
|
|
origin,
|
|
);
|
|
return;
|
|
}
|
|
if (!result.feedback) {
|
|
safeNotify(ctx, "Code review closed (no feedback).", "info", origin);
|
|
return;
|
|
}
|
|
if (isPRReview) {
|
|
// Platform PR actions (approve/comment) return approved:false with a
|
|
// status message — don't tell the agent to "address" a platform action.
|
|
sendUserMessageWithCurrentSessionFallback(
|
|
pi,
|
|
result.feedback,
|
|
{ deliverAs: "followUp" },
|
|
"Plannotator code review feedback could not be sent",
|
|
origin,
|
|
);
|
|
return;
|
|
}
|
|
sendUserMessageWithCurrentSessionFallback(
|
|
pi,
|
|
`${result.feedback}${getReviewDeniedSuffix("pi", loadConfig())}`,
|
|
{ deliverAs: "followUp" },
|
|
"Plannotator code review feedback could not be sent",
|
|
origin,
|
|
);
|
|
} catch (err) {
|
|
reportBackgroundError(ctx, "Plannotator code review feedback could not be sent", err, origin);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
reportBackgroundError(ctx, "Plannotator code review session failed", err, origin);
|
|
});
|
|
} catch (err) {
|
|
ctx.ui.notify(
|
|
`Failed to start code review UI: ${getStartupErrorMessage(err)}`,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("plannotator-annotate", {
|
|
description: "Open markdown file or folder in annotation UI",
|
|
handler: async (args, ctx) => {
|
|
// #570: split --gate / --json from the path. --json is silently
|
|
// accepted (Pi writes back via sendUserMessage, not stdout).
|
|
// `rawFilePath` keeps any leading `@` for the literal-@ fallback
|
|
// (scoped-package-style names).
|
|
const { filePath, rawFilePath, gate } = parseAnnotateArgs(args ?? "");
|
|
if (!filePath) {
|
|
ctx.ui.notify("Usage: /plannotator-annotate <file.md | file.html | https://... | folder/> [--gate] [--json]", "error");
|
|
return;
|
|
}
|
|
if (!hasPlanBrowserHtml()) {
|
|
ctx.ui.notify(
|
|
"Annotation UI not available. Run 'bun run build' in the pi-extension directory.",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let markdown: string;
|
|
let absolutePath: string;
|
|
let folderPath: string | undefined;
|
|
let mode: "annotate" | "annotate-folder" | undefined;
|
|
let sourceInfo: string | undefined;
|
|
let sourceConverted = false;
|
|
let isFolder = false;
|
|
|
|
// --- URL annotation ---
|
|
const isUrl = /^https?:\/\//i.test(filePath);
|
|
|
|
if (isUrl) {
|
|
const useJina = resolveUseJina(false, loadConfig());
|
|
ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
|
|
try {
|
|
const result = await urlToMarkdown(filePath, { useJina });
|
|
markdown = result.markdown;
|
|
sourceConverted = isConvertedSource(result.source);
|
|
} catch (err) {
|
|
ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
return;
|
|
}
|
|
absolutePath = filePath;
|
|
sourceInfo = filePath;
|
|
} else {
|
|
// Pick the interpretation of the user input that actually exists:
|
|
// stripped form first (reference-mode primary), literal as fallback
|
|
// for scoped-package-style names. Falls back to the stripped form
|
|
// for the error message if neither exists.
|
|
const resolvedCandidate = resolveAtReference(rawFilePath, (c) => {
|
|
const abs = resolveUserPath(c, ctx.cwd);
|
|
return existsSync(abs);
|
|
});
|
|
if (resolvedCandidate === null) {
|
|
absolutePath = resolveUserPath(filePath, ctx.cwd);
|
|
ctx.ui.notify(`File not found: ${absolutePath}`, "error");
|
|
return;
|
|
}
|
|
absolutePath = resolveUserPath(resolvedCandidate, ctx.cwd);
|
|
|
|
try {
|
|
isFolder = statSync(absolutePath).isDirectory();
|
|
} catch {
|
|
ctx.ui.notify(`Cannot access: ${absolutePath}`, "error");
|
|
return;
|
|
}
|
|
|
|
if (isFolder) {
|
|
if (!hasMarkdownFiles(absolutePath, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) {
|
|
ctx.ui.notify(`No markdown or HTML files found in ${absolutePath}`, "error");
|
|
return;
|
|
}
|
|
markdown = "";
|
|
folderPath = absolutePath;
|
|
mode = "annotate-folder";
|
|
ctx.ui.notify(`Opening annotation UI for folder ${filePath}...`, "info");
|
|
} else if (/\.html?$/i.test(absolutePath)) {
|
|
// HTML file annotation — convert to markdown via Turndown
|
|
const fileSize = statSync(absolutePath).size;
|
|
if (fileSize > 10 * 1024 * 1024) {
|
|
ctx.ui.notify(`File too large (${Math.round(fileSize / 1024 / 1024)}MB, max 10MB)`, "error");
|
|
return;
|
|
}
|
|
const html = readFileSync(absolutePath, "utf-8");
|
|
markdown = htmlToMarkdown(html);
|
|
sourceInfo = basename(absolutePath);
|
|
sourceConverted = true;
|
|
ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
|
|
} else {
|
|
markdown = readFileSync(absolutePath, "utf-8");
|
|
ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
|
|
}
|
|
}
|
|
|
|
currentPiSession.update(ctx);
|
|
const origin = getPiSessionIdentity(ctx);
|
|
|
|
try {
|
|
const session = await startMarkdownAnnotationSession(
|
|
ctx,
|
|
absolutePath,
|
|
markdown,
|
|
mode ?? "annotate",
|
|
folderPath,
|
|
sourceInfo,
|
|
sourceConverted,
|
|
gate,
|
|
);
|
|
ctx.ui.notify("Annotation opened. You can keep chatting while it runs.", "info");
|
|
void session
|
|
.waitForDecision()
|
|
.then((result) => {
|
|
try {
|
|
if (result.exit) {
|
|
safeNotify(ctx, "Annotation session closed.", "info", origin);
|
|
return;
|
|
}
|
|
if (result.approved) {
|
|
safeNotify(ctx, "Annotation approved.", "info", origin);
|
|
return;
|
|
}
|
|
if (!result.feedback) {
|
|
safeNotify(ctx, "Annotation closed (no feedback).", "info", origin);
|
|
return;
|
|
}
|
|
sendUserMessageWithCurrentSessionFallback(
|
|
pi,
|
|
getAnnotateFileFeedbackPrompt("pi", loadConfig(), {
|
|
fileHeader: isFolder ? "Folder" : "File",
|
|
filePath: absolutePath,
|
|
feedback: result.feedback,
|
|
}),
|
|
{ deliverAs: "followUp" },
|
|
"Plannotator annotation feedback could not be sent",
|
|
origin,
|
|
);
|
|
} catch (err) {
|
|
reportBackgroundError(ctx, "Plannotator annotation feedback could not be sent", err, origin);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
reportBackgroundError(ctx, "Plannotator annotation session failed", err, origin);
|
|
});
|
|
} catch (err) {
|
|
ctx.ui.notify(
|
|
`Failed to start annotation UI: ${getStartupErrorMessage(err)}`,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("plannotator-last", {
|
|
description: "Annotate the last assistant message",
|
|
handler: async (args, ctx) => {
|
|
// #570: support --gate on /plannotator-last for Stop-hook review gate.
|
|
const { gate } = parseAnnotateArgs(args ?? "");
|
|
|
|
if (!hasPlanBrowserHtml()) {
|
|
ctx.ui.notify(
|
|
"Annotation UI not available. Run 'bun run build' in the pi-extension directory.",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
currentPiSession.update(ctx);
|
|
const origin = getPiSessionIdentity(ctx);
|
|
|
|
const snapshot = getLastAssistantMessageSnapshot(ctx);
|
|
if (!snapshot) {
|
|
ctx.ui.notify("No assistant message found in session.", "error");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify("Opening annotation UI for last message...", "info");
|
|
|
|
try {
|
|
const session = await startLastMessageAnnotationSession(ctx, snapshot.text, gate);
|
|
ctx.ui.notify("Last-message annotation opened. You can keep chatting while it runs.", "info");
|
|
void session
|
|
.waitForDecision()
|
|
.then((result) => {
|
|
try {
|
|
if (result.exit) {
|
|
safeNotify(ctx, "Annotation session closed.", "info", origin);
|
|
return;
|
|
}
|
|
if (result.approved) {
|
|
safeNotify(ctx, "Message approved.", "info", origin);
|
|
return;
|
|
}
|
|
if (!result.feedback) {
|
|
safeNotify(ctx, "Annotation closed (no feedback).", "info", origin);
|
|
return;
|
|
}
|
|
const feedback = shouldAnchorLastMessageFeedback(ctx, snapshot.entryId, origin)
|
|
? anchorMessageFeedback(result.feedback, snapshot.text)
|
|
: result.feedback;
|
|
sendUserMessageWithCurrentSessionFallback(
|
|
pi,
|
|
getAnnotateMessageFeedbackPrompt("pi", loadConfig(), {
|
|
feedback,
|
|
}),
|
|
{ deliverAs: "followUp" },
|
|
"Plannotator message annotation feedback could not be sent",
|
|
origin,
|
|
);
|
|
} catch (err) {
|
|
reportBackgroundError(ctx, "Plannotator message annotation feedback could not be sent", err, origin);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
reportBackgroundError(ctx, "Plannotator message annotation session failed", err, origin);
|
|
});
|
|
} catch (err) {
|
|
ctx.ui.notify(
|
|
`Failed to start annotation UI: ${getStartupErrorMessage(err)}`,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("plannotator-archive", {
|
|
description: "Browse saved plan decisions",
|
|
handler: async (_args, ctx) => {
|
|
if (!hasPlanBrowserHtml()) {
|
|
ctx.ui.notify(
|
|
"Archive UI not available. Run 'bun run build' in the pi-extension directory.",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify("Opening plan archive...", "info");
|
|
|
|
try {
|
|
await openArchiveBrowserAction(ctx);
|
|
ctx.ui.notify("Archive browser closed.", "info");
|
|
} catch (err) {
|
|
ctx.ui.notify(
|
|
`Failed to start archive: ${getStartupErrorMessage(err)}`,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerShortcut(Key.ctrlAlt("p"), {
|
|
description: "Toggle plannotator",
|
|
handler: async (ctx) => {
|
|
await togglePlanMode(ctx);
|
|
},
|
|
});
|
|
|
|
// ── plannotator_submit_plan Tool ────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: PLAN_SUBMIT_TOOL,
|
|
label: "Submit Plan",
|
|
description:
|
|
"Submit your Plannotator plan for user review. " +
|
|
"Call this only while Plannotator planning mode is active, after writing your plan as a markdown file anywhere inside the working directory. " +
|
|
"Pass the path to the plan file (e.g. PLAN.md or plans/auth.md). " +
|
|
"The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " +
|
|
"If denied, edit the same file in place, then call this again with the same path.",
|
|
parameters: Type.Object({
|
|
filePath: Type.String({
|
|
description:
|
|
"Path to the markdown plan file, relative to the working directory. Must end in .md or .mdx and resolve inside cwd.",
|
|
}),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
// Guard: must be in planning phase
|
|
if (phase !== "planning") {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: Not in plan mode. Use /plannotator to enter planning mode first.",
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
const inputPath = (params as { filePath?: string })?.filePath?.trim();
|
|
if (!inputPath) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${PLAN_SUBMIT_TOOL} requires a filePath argument pointing to your markdown plan file (e.g. "PLAN.md" or "plans/auth.md").`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
if (!isPlanWritePathAllowed(inputPath, ctx.cwd)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: plan file must be a markdown file (.md or .mdx) inside the working directory. Rejected: ${inputPath}`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
const fullPath = resolve(ctx.cwd, inputPath);
|
|
|
|
try {
|
|
if (!statSync(fullPath).isFile()) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${inputPath} is not a regular file. Write your plan to a markdown file first, then call ${PLAN_SUBMIT_TOOL} with its path.`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
} catch {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${inputPath} does not exist. Write your plan using the write tool first, then call ${PLAN_SUBMIT_TOOL} again.`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
let planContent: string;
|
|
try {
|
|
planContent = readFileSync(fullPath, "utf-8");
|
|
} catch (err) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: failed to read ${inputPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
if (planContent.trim().length === 0) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${inputPath} is empty. Write your plan first, then call ${PLAN_SUBMIT_TOOL} again.`,
|
|
},
|
|
],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
lastSubmittedPath = inputPath;
|
|
checklistItems = parseChecklist(planContent);
|
|
|
|
// Non-interactive or no HTML: auto-approve
|
|
if (!ctx.hasUI || !hasPlanBrowserHtml()) {
|
|
phase = "executing";
|
|
await applyPhaseConfig(ctx, { restoreSavedState: true });
|
|
pi.appendEntry("plannotator-execute", { lastSubmittedPath });
|
|
persistState();
|
|
justApprovedPlan = true;
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: getPlanAutoApprovedPrompt("pi", loadConfig()),
|
|
},
|
|
],
|
|
details: { approved: true },
|
|
terminate: true,
|
|
};
|
|
}
|
|
|
|
let result: Awaited<ReturnType<typeof openPlanReviewBrowser>>;
|
|
try {
|
|
result = await openPlanReviewBrowser(ctx, planContent);
|
|
} catch (err) {
|
|
const message = `Failed to start plan review UI: ${getStartupErrorMessage(err)}`;
|
|
ctx.ui.notify(message, "error");
|
|
return {
|
|
content: [{ type: "text", text: message }],
|
|
details: { approved: false },
|
|
};
|
|
}
|
|
|
|
if (result.approved) {
|
|
phase = "executing";
|
|
await applyPhaseConfig(ctx, { restoreSavedState: true });
|
|
pi.appendEntry("plannotator-execute", { lastSubmittedPath });
|
|
persistState();
|
|
justApprovedPlan = true;
|
|
|
|
const doneMsg =
|
|
checklistItems.length > 0
|
|
? `After completing each step, include [DONE:n] in your response where n is the step number.`
|
|
: "";
|
|
|
|
if (result.feedback) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: getPlanApprovedWithNotesPrompt("pi", loadConfig(), {
|
|
planFilePath: inputPath,
|
|
doneMsg,
|
|
feedback: result.feedback,
|
|
}),
|
|
},
|
|
],
|
|
details: { approved: true, feedback: result.feedback },
|
|
terminate: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: getPlanApprovedPrompt("pi", loadConfig(), {
|
|
planFilePath: inputPath,
|
|
doneMsg,
|
|
}),
|
|
},
|
|
],
|
|
details: { approved: true },
|
|
terminate: true,
|
|
};
|
|
}
|
|
|
|
// Denied
|
|
persistState();
|
|
const feedbackText = result.feedback || "Plan rejected. Please revise.";
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: getPlanDeniedPrompt("pi", loadConfig(), {
|
|
toolName: getPlanToolName("pi"),
|
|
planFileRule: buildPlanFileRule(getPlanToolName("pi"), inputPath),
|
|
feedback: feedbackText,
|
|
}),
|
|
},
|
|
],
|
|
details: { approved: false, feedback: feedbackText },
|
|
};
|
|
},
|
|
});
|
|
|
|
// ── Event Handlers ───────────────────────────────────────────────────
|
|
|
|
// Gate writes during planning — only markdown files inside cwd.
|
|
pi.on("tool_call", async (event, ctx) => {
|
|
if (phase !== "planning") return;
|
|
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
|
|
const inputPath = event.input.path as string;
|
|
if (!isPlanWritePathAllowed(inputPath, ctx.cwd)) {
|
|
const verb = event.toolName === "write" ? "writes" : "edits";
|
|
return {
|
|
block: true,
|
|
reason: `Plannotator: during planning, ${verb} are limited to markdown files (.md, .mdx) inside the working directory. Blocked: ${inputPath}`,
|
|
};
|
|
}
|
|
});
|
|
|
|
// Inject phase-specific context
|
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
const profile = getPhaseProfile();
|
|
const planRef = lastSubmittedPath ?? "your plan file";
|
|
|
|
if (phase === "executing" && lastSubmittedPath) {
|
|
// Re-read from disk each turn to stay current
|
|
const fullPath = resolve(ctx.cwd, lastSubmittedPath);
|
|
try {
|
|
const planContent = readFileSync(fullPath, "utf-8");
|
|
checklistItems = parseChecklist(planContent);
|
|
} catch {
|
|
// File deleted during execution — degrade gracefully
|
|
}
|
|
}
|
|
|
|
const todoStats = phase === "executing" ? formatTodoList(checklistItems) : formatTodoList([]);
|
|
if (profile?.systemPrompt) {
|
|
const rendered = renderTemplate(
|
|
profile.systemPrompt,
|
|
buildPromptVariables({
|
|
planFilePath: planRef,
|
|
phase,
|
|
todoList: todoStats.todoList,
|
|
completedCount: todoStats.completedCount,
|
|
totalCount: todoStats.totalCount,
|
|
remainingCount: todoStats.remainingCount,
|
|
}),
|
|
);
|
|
if (rendered.unknownVariables.length > 0) {
|
|
ctx.ui.notify(
|
|
"Plannotator: unknown template variables in " + phase + " prompt: " + rendered.unknownVariables.join(", "),
|
|
"warning",
|
|
);
|
|
}
|
|
|
|
return { systemPrompt: rendered.text };
|
|
}
|
|
|
|
if (phase === "planning") {
|
|
return {
|
|
message: {
|
|
customType: "plannotator-context",
|
|
content: `[PLANNOTATOR - PLANNING PHASE]
|
|
You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. During planning you may only write or edit markdown files (.md, .mdx) inside the working directory.
|
|
|
|
Available tools: read, bash, grep, find, ls, write (markdown only), edit (markdown only), ${PLAN_SUBMIT_TOOL}
|
|
|
|
Do not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine.
|
|
|
|
## Iterative Planning Workflow
|
|
|
|
You are pair-planning with the user. Explore the code to build context, then write your findings into a markdown plan file as you go. The plan starts as a rough skeleton and gradually becomes the final plan.
|
|
|
|
### Picking a plan file
|
|
|
|
Choose a descriptive filename for your plan. Convention: \`PLAN.md\` at the repo root for a single focused plan, or \`plans/<short-name>.md\` for projects that keep multiple plans. Reuse the same filename across revisions of the same plan so version history links up.
|
|
|
|
### The Loop
|
|
|
|
Repeat this cycle until the plan is complete:
|
|
|
|
1. **Explore** — Use read, grep, find, ls, and bash to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.
|
|
2. **Update the plan file** — After each discovery, immediately capture what you learned in the plan. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates.
|
|
3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, ask. Then go back to step 1.
|
|
|
|
### First Turn
|
|
|
|
Start by quickly scanning key files to form an initial understanding of the task scope. Then write a skeleton plan (headers and rough notes) and ask the user your first round of questions. Don't explore exhaustively before engaging the user.
|
|
|
|
### Asking Good Questions
|
|
|
|
- Never ask what you could find out by reading the code.
|
|
- Batch related questions together.
|
|
- Focus on things only the user can answer: requirements, preferences, tradeoffs, edge-case priorities.
|
|
- Scale depth to the task — a vague feature request needs many rounds; a focused bug fix may need one or none.
|
|
|
|
### Plan File Structure
|
|
|
|
Your plan file should use markdown with clear sections:
|
|
- **Context** — Why this change is being made: the problem, what prompted it, the intended outcome.
|
|
- **Approach** — Your recommended approach only, not all alternatives considered.
|
|
- **Files to modify** — List the critical file paths that will be changed.
|
|
- **Reuse** — Reference existing functions and utilities you found, with their file paths.
|
|
- **Steps** — Implementation checklist:
|
|
- [ ] Step 1 description
|
|
- [ ] Step 2 description
|
|
- **Verification** — How to test the changes end-to-end (run the code, run tests, manual checks).
|
|
|
|
Keep the plan concise enough to scan quickly, but detailed enough to execute effectively.
|
|
|
|
### When to Submit
|
|
|
|
Your plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call ${PLAN_SUBMIT_TOOL} with the path to your plan file to submit for review.
|
|
|
|
### Revising After Feedback
|
|
|
|
When the user denies a plan with feedback:
|
|
1. Read the plan file to see the current plan.
|
|
2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file.
|
|
3. Call ${PLAN_SUBMIT_TOOL} again with the same filePath to resubmit.
|
|
|
|
### Ending Your Turn
|
|
|
|
Your turn should only end by either:
|
|
- Asking the user a question to gather more information.
|
|
- Calling ${PLAN_SUBMIT_TOOL} when the plan is ready for review.
|
|
|
|
Do not end your turn without doing one of these two things.`,
|
|
display: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (phase === "executing" && checklistItems.length > 0) {
|
|
const remaining = checklistItems.filter((t) => !t.completed);
|
|
if (remaining.length > 0) {
|
|
const todoList = remaining
|
|
.map((t) => `- [ ] ${t.step}. ${t.text}`)
|
|
.join("\n");
|
|
return {
|
|
message: {
|
|
customType: "plannotator-context",
|
|
content: `[PLANNOTATOR - EXECUTING PLAN]
|
|
Full tool access is enabled. Execute the plan from ${planRef}.
|
|
|
|
Remaining steps:
|
|
${todoList}
|
|
|
|
Execute each step in order. After completing a step, include [DONE:n] in your response where n is the step number.`,
|
|
display: false,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
// Filter stale context when idle
|
|
pi.on("context", async (event) => {
|
|
if (phase !== "idle") return;
|
|
|
|
return {
|
|
messages: event.messages.filter((m) => {
|
|
const msg = m as { customType?: string; role?: string; content?: unknown };
|
|
if (msg.customType === "plannotator-context") return false;
|
|
if (msg.role !== "user") return true;
|
|
|
|
const content = msg.content;
|
|
if (typeof content === "string") {
|
|
return !content.includes("[PLANNOTATOR -");
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return !content.some(
|
|
(c) =>
|
|
c.type === "text" &&
|
|
(c as { text?: string }).text?.includes("[PLANNOTATOR -"),
|
|
);
|
|
}
|
|
return true;
|
|
}),
|
|
};
|
|
});
|
|
|
|
// Track execution progress
|
|
pi.on("turn_end", async (event, ctx) => {
|
|
if (phase !== "executing" || checklistItems.length === 0) return;
|
|
|
|
const text = getAssistantMessageText(event.message);
|
|
if (!text) return;
|
|
if (markCompletedSteps(text, checklistItems) > 0) {
|
|
updateStatus(ctx);
|
|
updateWidget(ctx);
|
|
}
|
|
persistState();
|
|
});
|
|
|
|
// Detect execution completion
|
|
pi.on("agent_end", async (_event, ctx) => {
|
|
if (phase === "executing" && justApprovedPlan) {
|
|
justApprovedPlan = false;
|
|
setTimeout(() => {
|
|
pi.sendUserMessage("Continue with the approved plan.");
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
if (phase !== "executing" || checklistItems.length === 0) return;
|
|
|
|
if (checklistItems.every((t) => t.completed)) {
|
|
const completedList = checklistItems
|
|
.map((t) => `- [x] ~~${t.text}~~`)
|
|
.join("\n");
|
|
pi.sendMessage(
|
|
{
|
|
customType: "plannotator-complete",
|
|
content: `**Plan Complete!** ✓\n\n${completedList}`,
|
|
display: true,
|
|
},
|
|
{ triggerTurn: false },
|
|
);
|
|
phase = "idle";
|
|
checklistItems = [];
|
|
lastSubmittedPath = null;
|
|
|
|
await restoreSavedState(ctx);
|
|
savedState = null;
|
|
updateStatus(ctx);
|
|
updateWidget(ctx);
|
|
persistState();
|
|
}
|
|
});
|
|
|
|
// Restore state on session start/resume
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
const loadedConfig = loadPlannotatorConfig(ctx.cwd);
|
|
plannotatorConfig = loadedConfig.config;
|
|
for (const warning of loadedConfig.warnings) {
|
|
ctx.ui.notify(`Plannotator config: ${warning}`, "warning");
|
|
}
|
|
|
|
// Check --plan flag
|
|
if (pi.getFlag("plan") === true) {
|
|
phase = "planning";
|
|
}
|
|
|
|
// Restore persisted state
|
|
const entries = ctx.sessionManager.getEntries();
|
|
const stateEntry = entries
|
|
.filter(
|
|
(e: { type: string; customType?: string }) =>
|
|
e.type === "custom" && e.customType === "plannotator",
|
|
)
|
|
.pop() as { data?: PersistedPlannotatorState } | undefined;
|
|
|
|
if (stateEntry?.data) {
|
|
phase = stateEntry.data.phase ?? phase;
|
|
lastSubmittedPath = stateEntry.data.lastSubmittedPath ?? lastSubmittedPath;
|
|
savedState = stateEntry.data.savedState ?? savedState;
|
|
}
|
|
|
|
// Rebuild execution state from disk + session messages
|
|
if (phase === "executing") {
|
|
if (lastSubmittedPath) {
|
|
const fullPath = resolve(ctx.cwd, lastSubmittedPath);
|
|
if (existsSync(fullPath)) {
|
|
const content = readFileSync(fullPath, "utf-8");
|
|
checklistItems = parseChecklist(content);
|
|
|
|
// Find last execution marker and scan messages after it for [DONE:n]
|
|
let executeIndex = -1;
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i] as { type: string; customType?: string };
|
|
if (entry.customType === "plannotator-execute") {
|
|
executeIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if (entry.type === "message" && "message" in entry) {
|
|
const text = getAssistantMessageText(entry.message);
|
|
if (text) markCompletedSteps(text, checklistItems);
|
|
}
|
|
}
|
|
} else {
|
|
// Plan file gone — fall back to idle
|
|
phase = "idle";
|
|
lastSubmittedPath = null;
|
|
}
|
|
} else {
|
|
// No path recorded — can't rebuild, fall back to idle
|
|
phase = "idle";
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (phase === "planning") {
|
|
checklistItems = [];
|
|
const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() });
|
|
if (warning) {
|
|
ctx.ui.notify(warning, "warning");
|
|
}
|
|
}
|
|
|
|
if (phase === "idle") {
|
|
if (savedState) {
|
|
await restoreSavedState(ctx);
|
|
savedState = null;
|
|
} else {
|
|
// Strip planning-only tools on fresh sessions where savedState is null.
|
|
// Without this, plannotator_submit_plan stays in the active tool set
|
|
// even though plan mode hasn't been activated. See #387.
|
|
pi.setActiveTools(stripPlanningOnlyTools(pi.getActiveTools()));
|
|
}
|
|
} else if (phase === "planning" || phase === "executing") {
|
|
await applyPhaseConfig(ctx, { restoreSavedState: true });
|
|
}
|
|
|
|
updateStatus(ctx);
|
|
updateWidget(ctx);
|
|
persistState();
|
|
});
|
|
}
|