@plannotator/pi-extension 0.16.2 → 0.16.3

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.
package/index.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readFileSync, statSync } from "node:fs";
22
22
  import { dirname, resolve } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import type { AgentMessage } from "@mariozechner/pi-agent-core";
24
+ import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
25
25
  import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
26
26
  import { Type } from "@mariozechner/pi-ai";
27
27
  import type {
@@ -29,6 +29,7 @@ import type {
29
29
  ExtensionContext,
30
30
  } from "@mariozechner/pi-coding-agent";
31
31
  import { Key } from "@mariozechner/pi-tui";
32
+ import { buildPromptVariables, formatTodoList, loadPlannotatorConfig, renderTemplate, resolvePhaseProfile } from "./config.js";
32
33
  import {
33
34
  type ChecklistItem,
34
35
  markCompletedSteps,
@@ -85,6 +86,20 @@ try {
85
86
  // HTML not built yet — review feature will be unavailable
86
87
  }
87
88
 
89
+
90
+
91
+ type SavedPhaseState = {
92
+ activeTools: string[];
93
+ model?: { provider: string; id: string };
94
+ thinkingLevel: ThinkingLevel;
95
+ };
96
+
97
+ type PersistedPlannotatorState = {
98
+ phase: Phase;
99
+ planFilePath?: string;
100
+ savedState?: SavedPhaseState;
101
+ };
102
+
88
103
  function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
89
104
  return m.role === "assistant" && Array.isArray(m.content);
90
105
  }
@@ -122,11 +137,24 @@ function getStartupErrorMessage(err: unknown): string {
122
137
  return err instanceof Error ? err.message : "Unknown error";
123
138
  }
124
139
 
140
+ function getPlanReviewAvailabilityWarning(options: { hasUI: boolean; hasPlanHtml: boolean }): string | null {
141
+ const { hasUI, hasPlanHtml } = options;
142
+ if (hasUI && hasPlanHtml) return null;
143
+ if (!hasUI && !hasPlanHtml) {
144
+ 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.";
145
+ }
146
+ if (!hasUI) {
147
+ return "Plannotator: interactive plan review is unavailable in this session (no UI support). Plans will auto-approve on exit_plan_mode.";
148
+ }
149
+ return "Plannotator: interactive plan review assets are missing. Rebuild the extension to restore the browser UI. Plans will auto-approve on exit_plan_mode.";
150
+ }
151
+
125
152
  export default function plannotator(pi: ExtensionAPI): void {
126
153
  let phase: Phase = "idle";
127
154
  let planFilePath = "PLAN.md";
128
155
  let checklistItems: ChecklistItem[] = [];
129
- let preplanTools: string[] | null = null;
156
+ let savedState: SavedPhaseState | null = null;
157
+ let plannotatorConfig = {};
130
158
 
131
159
  // ── Flags ────────────────────────────────────────────────────────────
132
160
 
@@ -148,15 +176,25 @@ export default function plannotator(pi: ExtensionAPI): void {
148
176
  return resolve(cwd, planFilePath);
149
177
  }
150
178
 
179
+ function getPhaseProfile(): ReturnType<typeof resolvePhaseProfile> | undefined {
180
+ if (phase === "planning" || phase === "executing") {
181
+ return resolvePhaseProfile(plannotatorConfig, phase);
182
+ }
183
+ return undefined;
184
+ }
185
+
151
186
  function updateStatus(ctx: ExtensionContext): void {
187
+ const profile = getPhaseProfile();
152
188
  if (phase === "executing" && checklistItems.length > 0) {
153
189
  const completed = checklistItems.filter((t) => t.completed).length;
154
190
  ctx.ui.setStatus(
155
191
  "plannotator",
156
192
  ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`),
157
193
  );
158
- } else if (phase === "planning") {
159
- ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", "⏸ plan"));
194
+ } else if (phase === "planning" && profile?.statusLabel) {
195
+ ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", profile.statusLabel));
196
+ } else if (phase === "executing" && profile?.statusLabel) {
197
+ ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", profile.statusLabel));
160
198
  } else {
161
199
  ctx.ui.setStatus("plannotator", undefined);
162
200
  }
@@ -179,45 +217,109 @@ export default function plannotator(pi: ExtensionAPI): void {
179
217
  }
180
218
  }
181
219
 
220
+ function captureSavedState(ctx: ExtensionContext): void {
221
+ savedState = {
222
+ activeTools: pi.getActiveTools(),
223
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
224
+ thinkingLevel: pi.getThinkingLevel(),
225
+ };
226
+ }
227
+
182
228
  function persistState(): void {
183
- pi.appendEntry("plannotator", { phase, planFilePath, preplanTools });
229
+
230
+
231
+ pi.appendEntry("plannotator", { phase, planFilePath, savedState });
184
232
  }
185
233
 
186
- /** Apply tool visibility for the current phase, preserving tools from other extensions. */
187
- function applyToolsForPhase(): void {
188
- const baseTools = stripPlanningOnlyTools(preplanTools ?? pi.getActiveTools());
189
- pi.setActiveTools(getToolsForPhase(baseTools, phase));
234
+ async function applyModelRef(
235
+ ref: { provider: string; id: string },
236
+ ctx: ExtensionContext,
237
+ reason: string,
238
+ ): Promise<void> {
239
+ const model = ctx.modelRegistry.find(ref.provider, ref.id);
240
+ if (!model) {
241
+ ctx.ui.notify(`Plannotator: ${reason} model ${ref.provider}/${ref.id} not found.`, "warning");
242
+ return;
243
+ }
244
+
245
+ const success = await pi.setModel(model);
246
+ if (!success) {
247
+ ctx.ui.notify(`Plannotator: no API key for ${ref.provider}/${ref.id}.`, "warning");
248
+ }
190
249
  }
191
250
 
192
- function enterPlanning(ctx: ExtensionContext): void {
193
- phase = "planning";
194
- checklistItems = [];
195
- preplanTools = stripPlanningOnlyTools(pi.getActiveTools());
196
- applyToolsForPhase();
251
+ async function restoreSavedState(ctx: ExtensionContext): Promise<void> {
252
+ if (!savedState) return;
253
+
254
+ pi.setActiveTools(savedState.activeTools);
255
+ if (savedState.model) {
256
+ await applyModelRef(savedState.model, ctx, "restore");
257
+ }
258
+ pi.setThinkingLevel(savedState.thinkingLevel);
259
+ }
260
+
261
+ async function applyPhaseConfig(ctx: ExtensionContext, opts: { restoreSavedState?: boolean } = {}): Promise<void> {
262
+ const profile = getPhaseProfile();
263
+ if (opts.restoreSavedState !== false && savedState) {
264
+ await restoreSavedState(ctx);
265
+ }
266
+
267
+ if (phase === "planning" || phase === "executing") {
268
+ const baseTools = stripPlanningOnlyTools(savedState?.activeTools ?? pi.getActiveTools());
269
+ const toolSet = new Set(baseTools);
270
+ for (const tool of profile?.activeTools ?? []) toolSet.add(tool);
271
+ if (phase === "planning") {
272
+ pi.setActiveTools(getToolsForPhase([...toolSet], phase));
273
+ } else {
274
+ pi.setActiveTools([...toolSet]);
275
+ }
276
+ }
277
+
278
+ if (profile?.model) {
279
+ await applyModelRef(profile.model, ctx, phase);
280
+ }
281
+
282
+ if (profile?.thinking) {
283
+ pi.setThinkingLevel(profile.thinking);
284
+ }
285
+
197
286
  updateStatus(ctx);
198
287
  updateWidget(ctx);
288
+ }
289
+
290
+ async function enterPlanning(ctx: ExtensionContext): Promise<void> {
291
+ phase = "planning";
292
+ checklistItems = [];
293
+ captureSavedState(ctx);
294
+ await applyPhaseConfig(ctx, { restoreSavedState: false });
199
295
  persistState();
200
296
  ctx.ui.notify(
201
297
  `Plannotator: planning mode enabled. Write your plan to ${planFilePath}.`,
202
298
  );
299
+ const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: Boolean(planHtmlContent) });
300
+ if (warning) {
301
+ ctx.ui.notify(warning, "warning");
302
+ }
203
303
  }
204
304
 
205
- function exitToIdle(ctx: ExtensionContext): void {
305
+ async function exitToIdle(ctx: ExtensionContext): Promise<void> {
206
306
  phase = "idle";
207
307
  checklistItems = [];
208
- applyToolsForPhase();
209
- preplanTools = null;
308
+
309
+
310
+ await restoreSavedState(ctx);
311
+ savedState = null;
210
312
  updateStatus(ctx);
211
313
  updateWidget(ctx);
212
314
  persistState();
213
315
  ctx.ui.notify("Plannotator: disabled. Full access restored.");
214
316
  }
215
317
 
216
- function togglePlanMode(ctx: ExtensionContext): void {
318
+ async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
217
319
  if (phase === "idle") {
218
- enterPlanning(ctx);
320
+ await enterPlanning(ctx);
219
321
  } else {
220
- exitToIdle(ctx);
322
+ await exitToIdle(ctx);
221
323
  }
222
324
  }
223
325
 
@@ -227,7 +329,7 @@ export default function plannotator(pi: ExtensionAPI): void {
227
329
  description: "Toggle plannotator (file-based plan mode)",
228
330
  handler: async (args, ctx) => {
229
331
  if (phase !== "idle") {
230
- exitToIdle(ctx);
332
+ await exitToIdle(ctx);
231
333
  return;
232
334
  }
233
335
 
@@ -241,7 +343,7 @@ export default function plannotator(pi: ExtensionAPI): void {
241
343
  }
242
344
 
243
345
  if (targetPath) planFilePath = targetPath;
244
- enterPlanning(ctx);
346
+ await enterPlanning(ctx);
245
347
  },
246
348
  });
247
349
 
@@ -540,7 +642,9 @@ export default function plannotator(pi: ExtensionAPI): void {
540
642
 
541
643
  pi.registerShortcut(Key.ctrlAlt("p"), {
542
644
  description: "Toggle plannotator",
543
- handler: async (ctx) => togglePlanMode(ctx),
645
+ handler: async (ctx) => {
646
+ await togglePlanMode(ctx);
647
+ },
544
648
  });
545
649
 
546
650
  // ── plannotator_submit_plan Tool ────────────────────────────────────
@@ -610,8 +714,10 @@ export default function plannotator(pi: ExtensionAPI): void {
610
714
  // Non-interactive or no HTML: auto-approve
611
715
  if (!ctx.hasUI || !planHtmlContent) {
612
716
  phase = "executing";
613
- applyToolsForPhase();
614
- preplanTools = null;
717
+
718
+
719
+ await applyPhaseConfig(ctx, { restoreSavedState: true });
720
+ pi.appendEntry("plannotator-execute", { planFilePath });
615
721
  persistState();
616
722
  return {
617
723
  content: [
@@ -648,13 +754,11 @@ export default function plannotator(pi: ExtensionAPI): void {
648
754
 
649
755
  if (result.approved) {
650
756
  phase = "executing";
651
- applyToolsForPhase();
652
- preplanTools = null;
653
- updateStatus(ctx);
654
- updateWidget(ctx);
655
- persistState();
656
757
 
758
+
759
+ await applyPhaseConfig(ctx, { restoreSavedState: true });
657
760
  pi.appendEntry("plannotator-execute", { planFilePath });
761
+ persistState();
658
762
 
659
763
  const doneMsg =
660
764
  checklistItems.length > 0
@@ -731,6 +835,41 @@ export default function plannotator(pi: ExtensionAPI): void {
731
835
 
732
836
  // Inject phase-specific context
733
837
  pi.on("before_agent_start", async (_event, ctx) => {
838
+ const profile = getPhaseProfile();
839
+ if (phase === "executing") {
840
+ // Re-read from disk each turn to stay current
841
+ const fullPath = resolvePlanPath(ctx.cwd);
842
+ try {
843
+ const planContent = readFileSync(fullPath, "utf-8");
844
+ checklistItems = parseChecklist(planContent);
845
+ } catch {
846
+ // File deleted during execution — degrade gracefully
847
+ }
848
+ }
849
+
850
+ const todoStats = phase === "executing" ? formatTodoList(checklistItems) : formatTodoList([]);
851
+ if (profile?.systemPrompt) {
852
+ const rendered = renderTemplate(
853
+ profile.systemPrompt,
854
+ buildPromptVariables({
855
+ planFilePath,
856
+ phase,
857
+ todoList: todoStats.todoList,
858
+ completedCount: todoStats.completedCount,
859
+ totalCount: todoStats.totalCount,
860
+ remainingCount: todoStats.remainingCount,
861
+ }),
862
+ );
863
+ if (rendered.unknownVariables.length > 0) {
864
+ ctx.ui.notify(
865
+ "Plannotator: unknown template variables in " + phase + " prompt: " + rendered.unknownVariables.join(", "),
866
+ "warning",
867
+ );
868
+ }
869
+
870
+ return { systemPrompt: rendered.text };
871
+ }
872
+
734
873
  if (phase === "planning") {
735
874
  return {
736
875
  message: {
@@ -892,8 +1031,10 @@ Execute each step in order. After completing a step, include [DONE:n] in your re
892
1031
  );
893
1032
  phase = "idle";
894
1033
  checklistItems = [];
895
- applyToolsForPhase();
896
- preplanTools = null;
1034
+
1035
+
1036
+ await restoreSavedState(ctx);
1037
+ savedState = null;
897
1038
  updateStatus(ctx);
898
1039
  updateWidget(ctx);
899
1040
  persistState();
@@ -908,6 +1049,12 @@ Execute each step in order. After completing a step, include [DONE:n] in your re
908
1049
  planFilePath = flagPlanFile;
909
1050
  }
910
1051
 
1052
+ const loadedConfig = loadPlannotatorConfig(ctx.cwd);
1053
+ plannotatorConfig = loadedConfig.config;
1054
+ for (const warning of loadedConfig.warnings) {
1055
+ ctx.ui.notify(`Plannotator config: ${warning}`, "warning");
1056
+ }
1057
+
911
1058
  // Check --plan flag
912
1059
  if (pi.getFlag("plan") === true) {
913
1060
  phase = "planning";
@@ -920,14 +1067,16 @@ Execute each step in order. After completing a step, include [DONE:n] in your re
920
1067
  (e: { type: string; customType?: string }) =>
921
1068
  e.type === "custom" && e.customType === "plannotator",
922
1069
  )
923
- .pop() as {
924
- data?: { phase: Phase; planFilePath?: string; preplanTools?: string[] | null };
925
- } | undefined;
1070
+
1071
+
1072
+ .pop() as { data?: PersistedPlannotatorState } | undefined;
926
1073
 
927
1074
  if (stateEntry?.data) {
928
1075
  phase = stateEntry.data.phase ?? phase;
929
1076
  planFilePath = stateEntry.data.planFilePath ?? planFilePath;
930
- preplanTools = stateEntry.data.preplanTools ?? preplanTools;
1077
+
1078
+
1079
+ savedState = stateEntry.data.savedState ?? savedState;
931
1080
  }
932
1081
 
933
1082
  // Rebuild execution state from disk + session messages
@@ -964,11 +1113,25 @@ Execute each step in order. After completing a step, include [DONE:n] in your re
964
1113
  }
965
1114
  }
966
1115
 
967
- // Re-apply tool visibility on startup/resume so planning-only tools stay hidden
968
- // outside planning and pre-plan tool state is restored after approvals.
969
- applyToolsForPhase();
1116
+
1117
+
1118
+ if (phase === "planning") {
1119
+ checklistItems = [];
1120
+ const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: Boolean(planHtmlContent) });
1121
+ if (warning) {
1122
+ ctx.ui.notify(warning, "warning");
1123
+ }
1124
+ }
1125
+
1126
+ if (phase === "idle") {
1127
+ await restoreSavedState(ctx);
1128
+ savedState = null;
1129
+ } else if (phase === "planning" || phase === "executing") {
1130
+ await applyPhaseConfig(ctx, { restoreSavedState: true });
1131
+ }
970
1132
 
971
1133
  updateStatus(ctx);
972
1134
  updateWidget(ctx);
1135
+ persistState();
973
1136
  });
974
1137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "type": "module",
5
5
  "description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
6
6
  "author": "backnotprop",
@@ -23,6 +23,8 @@
23
23
  "index.ts",
24
24
  "server.ts",
25
25
  "tool-scope.ts",
26
+ "config.ts",
27
+ "plannotator.json",
26
28
  "server/",
27
29
  "generated/",
28
30
  "README.md",