@bacnh85/pi-plan 0.1.1 → 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.
- package/README.md +21 -11
- package/index.ts +257 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Pi extension that adds a lightweight plan mode inspired by Codex and Claude Code:
|
|
4
4
|
|
|
5
|
-
- Toggle plan mode with `/plan
|
|
6
|
-
-
|
|
5
|
+
- Toggle plan mode with `/plan` or `Ctrl+Alt+P`.
|
|
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
|
+
- 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.
|
|
9
10
|
- Prompts after a plan is written so you can approve execution, approve only, keep planning, or refine with feedback.
|
|
10
11
|
|
|
11
12
|
## Install
|
|
@@ -39,29 +40,38 @@ pi --plan
|
|
|
39
40
|
| Command / shortcut | Description |
|
|
40
41
|
| --- | --- |
|
|
41
42
|
| `/plan` | Toggle plan mode. |
|
|
42
|
-
| `/plan on` / `/plan off` | Explicitly enter or exit plan mode. |
|
|
43
|
-
| `/plan levels` | Select planning and normal/execution thinking levels. |
|
|
44
|
-
| `/plan status` | Show current mode, thinking levels, and last plan file. |
|
|
45
|
-
| `/plan high` | Set the plan-mode thinking level directly (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). |
|
|
46
43
|
| `Ctrl+Alt+P` | Toggle plan mode. |
|
|
47
44
|
|
|
48
45
|
## Workflow
|
|
49
46
|
|
|
50
47
|
1. Enter plan mode.
|
|
51
48
|
2. Ask pi to research the task and propose an implementation.
|
|
52
|
-
3. The model explores read-only context
|
|
53
|
-
4. The
|
|
54
|
-
5.
|
|
55
|
-
|
|
49
|
+
3. The model explores read-only context. If a consequential decision remains ambiguous, it can call `ask_plan_question` so you can choose an option or type your own answer.
|
|
50
|
+
4. The model calls `write_plan`.
|
|
51
|
+
5. The plan is saved under `.agents/plans/<timestamp>-<title>.md`.
|
|
52
|
+
6. Choose one of the approval options:
|
|
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.
|
|
56
55
|
- **Approve only**: exits plan mode without starting work.
|
|
57
56
|
- **Keep planning**: remains in plan mode.
|
|
58
57
|
- **Refine with feedback**: sends your feedback as a follow-up planning prompt.
|
|
59
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
|
+
|
|
61
|
+
## Reasoning levels
|
|
62
|
+
|
|
63
|
+
pi-plan remembers two reasoning levels:
|
|
64
|
+
|
|
65
|
+
- Change Pi's active reasoning level while plan mode is active to update the planning level.
|
|
66
|
+
- Change Pi's active reasoning level in normal or execution mode to update the normal/execution level.
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
60
70
|
## Design notes
|
|
61
71
|
|
|
62
72
|
Research findings used for this extension:
|
|
63
73
|
|
|
64
|
-
- Pi has no built-in plan mode by design, but extensions can implement it with commands, shortcuts, tool gating,
|
|
74
|
+
- Pi has no built-in plan mode by design, but extensions can implement it with commands, shortcuts, tools, tool gating, and prompt injection.
|
|
65
75
|
- Claude Code plan mode emphasizes read-only exploration, writing a Markdown plan, and an approval flow before edits.
|
|
66
76
|
- Codex guidance recommends plan mode for complex or ambiguous tasks and using higher reasoning levels for harder planning/debugging work.
|
|
67
77
|
|
package/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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";
|
|
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");
|
|
10
14
|
const PLAN_MODE_DISABLED_TOOLS = new Set(["edit", "write"]);
|
|
11
|
-
const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", PLAN_TOOL];
|
|
15
|
+
const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", PLAN_TOOL, PLAN_QUESTION_TOOL];
|
|
12
16
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
13
17
|
|
|
14
18
|
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
@@ -25,12 +29,29 @@ interface PlanState {
|
|
|
25
29
|
lastPlanStatus?: PlanStatus;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
interface PlanPreferences {
|
|
33
|
+
version: 1;
|
|
34
|
+
planThinking: ThinkingLevel;
|
|
35
|
+
normalThinking: ThinkingLevel;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
interface WritePlanParams {
|
|
29
39
|
title: string;
|
|
30
40
|
content: string;
|
|
31
41
|
status?: PlanStatus;
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
interface PlanQuestionOption {
|
|
45
|
+
label: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PlanQuestionParams {
|
|
50
|
+
question: string;
|
|
51
|
+
options: PlanQuestionOption[];
|
|
52
|
+
allowOther?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
const DESTRUCTIVE_BASH_PATTERNS = [
|
|
35
56
|
/\brm\b/i,
|
|
36
57
|
/\brmdir\b/i,
|
|
@@ -90,6 +111,43 @@ function relativeToCwd(cwd: string, absolutePath: string): string {
|
|
|
90
111
|
return path.relative(cwd, absolutePath).split(path.sep).join("/");
|
|
91
112
|
}
|
|
92
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
|
+
|
|
93
151
|
function isDestructiveBash(command: string): boolean {
|
|
94
152
|
return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
|
|
95
153
|
}
|
|
@@ -103,6 +161,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
103
161
|
let lastPlanPath: string | undefined;
|
|
104
162
|
let lastPlanTitle: string | undefined;
|
|
105
163
|
let lastPlanStatus: PlanStatus | undefined;
|
|
164
|
+
let applyingStoredThinking = false;
|
|
106
165
|
|
|
107
166
|
function setStatus(ctx: ExtensionContext): void {
|
|
108
167
|
if (planModeEnabled) {
|
|
@@ -116,17 +175,8 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
116
175
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
117
176
|
}
|
|
118
177
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
ctx.ui.setWidget(STATUS_KEY, [
|
|
125
|
-
ctx.ui.theme.fg("warning", "Plan mode: read-only exploration"),
|
|
126
|
-
`Plan file target: ${PLAN_DIR}/`,
|
|
127
|
-
`Plan thinking: ${planThinking}; normal thinking: ${normalThinking}`,
|
|
128
|
-
"Use write_plan when the plan is ready for approval.",
|
|
129
|
-
]);
|
|
178
|
+
function clearPlanWidget(ctx: ExtensionContext): void {
|
|
179
|
+
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
130
180
|
}
|
|
131
181
|
|
|
132
182
|
function persistState(): void {
|
|
@@ -142,6 +192,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
142
192
|
} satisfies PlanState);
|
|
143
193
|
}
|
|
144
194
|
|
|
195
|
+
function persistPreferences(): void {
|
|
196
|
+
void savePreferences({ version: 1, planThinking, normalThinking }).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
function enablePlanTools(): void {
|
|
146
200
|
const baseline = toolsBeforePlan ?? pi.getActiveTools();
|
|
147
201
|
toolsBeforePlan = baseline;
|
|
@@ -154,7 +208,24 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
154
208
|
}
|
|
155
209
|
|
|
156
210
|
function applyThinking(level: ThinkingLevel): void {
|
|
211
|
+
applyingStoredThinking = true;
|
|
157
212
|
pi.setThinkingLevel(level);
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
applyingStoredThinking = false;
|
|
215
|
+
}, 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function recordActiveThinkingLevel(level: ThinkingLevel, ctx: ExtensionContext): void {
|
|
219
|
+
if (planModeEnabled) {
|
|
220
|
+
if (planThinking === level) return;
|
|
221
|
+
planThinking = level;
|
|
222
|
+
} else {
|
|
223
|
+
if (normalThinking === level) return;
|
|
224
|
+
normalThinking = level;
|
|
225
|
+
}
|
|
226
|
+
setStatus(ctx);
|
|
227
|
+
persistState();
|
|
228
|
+
persistPreferences();
|
|
158
229
|
}
|
|
159
230
|
|
|
160
231
|
function enterPlanMode(ctx: ExtensionContext): void {
|
|
@@ -163,7 +234,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
163
234
|
enablePlanTools();
|
|
164
235
|
applyThinking(planThinking);
|
|
165
236
|
setStatus(ctx);
|
|
166
|
-
|
|
237
|
+
clearPlanWidget(ctx);
|
|
167
238
|
persistState();
|
|
168
239
|
ctx.ui.notify(`Plan mode enabled. Thinking=${planThinking}. Plans will be written to ${PLAN_DIR}/`, "info");
|
|
169
240
|
}
|
|
@@ -174,47 +245,62 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
174
245
|
restoreTools();
|
|
175
246
|
if (restoreThinking) applyThinking(normalThinking);
|
|
176
247
|
setStatus(ctx);
|
|
177
|
-
|
|
248
|
+
clearPlanWidget(ctx);
|
|
178
249
|
persistState();
|
|
179
250
|
ctx.ui.notify(`Plan mode disabled. Thinking=${pi.getThinkingLevel()}.`, "info");
|
|
180
251
|
}
|
|
181
252
|
|
|
182
|
-
async function
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (planModeEnabled)
|
|
188
|
-
else
|
|
253
|
+
async function handlePlanCommand(args: string, ctx: ExtensionContext): Promise<void> {
|
|
254
|
+
if (args.trim().length > 0) {
|
|
255
|
+
ctx.ui.notify("/plan does not take arguments; use /plan or Ctrl+Alt+P to toggle plan mode.", "warning");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (planModeEnabled) leavePlanMode(ctx);
|
|
259
|
+
else enterPlanMode(ctx);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function beginCurrentSessionExecution(ctx: ExtensionContext, relativePlan: string): void {
|
|
263
|
+
planModeEnabled = false;
|
|
264
|
+
executionMode = true;
|
|
265
|
+
lastPlanStatus = "approved";
|
|
266
|
+
restoreTools();
|
|
267
|
+
applyThinking(normalThinking);
|
|
189
268
|
setStatus(ctx);
|
|
190
|
-
|
|
269
|
+
clearPlanWidget(ctx);
|
|
191
270
|
persistState();
|
|
192
|
-
|
|
271
|
+
persistPreferences();
|
|
272
|
+
pi.sendUserMessage(buildExecutionPrompt(relativePlan, "current"), { deliverAs: "followUp" });
|
|
193
273
|
}
|
|
194
274
|
|
|
195
|
-
async function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (subcommand === "on" || subcommand === "start") return enterPlanMode(ctx);
|
|
199
|
-
if (subcommand === "off" || subcommand === "stop") return leavePlanMode(ctx);
|
|
200
|
-
if (subcommand === "status") {
|
|
201
|
-
ctx.ui.notify(
|
|
202
|
-
[`Mode: ${planModeEnabled ? "planning" : executionMode ? "executing" : "normal"}`, `Plan thinking: ${planThinking}`, `Normal thinking: ${normalThinking}`, `Last plan: ${lastPlanPath ? relativeToCwd(ctx.cwd, lastPlanPath) : "none"}`].join("\n"),
|
|
203
|
-
"info",
|
|
204
|
-
);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (subcommand.length > 0 && isThinkingLevel(subcommand)) {
|
|
208
|
-
planThinking = subcommand;
|
|
209
|
-
if (planModeEnabled) applyThinking(planThinking);
|
|
210
|
-
setStatus(ctx);
|
|
211
|
-
setPlanWidget(ctx);
|
|
212
|
-
persistState();
|
|
213
|
-
ctx.ui.notify(`Plan thinking set to ${planThinking}`, "info");
|
|
275
|
+
async function beginNewSessionExecution(ctx: ExtensionCommandContext): Promise<void> {
|
|
276
|
+
if (!lastPlanPath) {
|
|
277
|
+
ctx.ui.notify("No approved plan is available to execute.", "error");
|
|
214
278
|
return;
|
|
215
279
|
}
|
|
216
|
-
|
|
217
|
-
|
|
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");
|
|
218
304
|
}
|
|
219
305
|
|
|
220
306
|
pi.registerFlag("plan", {
|
|
@@ -228,7 +314,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
228
314
|
label: "Write Plan",
|
|
229
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.`,
|
|
230
316
|
promptSnippet: `Write the implementation plan to ${PLAN_DIR}/ as a Markdown file for user review`,
|
|
231
|
-
promptGuidelines: [
|
|
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
|
+
],
|
|
232
321
|
parameters: Type.Object({
|
|
233
322
|
title: Type.String({ description: "Short human-readable title for the plan" }),
|
|
234
323
|
content: Type.String({ description: "Complete Markdown plan content" }),
|
|
@@ -246,22 +335,107 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
246
335
|
lastPlanTitle = typedParams.title.trim() || "Plan";
|
|
247
336
|
lastPlanStatus = isPlanStatus(typedParams.status) ? typedParams.status : "draft";
|
|
248
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
|
+
: "";
|
|
249
341
|
return {
|
|
250
|
-
content: [{ type: "text", text: `Plan written to ${relativeToCwd(ctx.cwd, destination)}.
|
|
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}` }],
|
|
251
343
|
details: { path: destination, title: lastPlanTitle, status: lastPlanStatus },
|
|
252
344
|
};
|
|
253
345
|
},
|
|
254
346
|
});
|
|
255
347
|
|
|
256
|
-
pi.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
348
|
+
pi.registerTool({
|
|
349
|
+
name: PLAN_QUESTION_TOOL,
|
|
350
|
+
label: "Ask Plan Question",
|
|
351
|
+
description: "Ask the user a consequential planning question with selectable options and optional free-form input.",
|
|
352
|
+
promptSnippet: "Ask the user a concise planning question with 2-4 concrete options when an open decision materially affects the plan.",
|
|
353
|
+
promptGuidelines: [
|
|
354
|
+
"Use only after repository research leaves a consequential ambiguity that affects the plan.",
|
|
355
|
+
"Prefer 2-4 concrete options with short labels and useful descriptions.",
|
|
356
|
+
"Do not ask about details that can be discovered from the repository.",
|
|
357
|
+
"If the user already gave a preference, incorporate it instead of asking again.",
|
|
358
|
+
],
|
|
359
|
+
parameters: Type.Object({
|
|
360
|
+
question: Type.String({ description: "The planning question to ask the user" }),
|
|
361
|
+
options: Type.Array(
|
|
362
|
+
Type.Object({
|
|
363
|
+
label: Type.String({ description: "Short selectable option label" }),
|
|
364
|
+
description: Type.Optional(Type.String({ description: "Optional explanation shown with the option" })),
|
|
365
|
+
}),
|
|
366
|
+
{ description: "Concrete options for the user to choose from" },
|
|
367
|
+
),
|
|
368
|
+
allowOther: Type.Optional(Type.Boolean({ description: "Whether to allow a free-form user answer; defaults to true" })),
|
|
369
|
+
}),
|
|
370
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
371
|
+
const typedParams = params as PlanQuestionParams;
|
|
372
|
+
const options = typedParams.options ?? [];
|
|
373
|
+
if (options.length === 0) {
|
|
374
|
+
return {
|
|
375
|
+
content: [{ type: "text", text: "Error: ask_plan_question requires at least one option." }],
|
|
376
|
+
details: { question: typedParams.question, answer: null },
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (!ctx.hasUI) {
|
|
380
|
+
return {
|
|
381
|
+
content: [{ type: "text", text: "UI is not available. Ask this planning question directly in chat and wait for the user's answer." }],
|
|
382
|
+
details: { question: typedParams.question, options, answer: null },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const allowOther = typedParams.allowOther !== false;
|
|
387
|
+
const labels = options.map((option) =>
|
|
388
|
+
option.description ? `${option.label} — ${option.description}` : option.label,
|
|
389
|
+
);
|
|
390
|
+
const otherLabel = "Other / type my answer";
|
|
391
|
+
const choice = await ctx.ui.select(typedParams.question, allowOther ? [...labels, otherLabel] : labels);
|
|
392
|
+
if (!choice) {
|
|
393
|
+
return {
|
|
394
|
+
content: [{ type: "text", text: "User cancelled the planning question." }],
|
|
395
|
+
details: { question: typedParams.question, options, answer: null, cancelled: true },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (choice === otherLabel) {
|
|
400
|
+
const answer = (await ctx.ui.editor("Your answer", ""))?.trim();
|
|
401
|
+
if (!answer) {
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: "text", text: "User cancelled the planning question." }],
|
|
404
|
+
details: { question: typedParams.question, options, answer: null, cancelled: true },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
content: [{ type: "text", text: `User wrote: ${answer}` }],
|
|
409
|
+
details: { question: typedParams.question, options, answer, wasCustom: true },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const selectedIndex = labels.indexOf(choice);
|
|
414
|
+
const selected = options[selectedIndex];
|
|
415
|
+
const answer = selected?.label ?? choice;
|
|
416
|
+
return {
|
|
417
|
+
content: [{ type: "text", text: `User selected: ${answer}` }],
|
|
418
|
+
details: { question: typedParams.question, options, answer, selectedIndex: selectedIndex + 1, wasCustom: false },
|
|
419
|
+
};
|
|
261
420
|
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
pi.registerCommand("plan", {
|
|
424
|
+
description: "Toggle pi-plan mode",
|
|
262
425
|
handler: async (args, ctx) => handlePlanCommand(args, ctx),
|
|
263
426
|
});
|
|
264
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
|
+
|
|
265
439
|
pi.registerShortcut("ctrl+alt+p", {
|
|
266
440
|
description: "Toggle pi-plan mode",
|
|
267
441
|
handler: async (ctx) => {
|
|
@@ -271,27 +445,41 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
271
445
|
});
|
|
272
446
|
|
|
273
447
|
pi.on("session_start", async (_event, ctx) => {
|
|
448
|
+
const preferences = await loadPreferences();
|
|
274
449
|
const entries = ctx.sessionManager.getEntries();
|
|
275
450
|
const saved = entries
|
|
276
451
|
.filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === "pi-plan")
|
|
277
452
|
.pop() as { data?: PlanState } | undefined;
|
|
453
|
+
if (preferences) {
|
|
454
|
+
planThinking = preferences.planThinking;
|
|
455
|
+
normalThinking = preferences.normalThinking;
|
|
456
|
+
}
|
|
278
457
|
if (saved?.data) {
|
|
279
458
|
planModeEnabled = saved.data.enabled ?? planModeEnabled;
|
|
280
459
|
executionMode = saved.data.executing ?? executionMode;
|
|
281
|
-
|
|
282
|
-
|
|
460
|
+
if (!preferences && isThinkingLevel(saved.data.planThinking)) planThinking = saved.data.planThinking;
|
|
461
|
+
if (!preferences && isThinkingLevel(saved.data.normalThinking)) normalThinking = saved.data.normalThinking;
|
|
283
462
|
toolsBeforePlan = saved.data.toolsBeforePlan ?? toolsBeforePlan;
|
|
284
463
|
lastPlanPath = saved.data.lastPlanPath ?? lastPlanPath;
|
|
285
464
|
lastPlanTitle = saved.data.lastPlanTitle ?? lastPlanTitle;
|
|
286
465
|
lastPlanStatus = saved.data.lastPlanStatus ?? lastPlanStatus;
|
|
287
466
|
}
|
|
467
|
+
if (!preferences) persistPreferences();
|
|
288
468
|
if (pi.getFlag("plan") === true) planModeEnabled = true;
|
|
289
469
|
if (planModeEnabled) {
|
|
290
470
|
enablePlanTools();
|
|
291
471
|
applyThinking(planThinking);
|
|
472
|
+
} else if (executionMode) {
|
|
473
|
+
applyThinking(normalThinking);
|
|
292
474
|
}
|
|
293
475
|
setStatus(ctx);
|
|
294
|
-
|
|
476
|
+
clearPlanWidget(ctx);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
pi.on("thinking_level_select", async (event, ctx) => {
|
|
480
|
+
if (applyingStoredThinking) return;
|
|
481
|
+
if (!isThinkingLevel(event.level)) return;
|
|
482
|
+
recordActiveThinkingLevel(event.level, ctx);
|
|
295
483
|
});
|
|
296
484
|
|
|
297
485
|
pi.on("tool_call", async (event) => {
|
|
@@ -311,7 +499,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
311
499
|
return {
|
|
312
500
|
message: {
|
|
313
501
|
customType: "pi-plan-context",
|
|
314
|
-
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.\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.`,
|
|
315
503
|
display: false,
|
|
316
504
|
},
|
|
317
505
|
};
|
|
@@ -337,22 +525,29 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
337
525
|
pi.on("agent_end", async (_event, ctx) => {
|
|
338
526
|
if (!planModeEnabled || !lastPlanPath || !ctx.hasUI) return;
|
|
339
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)";
|
|
340
530
|
const choice = await ctx.ui.select(`Plan written: ${relativePlan}`, [
|
|
341
|
-
|
|
531
|
+
currentSessionChoice,
|
|
532
|
+
newSessionChoice,
|
|
342
533
|
"Approve only (exit plan mode)",
|
|
343
534
|
"Keep planning",
|
|
344
535
|
"Refine with feedback",
|
|
345
536
|
]);
|
|
346
|
-
if (choice ===
|
|
537
|
+
if (choice === currentSessionChoice) {
|
|
538
|
+
beginCurrentSessionExecution(ctx, relativePlan);
|
|
539
|
+
} else if (choice === newSessionChoice) {
|
|
347
540
|
planModeEnabled = false;
|
|
348
|
-
executionMode =
|
|
541
|
+
executionMode = false;
|
|
349
542
|
lastPlanStatus = "approved";
|
|
350
543
|
restoreTools();
|
|
351
544
|
applyThinking(normalThinking);
|
|
352
545
|
setStatus(ctx);
|
|
353
|
-
|
|
546
|
+
clearPlanWidget(ctx);
|
|
354
547
|
persistState();
|
|
355
|
-
|
|
548
|
+
persistPreferences();
|
|
549
|
+
ctx.ui.setEditorText(`/${PLAN_EXECUTE_COMMAND} new`);
|
|
550
|
+
ctx.ui.notify("Press Enter to start execution in a new session.", "info");
|
|
356
551
|
} else if (choice === "Approve only (exit plan mode)") {
|
|
357
552
|
lastPlanStatus = "approved";
|
|
358
553
|
leavePlanMode(ctx);
|