@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.
Files changed (3) hide show
  1. package/README.md +10 -4
  2. package/package.json +1 -1
  3. 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
- - Restricts active tools to read-only tools while Plan mode is active.
14
- - Blocks mutating bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
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 active-tool restriction plus bash filtering, so it may be stricter or looser than Codex core in edge cases.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 READ_ONLY_TOOLS = ["read", "bash"];
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) activateReadOnlyTools();
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 (MUTATING_TOOLS.has(event.toolName)) {
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
- activateReadOnlyTools();
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
- ? ["Show latest proposed plan", "Implement this plan", "Stay in Plan mode", "Exit Plan mode"]
238
- : ["Stay in Plan mode", "Exit Plan mode"];
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 activateReadOnlyTools() {
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
- pi.setActiveTools(READ_ONLY_TOOLS);
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, ["Plan mode: read-only", "Produce a <proposed_plan> block."]);
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 "Plan mode is active and a proposed plan is ready.";
335
- return "Plan mode is active. Explore, ask, and produce a <proposed_plan> block.";
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>