@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.
- package/README.md +53 -0
- package/index.ts +291 -0
- package/package.json +21 -0
- package/plan.md +334 -0
- 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
|
+
|