feat(pi): add programmatic plan mode event (#898)

This commit is contained in:
Vyacheslav
2026-06-14 03:36:39 +03:00
committed by GitHub
parent 6c64b96dd7
commit d682c2cd9e
3 changed files with 71 additions and 2 deletions
+19
View File
@@ -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:
+15 -1
View File
@@ -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;
+37 -1
View File
@@ -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<A extends PlannotatorAction, P, R> {
respond: (response: PlannotatorResponse<R>) => 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<PlannotatorPlanModeResult>;
"plan-review": PlannotatorResponse<PlannotatorPlanReviewStartResult>;
"review-status": PlannotatorResponse<PlannotatorReviewStatusResult>;
"code-review": PlannotatorResponse<PlannotatorCodeReviewResult>;
@@ -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<PlannotatorPlanModePayload["mode"]>,
ctx: ExtensionContext,
) => Promise<PlannotatorPlanModeResult> | 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()) {