@narumitw/pi-plan-mode 0.17.2 → 0.18.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 CHANGED
@@ -12,7 +12,7 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
12
12
  - Adds `--plan` to start a session in Plan mode.
13
13
  - Enables built-in read-only tools by default while Plan mode is active.
14
14
  - Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
15
- - Blocks `update_plan`, mutating built-in tools, and unsafe shell forms such as writes, substitutions, background jobs, dependency installs, and mutating Git commands.
15
+ - Blocks `update_plan`, mutating built-in tools, and unsafe `bash` forms such as writes, substitutions, background jobs, dependency installs, and mutating Git commands.
16
16
  - Injects Codex-like Plan mode instructions: explore first, ask decision questions for high-impact ambiguity, do not mutate files, and finalize only when decision-complete.
17
17
  - Adds required `plan_mode_question` and `plan_mode_complete` tools for structured questions and completion.
18
18
  - Presents the complete plan and prompts you to implement, stay in Plan mode, or exit and discard it.
@@ -58,7 +58,7 @@ When Plan mode is active, ask the agent to design the change. The agent may insp
58
58
 
59
59
  By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, available read-only built-ins such as `grep`, `find`, and `ls`, plus the required `plan_mode_question` and `plan_mode_complete` tools. Built-in `edit` and `write` are blocked. `update_plan` is also blocked because it tracks execution progress rather than conversational planning. 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 `lsp_diagnostics` if those extensions are loaded and you want to use them during planning.
60
60
 
61
- Limited `bash` uses a fail-closed policy. It accepts common inspection commands, read-only Git and npm queries, pipelines and command lists composed entirely of accepted commands, plus selected checks such as `npm test`, `npm run typecheck`, and `cargo test`. It rejects output/input redirects, shell expansion, substitutions, subshells, background jobs, mutating flags, dependency changes, editors, and unknown commands. Tests and builds may still write ignored caches or build artifacts and may execute project-defined hooks; enable or invoke them only when the repository is trusted. This is extension-level risk reduction, not an OS sandbox.
61
+ Limited `bash` uses a fail-closed policy, including when an extension overrides the canonical `bash` tool name. It accepts common inspection commands, read-only Git and npm queries, pipelines and command lists composed entirely of accepted commands, plus selected checks such as `npm test`, `npm run typecheck`, and `cargo test`. It rejects output/input redirects, shell expansion, substitutions, subshells, background jobs, mutating flags, dependency changes, editors, and unknown commands. Tests and builds may still write ignored caches or build artifacts and may execute project-defined hooks; enable or invoke them only when the repository is trusted. This is extension-level risk reduction, not an OS sandbox.
62
62
 
63
63
  `plan_mode_question` follows Codex's `request_user_input` pattern: the agent can ask 1-3 concise questions, each with meaningful options and a free-form Other path. If you cancel or no interactive UI is available, the agent should ask a concise plain-text question or proceed only with a clearly stated low-risk assumption instead of prematurely producing a final plan.
64
64
 
@@ -102,7 +102,7 @@ Create `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-m
102
102
 
103
103
  `defaultPlanTools` defines the initial tool selection when a session has no stored `/plan tools` selection. Omit it to keep the available safe built-ins as the default. An explicit empty array is valid and enables only the required `plan_mode_question` and `plan_mode_complete` tools.
104
104
 
105
- Tool names must be non-empty strings; duplicates are removed in first-seen order. Unknown, unavailable, and Plan-mode-blocked names are ignored when tools are activated. A tool registered after Plan mode is already active is not added automatically; re-enter Plan mode or reopen `/plan tools` to reapply the selection. Non-built-in tools named in this global setting are an explicit user-risk opt-in, just like selecting them with `/plan tools`. Pi resolves tools by name, so if an extension overrides a built-in name, the effective extension tool is selected instead.
105
+ Tool names must be non-empty strings; duplicates are removed in first-seen order. Unknown, unavailable, and Plan-mode-blocked names are ignored when tools are activated. A tool registered after Plan mode is already active is not added automatically; re-enter Plan mode or reopen `/plan tools` to reapply the selection. Non-built-in tools named in this global setting are an explicit user-risk opt-in, just like selecting them with `/plan tools`. Pi resolves tools by name, so if an extension overrides a built-in name, the effective extension tool is selected instead. An effective tool named `bash` remains subject to the limited-shell policy regardless of its source metadata.
106
106
 
107
107
  A selection made with `/plan tools` is stored in that Pi session and takes precedence over `defaultPlanTools` when the session resumes. The global setting remains the baseline for fresh sessions and sessions without an explicit selection.
108
108
 
@@ -160,7 +160,7 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
160
160
  - The agent completes with a standalone `plan_mode_complete` tool call instead of relying on semantic prose detection.
161
161
  - `update_plan` checklist use is blocked while Plan mode is active.
162
162
  - The implementation boundary is explicit: Plan mode restores tools before starting implementation, choosing implementation immediately triggers a normal agent turn with full tool access, and plain exit/off discards the stored plan.
163
- - Pi extension safety is approximated with tool classification and fail-closed bash filtering; non-built-in tools remain user-selected at user risk because Pi does not expose standardized tool mutability metadata.
163
+ - Pi extension safety is approximated with tool classification and fail-closed filtering for every effective tool named `bash`; other non-built-in tools remain user-selected at user risk because Pi does not expose standardized tool mutability metadata.
164
164
  - Unlike native Codex, this extension uses a terminating Pi tool plus an `agent_settled` ready flow; Pi cannot provide sandbox-level enforcement.
165
165
 
166
166
  ## 🗂️ Package layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
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
@@ -23,6 +23,7 @@ import {
23
23
  planModeQuestionAnswered,
24
24
  planModeQuestionCancelled,
25
25
  } from "./question-tool.js";
26
+ import { showPersistentSelector } from "./selector-ui.js";
26
27
  import {
27
28
  configuredThinkingLevel,
28
29
  type PlanModeSettings,
@@ -58,6 +59,10 @@ interface ReadyPresentationIntent {
58
59
  source: PlanCompletionSource;
59
60
  }
60
61
 
62
+ type PlanToolSelectorValue =
63
+ | { kind: "tool"; tool: ToolInfo }
64
+ | { kind: "action"; action: "previous" | "next" | "done" };
65
+
61
66
  const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
62
67
  { value: "show", label: "show", description: "Show the completed plan" },
63
68
  { value: "finalize", label: "finalize", description: "Request a completed plan" },
@@ -256,7 +261,8 @@ export default function planMode(pi: ExtensionAPI) {
256
261
  reason: `Plan mode blocks built-in tool '${event.toolName}' because its metadata is unavailable.`,
257
262
  };
258
263
  }
259
- if (event.toolName !== "bash" || !isBuiltinToolName(event.toolName)) return;
264
+ // Built-in-compatible overrides retain the canonical name but replace its source metadata.
265
+ if (event.toolName !== "bash") return;
260
266
 
261
267
  const command = readCommand(event.input);
262
268
  if (!isSafeCommand(command, settings.safeSubcommands)) {
@@ -547,6 +553,56 @@ export default function planMode(pi: ExtensionAPI) {
547
553
  return;
548
554
  }
549
555
 
556
+ const tools = selectableTools();
557
+ const pageCount = toolSelectorPageCount(tools);
558
+ let pageIndex = 0;
559
+ const customHandled = await showPersistentSelector(
560
+ ctx,
561
+ () => {
562
+ pageIndex = Math.min(pageIndex, pageCount - 1);
563
+ const pageStart = pageIndex * TOOL_SELECTOR_PAGE_SIZE;
564
+ const pageTools = tools.slice(pageStart, pageStart + TOOL_SELECTOR_PAGE_SIZE);
565
+ const selectedNames = planModeSelectedNames(tools);
566
+ const rows: Array<{ value: PlanToolSelectorValue; label: string }> = pageTools.map(
567
+ (tool, index) => ({
568
+ value: { kind: "tool", tool },
569
+ label: formatToolChoice(tool, selectedNames.has(tool.name), pageStart + index),
570
+ }),
571
+ );
572
+ if (pageIndex > 0) {
573
+ rows.push({ value: { kind: "action", action: "previous" }, label: "Previous page" });
574
+ }
575
+ if (pageIndex < pageCount - 1) {
576
+ rows.push({ value: { kind: "action", action: "next" }, label: "Next page" });
577
+ }
578
+ rows.push({ value: { kind: "action", action: "done" }, label: "Done" });
579
+ return {
580
+ title: `Plan-mode tools (${pageIndex + 1}/${pageCount}). Non-built-in tools run at user risk.`,
581
+ rows,
582
+ };
583
+ },
584
+ (value) => {
585
+ if (value.kind === "action") {
586
+ if (value.action === "done") return "close";
587
+ pageIndex += value.action === "previous" ? -1 : 1;
588
+ return "reset";
589
+ }
590
+ if (!canSelectToolInPlanMode(value.tool)) {
591
+ ctx.ui.notify(`${value.tool.name} is blocked in Plan mode.`, "warning");
592
+ return "stay";
593
+ }
594
+ togglePlanModeTool(value.tool, tools, ctx);
595
+ return "stay";
596
+ },
597
+ );
598
+ if (!customHandled) await showDialogToolSelector(ctx);
599
+
600
+ applyPlanModeTools();
601
+ persistState();
602
+ updateUi(ctx);
603
+ }
604
+
605
+ async function showDialogToolSelector(ctx: ExtensionContext) {
550
606
  let pageIndex = 0;
551
607
  while (true) {
552
608
  const tools = selectableTools();
@@ -570,7 +626,7 @@ export default function planMode(pi: ExtensionAPI) {
570
626
  `Plan-mode tools (${pageIndex + 1}/${pageCount}). Non-built-in tools run at user risk.`,
571
627
  [...choices, ...navigationChoices],
572
628
  );
573
- if (!choice || choice === doneChoice) break;
629
+ if (!choice || choice === doneChoice) return;
574
630
  if (choice === previousChoice) {
575
631
  pageIndex = Math.max(0, pageIndex - 1);
576
632
  continue;
@@ -579,28 +635,24 @@ export default function planMode(pi: ExtensionAPI) {
579
635
  pageIndex = Math.min(pageCount - 1, pageIndex + 1);
580
636
  continue;
581
637
  }
582
-
583
- const selectedIndex = choices.indexOf(choice);
584
- const tool = pageTools[selectedIndex];
638
+ const tool = pageTools[choices.indexOf(choice)];
585
639
  if (!tool) continue;
586
640
  if (!canSelectToolInPlanMode(tool)) {
587
641
  ctx.ui.notify(`${tool.name} is blocked in Plan mode.`, "warning");
588
642
  continue;
589
643
  }
590
-
591
- const nextSelectedNames = planModeSelectedNames(tools);
592
- if (nextSelectedNames.has(tool.name)) nextSelectedNames.delete(tool.name);
593
- else nextSelectedNames.add(tool.name);
594
-
595
- state = {
596
- ...state,
597
- selectedToolNames: filterAvailableSelectedNames(Array.from(nextSelectedNames), tools),
598
- };
599
- applyPlanModeTools();
600
- persistState();
601
- updateUi(ctx);
644
+ togglePlanModeTool(tool, tools, ctx);
602
645
  }
646
+ }
603
647
 
648
+ function togglePlanModeTool(tool: ToolInfo, tools: ToolInfo[], ctx: ExtensionContext) {
649
+ const nextSelectedNames = planModeSelectedNames(tools);
650
+ if (nextSelectedNames.has(tool.name)) nextSelectedNames.delete(tool.name);
651
+ else nextSelectedNames.add(tool.name);
652
+ state = {
653
+ ...state,
654
+ selectedToolNames: filterAvailableSelectedNames(Array.from(nextSelectedNames), tools),
655
+ };
604
656
  applyPlanModeTools();
605
657
  persistState();
606
658
  updateUi(ctx);
@@ -806,11 +858,6 @@ export default function planMode(pi: ExtensionAPI) {
806
858
  return `Tools: ${names.length > 0 ? names.join(", ") : "none"}`;
807
859
  }
808
860
 
809
- function isBuiltinToolName(toolName: string) {
810
- const tool = toolByName(toolName);
811
- return tool ? isBuiltinTool(tool) : toolName === "bash";
812
- }
813
-
814
861
  function toolByName(toolName: string) {
815
862
  return safeGetAllTools().find((candidate) => candidate.name === toolName);
816
863
  }
@@ -0,0 +1,95 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface PersistentSelectorRow<T> {
4
+ value: T;
5
+ label: string;
6
+ }
7
+
8
+ export interface PersistentSelectorView<T> {
9
+ title: string;
10
+ rows: PersistentSelectorRow<T>[];
11
+ help?: string;
12
+ }
13
+
14
+ export type PersistentSelectorActivation = "stay" | "reset" | "close";
15
+
16
+ export async function showPersistentSelector<T>(
17
+ ctx: ExtensionContext,
18
+ getView: () => PersistentSelectorView<T>,
19
+ onActivate: (value: T) => PersistentSelectorActivation,
20
+ ): Promise<boolean> {
21
+ const result = await ctx.ui.custom<"closed" | undefined>((tui, theme, keybindings, done) => {
22
+ let selectedIndex = 0;
23
+ const currentView = () => {
24
+ const view = getView();
25
+ selectedIndex = Math.min(selectedIndex, Math.max(0, view.rows.length - 1));
26
+ return view;
27
+ };
28
+ const moveSelection = (delta: number) => {
29
+ const { rows } = currentView();
30
+ if (rows.length === 0) return;
31
+ selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
32
+ };
33
+ const activateSelectedRow = () => {
34
+ const row = currentView().rows[selectedIndex];
35
+ if (!row) return;
36
+ const action = onActivate(row.value);
37
+ if (action === "reset") selectedIndex = 0;
38
+ else if (action === "close") done("closed");
39
+ };
40
+
41
+ return {
42
+ invalidate() {},
43
+ render(width: number) {
44
+ const view = currentView();
45
+ return [
46
+ theme.fg("accent", theme.bold(clipLine(view.title, width))),
47
+ "",
48
+ ...view.rows.map((row, index) => {
49
+ const prefix = index === selectedIndex ? "› " : " ";
50
+ const line = clipLine(`${prefix}${row.label}`, width);
51
+ return index === selectedIndex ? theme.fg("accent", line) : line;
52
+ }),
53
+ "",
54
+ theme.fg(
55
+ "dim",
56
+ clipLine(view.help ?? "↑↓ navigate • Enter/Space toggle • Esc close", width),
57
+ ),
58
+ ];
59
+ },
60
+ handleInput(data: string) {
61
+ if (keybindings.matches(data, "tui.select.up")) {
62
+ moveSelection(-1);
63
+ tui.requestRender();
64
+ return;
65
+ }
66
+ if (keybindings.matches(data, "tui.select.down")) {
67
+ moveSelection(1);
68
+ tui.requestRender();
69
+ return;
70
+ }
71
+ if (keybindings.matches(data, "tui.select.pageUp")) {
72
+ selectedIndex = 0;
73
+ tui.requestRender();
74
+ return;
75
+ }
76
+ if (keybindings.matches(data, "tui.select.pageDown")) {
77
+ selectedIndex = Math.max(0, currentView().rows.length - 1);
78
+ tui.requestRender();
79
+ return;
80
+ }
81
+ if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
82
+ activateSelectedRow();
83
+ tui.requestRender();
84
+ return;
85
+ }
86
+ if (keybindings.matches(data, "tui.select.cancel")) done("closed");
87
+ },
88
+ };
89
+ });
90
+ return result === "closed";
91
+ }
92
+
93
+ function clipLine(value: string, width: number) {
94
+ return Array.from(value).slice(0, Math.max(0, width)).join("");
95
+ }