@plannotator/pi-extension 0.16.4 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import {
6
+ getGitContext,
7
+ runGitDiff,
8
+ startAnnotateServer,
9
+ startPlanReviewServer,
10
+ startReviewServer,
11
+ type DiffType,
12
+ } from "./server.js";
13
+ import { openBrowser } from "./server/network.js";
14
+
15
+ export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
16
+ export interface PlanReviewDecision {
17
+ approved: boolean;
18
+ feedback?: string;
19
+ savedPath?: string;
20
+ agentSwitch?: string;
21
+ permissionMode?: string;
22
+ }
23
+
24
+ export interface PlanReviewBrowserSession {
25
+ reviewId: string;
26
+ url: string;
27
+ waitForDecision: () => Promise<PlanReviewDecision>;
28
+ onDecision: (listener: (result: PlanReviewDecision) => void | Promise<void>) => () => void;
29
+ stop: () => void;
30
+ }
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ let planHtmlContent = "";
34
+ let reviewHtmlContent = "";
35
+
36
+ try {
37
+ planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8");
38
+ } catch {
39
+ // built assets unavailable
40
+ }
41
+
42
+ try {
43
+ reviewHtmlContent = readFileSync(resolve(__dirname, "review-editor.html"), "utf-8");
44
+ } catch {
45
+ // built assets unavailable
46
+ }
47
+
48
+ function delay(ms: number): Promise<void> {
49
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
50
+ }
51
+
52
+ export function hasPlanBrowserHtml(): boolean {
53
+ return Boolean(planHtmlContent);
54
+ }
55
+
56
+ export function hasReviewBrowserHtml(): boolean {
57
+ return Boolean(reviewHtmlContent);
58
+ }
59
+
60
+ export function getStartupErrorMessage(err: unknown): string {
61
+ return err instanceof Error ? err.message : "Unknown error";
62
+ }
63
+
64
+ type AssistantTextBlock = { type?: string; text?: string };
65
+
66
+ type AssistantMessageLike = { role?: unknown; content?: unknown };
67
+
68
+ function isAssistantMessage(message: AssistantMessageLike): message is { role: "assistant"; content: AssistantTextBlock[] } {
69
+ return message.role === "assistant" && Array.isArray(message.content);
70
+ }
71
+
72
+ function getTextContent(message: { content: AssistantTextBlock[] }): string {
73
+ return message.content
74
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
75
+ .map((block) => block.text)
76
+ .join("\n");
77
+ }
78
+
79
+ export async function getLastAssistantMessageText(ctx: ExtensionContext): Promise<string | null> {
80
+ const entries = ctx.sessionManager.getEntries();
81
+ for (let i = entries.length - 1; i >= 0; i--) {
82
+ const entry = entries[i] as { type: string; message?: AssistantMessageLike };
83
+ if (entry.type === "message" && entry.message && isAssistantMessage(entry.message)) {
84
+ const text = getTextContent(entry.message);
85
+ if (text.trim()) return text;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void {
92
+ const browserResult = openBrowser(serverUrl);
93
+ if (browserResult.isRemote) {
94
+ ctx.ui.notify(`Remote session. Open manually: ${browserResult.url}`, "info");
95
+ } else if (!browserResult.opened) {
96
+ ctx.ui.notify(`Open this URL to review: ${serverUrl}`, "info");
97
+ }
98
+ }
99
+
100
+ async function openBrowserAndWait<T>(
101
+ server: { url: string; stop: () => void },
102
+ ctx: ExtensionContext,
103
+ waitForResult: () => Promise<T>,
104
+ ): Promise<T> {
105
+ openBrowserForServer(server.url, ctx);
106
+
107
+ const result = await waitForResult();
108
+ await delay(1500);
109
+ server.stop();
110
+ return result;
111
+ }
112
+
113
+ export async function startPlanReviewBrowserSession(
114
+ ctx: ExtensionContext,
115
+ planContent: string,
116
+ ): Promise<PlanReviewBrowserSession> {
117
+ if (!ctx.hasUI || !planHtmlContent) {
118
+ throw new Error("Plannotator browser review is unavailable in this session.");
119
+ }
120
+
121
+ const server = await startPlanReviewServer({
122
+ plan: planContent,
123
+ htmlContent: planHtmlContent,
124
+ origin: "pi",
125
+ sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
126
+ shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
127
+ pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
128
+ });
129
+
130
+ openBrowserForServer(server.url, ctx);
131
+ server.onDecision(() => {
132
+ setTimeout(() => server.stop(), 1500);
133
+ });
134
+
135
+ return {
136
+ reviewId: server.reviewId,
137
+ url: server.url,
138
+ waitForDecision: server.waitForDecision,
139
+ onDecision: server.onDecision,
140
+ stop: server.stop,
141
+ };
142
+ }
143
+
144
+ export async function openPlanReviewBrowser(
145
+ ctx: ExtensionContext,
146
+ planContent: string,
147
+ ): Promise<PlanReviewDecision> {
148
+ const session = await startPlanReviewBrowserSession(ctx, planContent);
149
+ return session.waitForDecision();
150
+ }
151
+
152
+ export async function openCodeReview(
153
+ ctx: ExtensionContext,
154
+ options: { cwd?: string; defaultBranch?: string; diffType?: DiffType } = {},
155
+ ): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> {
156
+ if (!ctx.hasUI || !reviewHtmlContent) {
157
+ throw new Error("Plannotator code review browser is unavailable in this session.");
158
+ }
159
+
160
+ const cwd = options.cwd ?? ctx.cwd;
161
+ const gitCtx = await getGitContext(cwd);
162
+ const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
163
+ const diffType: DiffType = options.diffType ?? "uncommitted";
164
+ const { patch: rawPatch, label: gitRef, error } = await runGitDiff(diffType, defaultBranch, cwd);
165
+ const server = await startReviewServer({
166
+ rawPatch,
167
+ gitRef,
168
+ error,
169
+ origin: "pi",
170
+ diffType,
171
+ gitContext: gitCtx,
172
+ htmlContent: reviewHtmlContent,
173
+ sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
174
+ shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
175
+ });
176
+
177
+ return openBrowserAndWait(server, ctx, server.waitForDecision);
178
+ }
179
+
180
+ export async function openMarkdownAnnotation(
181
+ ctx: ExtensionContext,
182
+ filePath: string,
183
+ markdown: string,
184
+ mode: AnnotateMode,
185
+ folderPath?: string,
186
+ ): Promise<{ feedback: string }> {
187
+ if (!ctx.hasUI || !planHtmlContent) {
188
+ throw new Error("Plannotator annotation browser is unavailable in this session.");
189
+ }
190
+
191
+ let resolvedMarkdown = markdown;
192
+ if (!resolvedMarkdown.trim() && existsSync(filePath)) {
193
+ try {
194
+ const fileStat = statSync(filePath);
195
+ if (!fileStat.isDirectory()) {
196
+ resolvedMarkdown = readFileSync(filePath, "utf-8");
197
+ }
198
+ } catch {
199
+ // fall back to provided markdown
200
+ }
201
+ }
202
+
203
+ const server = await startAnnotateServer({
204
+ markdown: resolvedMarkdown,
205
+ filePath,
206
+ origin: "pi",
207
+ mode,
208
+ folderPath,
209
+ htmlContent: planHtmlContent,
210
+ sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
211
+ shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
212
+ pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
213
+ });
214
+
215
+ return openBrowserAndWait(server, ctx, server.waitForDecision);
216
+ }
217
+
218
+ export async function openLastMessageAnnotation(
219
+ ctx: ExtensionContext,
220
+ lastText: string,
221
+ ): Promise<{ feedback: string }> {
222
+ return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last");
223
+ }
224
+
225
+ export async function openArchiveBrowserAction(
226
+ ctx: ExtensionContext,
227
+ customPlanPath?: string,
228
+ ): Promise<{ opened: boolean }> {
229
+ if (!ctx.hasUI || !planHtmlContent) {
230
+ throw new Error("Plannotator archive browser is unavailable in this session.");
231
+ }
232
+
233
+ const server = await startPlanReviewServer({
234
+ plan: "",
235
+ htmlContent: planHtmlContent,
236
+ origin: "pi",
237
+ mode: "archive",
238
+ customPlanPath,
239
+ sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
240
+ shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
241
+ pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
242
+ });
243
+
244
+ return openBrowserAndWait(server, ctx, async () => {
245
+ if (server.waitForDone) {
246
+ await server.waitForDone();
247
+ }
248
+ return { opened: true };
249
+ });
250
+ }
@@ -0,0 +1,316 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { DiffType } from "./server.js";
6
+ import {
7
+ getLastAssistantMessageText,
8
+ getStartupErrorMessage,
9
+ openArchiveBrowserAction,
10
+ openCodeReview,
11
+ openLastMessageAnnotation,
12
+ openMarkdownAnnotation,
13
+ startPlanReviewBrowserSession,
14
+ } from "./plannotator-browser.js";
15
+
16
+ export const PLANNOTATOR_REQUEST_CHANNEL = "plannotator:request" as const;
17
+ export const PLANNOTATOR_REVIEW_RESULT_CHANNEL = "plannotator:review-result" as const;
18
+ export const PLANNOTATOR_TIMEOUT_MS = 5_000;
19
+
20
+ export type PlannotatorAction =
21
+ | "plan-review"
22
+ | "review-status"
23
+ | "code-review"
24
+ | "annotate"
25
+ | "annotate-last"
26
+ | "archive";
27
+
28
+ export interface PlannotatorHandledResponse<T> {
29
+ status: "handled";
30
+ result: T;
31
+ }
32
+
33
+ export interface PlannotatorUnavailableResponse {
34
+ status: "unavailable";
35
+ error?: string;
36
+ }
37
+
38
+ export interface PlannotatorErrorResponse {
39
+ status: "error";
40
+ error: string;
41
+ }
42
+
43
+ export type PlannotatorResponse<T> =
44
+ | PlannotatorHandledResponse<T>
45
+ | PlannotatorUnavailableResponse
46
+ | PlannotatorErrorResponse;
47
+
48
+ export interface PlannotatorRequestBase<A extends PlannotatorAction, P, R> {
49
+ requestId: string;
50
+ action: A;
51
+ payload: P;
52
+ respond: (response: PlannotatorResponse<R>) => void;
53
+ }
54
+
55
+ export interface PlannotatorPlanReviewPayload {
56
+ planFilePath?: string;
57
+ planContent: string;
58
+ origin?: string;
59
+ }
60
+
61
+ export interface PlannotatorPlanReviewStartResult {
62
+ status: "pending";
63
+ reviewId: string;
64
+ }
65
+
66
+ export interface PlannotatorReviewResultEvent {
67
+ reviewId: string;
68
+ approved: boolean;
69
+ feedback?: string;
70
+ savedPath?: string;
71
+ agentSwitch?: string;
72
+ permissionMode?: string;
73
+ }
74
+
75
+ export interface PlannotatorReviewStatusPayload {
76
+ reviewId: string;
77
+ }
78
+
79
+ export type PlannotatorReviewStatusResult =
80
+ | { status: "pending" }
81
+ | ({ status: "completed" } & PlannotatorReviewResultEvent)
82
+ | { status: "missing" };
83
+
84
+ export interface PlannotatorCodeReviewPayload {
85
+ diffType?: DiffType;
86
+ defaultBranch?: string;
87
+ cwd?: string;
88
+ }
89
+
90
+ export interface PlannotatorCodeReviewResult {
91
+ approved: boolean;
92
+ feedback?: string;
93
+ annotations?: unknown[];
94
+ agentSwitch?: string;
95
+ }
96
+
97
+ export interface PlannotatorAnnotatePayload {
98
+ filePath: string;
99
+ markdown?: string;
100
+ mode?: "annotate" | "annotate-folder" | "annotate-last";
101
+ folderPath?: string;
102
+ }
103
+
104
+ export interface PlannotatorAnnotationResult {
105
+ feedback: string;
106
+ }
107
+
108
+ export interface PlannotatorArchivePayload {
109
+ customPlanPath?: string;
110
+ }
111
+
112
+ export interface PlannotatorArchiveResult {
113
+ opened: boolean;
114
+ }
115
+
116
+ export type PlannotatorRequestMap = {
117
+ "plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>;
118
+ "review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>;
119
+ "code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewResult>;
120
+ annotate: PlannotatorRequestBase<"annotate", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
121
+ "annotate-last": PlannotatorRequestBase<"annotate-last", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
122
+ archive: PlannotatorRequestBase<"archive", PlannotatorArchivePayload, PlannotatorArchiveResult>;
123
+ };
124
+ export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction];
125
+ export type PlannotatorResponseMap = {
126
+ "plan-review": PlannotatorResponse<PlannotatorPlanReviewStartResult>;
127
+ "review-status": PlannotatorResponse<PlannotatorReviewStatusResult>;
128
+ "code-review": PlannotatorResponse<PlannotatorCodeReviewResult>;
129
+ annotate: PlannotatorResponse<PlannotatorAnnotationResult>;
130
+ "annotate-last": PlannotatorResponse<PlannotatorAnnotationResult>;
131
+ archive: PlannotatorResponse<PlannotatorArchiveResult>;
132
+ };
133
+ function isPlannotatorAction(value: unknown): value is PlannotatorAction {
134
+ return (
135
+ value === "plan-review" ||
136
+ value === "review-status" ||
137
+ value === "code-review" ||
138
+ value === "annotate" ||
139
+ value === "annotate-last" ||
140
+ value === "archive"
141
+ );
142
+ }
143
+
144
+ const REVIEW_STATUS_PATH = join(homedir(), ".pi", "plannotator-review-status.json");
145
+
146
+ type StoredReviewStatus = Record<string, PlannotatorReviewStatusResult>;
147
+
148
+ function readStoredReviewStatuses(): StoredReviewStatus {
149
+ try {
150
+ if (!existsSync(REVIEW_STATUS_PATH)) return {};
151
+ const raw = readFileSync(REVIEW_STATUS_PATH, "utf-8");
152
+ const parsed = JSON.parse(raw) as StoredReviewStatus;
153
+ return parsed && typeof parsed === "object" ? parsed : {};
154
+ } catch {
155
+ return {};
156
+ }
157
+ }
158
+
159
+ function writeStoredReviewStatuses(statuses: StoredReviewStatus): void {
160
+ mkdirSync(dirname(REVIEW_STATUS_PATH), { recursive: true });
161
+ writeFileSync(REVIEW_STATUS_PATH, JSON.stringify(statuses, null, 2));
162
+ }
163
+
164
+ function setStoredReviewStatus(reviewId: string, status: PlannotatorReviewStatusResult): void {
165
+ const statuses = readStoredReviewStatuses();
166
+ statuses[reviewId] = status;
167
+ writeStoredReviewStatuses(statuses);
168
+ }
169
+
170
+ function getStoredReviewStatus(reviewId: string): PlannotatorReviewStatusResult {
171
+ return readStoredReviewStatuses()[reviewId] ?? { status: "missing" };
172
+ }
173
+
174
+ function createActiveSessionContext() {
175
+ let currentCtx: ExtensionContext | undefined;
176
+
177
+ return {
178
+ set(ctx: ExtensionContext): void {
179
+ currentCtx = ctx;
180
+ },
181
+ clear(): void {
182
+ currentCtx = undefined;
183
+ },
184
+ get(): ExtensionContext | undefined {
185
+ return currentCtx;
186
+ },
187
+ };
188
+ }
189
+
190
+ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
191
+ const activeSessionContext = createActiveSessionContext();
192
+
193
+ // Plannotator event requests are handled against the latest active session.
194
+ // The active context is intentionally session-scoped and replaced on each session_start.
195
+ pi.on("session_start", async (_event, ctx) => {
196
+ activeSessionContext.set(ctx);
197
+ });
198
+ pi.events.on(PLANNOTATOR_REQUEST_CHANNEL, async (data) => {
199
+ const request = data as Partial<PlannotatorRequest> | null;
200
+ const ctx = activeSessionContext.get();
201
+
202
+ if (!request || typeof request.respond !== "function" || !isPlannotatorAction(request.action)) {
203
+ return;
204
+ }
205
+
206
+ try {
207
+ if (request.action === "review-status") {
208
+ const reviewId = request.payload?.reviewId;
209
+ if (typeof reviewId !== "string" || !reviewId.trim()) {
210
+ request.respond({ status: "error", error: "Missing reviewId for review-status request." });
211
+ return;
212
+ }
213
+ request.respond({ status: "handled", result: getStoredReviewStatus(reviewId) });
214
+ return;
215
+ }
216
+
217
+ if (!ctx) {
218
+ request.respond({ status: "unavailable", error: "Plannotator context is not ready yet." });
219
+ return;
220
+ }
221
+
222
+ switch (request.action) {
223
+ case "plan-review": {
224
+ const planContent = request.payload?.planContent;
225
+ if (typeof planContent !== "string" || !planContent.trim()) {
226
+ request.respond({ status: "error", error: "Missing planContent for plan-review request." });
227
+ return;
228
+ }
229
+ const session = await startPlanReviewBrowserSession(ctx, planContent);
230
+ setStoredReviewStatus(session.reviewId, { status: "pending" });
231
+ session.onDecision((result) => {
232
+ const reviewResult = {
233
+ reviewId: session.reviewId,
234
+ approved: result.approved,
235
+ feedback: result.feedback,
236
+ savedPath: result.savedPath,
237
+ agentSwitch: result.agentSwitch,
238
+ permissionMode: result.permissionMode,
239
+ } satisfies PlannotatorReviewResultEvent;
240
+ setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult });
241
+ pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
242
+ });
243
+ request.respond({
244
+ status: "handled",
245
+ result: {
246
+ status: "pending",
247
+ reviewId: session.reviewId,
248
+ },
249
+ });
250
+ return;
251
+ }
252
+ case "code-review": {
253
+ const result = await openCodeReview(ctx, {
254
+ cwd: request.payload?.cwd,
255
+ defaultBranch: request.payload?.defaultBranch,
256
+ diffType: request.payload?.diffType,
257
+ });
258
+ request.respond({ status: "handled", result });
259
+ return;
260
+ }
261
+ case "annotate": {
262
+ const payload = request.payload;
263
+ if (!payload?.filePath) {
264
+ request.respond({ status: "error", error: "Missing filePath for annotate request." });
265
+ return;
266
+ }
267
+ const result = await openMarkdownAnnotation(
268
+ ctx,
269
+ payload.filePath,
270
+ payload.markdown ?? "",
271
+ payload.mode ?? "annotate",
272
+ payload.folderPath,
273
+ );
274
+ request.respond({ status: "handled", result });
275
+ return;
276
+ }
277
+ case "annotate-last": {
278
+ const payload = request.payload;
279
+ const lastText = payload?.markdown?.trim() ? payload.markdown : await getLastAssistantMessageText(ctx);
280
+ if (!lastText) {
281
+ request.respond({ status: "unavailable", error: "No assistant message found in session." });
282
+ return;
283
+ }
284
+ const result = await openLastMessageAnnotation(ctx, lastText);
285
+ request.respond({ status: "handled", result });
286
+ return;
287
+ }
288
+ case "archive": {
289
+ const result = await openArchiveBrowserAction(ctx, request.payload?.customPlanPath);
290
+ request.respond({ status: "handled", result });
291
+ return;
292
+ }
293
+ }
294
+ } catch (err) {
295
+ const message = getStartupErrorMessage(err);
296
+ if (/unavailable|not available/i.test(message)) {
297
+ request.respond({ status: "unavailable", error: message });
298
+ return;
299
+ }
300
+ request.respond({ status: "error", error: message });
301
+ }
302
+ });
303
+ }
304
+
305
+ export {
306
+ getLastAssistantMessageText,
307
+ hasPlanBrowserHtml,
308
+ hasReviewBrowserHtml,
309
+ getStartupErrorMessage,
310
+ openArchiveBrowserAction,
311
+ openCodeReview,
312
+ openLastMessageAnnotation,
313
+ openMarkdownAnnotation,
314
+ openPlanReviewBrowser,
315
+ startPlanReviewBrowserSession,
316
+ } from "./plannotator-browser.js";