@bacnh85/pi-plan 0.1.2 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/index.ts +138 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Pi extension that adds a lightweight plan mode inspired by Codex and Claude Code:
4
4
 
5
5
  - Toggle plan mode with `/plan` or `Ctrl+Alt+P`.
6
- - Remembers separate thinking/reasoning levels for planning and normal execution based on the active mode when you change Pi's reasoning level.
6
+ - Remembers separate thinking/reasoning levels for planning and normal execution across sessions based on the active mode when you change Pi's reasoning level.
7
7
  - Keeps planning read-only by disabling built-in `edit`/`write` and blocking destructive shell commands.
8
8
  - Provides a `write_plan` tool so the agent writes reviewable Markdown plans into `.agents/plans/` in the current workspace.
9
9
  - Provides an `ask_plan_question` tool so the agent can ask selection-style clarifying questions during planning, with an option for free-form user input.
@@ -50,11 +50,14 @@ pi --plan
50
50
  4. The model calls `write_plan`.
51
51
  5. The plan is saved under `.agents/plans/<timestamp>-<title>.md`.
52
52
  6. Choose one of the approval options:
53
- - **Approve and execute**: exits plan mode, restores tools, applies normal thinking level, and sends a follow-up execution prompt.
53
+ - **Execute in current session**: exits plan mode, restores tools, applies normal thinking level, shows current context usage in the option label, and sends a follow-up execution prompt.
54
+ - **Execute in new session**: exits plan mode and prepares a fresh session that uses the saved Markdown plan file as the handoff artifact.
54
55
  - **Approve only**: exits plan mode without starting work.
55
56
  - **Keep planning**: remains in plan mode.
56
57
  - **Refine with feedback**: sends your feedback as a follow-up planning prompt.
57
58
 
59
+ Agents should ask blocking, user-answerable planning questions with `ask_plan_question` before finalizing a plan. Final plans may still list non-blocking uncertainties or implementation-time checks, but should not leave consequential user decisions unresolved.
60
+
58
61
  ## Reasoning levels
59
62
 
60
63
  pi-plan remembers two reasoning levels:
@@ -62,7 +65,7 @@ pi-plan remembers two reasoning levels:
62
65
  - Change Pi's active reasoning level while plan mode is active to update the planning level.
63
66
  - Change Pi's active reasoning level in normal or execution mode to update the normal/execution level.
64
67
 
65
- The remembered levels are restored when switching modes and across resumed sessions.
68
+ The remembered levels are restored when switching modes and across independent or resumed sessions. pi-plan stores only these non-sensitive preferences under your user Pi agent directory.
66
69
 
67
70
  ## Design notes
68
71
 
package/index.ts CHANGED
@@ -1,13 +1,16 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { isToolCallEventType, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
 
7
8
  const STATUS_KEY = "pi-plan";
8
9
  const PLAN_DIR = path.join(".agents", "plans");
9
10
  const PLAN_TOOL = "write_plan";
10
11
  const PLAN_QUESTION_TOOL = "ask_plan_question";
12
+ const PLAN_EXECUTE_COMMAND = "plan-execute";
13
+ const PREFERENCES_FILE = path.join(os.homedir(), ".pi", "agent", "pi-plan", "preferences.json");
11
14
  const PLAN_MODE_DISABLED_TOOLS = new Set(["edit", "write"]);
12
15
  const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", PLAN_TOOL, PLAN_QUESTION_TOOL];
13
16
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
@@ -26,6 +29,12 @@ interface PlanState {
26
29
  lastPlanStatus?: PlanStatus;
27
30
  }
28
31
 
32
+ interface PlanPreferences {
33
+ version: 1;
34
+ planThinking: ThinkingLevel;
35
+ normalThinking: ThinkingLevel;
36
+ }
37
+
29
38
  interface WritePlanParams {
30
39
  title: string;
31
40
  content: string;
@@ -102,6 +111,43 @@ function relativeToCwd(cwd: string, absolutePath: string): string {
102
111
  return path.relative(cwd, absolutePath).split(path.sep).join("/");
103
112
  }
104
113
 
114
+ function formatContextUsage(ctx: ExtensionContext): string {
115
+ const usage = ctx.getContextUsage();
116
+ if (!usage || usage.percent === null) return "context usage unknown";
117
+ const percent = `${Math.round(usage.percent)}% context used`;
118
+ if (usage.tokens === null) return percent;
119
+ return `${percent} (${Math.round(usage.tokens / 1000)}k / ${Math.round(usage.contextWindow / 1000)}k tokens)`;
120
+ }
121
+
122
+ function buildExecutionPrompt(relativePlan: string, mode: "current" | "new"): string {
123
+ const prefix = mode === "new" ? "This is a fresh session created from an approved pi-plan. " : "";
124
+ return `${prefix}Execute the approved plan in ${relativePlan}. Read the plan file if needed, keep the implementation scoped to the plan, update the plan if reality differs materially, and run the verification described there.`;
125
+ }
126
+
127
+ function hasOpenQuestionWarning(content: string): boolean {
128
+ return /(^|\n)#{1,6}\s+.*open questions?.*\n[\s\S]*\?/i.test(content);
129
+ }
130
+
131
+ async function loadPreferences(): Promise<PlanPreferences | undefined> {
132
+ try {
133
+ const raw = await readFile(PREFERENCES_FILE, "utf8");
134
+ const parsed = JSON.parse(raw) as Partial<PlanPreferences>;
135
+ const savedPlanThinking = parsed.planThinking;
136
+ const savedNormalThinking = parsed.normalThinking;
137
+ if (parsed.version !== 1 || !savedPlanThinking || !savedNormalThinking || !isThinkingLevel(savedPlanThinking) || !isThinkingLevel(savedNormalThinking)) return undefined;
138
+ return { version: 1, planThinking: savedPlanThinking, normalThinking: savedNormalThinking };
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ async function savePreferences(preferences: PlanPreferences): Promise<void> {
145
+ await mkdir(path.dirname(PREFERENCES_FILE), { recursive: true });
146
+ const temporaryPath = `${PREFERENCES_FILE}.${process.pid}.tmp`;
147
+ await writeFile(temporaryPath, `${JSON.stringify(preferences, null, 2)}\n`, "utf8");
148
+ await rename(temporaryPath, PREFERENCES_FILE);
149
+ }
150
+
105
151
  function isDestructiveBash(command: string): boolean {
106
152
  return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
107
153
  }
@@ -146,6 +192,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
146
192
  } satisfies PlanState);
147
193
  }
148
194
 
195
+ function persistPreferences(): void {
196
+ void savePreferences({ version: 1, planThinking, normalThinking }).catch(() => undefined);
197
+ }
198
+
149
199
  function enablePlanTools(): void {
150
200
  const baseline = toolsBeforePlan ?? pi.getActiveTools();
151
201
  toolsBeforePlan = baseline;
@@ -175,6 +225,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
175
225
  }
176
226
  setStatus(ctx);
177
227
  persistState();
228
+ persistPreferences();
178
229
  }
179
230
 
180
231
  function enterPlanMode(ctx: ExtensionContext): void {
@@ -208,6 +259,50 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
208
259
  else enterPlanMode(ctx);
209
260
  }
210
261
 
262
+ function beginCurrentSessionExecution(ctx: ExtensionContext, relativePlan: string): void {
263
+ planModeEnabled = false;
264
+ executionMode = true;
265
+ lastPlanStatus = "approved";
266
+ restoreTools();
267
+ applyThinking(normalThinking);
268
+ setStatus(ctx);
269
+ clearPlanWidget(ctx);
270
+ persistState();
271
+ persistPreferences();
272
+ pi.sendUserMessage(buildExecutionPrompt(relativePlan, "current"), { deliverAs: "followUp" });
273
+ }
274
+
275
+ async function beginNewSessionExecution(ctx: ExtensionCommandContext): Promise<void> {
276
+ if (!lastPlanPath) {
277
+ ctx.ui.notify("No approved plan is available to execute.", "error");
278
+ return;
279
+ }
280
+ await ctx.waitForIdle();
281
+ const planPathToExecute = lastPlanPath;
282
+ const planTitleToExecute = lastPlanTitle;
283
+ const relativePlan = relativeToCwd(ctx.cwd, planPathToExecute);
284
+ const parentSession = ctx.sessionManager.getSessionFile();
285
+ const state: PlanState = {
286
+ enabled: false,
287
+ executing: true,
288
+ planThinking,
289
+ normalThinking,
290
+ lastPlanPath: planPathToExecute,
291
+ lastPlanTitle: planTitleToExecute,
292
+ lastPlanStatus: "approved",
293
+ };
294
+ const result = await ctx.newSession({
295
+ parentSession,
296
+ setup: async (sessionManager) => {
297
+ sessionManager.appendCustomEntry("pi-plan", state);
298
+ },
299
+ withSession: async (replacementCtx) => {
300
+ await replacementCtx.sendUserMessage(buildExecutionPrompt(relativePlan, "new"));
301
+ },
302
+ });
303
+ if (result.cancelled) ctx.ui.notify("New-session execution cancelled.", "info");
304
+ }
305
+
211
306
  pi.registerFlag("plan", {
212
307
  description: "Start in pi-plan read-only planning mode",
213
308
  type: "boolean",
@@ -219,7 +314,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
219
314
  label: "Write Plan",
220
315
  description: `Write or replace the current implementation plan as Markdown under ${PLAN_DIR}/. Use in plan mode when the plan is ready for user review.`,
221
316
  promptSnippet: `Write the implementation plan to ${PLAN_DIR}/ as a Markdown file for user review`,
222
- promptGuidelines: [`Use ${PLAN_TOOL} in plan mode after repository exploration; do not use edit/write for implementation until the user approves the plan.`],
317
+ promptGuidelines: [
318
+ `Use ${PLAN_TOOL} in plan mode after repository exploration; do not use edit/write for implementation until the user approves the plan.`,
319
+ `Do not call ${PLAN_TOOL} while blocking user-answerable questions remain; use ${PLAN_QUESTION_TOOL} first.`,
320
+ ],
223
321
  parameters: Type.Object({
224
322
  title: Type.String({ description: "Short human-readable title for the plan" }),
225
323
  content: Type.String({ description: "Complete Markdown plan content" }),
@@ -237,8 +335,11 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
237
335
  lastPlanTitle = typedParams.title.trim() || "Plan";
238
336
  lastPlanStatus = isPlanStatus(typedParams.status) ? typedParams.status : "draft";
239
337
  persistState();
338
+ const warning = hasOpenQuestionWarning(content)
339
+ ? ` If the plan contains blocking user-answerable open questions, call ${PLAN_QUESTION_TOOL} before requesting approval.`
340
+ : "";
240
341
  return {
241
- content: [{ type: "text", text: `Plan written to ${relativeToCwd(ctx.cwd, destination)}. Ask the user to approve, refine, or keep planning.` }],
342
+ content: [{ type: "text", text: `Plan written to ${relativeToCwd(ctx.cwd, destination)}. If no blocking user-answerable questions remain, ask the user to approve, refine, execute in current session, execute in a new session, or keep planning.${warning}` }],
242
343
  details: { path: destination, title: lastPlanTitle, status: lastPlanStatus },
243
344
  };
244
345
  },
@@ -324,6 +425,17 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
324
425
  handler: async (args, ctx) => handlePlanCommand(args, ctx),
325
426
  });
326
427
 
428
+ pi.registerCommand(PLAN_EXECUTE_COMMAND, {
429
+ description: "Internal pi-plan execution bridge",
430
+ handler: async (args, ctx) => {
431
+ if (args.trim() !== "new") {
432
+ ctx.ui.notify(`Usage: /${PLAN_EXECUTE_COMMAND} new`, "warning");
433
+ return;
434
+ }
435
+ await beginNewSessionExecution(ctx);
436
+ },
437
+ });
438
+
327
439
  pi.registerShortcut("ctrl+alt+p", {
328
440
  description: "Toggle pi-plan mode",
329
441
  handler: async (ctx) => {
@@ -333,24 +445,32 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
333
445
  });
334
446
 
335
447
  pi.on("session_start", async (_event, ctx) => {
448
+ const preferences = await loadPreferences();
336
449
  const entries = ctx.sessionManager.getEntries();
337
450
  const saved = entries
338
451
  .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === "pi-plan")
339
452
  .pop() as { data?: PlanState } | undefined;
453
+ if (preferences) {
454
+ planThinking = preferences.planThinking;
455
+ normalThinking = preferences.normalThinking;
456
+ }
340
457
  if (saved?.data) {
341
458
  planModeEnabled = saved.data.enabled ?? planModeEnabled;
342
459
  executionMode = saved.data.executing ?? executionMode;
343
- planThinking = saved.data.planThinking ?? planThinking;
344
- normalThinking = saved.data.normalThinking ?? normalThinking;
460
+ if (!preferences && isThinkingLevel(saved.data.planThinking)) planThinking = saved.data.planThinking;
461
+ if (!preferences && isThinkingLevel(saved.data.normalThinking)) normalThinking = saved.data.normalThinking;
345
462
  toolsBeforePlan = saved.data.toolsBeforePlan ?? toolsBeforePlan;
346
463
  lastPlanPath = saved.data.lastPlanPath ?? lastPlanPath;
347
464
  lastPlanTitle = saved.data.lastPlanTitle ?? lastPlanTitle;
348
465
  lastPlanStatus = saved.data.lastPlanStatus ?? lastPlanStatus;
349
466
  }
467
+ if (!preferences) persistPreferences();
350
468
  if (pi.getFlag("plan") === true) planModeEnabled = true;
351
469
  if (planModeEnabled) {
352
470
  enablePlanTools();
353
471
  applyThinking(planThinking);
472
+ } else if (executionMode) {
473
+ applyThinking(normalThinking);
354
474
  }
355
475
  setStatus(ctx);
356
476
  clearPlanWidget(ctx);
@@ -379,7 +499,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
379
499
  return {
380
500
  message: {
381
501
  customType: "pi-plan-context",
382
- content: `[PI-PLAN MODE ACTIVE]\nYou are in read-only planning mode. Research the codebase and produce a reviewable implementation plan before making changes.\n\nRules:\n- Do not edit source files, configs, lockfiles, or git state.\n- You may read files, search, inspect git state, and run read-only shell commands.\n- Ask concise clarifying questions if requirements are ambiguous. Use ${PLAN_QUESTION_TOOL} for consequential open decisions with 2-4 clear options and an Other/user-opinion path.\n- Do not ask about details you can discover from repository evidence. If the user already gave an opinion, incorporate it instead of asking again.\n- When the plan is ready, call ${PLAN_TOOL} with a complete Markdown plan.\n- The plan file must live in ${PLAN_DIR}/. Current/next plan path: ${relativePlan}\n\nPlan content should include:\n1. Goal and assumptions.\n2. Key findings with durable file/symbol paths.\n3. Proposed implementation steps.\n4. Verification plan.\n5. Risks, open questions, and rejected alternatives if relevant.`,
502
+ content: `[PI-PLAN MODE ACTIVE]\nYou are in read-only planning mode. Research the codebase and produce a reviewable implementation plan before making changes.\n\nRules:\n- Do not edit source files, configs, lockfiles, or git state.\n- You may read files, search, inspect git state, and run read-only shell commands.\n- Ask concise clarifying questions if requirements are ambiguous. Use ${PLAN_QUESTION_TOOL} for consequential open decisions with 2-4 clear options and an Other/user-opinion path.\n- Do not ask about details you can discover from repository evidence. If the user already gave an opinion, incorporate it instead of asking again.\n- Before calling ${PLAN_TOOL}, if any consequential, user-answerable decision remains, call ${PLAN_QUESTION_TOOL} and wait for the answer. Do not place blocking user decisions in the final plan as open questions.\n- When the plan is ready, call ${PLAN_TOOL} with a complete Markdown plan.\n- The plan file must live in ${PLAN_DIR}/. Current/next plan path: ${relativePlan}\n\nPlan content should include:\n1. Goal and assumptions.\n2. Key findings with durable file/symbol paths.\n3. Proposed implementation steps.\n4. Verification plan.\n5. Risks, non-blocking open questions, and rejected alternatives if relevant.`,
383
503
  display: false,
384
504
  },
385
505
  };
@@ -405,22 +525,29 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
405
525
  pi.on("agent_end", async (_event, ctx) => {
406
526
  if (!planModeEnabled || !lastPlanPath || !ctx.hasUI) return;
407
527
  const relativePlan = relativeToCwd(ctx.cwd, lastPlanPath);
528
+ const currentSessionChoice = `Execute in current session (${formatContextUsage(ctx)})`;
529
+ const newSessionChoice = "Execute in new session (fresh context)";
408
530
  const choice = await ctx.ui.select(`Plan written: ${relativePlan}`, [
409
- "Approve and execute",
531
+ currentSessionChoice,
532
+ newSessionChoice,
410
533
  "Approve only (exit plan mode)",
411
534
  "Keep planning",
412
535
  "Refine with feedback",
413
536
  ]);
414
- if (choice === "Approve and execute") {
537
+ if (choice === currentSessionChoice) {
538
+ beginCurrentSessionExecution(ctx, relativePlan);
539
+ } else if (choice === newSessionChoice) {
415
540
  planModeEnabled = false;
416
- executionMode = true;
541
+ executionMode = false;
417
542
  lastPlanStatus = "approved";
418
543
  restoreTools();
419
544
  applyThinking(normalThinking);
420
545
  setStatus(ctx);
421
546
  clearPlanWidget(ctx);
422
547
  persistState();
423
- pi.sendUserMessage(`Execute the approved plan in ${relativePlan}. Keep the implementation scoped to the plan, update the plan if reality differs materially, and run the verification described there.`, { deliverAs: "followUp" });
548
+ persistPreferences();
549
+ ctx.ui.setEditorText(`/${PLAN_EXECUTE_COMMAND} new`);
550
+ ctx.ui.notify("Press Enter to start execution in a new session.", "info");
424
551
  } else if (choice === "Approve only (exit plan mode)") {
425
552
  lastPlanStatus = "approved";
426
553
  leavePlanMode(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Pi extension that adds a plan mode with workspace markdown plans and thinking-level presets.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {