@burneikis/pi-plan 1.0.0

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 (5) hide show
  1. package/README.md +53 -0
  2. package/index.ts +291 -0
  3. package/package.json +21 -0
  4. package/plan.md +334 -0
  5. package/utils.ts +71 -0
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Pi Plan
2
+
3
+ A [pi](https://github.com/badlogic/pi-mono) extension that adds a `/plan` command for structured plan-driven development. The agent creates a plan, you review and edit it, then the agent executes it in a fresh session.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pi install npm:@burneikis/pi-plan
9
+ ```
10
+
11
+ Or test without installing:
12
+
13
+ ```bash
14
+ pi -e npm:@burneikis/pi-plan
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ /plan make a todo app with React and TypeScript
21
+ ```
22
+
23
+ ## Flow
24
+
25
+ 1. **Plan** — You run `/plan <description>`. The agent explores the codebase and writes a `plan.md` file.
26
+ 2. **Review** — You're prompted with options:
27
+ - **Ready** — Execute the plan in a new session
28
+ - **Edit** — Describe changes, agent rewrites the plan
29
+ - **Open in $EDITOR** — Edit the plan file manually
30
+ - **Cancel** — Discard and return to normal mode
31
+ 3. **Execute** — A new session starts with the plan as context and full tool access.
32
+
33
+ ## Plan Storage
34
+
35
+ Plans are stored at `~/.pi/agent/plans/<session_id>/plan.md` and persist across restarts.
36
+
37
+ ## Plan Format
38
+
39
+ ```markdown
40
+ # Plan: <title>
41
+
42
+ ## Goal
43
+ Brief description of what we're building
44
+
45
+ ## Steps
46
+
47
+ 1. First step
48
+ 2. Second step
49
+ 3. Third step
50
+
51
+ ## Notes
52
+ Additional context, constraints, or decisions
53
+ ```
package/index.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Pi Plan Extension
3
+ *
4
+ * Adds a `/plan` command for structured plan-driven development.
5
+ * The agent creates a plan, the user reviews/edits it, then the agent executes it.
6
+ *
7
+ * Flow:
8
+ * 1. User runs `/plan <description>`
9
+ * 2. Agent analyzes codebase (read-only) and writes a plan.md file
10
+ * 3. User reviews with options: Ready, Edit, Open in $EDITOR, Cancel
11
+ * 4. Agent executes the plan in a new session with full tool access
12
+ */
13
+
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionContext,
17
+ ExtensionCommandContext,
18
+ } from "@mariozechner/pi-coding-agent";
19
+ import { mkdir, readFile, access } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { spawnSync } from "node:child_process";
23
+ import { deriveSessionId, extractPlanTitle, parsePlanSteps } from "./utils.js";
24
+
25
+ export default function piPlanExtension(pi: ExtensionAPI): void {
26
+ let planFilePath: string | null = null;
27
+ let isPlanMode = false;
28
+ let savedCommandCtx: ExtensionCommandContext | null = null;
29
+
30
+ function updateUI(ctx: ExtensionContext): void {
31
+ if (isPlanMode) {
32
+ ctx.ui.setStatus("pi-plan", ctx.ui.theme.fg("warning", "planning"));
33
+ } else {
34
+ ctx.ui.setStatus("pi-plan", undefined);
35
+ }
36
+ }
37
+
38
+ function persistState(): void {
39
+ pi.appendEntry("pi-plan", { planFilePath, isPlanMode });
40
+ }
41
+
42
+ async function reviewLoop(ctx: ExtensionContext): Promise<void> {
43
+ while (true) {
44
+ let planContent: string;
45
+ try {
46
+ planContent = await readFile(planFilePath!, "utf-8");
47
+ } catch {
48
+ ctx.ui.notify("Could not read plan file.", "error");
49
+ return;
50
+ }
51
+
52
+ const steps = parsePlanSteps(planContent);
53
+
54
+ const choice = await ctx.ui.select(
55
+ `Plan (${steps.length} steps) — What would you like to do?`,
56
+ [
57
+ "Ready — Execute the plan",
58
+ "Edit — Ask for changes",
59
+ "Open in $EDITOR — Edit manually",
60
+ "Cancel — Discard the plan",
61
+ ],
62
+ );
63
+
64
+ if (!choice || choice.startsWith("Cancel")) {
65
+ isPlanMode = false;
66
+ updateUI(ctx);
67
+ persistState();
68
+ ctx.ui.notify("Plan cancelled.", "info");
69
+ return;
70
+ }
71
+
72
+ if (choice.startsWith("Ready")) {
73
+ if (savedCommandCtx) {
74
+ await startExecution(ctx, savedCommandCtx);
75
+ } else {
76
+ // Fallback: execute in same session if command context lost
77
+ await startExecutionInPlace(ctx);
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (choice.startsWith("Edit")) {
83
+ const changes = await ctx.ui.editor(
84
+ "What changes would you like to the plan?",
85
+ "",
86
+ );
87
+ if (changes?.trim()) {
88
+ pi.sendUserMessage(
89
+ `Update the plan at ${planFilePath} with these changes:\n\n${changes.trim()}\n\n` +
90
+ `Keep the same format.`,
91
+ );
92
+ return; // agent_end will re-trigger the review loop
93
+ }
94
+ continue;
95
+ }
96
+
97
+ if (choice.startsWith("Open")) {
98
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
99
+ try {
100
+ spawnSync(editor, [planFilePath!], { stdio: "inherit" });
101
+ } catch (err) {
102
+ ctx.ui.notify(`Failed to open editor: ${err}`, "error");
103
+ }
104
+ continue;
105
+ }
106
+ }
107
+ }
108
+
109
+ async function startExecution(
110
+ ctx: ExtensionContext,
111
+ cmdCtx: ExtensionCommandContext,
112
+ ): Promise<void> {
113
+ isPlanMode = false;
114
+ updateUI(ctx);
115
+ persistState();
116
+
117
+ let planContent: string;
118
+ try {
119
+ planContent = await readFile(planFilePath!, "utf-8");
120
+ } catch {
121
+ ctx.ui.notify("Could not read plan file for execution.", "error");
122
+ return;
123
+ }
124
+
125
+ const title = extractPlanTitle(planContent);
126
+
127
+ const result = await cmdCtx.newSession({
128
+ parentSession: ctx.sessionManager.getSessionFile(),
129
+ });
130
+
131
+ if (result.cancelled) {
132
+ ctx.ui.notify("Execution cancelled.", "warning");
133
+ return;
134
+ }
135
+
136
+ if (title) {
137
+ pi.setSessionName(`Plan: ${title}`);
138
+ }
139
+
140
+ pi.sendUserMessage(
141
+ `Execute the following plan step by step. After completing each step, note which step you just finished.\n\n${planContent}`,
142
+ { deliverAs: "followUp" },
143
+ );
144
+ }
145
+
146
+ async function startExecutionInPlace(ctx: ExtensionContext): Promise<void> {
147
+ isPlanMode = false;
148
+ updateUI(ctx);
149
+ persistState();
150
+
151
+ let planContent: string;
152
+ try {
153
+ planContent = await readFile(planFilePath!, "utf-8");
154
+ } catch {
155
+ ctx.ui.notify("Could not read plan file for execution.", "error");
156
+ return;
157
+ }
158
+
159
+ const title = extractPlanTitle(planContent);
160
+ if (title) {
161
+ pi.setSessionName(`Plan: ${title}`);
162
+ }
163
+
164
+ pi.sendUserMessage(
165
+ `Execute the following plan step by step. After completing each step, note which step you just finished.\n\n${planContent}`,
166
+ { deliverAs: "followUp" },
167
+ );
168
+ }
169
+
170
+ // --- Command Registration ---
171
+
172
+ pi.registerCommand("plan", {
173
+ description: "Create and execute a structured plan",
174
+ handler: async (args, ctx) => {
175
+ if (!args?.trim()) {
176
+ ctx.ui.notify("Usage: /plan <description of what to build>", "warning");
177
+ return;
178
+ }
179
+
180
+ // Save command context for newSession later
181
+ savedCommandCtx = ctx;
182
+
183
+ // Derive plan file path
184
+ const sessionFile = ctx.sessionManager.getSessionFile();
185
+ const sessionId = deriveSessionId(sessionFile);
186
+ const planDir = join(homedir(), ".pi", "agent", "plans", sessionId);
187
+ await mkdir(planDir, { recursive: true });
188
+ planFilePath = join(planDir, "plan.md");
189
+
190
+ // Enter planning mode
191
+ isPlanMode = true;
192
+ updateUI(ctx);
193
+ persistState();
194
+
195
+ // Ask the agent to create the plan
196
+ pi.sendUserMessage(
197
+ `Analyze the codebase and create a detailed plan for: ${args.trim()}\n\n` +
198
+ `Write the plan to: ${planFilePath}\n\n` +
199
+ `Use this format:\n\n` +
200
+ `# Plan: <title>\n\n` +
201
+ `## Goal\n<brief description of what we're building>\n\n` +
202
+ `## Steps\n\n` +
203
+ `1. Step one description\n` +
204
+ `2. Step two description\n` +
205
+ `3. Step three description\n...\n\n` +
206
+ `## Notes\n<any additional context, constraints, or decisions>\n\n` +
207
+ `Be specific and actionable in each step.`,
208
+ { deliverAs: "followUp" },
209
+ );
210
+ },
211
+ });
212
+
213
+ // --- Event Handlers ---
214
+
215
+ // Inject planning instructions
216
+ pi.on("before_agent_start", async () => {
217
+ if (!isPlanMode) return;
218
+
219
+ return {
220
+ message: {
221
+ customType: "pi-plan-context",
222
+ content: `[PLANNING MODE ACTIVE]
223
+ You are in planning mode. Your job is to explore the codebase and write a detailed, actionable plan.
224
+
225
+ Focus on reading and understanding the code — do NOT make any changes to the codebase yet.
226
+ Write the plan file using the specified format with numbered steps.`,
227
+ display: false,
228
+ },
229
+ };
230
+ });
231
+
232
+ // After agent finishes in planning mode, enter review loop
233
+ pi.on("agent_end", async (_event, ctx) => {
234
+ if (!isPlanMode || !planFilePath) return;
235
+ if (!ctx.hasUI) return;
236
+
237
+ // Check if the plan file exists
238
+ try {
239
+ await access(planFilePath);
240
+ } catch {
241
+ return; // Plan not written yet
242
+ }
243
+
244
+ // Read and validate the plan
245
+ const planContent = await readFile(planFilePath, "utf-8");
246
+ const steps = parsePlanSteps(planContent);
247
+
248
+ if (steps.length === 0) {
249
+ ctx.ui.notify(
250
+ "No steps found in the plan. Ask the agent to refine it.",
251
+ "warning",
252
+ );
253
+ return;
254
+ }
255
+
256
+ // Enter the review loop
257
+ await reviewLoop(ctx);
258
+ });
259
+
260
+ // Filter stale planning context from LLM messages
261
+ pi.on("context", async (event) => {
262
+ if (isPlanMode) return;
263
+
264
+ return {
265
+ messages: event.messages.filter((m) => {
266
+ const msg = m as typeof m & { customType?: string };
267
+ return msg.customType !== "pi-plan-context";
268
+ }),
269
+ };
270
+ });
271
+
272
+ // Restore state on session start
273
+ pi.on("session_start", async (_event, ctx) => {
274
+ const entries = ctx.sessionManager.getEntries();
275
+ const lastState = entries
276
+ .filter(
277
+ (e: { type: string; customType?: string }) =>
278
+ e.type === "custom" && e.customType === "pi-plan",
279
+ )
280
+ .pop() as
281
+ | { data?: { planFilePath: string | null; isPlanMode: boolean } }
282
+ | undefined;
283
+
284
+ if (lastState?.data) {
285
+ planFilePath = lastState.data.planFilePath ?? null;
286
+ isPlanMode = lastState.data.isPlanMode ?? false;
287
+ }
288
+
289
+ updateUI(ctx);
290
+ });
291
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@burneikis/pi-plan",
3
+ "version": "1.0.0",
4
+ "description": "A pi extension for structured plan-driven development",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/burneikis/pi-plan"
12
+ },
13
+ "peerDependencies": {
14
+ "@mariozechner/pi-coding-agent": "*"
15
+ },
16
+ "pi": {
17
+ "extensions": [
18
+ "./index.ts"
19
+ ]
20
+ }
21
+ }
package/plan.md ADDED
@@ -0,0 +1,334 @@
1
+ # Pi Plan Extension
2
+
3
+ A pi extension that adds a `/plan` command for structured plan-driven development. The agent creates a plan, the user reviews/edits it, then the agent executes it.
4
+
5
+ ## Usage
6
+
7
+ ```
8
+ /plan make a todo app with React and TypeScript
9
+ ```
10
+
11
+ ## Core Flow
12
+
13
+ 1. User runs `/plan <description>`
14
+ 2. Agent analyzes the codebase (read-only) and writes a `plan.md` file
15
+ 3. Extension displays the plan and prompts the user with options:
16
+ - **Ready** → clear context, start a new session, execute the plan
17
+ - **Edit** → ask the user what to change, agent rewrites plan, re-prompt
18
+ - **Open in $EDITOR** → open `plan.md` in the user's editor; on close, re-prompt with same options
19
+ 4. Agent executes the plan with full tool access
20
+
21
+ ## File Structure
22
+
23
+ ```
24
+ pi-plan/
25
+ ├── index.ts # Extension entry point
26
+ ├── utils.ts # Pure utility functions (plan parsing)
27
+ ├── plan.md # This plan document
28
+ ├── package.json # Extension metadata
29
+ └── README.md # User-facing documentation
30
+ ```
31
+
32
+ ## Plan Storage
33
+
34
+ - Plans are stored at `~/.pi/agent/plans/<session_id>/plan.md`
35
+ - `session_id` is derived from `ctx.sessionManager.getSessionFile()` (the basename without extension, or a fallback hash of the session path)
36
+ - The plan directory is created automatically via `node:fs/promises` `mkdir({ recursive: true })`
37
+ - Plans persist across session restarts and can be revisited
38
+
39
+ ## Plan Format
40
+
41
+ The agent writes plans in a consistent markdown format:
42
+
43
+ ```markdown
44
+ # Plan: <title>
45
+
46
+ ## Goal
47
+ <brief description of what we're building>
48
+
49
+ ## Steps
50
+
51
+ 1. Step one description
52
+ 2. Step two description
53
+ 3. Step three description
54
+ ...
55
+
56
+ ## Notes
57
+ <any additional context, constraints, or decisions>
58
+ ```
59
+
60
+ ## Implementation Details
61
+
62
+ ### Extension Entry Point (`index.ts`)
63
+
64
+ **Registration:**
65
+ - `pi.registerCommand("plan", { ... })` — the `/plan` command
66
+
67
+ **State:**
68
+ - `planFilePath: string | null` — path to current plan.md
69
+ - `isPlanMode: boolean` — whether we're in planning (read-only) mode
70
+
71
+ **Session persistence:**
72
+ - Use `pi.appendEntry("pi-plan", { planFilePath, isPlanMode })` to persist state
73
+ - Restore on `session_start` by scanning `ctx.sessionManager.getEntries()` for the last `pi-plan` custom entry
74
+
75
+ ### Command Handler (`/plan`)
76
+
77
+ ```typescript
78
+ pi.registerCommand("plan", {
79
+ description: "Create and execute a structured plan",
80
+ handler: async (args, ctx) => {
81
+ if (!args?.trim()) {
82
+ ctx.ui.notify("Usage: /plan <description of what to build>", "warning");
83
+ return;
84
+ }
85
+
86
+ // 1. Derive plan file path
87
+ const sessionFile = ctx.sessionManager.getSessionFile();
88
+ const sessionId = deriveSessionId(sessionFile);
89
+ const planDir = path.join(os.homedir(), ".pi", "agent", "plans", sessionId);
90
+ await mkdir(planDir, { recursive: true });
91
+ planFilePath = path.join(planDir, "plan.md");
92
+
93
+ // 2. Enter planning mode (read-only tools)
94
+ isPlanMode = true;
95
+ pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
96
+ updateUI(ctx);
97
+
98
+ // 3. Send a message to the agent asking it to create the plan
99
+ // The agent will use read-only tools to explore, then write the plan
100
+ pi.sendUserMessage(
101
+ `Analyze the codebase and create a detailed plan for: ${args.trim()}\n\n` +
102
+ `Write the plan to: ${planFilePath}\n\n` +
103
+ `Use the plan format with numbered steps.\n` +
104
+ `Include a Goal section and a Steps section. Be specific and actionable.`,
105
+ { deliverAs: "followUp" }
106
+ );
107
+ },
108
+ });
109
+ ```
110
+
111
+ ### Plan Review Loop
112
+
113
+ After the agent finishes writing the plan, prompt the user in the `agent_end` event:
114
+
115
+ ```typescript
116
+ pi.on("agent_end", async (event, ctx) => {
117
+ if (!isPlanMode || !planFilePath) return;
118
+ if (!ctx.hasUI) return;
119
+
120
+ // Check if the plan file exists
121
+ try {
122
+ await access(planFilePath);
123
+ } catch {
124
+ return; // Plan not written yet, agent may still be working
125
+ }
126
+
127
+ // Read and parse the plan
128
+ const planContent = await readFile(planFilePath, "utf-8");
129
+ const steps = parsePlanSteps(planContent);
130
+
131
+ if (steps.length === 0) {
132
+ ctx.ui.notify("No steps found in the plan. Ask the agent to refine it.", "warning");
133
+ return;
134
+ }
135
+
136
+ // Enter the review loop
137
+ await reviewLoop(ctx);
138
+ });
139
+ ```
140
+
141
+ **Review Loop Function:**
142
+
143
+ ```typescript
144
+ async function reviewLoop(ctx: ExtensionContext): Promise<void> {
145
+ while (true) {
146
+ const planContent = await readFile(planFilePath!, "utf-8");
147
+ const steps = parsePlanSteps(planContent);
148
+
149
+ const choice = await ctx.ui.select(
150
+ `Plan (${steps.length} steps) — What would you like to do?`,
151
+ [
152
+ "Ready — Execute the plan",
153
+ "Edit — Ask for changes",
154
+ "Open in $EDITOR — Edit manually",
155
+ "Cancel — Discard the plan",
156
+ ]
157
+ );
158
+
159
+ if (!choice || choice.startsWith("Cancel")) {
160
+ isPlanMode = false;
161
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
162
+ updateUI(ctx);
163
+ ctx.ui.notify("Plan cancelled.", "info");
164
+ return;
165
+ }
166
+
167
+ if (choice.startsWith("Ready")) {
168
+ await startExecution(ctx);
169
+ return;
170
+ }
171
+
172
+ if (choice.startsWith("Edit")) {
173
+ const changes = await ctx.ui.editor("What changes would you like to the plan?", "");
174
+ if (changes?.trim()) {
175
+ pi.sendUserMessage(
176
+ `Update the plan at ${planFilePath} with these changes:\n\n${changes.trim()}\n\n` +
177
+ `Keep the same format. Rewrite the full plan file.`
178
+ );
179
+ return; // agent_end will re-trigger the review loop
180
+ }
181
+ continue;
182
+ }
183
+
184
+ if (choice.startsWith("Open")) {
185
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
186
+ const result = await pi.exec(editor, [planFilePath!], {
187
+ stdio: "inherit",
188
+ });
189
+ continue;
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ **Note on $EDITOR:** Opening an external editor requires `pi.exec()` with the editor and file path. Since pi's TUI owns the terminal, we may need to use `ctx.ui.custom()` to temporarily yield control, or use `child_process.spawnSync` with `stdio: "inherit"` directly. This needs testing — if `pi.exec` doesn't support interactive terminal handoff, we'll use Node's `child_process` directly and handle terminal state manually.
196
+
197
+ ### Execution Mode
198
+
199
+ ```typescript
200
+ async function startExecution(ctx: ExtensionContext): Promise<void> {
201
+ isPlanMode = false;
202
+
203
+ // Restore full tool access
204
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
205
+ updateUI(ctx);
206
+ persistState();
207
+
208
+ // Read the plan content to include in the execution message
209
+ const planContent = await readFile(planFilePath!, "utf-8");
210
+
211
+ // Start a new session with the plan as context
212
+ const result = await ctx.newSession({
213
+ parentSession: ctx.sessionManager.getSessionFile(),
214
+ setup: async (sm) => {
215
+ sm.appendMessage({
216
+ role: "user",
217
+ content: [{ type: "text", text:
218
+ `Execute the following plan step by step.\n\n${planContent}`
219
+ }],
220
+ timestamp: Date.now(),
221
+ });
222
+ },
223
+ });
224
+
225
+ if (result.cancelled) {
226
+ updateUI(ctx);
227
+ ctx.ui.notify("Execution cancelled.", "warning");
228
+ return;
229
+ }
230
+
231
+ // Name the new session after the plan
232
+ const title = extractPlanTitle(planContent);
233
+ if (title) {
234
+ pi.setSessionName(`Plan: ${title}`);
235
+ }
236
+ }
237
+ ```
238
+
239
+ ### UI Updates
240
+
241
+ **Footer status:**
242
+ - Planning mode: `planning` (warning color)
243
+ - Neither: cleared
244
+
245
+ ```typescript
246
+ function updateUI(ctx: ExtensionContext): void {
247
+ if (isPlanMode) {
248
+ ctx.ui.setStatus("pi-plan", ctx.ui.theme.fg("warning", "planning"));
249
+ } else {
250
+ ctx.ui.setStatus("pi-plan", undefined);
251
+ }
252
+ }
253
+ ```
254
+
255
+ ### Injected Context
256
+
257
+ Use `before_agent_start` to inject planning instructions when in plan mode:
258
+
259
+ - **Planning mode:** Inject read-only instructions + plan format requirements
260
+ - **Neither:** No injection
261
+
262
+ ### Tool Restrictions in Planning Mode
263
+
264
+ During planning, restrict to read-only tools via `pi.setActiveTools()`:
265
+ - `read`, `bash`, `grep`, `find`, `ls`
266
+
267
+ Additionally, filter bash commands via `tool_call` event to block destructive operations (same approach as the existing plan-mode example — use allowlist of safe commands).
268
+
269
+ ### Session Restoration
270
+
271
+ On `session_start`, restore state from persisted entries:
272
+
273
+ ```typescript
274
+ pi.on("session_start", async (_event, ctx) => {
275
+ const entries = ctx.sessionManager.getEntries();
276
+ const lastState = entries
277
+ .filter(e => e.type === "custom" && e.customType === "pi-plan")
278
+ .pop();
279
+
280
+ if (lastState?.data) {
281
+ planFilePath = lastState.data.planFilePath;
282
+ isPlanMode = lastState.data.isPlanMode ?? false;
283
+ }
284
+
285
+ if (isPlanMode) {
286
+ pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
287
+ }
288
+ updateUI(ctx);
289
+ });
290
+ ```
291
+
292
+ ## Utility Functions (`utils.ts`)
293
+
294
+ ### `parsePlanSteps(content: string): PlanStep[]`
295
+ Parse the plan.md file and extract numbered steps from the Steps section.
296
+
297
+ ### `extractPlanTitle(content: string): string | null`
298
+ Extract the title from `# Plan: <title>` header.
299
+
300
+ ### `deriveSessionId(sessionFile: string | null): string`
301
+ Extract a safe directory name from the session file path.
302
+
303
+ ### `isSafeCommand(command: string): boolean`
304
+ Check if a bash command is on the read-only allowlist (reuse logic from existing plan-mode example).
305
+
306
+ ### Types
307
+
308
+ ```typescript
309
+ interface PlanStep {
310
+ step: number;
311
+ text: string;
312
+ }
313
+ ```
314
+
315
+ ## Edge Cases & Error Handling
316
+
317
+ - **No args to `/plan`:** Show usage hint via `ctx.ui.notify()`
318
+ - **Plan file doesn't exist when review loop runs:** Agent hasn't written it yet; skip the review prompt (handled by `access()` check)
319
+ - **Empty plan (no steps parsed):** Notify user and stay in planning mode so agent can try again
320
+ - **$EDITOR not set:** Fall back to `nano`
321
+ - **$EDITOR fails or is killed:** Catch error, notify user, continue review loop
322
+ - **Session has no session file (ephemeral):** Use a hash/timestamp-based fallback for the plan directory name
323
+ - **Non-interactive mode (`ctx.hasUI === false`):** Skip the review loop and UI updates; plan file is still written and can be used externally
324
+
325
+ ## Dependencies
326
+
327
+ - `node:fs/promises` — file operations (mkdir, readFile, writeFile, access)
328
+ - `node:path` — path manipulation
329
+ - `node:os` — homedir
330
+ - `node:child_process` — for $EDITOR handoff (if `pi.exec` doesn't support interactive stdio)
331
+ - `@mariozechner/pi-coding-agent` — extension types
332
+ - `@mariozechner/pi-tui` — Key for shortcuts
333
+
334
+ No external npm dependencies needed.
package/utils.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Pure utility functions for pi-plan extension.
3
+ */
4
+
5
+ import { basename } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+
8
+ export interface PlanStep {
9
+ step: number;
10
+ text: string;
11
+ }
12
+
13
+ /**
14
+ * Parse numbered steps from the "## Steps" section of a plan.
15
+ */
16
+ export function parsePlanSteps(content: string): PlanStep[] {
17
+ const steps: PlanStep[] = [];
18
+
19
+ // Find the ## Steps section
20
+ const stepsMatch = content.match(/^##\s+Steps\s*$/m);
21
+ if (!stepsMatch) return steps;
22
+
23
+ const stepsStart = content.indexOf(stepsMatch[0]) + stepsMatch[0].length;
24
+
25
+ // Extract until the next ## heading or end of file
26
+ const nextHeading = content.slice(stepsStart).match(/^##\s+/m);
27
+ const stepsSection = nextHeading
28
+ ? content.slice(stepsStart, stepsStart + nextHeading.index!)
29
+ : content.slice(stepsStart);
30
+
31
+ // Match numbered steps (e.g., "1. Step description")
32
+ const numberedPattern = /^\s*(\d+)[.)]\s+(.+)/gm;
33
+ for (const match of stepsSection.matchAll(numberedPattern)) {
34
+ const stepNum = parseInt(match[1], 10);
35
+ const text = match[2].trim();
36
+ if (text.length > 0) {
37
+ steps.push({ step: stepNum, text });
38
+ }
39
+ }
40
+
41
+ return steps;
42
+ }
43
+
44
+ /**
45
+ * Extract the plan title from "# Plan: <title>".
46
+ */
47
+ export function extractPlanTitle(content: string): string | null {
48
+ const match = content.match(/^#\s+Plan:\s*(.+)/m);
49
+ return match ? match[1].trim() : null;
50
+ }
51
+
52
+ /**
53
+ * Derive a safe directory name from the session file path.
54
+ * Uses the basename without extension, or a hash fallback.
55
+ */
56
+ export function deriveSessionId(sessionFile: string | null): string {
57
+ if (!sessionFile) {
58
+ // Fallback for ephemeral sessions
59
+ const hash = createHash("sha256").update(`${Date.now()}-${Math.random()}`).digest("hex");
60
+ return hash.slice(0, 16);
61
+ }
62
+
63
+ const base = basename(sessionFile);
64
+ // Remove extension (.json, .jsonl, etc.)
65
+ const withoutExt = base.replace(/\.[^.]+$/, "");
66
+ // Sanitize: only allow alphanumeric, hyphens, underscores
67
+ const sanitized = withoutExt.replace(/[^a-zA-Z0-9_-]/g, "_");
68
+ return sanitized || createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
69
+ }
70
+
71
+