import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { DiffType } from "./server.js"; import { getLastAssistantMessageText, getStartupErrorMessage, openArchiveBrowserAction, openCodeReview, openLastMessageAnnotation, openMarkdownAnnotation, startCodeReviewBrowserSession, startLastMessageAnnotationSession, startMarkdownAnnotationSession, startPlanReviewBrowserSession, } from "./plannotator-browser.js"; export const PLANNOTATOR_REQUEST_CHANNEL = "plannotator:request" as const; export const PLANNOTATOR_REVIEW_RESULT_CHANNEL = "plannotator:review-result" as const; export const PLANNOTATOR_TIMEOUT_MS = 5_000; export type PlannotatorAction = | "plan-review" | "review-status" | "code-review" | "annotate" | "annotate-last" | "archive"; export interface PlannotatorHandledResponse { status: "handled"; result: T; } export interface PlannotatorUnavailableResponse { status: "unavailable"; error?: string; } export interface PlannotatorErrorResponse { status: "error"; error: string; } export type PlannotatorResponse = | PlannotatorHandledResponse | PlannotatorUnavailableResponse | PlannotatorErrorResponse; export interface PlannotatorRequestBase { requestId: string; action: A; payload: P; respond: (response: PlannotatorResponse) => void; } export interface PlannotatorPlanReviewPayload { planFilePath?: string; planContent: string; origin?: string; } export interface PlannotatorPlanReviewStartResult { status: "pending"; reviewId: string; } export interface PlannotatorReviewResultEvent { reviewId: string; approved: boolean; feedback?: string; savedPath?: string; agentSwitch?: string; permissionMode?: string; } export interface PlannotatorReviewStatusPayload { reviewId: string; } export type PlannotatorReviewStatusResult = | { status: "pending" } | ({ status: "completed" } & PlannotatorReviewResultEvent) | { status: "missing" }; export interface PlannotatorCodeReviewPayload { diffType?: DiffType; defaultBranch?: string; cwd?: string; prUrl?: string; } export interface PlannotatorCodeReviewResult { approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string; } export interface PlannotatorAnnotatePayload { filePath: string; markdown?: string; mode?: "annotate" | "annotate-folder" | "annotate-last"; folderPath?: string; /** Enable review-gate UX (Approve / Annotate / Close), #570 */ gate?: boolean; } export interface PlannotatorAnnotationResult { feedback: string; /** True when the reviewer closed the session without providing feedback. */ exit?: boolean; /** True when the reviewer clicked Approve in review-gate mode, #570 */ approved?: boolean; } export interface PlannotatorArchivePayload { customPlanPath?: string; } export interface PlannotatorArchiveResult { opened: boolean; } export type PlannotatorRequestMap = { "plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>; "review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>; "code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewResult>; annotate: PlannotatorRequestBase<"annotate", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>; "annotate-last": PlannotatorRequestBase<"annotate-last", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>; archive: PlannotatorRequestBase<"archive", PlannotatorArchivePayload, PlannotatorArchiveResult>; }; export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction]; export type PlannotatorResponseMap = { "plan-review": PlannotatorResponse; "review-status": PlannotatorResponse; "code-review": PlannotatorResponse; annotate: PlannotatorResponse; "annotate-last": PlannotatorResponse; archive: PlannotatorResponse; }; function isPlannotatorAction(value: unknown): value is PlannotatorAction { return ( value === "plan-review" || value === "review-status" || value === "code-review" || value === "annotate" || value === "annotate-last" || value === "archive" ); } const REVIEW_STATUS_PATH = join(homedir(), ".pi", "plannotator-review-status.json"); type StoredReviewStatus = Record; function readStoredReviewStatuses(): StoredReviewStatus { try { if (!existsSync(REVIEW_STATUS_PATH)) return {}; const raw = readFileSync(REVIEW_STATUS_PATH, "utf-8"); const parsed = JSON.parse(raw) as StoredReviewStatus; return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function writeStoredReviewStatuses(statuses: StoredReviewStatus): void { mkdirSync(dirname(REVIEW_STATUS_PATH), { recursive: true }); writeFileSync(REVIEW_STATUS_PATH, JSON.stringify(statuses, null, 2)); } function setStoredReviewStatus(reviewId: string, status: PlannotatorReviewStatusResult): void { const statuses = readStoredReviewStatuses(); statuses[reviewId] = status; writeStoredReviewStatuses(statuses); } function getStoredReviewStatus(reviewId: string): PlannotatorReviewStatusResult { return readStoredReviewStatuses()[reviewId] ?? { status: "missing" }; } function createActiveSessionContext() { let currentCtx: ExtensionContext | undefined; return { set(ctx: ExtensionContext): void { currentCtx = ctx; }, clear(): void { currentCtx = undefined; }, get(): ExtensionContext | undefined { return currentCtx; }, }; } export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { const activeSessionContext = createActiveSessionContext(); // Plannotator event requests are handled against the latest active session. // The active context is intentionally session-scoped and replaced on each session_start. pi.on("session_start", async (_event, ctx) => { activeSessionContext.set(ctx); }); pi.events.on(PLANNOTATOR_REQUEST_CHANNEL, async (data) => { const request = data as Partial | null; const ctx = activeSessionContext.get(); if (!request || typeof request.respond !== "function" || !isPlannotatorAction(request.action)) { return; } try { if (request.action === "review-status") { const reviewId = request.payload?.reviewId; if (typeof reviewId !== "string" || !reviewId.trim()) { request.respond({ status: "error", error: "Missing reviewId for review-status request." }); return; } request.respond({ status: "handled", result: getStoredReviewStatus(reviewId) }); return; } if (!ctx) { request.respond({ status: "unavailable", error: "Plannotator context is not ready yet." }); return; } switch (request.action) { case "plan-review": { const planContent = request.payload?.planContent; if (typeof planContent !== "string" || !planContent.trim()) { request.respond({ status: "error", error: "Missing planContent for plan-review request." }); return; } const session = await startPlanReviewBrowserSession(ctx, planContent); setStoredReviewStatus(session.reviewId, { status: "pending" }); session.onDecision((result) => { const reviewResult = { reviewId: session.reviewId, approved: result.approved, feedback: result.feedback, savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); }); request.respond({ status: "handled", result: { status: "pending", reviewId: session.reviewId, }, }); return; } case "code-review": { const result = await openCodeReview(ctx, { cwd: request.payload?.cwd, defaultBranch: request.payload?.defaultBranch, diffType: request.payload?.diffType, prUrl: request.payload?.prUrl, }); request.respond({ status: "handled", result }); return; } case "annotate": { const payload = request.payload; if (!payload?.filePath) { request.respond({ status: "error", error: "Missing filePath for annotate request." }); return; } const sourceConverted = /\.html?$/i.test(payload.filePath) || /^https?:\/\//i.test(payload.filePath); const result = await openMarkdownAnnotation( ctx, payload.filePath, payload.markdown ?? "", payload.mode ?? "annotate", payload.folderPath, undefined, sourceConverted, payload.gate, ); request.respond({ status: "handled", result }); return; } case "annotate-last": { const payload = request.payload; const lastText = payload?.markdown?.trim() ? payload.markdown : getLastAssistantMessageText(ctx); if (!lastText) { request.respond({ status: "unavailable", error: "No assistant message found in session." }); return; } const result = await openLastMessageAnnotation(ctx, lastText, payload?.gate); request.respond({ status: "handled", result }); return; } case "archive": { const result = await openArchiveBrowserAction(ctx, request.payload?.customPlanPath); request.respond({ status: "handled", result }); return; } } } catch (err) { const message = getStartupErrorMessage(err); if (/unavailable|not available/i.test(message)) { request.respond({ status: "unavailable", error: message }); return; } request.respond({ status: "error", error: message }); } }); } export { getLastAssistantMessageText, hasPlanBrowserHtml, hasReviewBrowserHtml, startCodeReviewBrowserSession, startLastMessageAnnotationSession, startMarkdownAnnotationSession, getStartupErrorMessage, openArchiveBrowserAction, openCodeReview, openLastMessageAnnotation, openMarkdownAnnotation, openPlanReviewBrowser, startPlanReviewBrowserSession, } from "./plannotator-browser.js";