@narumitw/pi-plan-mode 0.13.0 → 0.14.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 mutating built-in tools and bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
15
+ - Blocks `update_plan`, mutating built-in tools, and unsafe shell 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 finish with `<proposed_plan>` only when decision-complete.
17
17
  - Adds a required `plan_mode_question` tool so the agent can ask structured Plan-mode questions before finalizing a plan.
18
18
  - Detects proposed plan blocks and prompts you to implement, stay in Plan mode, or exit and discard the plan.
@@ -49,7 +49,9 @@ Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <
49
49
 
50
50
  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. It should explore first, then use structured questions when your preference or a tradeoff materially changes the plan.
51
51
 
52
- 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` tool. 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
+ 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` tool. 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.
53
+
54
+ 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, command substitution, 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.
53
55
 
54
56
  `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.
55
57
 
@@ -75,7 +77,9 @@ A complete Plan mode answer should appear only after the agent has resolved disc
75
77
  </proposed_plan>
76
78
  ```
77
79
 
78
- After a proposed plan is detected, `/plan` lets you choose whether to implement the plan, stay in Plan mode, or exit Plan mode. Choosing implementation disables Plan mode, restores full tool access, and immediately starts an implementation turn with the proposed plan. Choosing Stay keeps the plan ready while you decide what to do next; to revise the plan, choose Stay and type your revision feedback in the normal prompt. When that next Plan-mode turn starts, the previous plan is no longer treated as the latest implementable plan unless the agent produces an updated `<proposed_plan>`. Choosing exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
80
+ The tags must be on their own lines and the block must be non-empty, complete, and unique within the response. Empty, malformed, unclosed, or multiple blocks keep Plan mode active and produce a warning so the plan can be corrected.
81
+
82
+ After a valid proposed plan is detected, `/plan` lets you choose whether to implement the plan, stay in Plan mode, or exit Plan mode. Choosing implementation disables Plan mode, restores full tool access, and immediately starts an implementation turn with the proposed plan. Choosing Stay keeps the plan ready while you decide what to do next; to revise the plan, choose Stay and type your revision feedback in the normal prompt. When that next Plan-mode turn starts, the previous plan is no longer treated as the latest implementable plan unless the agent produces an updated `<proposed_plan>`. Choosing exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
79
83
 
80
84
  While Plan mode is enabled, the extension also publishes a compact status for Pi statuslines. With `@narumitw/pi-statusline`, this appears in the extension status area:
81
85
 
@@ -88,6 +92,18 @@ You can also exit directly. Direct exit discards the latest proposed plan instea
88
92
  /plan exit
89
93
  ```
90
94
 
95
+ ## ⚙️ Thinking level
96
+
97
+ Plan mode inherits Pi's current thinking level by default. To request a fixed level only while Plan mode is active, create `$PI_CODING_AGENT_DIR/plan-mode.json` (normally `~/.pi/agent/plan-mode.json`):
98
+
99
+ ```json
100
+ {
101
+ "thinkingLevel": "medium"
102
+ }
103
+ ```
104
+
105
+ Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. The extension snapshots the prior level and restores it on exit only if the level still matches the value it applied; a manual change made during Plan mode is preserved. The settings file is optional, is read at session start, and never changes Pi's default setting. Invalid settings produce a warning and fall back to `inherit`.
106
+
91
107
  ## 🧠 Codex-like behavior
92
108
 
93
109
  This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
@@ -95,23 +111,25 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
95
111
  - Plan mode is a conversational collaboration mode, not TODO/progress tracking.
96
112
  - `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
97
113
  - The agent should use `plan_mode_question` for important non-discoverable preferences or tradeoffs before finalizing.
98
- - `update_plan`-style checklist use is discouraged while Plan mode is active.
114
+ - `update_plan` checklist use is blocked while Plan mode is active.
99
115
  - 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 proposed plan.
100
- - 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.
116
+ - 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.
117
+ - Unlike native Codex, Pi detects complete plan blocks after the turn rather than streaming protocol-level Plan items, and it cannot provide sandbox-level enforcement.
101
118
 
102
119
  ## 🗂️ Package layout
103
120
 
104
121
  ```txt
105
122
  extensions/pi-plan-mode/
106
123
  ├── src/
107
- └── plan-mode.ts
124
+ ├── plan-mode.ts # Pi entrypoint and mode state
125
+ │ └── *.ts # Package-local prompt, policy, question, and message modules
108
126
  ├── README.md
109
127
  ├── LICENSE
110
128
  ├── tsconfig.json
111
129
  └── package.json
112
130
  ```
113
131
 
114
- The package exposes its Pi extension through `package.json`:
132
+ Only `plan-mode.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
115
133
 
116
134
  ```json
117
135
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "devDependencies": {
31
- "@biomejs/biome": "2.5.2",
31
+ "@biomejs/biome": "2.5.3",
32
32
  "@earendil-works/pi-coding-agent": "0.80.3",
33
33
  "typescript": "6.0.3"
34
34
  },
@@ -0,0 +1,120 @@
1
+ const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
2
+ const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
3
+ const PROPOSED_PLAN_PATTERN = /^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
4
+ const PROPOSED_PLAN_BLOCK_PATTERN = /^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
5
+
6
+ export type ProposedPlanParseResult =
7
+ | { kind: "absent" }
8
+ | { kind: "valid"; plan: string }
9
+ | { kind: "empty" }
10
+ | { kind: "multiple" }
11
+ | { kind: "malformed" }
12
+ | { kind: "unclosed" };
13
+
14
+ type SessionMessage = {
15
+ role?: string;
16
+ content?: unknown;
17
+ };
18
+
19
+ type TextBlock = {
20
+ type?: string;
21
+ text?: string;
22
+ };
23
+
24
+ export function parseProposedPlan(text: string): ProposedPlanParseResult {
25
+ const openingCount = text.match(/<proposed_plan>/gi)?.length ?? 0;
26
+ const closingCount = text.match(/<\/proposed_plan>/gi)?.length ?? 0;
27
+ if (openingCount === 0 && closingCount === 0) return { kind: "absent" };
28
+ if (openingCount > 1 || closingCount > 1) return { kind: "multiple" };
29
+ if (openingCount === 1 && closingCount === 0) return { kind: "unclosed" };
30
+ if (openingCount !== 1 || closingCount !== 1) return { kind: "malformed" };
31
+
32
+ const matches = Array.from(text.matchAll(PROPOSED_PLAN_PATTERN));
33
+ if (matches.length !== 1) return { kind: "malformed" };
34
+ const plan = matches[0]?.[1]?.trim() ?? "";
35
+ return plan ? { kind: "valid", plan } : { kind: "empty" };
36
+ }
37
+
38
+ export function extractProposedPlan(text: string) {
39
+ const result = parseProposedPlan(text);
40
+ return result.kind === "valid" ? result.plan : undefined;
41
+ }
42
+
43
+ export function latestAssistantText(messages: unknown) {
44
+ if (!Array.isArray(messages)) return "";
45
+ for (const entry of [...messages].reverse()) {
46
+ const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
47
+ if (message?.role !== "assistant") continue;
48
+ const text = messageText(message);
49
+ if (text) return text;
50
+ }
51
+ return "";
52
+ }
53
+
54
+ export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
55
+ return unwrapSessionMessage(message).customType === PLAN_CONTEXT_MESSAGE_TYPE;
56
+ }
57
+
58
+ export function messageContainsInactivePlanModeArtifact(message: unknown) {
59
+ return unwrapSessionMessage(message).customType === PROPOSED_PLAN_MESSAGE_TYPE;
60
+ }
61
+
62
+ export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
63
+ const candidate = unwrapSessionMessage(message);
64
+ if (candidate.role !== "assistant") return message;
65
+
66
+ const content = stripProposedPlanBlocksFromContent(candidate.content);
67
+ if (content === candidate.content) return message;
68
+
69
+ if (isSessionMessageEntry(message)) {
70
+ return { ...message, message: { ...candidate, content } };
71
+ }
72
+ return { ...candidate, content } as T;
73
+ }
74
+
75
+ function unwrapSessionMessage(message: unknown) {
76
+ const entry = message as { message?: unknown };
77
+ return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
78
+ }
79
+
80
+ function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
81
+ return typeof message === "object" && message !== null && "message" in message;
82
+ }
83
+
84
+ function stripProposedPlanBlocksFromContent(content: unknown) {
85
+ if (typeof content === "string") return stripProposedPlanBlocks(content);
86
+ if (!Array.isArray(content)) return content;
87
+
88
+ let changed = false;
89
+ const nextContent = content.map((block) => {
90
+ const textBlock = block as TextBlock;
91
+ if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
92
+
93
+ const text = stripProposedPlanBlocks(textBlock.text);
94
+ if (text === textBlock.text) return block;
95
+
96
+ changed = true;
97
+ return { ...textBlock, text };
98
+ });
99
+ return changed ? nextContent : content;
100
+ }
101
+
102
+ export function stripProposedPlanBlocks(text: string) {
103
+ return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
104
+ }
105
+
106
+ function messageText(message: SessionMessage) {
107
+ return contentText(message.content);
108
+ }
109
+
110
+ function contentText(content: unknown): string {
111
+ if (typeof content === "string") return content;
112
+ if (!Array.isArray(content)) return "";
113
+ return content
114
+ .map((block) => {
115
+ const textBlock = block as TextBlock;
116
+ return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
117
+ })
118
+ .filter(Boolean)
119
+ .join("\n");
120
+ }