diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index cdc37486..26a55c9c 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -63,6 +63,25 @@ When the agent calls `plannotator_submit_plan`, the Plannotator UI opens in your The agent iterates on the plan until you approve, then executes with full tool access. On resubmission, Plan Diff highlights what changed since the previous version. +### Programmatic plan-mode control + +Other Pi extensions can enter, exit, toggle, or query Plannotator plan mode through the shared Pi event bus without invoking the `/plannotator` slash command: + +```ts +import { PLANNOTATOR_REQUEST_CHANNEL } from "@plannotator/pi-extension/plannotator-events"; + +const response = await new Promise((resolve) => { + pi.events.emit(PLANNOTATOR_REQUEST_CHANNEL, { + requestId: crypto.randomUUID(), + action: "plan-mode", + payload: { mode: "enter" }, // "enter" | "exit" | "toggle" | "status" + respond: resolve, + }); +}); +``` + +A handled response returns the resulting phase, for example `{ status: "handled", result: { phase: "planning" } }`. + ### Configuring per-phase behavior Plannotator loads configuration in three layers: diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 031c81be..2867b405 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -211,7 +211,21 @@ function sendUserMessageWithCurrentSessionFallback( export default function plannotator(pi: ExtensionAPI): void { const currentPiSession = registerCurrentPiSession(pi); let phase: Phase = "idle"; - void registerPlannotatorEventListeners(pi); + void registerPlannotatorEventListeners(pi, { + handlePlanMode: async (mode, ctx) => { + if (mode === "status") return { phase }; + if (mode === "enter") { + if (phase === "idle") await enterPlanning(ctx); + return { phase }; + } + if (mode === "exit") { + if (phase !== "idle") await exitToIdle(ctx); + return { phase }; + } + await togglePlanMode(ctx); + return { phase }; + }, + }); let lastSubmittedPath: string | null = null; let checklistItems: ChecklistItem[] = []; let savedState: SavedPhaseState | null = null; diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 0b8edf22..aa879b65 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -22,6 +22,7 @@ export const PLANNOTATOR_REVIEW_RESULT_CHANNEL = "plannotator:review-result" as export const PLANNOTATOR_TIMEOUT_MS = 5_000; export type PlannotatorAction = + | "plan-mode" | "plan-review" | "review-status" | "code-review" @@ -56,6 +57,14 @@ export interface PlannotatorRequestBase { respond: (response: PlannotatorResponse) => void; } +export interface PlannotatorPlanModePayload { + mode?: "enter" | "exit" | "toggle" | "status"; +} + +export interface PlannotatorPlanModeResult { + phase: "idle" | "planning" | "executing"; +} + export interface PlannotatorPlanReviewPayload { planFilePath?: string; planContent: string; @@ -127,6 +136,7 @@ export interface PlannotatorArchiveResult { } export type PlannotatorRequestMap = { + "plan-mode": PlannotatorRequestBase<"plan-mode", PlannotatorPlanModePayload, PlannotatorPlanModeResult>; "plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>; "review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>; "code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewResult>; @@ -136,6 +146,7 @@ export type PlannotatorRequestMap = { }; export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction]; export type PlannotatorResponseMap = { + "plan-mode": PlannotatorResponse; "plan-review": PlannotatorResponse; "review-status": PlannotatorResponse; "code-review": PlannotatorResponse; @@ -145,6 +156,7 @@ export type PlannotatorResponseMap = { }; function isPlannotatorAction(value: unknown): value is PlannotatorAction { return ( + value === "plan-mode" || value === "plan-review" || value === "review-status" || value === "code-review" || @@ -200,7 +212,17 @@ function createActiveSessionContext() { }; } -export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { +export interface PlannotatorEventListenerOptions { + handlePlanMode?: ( + mode: NonNullable, + ctx: ExtensionContext, + ) => Promise | PlannotatorPlanModeResult; +} + +export function registerPlannotatorEventListeners( + pi: ExtensionAPI, + options: PlannotatorEventListenerOptions = {}, +): void { const activeSessionContext = createActiveSessionContext(); // Plannotator event requests are handled against the latest active session. @@ -233,6 +255,20 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { } switch (request.action) { + case "plan-mode": { + if (!options.handlePlanMode) { + request.respond({ status: "unavailable", error: "Plan mode control is not available in this session." }); + return; + } + const mode = request.payload?.mode ?? "toggle"; + if (mode !== "enter" && mode !== "exit" && mode !== "toggle" && mode !== "status") { + request.respond({ status: "error", error: "Invalid plan-mode payload.mode." }); + return; + } + const result = await options.handlePlanMode(mode, ctx); + request.respond({ status: "handled", result }); + return; + } case "plan-review": { const planContent = request.payload?.planContent; if (typeof planContent !== "string" || !planContent.trim()) {