@narumitw/pi-plan-mode 0.1.27 → 0.1.29

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
@@ -14,7 +14,7 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
14
14
  - Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
15
15
  - Blocks mutating built-in tools and bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
16
16
  - Injects Codex-like Plan mode instructions: explore first, ask only non-discoverable questions, do not mutate files, and finish with `<proposed_plan>`.
17
- - Detects proposed plan blocks and prompts you to implement, revise, or stay in Plan mode.
17
+ - Detects proposed plan blocks and prompts you to implement, revise, stay in Plan mode, or exit and discard the plan.
18
18
  - Shows Plan mode state in Pi's statusline as `📝 plan active` or `📝 plan ready`.
19
19
  - Persists Plan mode state in the Pi session so resume restores the mode.
20
20
 
@@ -72,14 +72,14 @@ A complete Plan mode answer should include exactly one block like this:
72
72
  </proposed_plan>
73
73
  ```
74
74
 
75
- After a proposed plan is detected, `/plan` lets you choose whether to implement the plan, revise it, 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.
75
+ After a proposed plan is detected, `/plan` lets you choose whether to implement the plan, revise it, 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 exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
76
76
 
77
77
  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:
78
78
 
79
79
  - `📝 plan active`: Plan mode is enabled and still gathering context or drafting a plan.
80
80
  - `📝 plan ready`: A `<proposed_plan>` was detected and is waiting for your next `/plan` action.
81
81
 
82
- You can also exit directly:
82
+ You can also exit directly. Direct exit discards the latest proposed plan instead of treating it as an implementation request:
83
83
 
84
84
  ```text
85
85
  /plan exit
@@ -92,7 +92,7 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
92
92
  - Plan mode is a conversational collaboration mode, not TODO/progress tracking.
93
93
  - `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
94
94
  - `update_plan`-style checklist use is discouraged while Plan mode is active.
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.
95
+ - 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.
96
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.
97
97
 
98
98
  ## 🗂️ Package layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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
@@ -3,12 +3,16 @@ import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-
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
+ const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
7
+ const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
8
+ const PLAN_IMPLEMENTATION_MESSAGE_TYPE = "plan-mode-implementation";
6
9
  const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
7
10
  const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
8
11
  const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
9
12
  const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
10
13
  const TOOL_SELECTOR_PAGE_SIZE = 10;
11
14
  const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
15
+ const PROPOSED_PLAN_BLOCK_PATTERN = /<proposed_plan>\s*[\s\S]*?\s*<\/proposed_plan>/gi;
12
16
 
13
17
  interface PlanModeState {
14
18
  enabled: boolean;
@@ -95,7 +99,7 @@ export default function planMode(pi: ExtensionAPI) {
95
99
  const command = prompt.toLowerCase();
96
100
  if (command === "exit" || command === "off") {
97
101
  exitPlanMode(ctx);
98
- ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
102
+ ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
99
103
  return;
100
104
  }
101
105
  if (command === "tools") {
@@ -150,7 +154,9 @@ export default function planMode(pi: ExtensionAPI) {
150
154
  pi.on("context", async (event) => {
151
155
  if (state.enabled) return;
152
156
  return {
153
- messages: event.messages.filter((message: unknown) => !messageContainsPlanModeContext(message)),
157
+ messages: event.messages
158
+ .filter((message: unknown) => !messageContainsInactivePlanModeArtifact(message))
159
+ .map(stripProposedPlanBlocksFromMessage),
154
160
  };
155
161
  });
156
162
 
@@ -159,7 +165,7 @@ export default function planMode(pi: ExtensionAPI) {
159
165
  applyPlanModeTools();
160
166
  return {
161
167
  message: {
162
- customType: "plan-mode-context",
168
+ customType: PLAN_CONTEXT_MESSAGE_TYPE,
163
169
  content: buildPlanModePrompt(),
164
170
  display: false,
165
171
  },
@@ -180,16 +186,18 @@ export default function planMode(pi: ExtensionAPI) {
180
186
  state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
181
187
  persistState();
182
188
  updateUi(ctx);
189
+
190
+ if (ctx.hasUI) await showPlanReadyMenu(ctx);
191
+ if (!state.enabled || !state.latestPlan) return;
192
+
183
193
  pi.sendMessage(
184
194
  {
185
- customType: "proposed-plan",
195
+ customType: PROPOSED_PLAN_MESSAGE_TYPE,
186
196
  content: `**Proposed Plan**\n\n${proposedPlan}`,
187
197
  display: true,
188
198
  },
189
199
  { triggerTurn: false },
190
200
  );
191
-
192
- if (ctx.hasUI) await showPlanReadyMenu(ctx);
193
201
  });
194
202
 
195
203
  function enterPlanMode(ctx: ExtensionContext) {
@@ -211,8 +219,9 @@ export default function planMode(pi: ExtensionAPI) {
211
219
  }
212
220
 
213
221
  function exitPlanMode(ctx: ExtensionContext) {
214
- state = { ...state, enabled: false, awaitingAction: false };
215
- restoreTools();
222
+ const wasEnabled = state.enabled;
223
+ state = { ...state, enabled: false, latestPlan: undefined, awaitingAction: false };
224
+ if (wasEnabled) restoreTools();
216
225
  persistState();
217
226
  updateUi(ctx);
218
227
  }
@@ -228,7 +237,7 @@ export default function planMode(pi: ExtensionAPI) {
228
237
 
229
238
  pi.sendMessage(
230
239
  {
231
- customType: "plan-mode-implementation",
240
+ customType: PLAN_IMPLEMENTATION_MESSAGE_TYPE,
232
241
  content: `Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:\n\n${plan}`,
233
242
  display: true,
234
243
  },
@@ -266,7 +275,7 @@ export default function planMode(pi: ExtensionAPI) {
266
275
  }
267
276
  if (choice === "Exit Plan mode") {
268
277
  exitPlanMode(ctx);
269
- ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
278
+ ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
270
279
  return;
271
280
  }
272
281
  updateUi(ctx);
@@ -278,6 +287,7 @@ export default function planMode(pi: ExtensionAPI) {
278
287
  "Revise plan",
279
288
  "Configure Plan-mode tools",
280
289
  "Stay in Plan mode",
290
+ "Exit Plan mode",
281
291
  ]);
282
292
  if (choice === "Implement this plan") {
283
293
  startImplementation(ctx);
@@ -290,6 +300,11 @@ export default function planMode(pi: ExtensionAPI) {
290
300
  if (choice === "Revise plan") {
291
301
  const refinement = await ctx.ui.editor("Revise the plan", "");
292
302
  if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
303
+ return;
304
+ }
305
+ if (choice === "Exit Plan mode") {
306
+ exitPlanMode(ctx);
307
+ ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
293
308
  }
294
309
  }
295
310
 
@@ -446,10 +461,11 @@ export default function planMode(pi: ExtensionAPI) {
446
461
  .filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
447
462
  .pop();
448
463
  if (!entry?.data) return;
464
+ const enabled = entry.data.enabled ?? false;
449
465
  state = {
450
- enabled: entry.data.enabled ?? false,
451
- latestPlan: entry.data.latestPlan,
452
- awaitingAction: entry.data.awaitingAction ?? false,
466
+ enabled,
467
+ latestPlan: enabled ? entry.data.latestPlan : undefined,
468
+ awaitingAction: enabled ? (entry.data.awaitingAction ?? false) : false,
453
469
  selectedToolNames: entry.data.selectedToolNames,
454
470
  selectedToolKeys: entry.data.selectedToolKeys,
455
471
  };
@@ -616,10 +632,57 @@ function latestAssistantText(messages: unknown) {
616
632
  return "";
617
633
  }
618
634
 
619
- function messageContainsPlanModeContext(message: unknown) {
620
- const candidate = message as { customType?: string; content?: unknown };
621
- if (candidate.customType === "plan-mode-context") return true;
622
- return contentText(candidate.content).includes(PLAN_CONTEXT_MARKER);
635
+ function messageContainsInactivePlanModeArtifact(message: unknown) {
636
+ const candidate = unwrapSessionMessage(message);
637
+ return (
638
+ candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE ||
639
+ candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
640
+ contentText(candidate.content).includes(PLAN_CONTEXT_MARKER)
641
+ );
642
+ }
643
+
644
+ function stripProposedPlanBlocksFromMessage<T>(message: T): T {
645
+ const candidate = unwrapSessionMessage(message);
646
+ if (candidate.role !== "assistant") return message;
647
+
648
+ const content = stripProposedPlanBlocksFromContent(candidate.content);
649
+ if (content === candidate.content) return message;
650
+
651
+ if (isSessionMessageEntry(message)) {
652
+ return { ...message, message: { ...candidate, content } };
653
+ }
654
+ return { ...candidate, content } as T;
655
+ }
656
+
657
+ function unwrapSessionMessage(message: unknown) {
658
+ const entry = message as { message?: unknown };
659
+ return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
660
+ }
661
+
662
+ function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
663
+ return typeof message === "object" && message !== null && "message" in message;
664
+ }
665
+
666
+ function stripProposedPlanBlocksFromContent(content: unknown) {
667
+ if (typeof content === "string") return stripProposedPlanBlocks(content);
668
+ if (!Array.isArray(content)) return content;
669
+
670
+ let changed = false;
671
+ const nextContent = content.map((block) => {
672
+ const textBlock = block as TextBlock;
673
+ if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
674
+
675
+ const text = stripProposedPlanBlocks(textBlock.text);
676
+ if (text === textBlock.text) return block;
677
+
678
+ changed = true;
679
+ return { ...textBlock, text };
680
+ });
681
+ return changed ? nextContent : content;
682
+ }
683
+
684
+ function stripProposedPlanBlocks(text: string) {
685
+ return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
623
686
  }
624
687
 
625
688
  function messageText(message: SessionMessage) {