@narumitw/pi-plan-mode 0.1.23 → 0.1.25
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 +10 -4
- package/package.json +1 -1
- package/src/plan-mode.ts +212 -15
package/README.md
CHANGED
|
@@ -10,8 +10,9 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
|
|
|
10
10
|
|
|
11
11
|
- Adds `/plan` to enter or manage Plan mode.
|
|
12
12
|
- Adds `--plan` to start a session in Plan mode.
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- Enables built-in read-only tools by default while Plan mode is active.
|
|
14
|
+
- Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
|
|
15
|
+
- Blocks mutating built-in tools and bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
|
|
15
16
|
- Injects Codex-like Plan mode instructions: explore first, ask only non-discoverable questions, do not mutate files, and finish with `<proposed_plan>`.
|
|
16
17
|
- Detects proposed plan blocks and prompts you to implement, revise, or stay in Plan mode.
|
|
17
18
|
- Shows Plan mode state in Pi's statusline as `📝 plan active` or `📝 plan ready`.
|
|
@@ -40,12 +41,17 @@ pi -e ./extensions/pi-plan-mode
|
|
|
40
41
|
```text
|
|
41
42
|
/plan
|
|
42
43
|
/plan <prompt>
|
|
44
|
+
/plan tools
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <prompt>` to enter Plan mode and immediately submit `<prompt>` as the first Plan-mode user message.
|
|
47
|
+
Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <prompt>` to enter Plan mode and immediately submit `<prompt>` as the first Plan-mode user message. Use `/plan tools` to choose which tools are active while Plan mode is enabled.
|
|
46
48
|
|
|
47
49
|
When Plan mode is active, ask the agent to design the change. The agent may inspect files and run read-only commands, but it should not edit files or execute the implementation.
|
|
48
50
|
|
|
51
|
+
By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, and available read-only built-ins such as `grep`, `find`, and `ls`. Built-in `edit` and `write` are blocked. Extension and custom tools are disabled by default because Pi tools do not expose standardized mutability metadata; enable them from `/plan tools` only when you accept the risk for that session. For example, you can opt into `firecrawl_scrape`, `firecrawl_search`, or `biome_lsp_diagnostics` if those extensions are loaded and you want to use them during planning.
|
|
52
|
+
|
|
53
|
+
Pi activates tools by tool name. The `/plan tools` selector stores selections by name and shows each currently effective tool's source from Pi metadata, such as `built-in`, a user extension path, or a project extension path. If an extension overrides a built-in tool with the same name, Pi exposes the effective tool for that name and the selector shows that source.
|
|
54
|
+
|
|
49
55
|
A complete Plan mode answer should include exactly one block like this:
|
|
50
56
|
|
|
51
57
|
```xml
|
|
@@ -87,7 +93,7 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
|
87
93
|
- `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
|
|
88
94
|
- `update_plan`-style checklist use is discouraged while Plan mode is active.
|
|
89
95
|
- The implementation boundary is explicit: Plan mode restores tools before starting implementation, and choosing implementation immediately triggers a normal agent turn with full tool access.
|
|
90
|
-
- Pi extension safety is approximated with
|
|
96
|
+
- Pi extension safety is approximated with built-in tool restriction plus bash filtering; non-built-in tools are user-selected at user risk because Plan mode does not classify extension/custom tool behavior.
|
|
91
97
|
|
|
92
98
|
## 🗂️ Package layout
|
|
93
99
|
|
package/package.json
CHANGED
package/src/plan-mode.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
4
4
|
const STATUS_KEY = "plan-mode";
|
|
5
5
|
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
6
6
|
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
7
|
-
const
|
|
7
|
+
const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
8
|
+
const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
|
|
8
9
|
const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
|
9
|
-
const MUTATING_TOOLS = new Set(["edit", "write"]);
|
|
10
10
|
const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
|
|
11
11
|
|
|
12
12
|
interface PlanModeState {
|
|
13
13
|
enabled: boolean;
|
|
14
14
|
latestPlan?: string;
|
|
15
15
|
awaitingAction: boolean;
|
|
16
|
+
selectedToolNames?: string[];
|
|
17
|
+
selectedToolKeys?: string[];
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
type SessionEntry = {
|
|
@@ -95,6 +97,11 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
95
97
|
ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
|
|
96
98
|
return;
|
|
97
99
|
}
|
|
100
|
+
if (command === "tools") {
|
|
101
|
+
if (!state.enabled) enterPlanMode(ctx);
|
|
102
|
+
await showToolSelector(ctx);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
98
105
|
if (prompt) {
|
|
99
106
|
enterPlanModeWithPrompt(prompt, ctx);
|
|
100
107
|
return;
|
|
@@ -111,7 +118,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
111
118
|
pi.on("session_start", (_event, ctx) => {
|
|
112
119
|
restoreState(ctx);
|
|
113
120
|
if (pi.getFlag("plan") === true) state.enabled = true;
|
|
114
|
-
if (state.enabled)
|
|
121
|
+
if (state.enabled) activatePlanModeTools();
|
|
115
122
|
updateUi(ctx);
|
|
116
123
|
});
|
|
117
124
|
|
|
@@ -122,13 +129,13 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
122
129
|
|
|
123
130
|
pi.on("tool_call", async (event) => {
|
|
124
131
|
if (!state.enabled) return;
|
|
125
|
-
if (
|
|
132
|
+
if (isBlockedBuiltinToolName(event.toolName)) {
|
|
126
133
|
return {
|
|
127
134
|
block: true,
|
|
128
|
-
reason: `Plan mode blocks mutating tool '${event.toolName}'. Use /plan and choose implementation when the plan is ready.`,
|
|
135
|
+
reason: `Plan mode blocks built-in mutating tool '${event.toolName}'. Use /plan and choose implementation when the plan is ready.`,
|
|
129
136
|
};
|
|
130
137
|
}
|
|
131
|
-
if (event.toolName !== "bash") return;
|
|
138
|
+
if (event.toolName !== "bash" || !isBuiltinToolName(event.toolName)) return;
|
|
132
139
|
|
|
133
140
|
const command = readCommand(event.input);
|
|
134
141
|
if (!isSafeCommand(command)) {
|
|
@@ -148,6 +155,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
148
155
|
|
|
149
156
|
pi.on("before_agent_start", () => {
|
|
150
157
|
if (!state.enabled) return;
|
|
158
|
+
applyPlanModeTools();
|
|
151
159
|
return {
|
|
152
160
|
message: {
|
|
153
161
|
customType: "plan-mode-context",
|
|
@@ -186,7 +194,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
186
194
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
187
195
|
if (!state.enabled) previousTools = safeGetActiveTools();
|
|
188
196
|
state = { ...state, enabled: true, awaitingAction: false };
|
|
189
|
-
|
|
197
|
+
activatePlanModeTools();
|
|
190
198
|
persistState();
|
|
191
199
|
updateUi(ctx);
|
|
192
200
|
}
|
|
@@ -234,8 +242,14 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
const choices = state.latestPlan
|
|
237
|
-
? [
|
|
238
|
-
|
|
245
|
+
? [
|
|
246
|
+
"Show latest proposed plan",
|
|
247
|
+
"Implement this plan",
|
|
248
|
+
"Configure Plan-mode tools",
|
|
249
|
+
"Stay in Plan mode",
|
|
250
|
+
"Exit Plan mode",
|
|
251
|
+
]
|
|
252
|
+
: ["Configure Plan-mode tools", "Stay in Plan mode", "Exit Plan mode"];
|
|
239
253
|
const choice = await ctx.ui.select(planStatusText(), choices);
|
|
240
254
|
if (choice === "Show latest proposed plan") {
|
|
241
255
|
ctx.ui.notify(state.latestPlan ?? "No proposed plan yet.", "info");
|
|
@@ -245,6 +259,10 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
245
259
|
startImplementation(ctx);
|
|
246
260
|
return;
|
|
247
261
|
}
|
|
262
|
+
if (choice === "Configure Plan-mode tools") {
|
|
263
|
+
await showToolSelector(ctx);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
248
266
|
if (choice === "Exit Plan mode") {
|
|
249
267
|
exitPlanMode(ctx);
|
|
250
268
|
ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
|
|
@@ -257,21 +275,127 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
257
275
|
const choice = await ctx.ui.select("Proposed plan ready. What next?", [
|
|
258
276
|
"Implement this plan",
|
|
259
277
|
"Revise plan",
|
|
278
|
+
"Configure Plan-mode tools",
|
|
260
279
|
"Stay in Plan mode",
|
|
261
280
|
]);
|
|
262
281
|
if (choice === "Implement this plan") {
|
|
263
282
|
startImplementation(ctx);
|
|
264
283
|
return;
|
|
265
284
|
}
|
|
285
|
+
if (choice === "Configure Plan-mode tools") {
|
|
286
|
+
await showToolSelector(ctx);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
266
289
|
if (choice === "Revise plan") {
|
|
267
290
|
const refinement = await ctx.ui.editor("Revise the plan", "");
|
|
268
291
|
if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
|
|
269
292
|
}
|
|
270
293
|
}
|
|
271
294
|
|
|
272
|
-
function
|
|
295
|
+
async function showToolSelector(ctx: ExtensionContext) {
|
|
296
|
+
if (!ctx.hasUI) {
|
|
297
|
+
ctx.ui.notify(formatToolSummary(), "info");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
while (true) {
|
|
302
|
+
const tools = selectableTools();
|
|
303
|
+
const selectedNames = planModeSelectedNames(tools);
|
|
304
|
+
const choices = tools.map((tool, index) =>
|
|
305
|
+
formatToolChoice(tool, selectedNames.has(tool.name), index),
|
|
306
|
+
);
|
|
307
|
+
const doneChoice = "Done";
|
|
308
|
+
const choice = await ctx.ui.select(
|
|
309
|
+
"Plan-mode tools. Non-built-in tools run at user risk.",
|
|
310
|
+
[...choices, doneChoice],
|
|
311
|
+
);
|
|
312
|
+
if (!choice || choice === doneChoice) break;
|
|
313
|
+
|
|
314
|
+
const selectedIndex = choices.indexOf(choice);
|
|
315
|
+
const tool = tools[selectedIndex];
|
|
316
|
+
if (!tool) continue;
|
|
317
|
+
if (!canSelectToolInPlanMode(tool)) {
|
|
318
|
+
ctx.ui.notify(`${tool.name} is blocked in Plan mode.`, "warning");
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const nextSelectedNames = planModeSelectedNames(tools);
|
|
323
|
+
if (nextSelectedNames.has(tool.name)) nextSelectedNames.delete(tool.name);
|
|
324
|
+
else nextSelectedNames.add(tool.name);
|
|
325
|
+
|
|
326
|
+
state = {
|
|
327
|
+
...state,
|
|
328
|
+
selectedToolNames: filterAvailableSelectedNames(Array.from(nextSelectedNames), tools),
|
|
329
|
+
};
|
|
330
|
+
applyPlanModeTools();
|
|
331
|
+
persistState();
|
|
332
|
+
updateUi(ctx);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
applyPlanModeTools();
|
|
336
|
+
persistState();
|
|
337
|
+
updateUi(ctx);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function activatePlanModeTools() {
|
|
273
341
|
previousTools ??= safeGetActiveTools();
|
|
274
|
-
|
|
342
|
+
applyPlanModeTools();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyPlanModeTools() {
|
|
346
|
+
pi.setActiveTools(planModeToolNames());
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function planModeToolNames() {
|
|
350
|
+
const tools = selectableTools();
|
|
351
|
+
if (tools.length === 0) return ["read", "bash"];
|
|
352
|
+
|
|
353
|
+
const selectedNames = planModeSelectedNames(tools);
|
|
354
|
+
return tools
|
|
355
|
+
.filter((tool) => selectedNames.has(tool.name) && canSelectToolInPlanMode(tool))
|
|
356
|
+
.map((tool) => tool.name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function planModeSelectedNames(tools: ToolInfo[]) {
|
|
360
|
+
const selectedToolNames = state.selectedToolNames ?? migrateSelectedToolKeys(tools);
|
|
361
|
+
if (selectedToolNames === undefined) return new Set(defaultPlanModeToolNames(tools));
|
|
362
|
+
|
|
363
|
+
state = {
|
|
364
|
+
...state,
|
|
365
|
+
selectedToolNames: filterAvailableSelectedNames(selectedToolNames, tools),
|
|
366
|
+
selectedToolKeys: undefined,
|
|
367
|
+
};
|
|
368
|
+
return new Set(state.selectedToolNames);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function defaultPlanModeToolNames(tools: ToolInfo[]) {
|
|
372
|
+
return tools
|
|
373
|
+
.filter((tool) => isBuiltinTool(tool) && SAFE_BUILTIN_PLAN_TOOLS.has(tool.name))
|
|
374
|
+
.map((tool) => tool.name);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function migrateSelectedToolKeys(tools: ToolInfo[]) {
|
|
378
|
+
if (state.selectedToolKeys === undefined) return undefined;
|
|
379
|
+
return state.selectedToolKeys
|
|
380
|
+
.map((key) => toolNameFromLegacyKey(key, tools))
|
|
381
|
+
.filter((name): name is string => name !== undefined);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function filterAvailableSelectedNames(names: string[], tools: ToolInfo[]) {
|
|
385
|
+
const availableNames = new Set(tools.filter(canSelectToolInPlanMode).map((tool) => tool.name));
|
|
386
|
+
return unique(names.filter((name) => availableNames.has(name)));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function selectableTools() {
|
|
390
|
+
return safeGetAllTools().sort(compareTools);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function safeGetAllTools() {
|
|
394
|
+
try {
|
|
395
|
+
return pi.getAllTools();
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
275
399
|
}
|
|
276
400
|
|
|
277
401
|
function restoreTools() {
|
|
@@ -301,6 +425,8 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
301
425
|
enabled: entry.data.enabled ?? false,
|
|
302
426
|
latestPlan: entry.data.latestPlan,
|
|
303
427
|
awaitingAction: entry.data.awaitingAction ?? false,
|
|
428
|
+
selectedToolNames: entry.data.selectedToolNames,
|
|
429
|
+
selectedToolKeys: entry.data.selectedToolKeys,
|
|
304
430
|
};
|
|
305
431
|
}
|
|
306
432
|
|
|
@@ -312,7 +438,11 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
312
438
|
"Use /plan to implement, revise, or exit Plan mode.",
|
|
313
439
|
]);
|
|
314
440
|
} else if (state.enabled) {
|
|
315
|
-
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
441
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
442
|
+
"Plan mode: planning",
|
|
443
|
+
formatToolSummary(),
|
|
444
|
+
"Produce a <proposed_plan> block.",
|
|
445
|
+
]);
|
|
316
446
|
} else {
|
|
317
447
|
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
318
448
|
}
|
|
@@ -331,9 +461,75 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
331
461
|
|
|
332
462
|
function planStatusText() {
|
|
333
463
|
if (!state.enabled) return "Plan mode is off.";
|
|
334
|
-
if (state.latestPlan) return
|
|
335
|
-
return
|
|
464
|
+
if (state.latestPlan) return `Plan mode is active and a proposed plan is ready. ${formatToolSummary()}`;
|
|
465
|
+
return `Plan mode is active. ${formatToolSummary()} Explore, ask, and produce a <proposed_plan> block.`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function formatToolSummary() {
|
|
469
|
+
const names = planModeToolNames();
|
|
470
|
+
return `Tools: ${names.length > 0 ? names.join(", ") : "none"}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isBlockedBuiltinToolName(toolName: string) {
|
|
474
|
+
if (!BLOCKED_BUILTIN_TOOLS.has(toolName)) return false;
|
|
475
|
+
const tool = toolByName(toolName);
|
|
476
|
+
return tool ? isBuiltinTool(tool) : true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isBuiltinToolName(toolName: string) {
|
|
480
|
+
const tool = toolByName(toolName);
|
|
481
|
+
return tool ? isBuiltinTool(tool) : toolName === "bash";
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function toolByName(toolName: string) {
|
|
485
|
+
return safeGetAllTools().find((candidate) => candidate.name === toolName);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isBuiltinTool(tool: ToolInfo) {
|
|
490
|
+
return tool.sourceInfo.source === "builtin";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
494
|
+
if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function toolNameFromLegacyKey(key: string, tools: ToolInfo[]) {
|
|
499
|
+
const directName = tools.find((tool) => tool.name === key)?.name;
|
|
500
|
+
if (directName) return directName;
|
|
501
|
+
const [name] = key.split("\u001f");
|
|
502
|
+
return tools.find((tool) => tool.name === name) ? name : undefined;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function compareTools(left: ToolInfo, right: ToolInfo) {
|
|
506
|
+
const leftBuiltin = isBuiltinTool(left);
|
|
507
|
+
const rightBuiltin = isBuiltinTool(right);
|
|
508
|
+
if (leftBuiltin !== rightBuiltin) return leftBuiltin ? -1 : 1;
|
|
509
|
+
return left.name.localeCompare(right.name);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatToolChoice(tool: ToolInfo, selected: boolean, index: number) {
|
|
513
|
+
const marker = selected ? "[x]" : "[ ]";
|
|
514
|
+
return `${marker} ${index + 1}. ${tool.name} (${toolPolicyLabel(tool)})`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function toolPolicyLabel(tool: ToolInfo) {
|
|
518
|
+
if (isBuiltinTool(tool)) {
|
|
519
|
+
if (!SAFE_BUILTIN_PLAN_TOOLS.has(tool.name)) return "built-in blocked";
|
|
520
|
+
return tool.name === "bash" ? "built-in limited" : "built-in";
|
|
336
521
|
}
|
|
522
|
+
return `user risk: ${toolSourceLabel(tool)}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function toolSourceLabel(tool: ToolInfo) {
|
|
526
|
+
const sourceInfo = tool.sourceInfo;
|
|
527
|
+
const source = `${sourceInfo.scope}/${sourceInfo.source}`;
|
|
528
|
+
return sourceInfo.path ? `${source} ${sourceInfo.path}` : source;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function unique(values: string[]) {
|
|
532
|
+
return Array.from(new Set(values));
|
|
337
533
|
}
|
|
338
534
|
|
|
339
535
|
function buildPlanModePrompt() {
|
|
@@ -346,6 +542,7 @@ Mode rules:
|
|
|
346
542
|
- Use non-mutating exploration first: read files, search, inspect configuration, run read-only checks, and resolve discoverable facts before asking the user.
|
|
347
543
|
- Ask the user only for preferences or tradeoffs that cannot be discovered from the repository.
|
|
348
544
|
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
545
|
+
- Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
|
|
349
546
|
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
350
547
|
- When the plan is decision-complete, output exactly one proposed plan block using:
|
|
351
548
|
<proposed_plan>
|