@narumitw/pi-plan-mode 0.14.0 → 0.15.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-plan-mode)](https://www.npmjs.com/package/@narumitw/pi-plan-mode) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-plan-mode` adds a Codex-like `/plan` collaboration mode to Pi. Plan mode is for read-only exploration, clarifying questions, and a final implementation-ready `<proposed_plan>` block before any code mutation happens.
5
+ `@narumitw/pi-plan-mode` adds a Codex-like `/plan` collaboration mode to Pi. Plan mode is for read-only exploration, clarifying questions, and a structured implementation-ready plan before any code mutation happens.
6
6
 
7
7
  Pi core intentionally does not ship a built-in plan mode; this package provides one as an independently installable extension.
8
8
 
@@ -13,14 +13,17 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
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
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
- - 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
- - Adds a required `plan_mode_question` tool so the agent can ask structured Plan-mode questions before finalizing a plan.
18
- - Detects proposed plan blocks and prompts you to implement, stay in Plan mode, or exit and discard the plan.
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
+ - Adds required `plan_mode_question` and `plan_mode_complete` tools for structured questions and completion.
18
+ - Presents the complete plan and prompts you to implement, stay in Plan mode, or exit and discard it.
19
+ - Keeps legacy `<proposed_plan>` responses compatible without advertising XML as the primary workflow.
19
20
  - Shows Plan mode state in Pi's statusline as `plan active` or `plan ready`; `@narumitw/pi-statusline` adds the default `📝` icon unless configured otherwise.
20
21
  - Persists Plan mode state in the Pi session so resume restores the mode.
21
22
 
22
23
  ## 📦 Install
23
24
 
25
+ This release requires Pi 0.80.6 or newer.
26
+
24
27
  ```bash
25
28
  pi install npm:@narumitw/pi-plan-mode
26
29
  ```
@@ -43,13 +46,17 @@ pi -e ./extensions/pi-plan-mode
43
46
  /plan
44
47
  /plan <prompt>
45
48
  /plan tools
49
+ /plan show
50
+ /plan finalize
51
+ /plan implement
52
+ /plan exit
46
53
  ```
47
54
 
48
- 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; the selector is paginated at 10 tools per page.
55
+ 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; the selector is paginated at 10 tools per page. `/plan show` displays the stored plan without starting a model turn, `/plan finalize` explicitly asks the agent to complete the plan or ask one remaining material question, and `/plan implement` hands a stored plan to a normal implementation turn. `show` and `implement` fail closed when no plan is stored; `finalize` requires active Plan mode.
49
56
 
50
57
  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
58
 
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.
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.
53
60
 
54
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, 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.
55
62
 
@@ -57,34 +64,18 @@ Limited `bash` uses a fail-closed policy. It accepts common inspection commands,
57
64
 
58
65
  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.
59
66
 
60
- A complete Plan mode answer should appear only after the agent has resolved discoverable facts and any high-impact user decisions. It should include exactly one block like this:
61
-
62
- ```xml
63
- <proposed_plan>
64
- # Title
65
-
66
- ## Summary
67
- ...
67
+ A complete Plan mode answer should appear only after the agent has resolved discoverable facts and high-impact user decisions. The agent must call `plan_mode_complete({ plan })` alone as its final action, passing the complete Markdown plan. The tool rejects empty or whitespace-only plans and plans longer than 50,000 JavaScript characters; it does not truncate. Its visible result contains the full plan, and versioned result details let the extension restore it safely from the active session branch.
68
68
 
69
- ## Key Changes
70
- ...
69
+ `plan_mode_complete` uses Pi's `terminate: true` hint. Termination is best effort: if a model puts it in a parallel tool batch, Pi terminates the batch early only when every finalized sibling tool also terminates. The prompt therefore requires the completion call to be standalone and last. The extension deliberately does not infer completion from phrases such as “I will present the plan,” and it does not automatically retry a turn with no plan because research and clarification turns may legitimately remain unfinished. If a turn ends without a plan, Plan mode stays active; use `/plan finalize` for explicit recovery.
71
70
 
72
- ## Test Plan
73
- ...
71
+ Legacy sessions and models may still submit one non-empty `<proposed_plan>` block with tags on their own lines. That compatibility path remains accepted, but it is not the primary workflow. Empty, malformed, unclosed, or multiple legacy blocks keep Plan mode active and produce a warning.
74
72
 
75
- ## Assumptions
76
- ...
77
- </proposed_plan>
78
- ```
79
-
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.
73
+ After completion, `/plan` opens the ready actions when interactive UI is available. Choosing implementation—or running `/plan implement`—disables Plan mode, restores full tool access, and starts an implementation turn with the stored plan. Choosing Stay keeps the plan ready. Revision feedback starts another Plan-mode turn and clears the previous implementable plan until an updated completion arrives. For clarification-only follow-ups, the agent answers and resubmits the complete unchanged plan so it remains implementable. Exit/off discards the plan and removes its completion result from later non-Plan model context. Without interactive UI, the plan remains visible in the tool result and stored as `plan ready`; use `/plan show`, `/plan implement`, or `/plan exit` directly.
83
74
 
84
75
  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:
85
76
 
86
77
  - `plan active`: Plan mode is enabled and still gathering context or drafting a plan.
87
- - `plan ready`: A `<proposed_plan>` was detected and remains ready until you implement it, continue planning, or exit Plan mode.
78
+ - `plan ready`: A completed plan is stored until you implement it, continue planning, or exit Plan mode.
88
79
 
89
80
  You can also exit directly. Direct exit discards the latest proposed plan instead of treating it as an implementation request:
90
81
 
@@ -94,7 +85,7 @@ You can also exit directly. Direct exit discards the latest proposed plan instea
94
85
 
95
86
  ## ⚙️ Thinking level
96
87
 
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`):
88
+ 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/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`):
98
89
 
99
90
  ```json
100
91
  {
@@ -102,7 +93,9 @@ Plan mode inherits Pi's current thinking level by default. To request a fixed le
102
93
  }
103
94
  ```
104
95
 
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`.
96
+ Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. 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`.
97
+
98
+ Compatibility: a valid legacy `plan-mode.json` is migrated automatically to `pi-plan-mode.json`. If both files exist, the new filename takes precedence.
106
99
 
107
100
  ## 🧠 Codex-like behavior
108
101
 
@@ -111,10 +104,11 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
111
104
  - Plan mode is a conversational collaboration mode, not TODO/progress tracking.
112
105
  - `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
113
106
  - The agent should use `plan_mode_question` for important non-discoverable preferences or tradeoffs before finalizing.
107
+ - The agent completes with a standalone `plan_mode_complete` tool call instead of relying on semantic prose detection.
114
108
  - `update_plan` checklist use is blocked while Plan mode is active.
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.
109
+ - 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.
116
110
  - 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.
111
+ - Unlike native Codex, this extension uses a terminating Pi tool plus an `agent_settled` ready flow; Pi cannot provide sandbox-level enforcement.
118
112
 
119
113
  ## 🗂️ Package layout
120
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@biomejs/biome": "2.5.3",
32
- "@earendil-works/pi-coding-agent": "0.80.3",
32
+ "@earendil-works/pi-coding-agent": "0.80.6",
33
33
  "typescript": "6.0.3"
34
34
  },
35
35
  "repository": {
@@ -0,0 +1,70 @@
1
+ export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
2
+ export const PLAN_MODE_COMPLETE_VERSION = 1;
3
+ export const PLAN_MODE_MAX_CHARS = 50_000;
4
+
5
+ export type PlanModeCompletionDetails = {
6
+ version: typeof PLAN_MODE_COMPLETE_VERSION;
7
+ source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
8
+ plan: string;
9
+ };
10
+
11
+ export const PLAN_MODE_COMPLETE_PARAMS = {
12
+ type: "object",
13
+ additionalProperties: false,
14
+ required: ["plan"],
15
+ properties: {
16
+ plan: {
17
+ type: "string",
18
+ minLength: 1,
19
+ maxLength: PLAN_MODE_MAX_CHARS,
20
+ description: "The complete decision-ready implementation plan in Markdown.",
21
+ },
22
+ },
23
+ } as const;
24
+
25
+ type NormalizePlanModeCompletionResult =
26
+ | { ok: true; plan: string }
27
+ | { ok: false; error: string };
28
+
29
+ export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCompletionResult {
30
+ if (!isRecord(input) || typeof input.plan !== "string") {
31
+ return { ok: false, error: "plan must be a string" };
32
+ }
33
+ const plan = input.plan.trim();
34
+ if (!plan) return { ok: false, error: "plan must not be empty" };
35
+ if (plan.length > PLAN_MODE_MAX_CHARS) {
36
+ return {
37
+ ok: false,
38
+ error: `plan must not exceed ${PLAN_MODE_MAX_CHARS} characters`,
39
+ };
40
+ }
41
+ return { ok: true, plan };
42
+ }
43
+
44
+ export function planFromCompletionDetails(value: unknown) {
45
+ if (!isRecord(value)) return undefined;
46
+ if (
47
+ value.version !== PLAN_MODE_COMPLETE_VERSION ||
48
+ value.source !== PLAN_MODE_COMPLETE_TOOL_NAME
49
+ ) {
50
+ return undefined;
51
+ }
52
+ const normalized = normalizePlanModeCompletion({ plan: value.plan });
53
+ return normalized.ok ? normalized.plan : undefined;
54
+ }
55
+
56
+ export function planModeCompleted(plan: string) {
57
+ return {
58
+ content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
59
+ details: {
60
+ version: PLAN_MODE_COMPLETE_VERSION,
61
+ source: PLAN_MODE_COMPLETE_TOOL_NAME,
62
+ plan,
63
+ } satisfies PlanModeCompletionDetails,
64
+ terminate: true,
65
+ };
66
+ }
67
+
68
+ function isRecord(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
@@ -1,3 +1,5 @@
1
+ import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
2
+
1
3
  const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
2
4
  const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
3
5
  const PROPOSED_PLAN_PATTERN = /^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
@@ -56,14 +58,47 @@ export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
56
58
  }
57
59
 
58
60
  export function messageContainsInactivePlanModeArtifact(message: unknown) {
59
- return unwrapSessionMessage(message).customType === PROPOSED_PLAN_MESSAGE_TYPE;
61
+ const candidate = unwrapSessionMessage(message);
62
+ return (
63
+ candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
64
+ (candidate.role === "toolResult" && candidate.toolName === PLAN_MODE_COMPLETE_TOOL_NAME)
65
+ );
60
66
  }
61
67
 
62
68
  export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
69
+ return replaceAssistantContent(message, stripProposedPlanBlocksFromContent);
70
+ }
71
+
72
+ export function stripPlanModeCompletionCallsFromMessage<T>(message: T): T {
73
+ return replaceAssistantContent(message, (content) => {
74
+ if (!Array.isArray(content)) return content;
75
+ const nextContent = content.filter((block) => {
76
+ const candidate = block as { type?: string; name?: string };
77
+ return !(
78
+ candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME
79
+ );
80
+ });
81
+ return nextContent.length === content.length ? content : nextContent;
82
+ });
83
+ }
84
+
85
+ export function isEmptyAssistantMessage(message: unknown) {
86
+ const candidate = unwrapSessionMessage(message);
87
+ return (
88
+ candidate.role === "assistant" &&
89
+ Array.isArray(candidate.content) &&
90
+ candidate.content.length === 0
91
+ );
92
+ }
93
+
94
+ function replaceAssistantContent<T>(
95
+ message: T,
96
+ transform: (content: unknown) => unknown,
97
+ ): T {
63
98
  const candidate = unwrapSessionMessage(message);
64
99
  if (candidate.role !== "assistant") return message;
65
100
 
66
- const content = stripProposedPlanBlocksFromContent(candidate.content);
101
+ const content = transform(candidate.content);
67
102
  if (content === candidate.content) return message;
68
103
 
69
104
  if (isSessionMessageEntry(message)) {
@@ -74,7 +109,12 @@ export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
74
109
 
75
110
  function unwrapSessionMessage(message: unknown) {
76
111
  const entry = message as { message?: unknown };
77
- return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
112
+ return (entry.message ?? message) as {
113
+ role?: string;
114
+ customType?: string;
115
+ toolName?: string;
116
+ content?: unknown;
117
+ };
78
118
  }
79
119
 
80
120
  function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
package/src/plan-mode.ts CHANGED
@@ -3,12 +3,20 @@ import type {
3
3
  ExtensionContext,
4
4
  ToolInfo,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ PLAN_MODE_COMPLETE_PARAMS,
8
+ PLAN_MODE_COMPLETE_TOOL_NAME,
9
+ normalizePlanModeCompletion,
10
+ planModeCompleted,
11
+ } from "./completion-tool.js";
6
12
  import {
7
13
  extractProposedPlan,
14
+ isEmptyAssistantMessage,
8
15
  latestAssistantText,
9
16
  parseProposedPlan,
10
17
  messageContainsInactivePlanModeArtifact,
11
18
  messageContainsLegacyPlanModeContextArtifact,
19
+ stripPlanModeCompletionCallsFromMessage,
12
20
  stripProposedPlanBlocksFromMessage,
13
21
  } from "./message-transform.js";
14
22
  import { buildPlanModePrompt } from "./prompt.js";
@@ -30,11 +38,14 @@ import {
30
38
  } from "./tool-policy.js";
31
39
  import {
32
40
  configuredThinkingLevel,
33
- PLAN_MODE_THINKING_LEVELS,
34
41
  readPlanModeSettings,
35
- type PlanModeFixedThinkingLevel,
36
42
  type PlanModeSettings,
37
43
  } from "./settings.js";
44
+ import {
45
+ restorePlanModeState,
46
+ type PlanCompletionSource,
47
+ type PlanModeState,
48
+ } from "./state.js";
38
49
 
39
50
  const STATE_ENTRY_TYPE = "plan-mode-state";
40
51
  const STATUS_KEY = "plan-mode";
@@ -50,35 +61,21 @@ interface CommandArgumentCompletion {
50
61
  description?: string;
51
62
  }
52
63
 
53
- interface PlanModeState {
54
- enabled: boolean;
55
- latestPlan?: string;
56
- awaitingAction: boolean;
57
- selectedToolNames?: string[];
58
- selectedToolKeys?: string[];
59
- previousThinkingLevel?: PlanModeFixedThinkingLevel;
60
- appliedThinkingLevel?: PlanModeFixedThinkingLevel;
61
- manualThinkingLevel?: PlanModeFixedThinkingLevel;
64
+ interface ReadyPresentationIntent {
65
+ nonce: number;
66
+ plan: string;
67
+ source: PlanCompletionSource;
62
68
  }
63
69
 
64
- type SessionEntry = {
65
- type?: string;
66
- customType?: string;
67
- data?: Partial<PlanModeState>;
68
- message?: SessionMessage;
69
- };
70
-
71
- type SessionMessage = {
72
- role?: string;
73
- content?: unknown;
74
- };
75
-
76
70
  type TextBlock = {
77
71
  type?: string;
78
72
  text?: string;
79
73
  };
80
74
 
81
75
  const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
76
+ { value: "show", label: "show", description: "Show the completed plan" },
77
+ { value: "finalize", label: "finalize", description: "Request a completed plan" },
78
+ { value: "implement", label: "implement", description: "Implement the completed plan" },
82
79
  { value: "exit", label: "exit", description: "Leave Plan mode" },
83
80
  { value: "off", label: "off", description: "Leave Plan mode" },
84
81
  { value: "tools", label: "tools", description: "Select tools allowed in Plan mode" },
@@ -88,6 +85,8 @@ export default function planMode(pi: ExtensionAPI) {
88
85
  let state: PlanModeState = { enabled: false, awaitingAction: false };
89
86
  let settings: PlanModeSettings = { thinkingLevel: "inherit" };
90
87
  let previousTools: string[] | undefined;
88
+ let readyPresentationIntent: ReadyPresentationIntent | undefined;
89
+ let nextReadyPresentationNonce = 0;
91
90
 
92
91
  pi.registerFlag("plan", {
93
92
  description: "Start in Codex-like Plan mode",
@@ -140,12 +139,50 @@ export default function planMode(pi: ExtensionAPI) {
140
139
  },
141
140
  });
142
141
 
142
+ pi.registerTool({
143
+ name: PLAN_MODE_COMPLETE_TOOL_NAME,
144
+ label: "Complete plan",
145
+ description:
146
+ "Submit the complete decision-ready implementation plan for user review. Only available while Plan mode is active, and must be the final standalone action.",
147
+ promptSnippet: "Submit the final Plan-mode implementation plan",
148
+ promptGuidelines: [
149
+ "Call plan_mode_complete alone as the final action only after the implementation plan is decision-complete.",
150
+ ],
151
+ parameters: PLAN_MODE_COMPLETE_PARAMS,
152
+ async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
153
+ if (!state.enabled) {
154
+ throw new Error("plan_mode_complete is only available while Plan mode is active");
155
+ }
156
+ const parsed = normalizePlanModeCompletion(params);
157
+ if (!parsed.ok) throw new Error(parsed.error);
158
+
159
+ acceptCompletedPlan(parsed.plan, PLAN_MODE_COMPLETE_TOOL_NAME, ctx);
160
+ return planModeCompleted(parsed.plan);
161
+ },
162
+ });
163
+
143
164
  pi.registerCommand("plan", {
144
165
  description: "Enter or manage Codex-like Plan mode",
145
166
  getArgumentCompletions: completePlanArguments,
146
167
  handler: async (args, ctx) => {
147
168
  const prompt = args.trim();
148
169
  const command = prompt.toLowerCase();
170
+ if (command === "show") {
171
+ showStoredPlan(ctx);
172
+ return;
173
+ }
174
+ if (command === "finalize") {
175
+ requestFinalPlan(ctx);
176
+ return;
177
+ }
178
+ if (command === "implement") {
179
+ if (!state.enabled || !state.latestPlan?.trim()) {
180
+ ctx.ui.notify("No completed plan is available to implement.", "warning");
181
+ return;
182
+ }
183
+ startImplementation(ctx);
184
+ return;
185
+ }
149
186
  if (command === "exit" || command === "off") {
150
187
  exitPlanMode(ctx);
151
188
  ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
@@ -170,12 +207,14 @@ export default function planMode(pi: ExtensionAPI) {
170
207
  });
171
208
 
172
209
  pi.on("session_start", async (_event, ctx) => {
210
+ readyPresentationIntent = undefined;
173
211
  settings = { thinkingLevel: "inherit" };
174
212
  const loadedSettings = await readPlanModeSettings();
175
213
  if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
176
214
  else if (loadedSettings.kind === "invalid") {
177
215
  ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
178
216
  }
217
+ if (loadedSettings.notice) ctx.ui.notify(loadedSettings.notice, "warning");
179
218
  restoreState(ctx);
180
219
  if (pi.getFlag("plan") === true) state.enabled = true;
181
220
  if (state.enabled) {
@@ -199,6 +238,7 @@ export default function planMode(pi: ExtensionAPI) {
199
238
  });
200
239
 
201
240
  pi.on("session_shutdown", (_event, ctx) => {
241
+ readyPresentationIntent = undefined;
202
242
  captureManualThinkingLevel();
203
243
  persistState();
204
244
  if (state.enabled) {
@@ -249,14 +289,22 @@ export default function planMode(pi: ExtensionAPI) {
249
289
  return {
250
290
  messages: messagesWithoutLegacyPlanContext
251
291
  .filter((message: unknown) => !messageContainsInactivePlanModeArtifact(message))
252
- .map(stripProposedPlanBlocksFromMessage),
292
+ .map(stripProposedPlanBlocksFromMessage)
293
+ .map(stripPlanModeCompletionCallsFromMessage)
294
+ .filter((message: unknown) => !isEmptyAssistantMessage(message)),
253
295
  };
254
296
  });
255
297
 
256
298
  pi.on("before_agent_start", (event, ctx) => {
257
299
  if (!state.enabled) return;
258
300
  if (state.latestPlan || state.awaitingAction) {
259
- state = { ...state, latestPlan: undefined, awaitingAction: false };
301
+ readyPresentationIntent = undefined;
302
+ state = {
303
+ ...state,
304
+ latestPlan: undefined,
305
+ latestPlanSource: undefined,
306
+ awaitingAction: false,
307
+ };
260
308
  persistState();
261
309
  updateUi(ctx);
262
310
  }
@@ -279,30 +327,36 @@ export default function planMode(pi: ExtensionAPI) {
279
327
  updateUi(ctx);
280
328
  return;
281
329
  }
282
- const proposedPlan = parsedPlan.plan;
283
-
284
- state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
285
- persistState();
286
- updateUi(ctx);
330
+ acceptCompletedPlan(parsedPlan.plan, "legacy_proposed_plan", ctx);
331
+ });
287
332
 
288
- scheduleAfterCurrentAgentRun(async () => {
289
- if (!state.enabled || state.latestPlan !== proposedPlan) return;
290
- if (ctx.hasUI) await showPlanReadyMenu(ctx);
291
- if (!state.enabled || state.latestPlan !== proposedPlan) return;
333
+ pi.on("agent_settled", async (_event, ctx) => {
334
+ const intent = readyPresentationIntent;
335
+ if (!intent || !readyPresentationIsCurrent(intent)) return;
336
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
292
337
 
293
- pi.sendMessage(
294
- {
295
- customType: PROPOSED_PLAN_MESSAGE_TYPE,
296
- content: `**Proposed Plan**\n\n${proposedPlan}`,
297
- display: true,
298
- },
299
- { triggerTurn: false },
300
- );
301
- });
338
+ readyPresentationIntent = undefined;
339
+ try {
340
+ if (intent.source === "legacy_proposed_plan") {
341
+ pi.sendMessage(
342
+ {
343
+ customType: PROPOSED_PLAN_MESSAGE_TYPE,
344
+ content: `**Proposed Plan**\n\n${intent.plan}`,
345
+ display: true,
346
+ },
347
+ { triggerTurn: false },
348
+ );
349
+ }
350
+ if (ctx.hasUI && completedPlanIsCurrent(intent)) {
351
+ await showPlanReadyMenu(ctx);
352
+ }
353
+ } catch (error: unknown) {
354
+ if (!isStaleExtensionContextError(error)) throw error;
355
+ }
302
356
  });
303
357
 
304
358
  function enterPlanMode(ctx: ExtensionContext) {
305
- if (!state.enabled) previousTools = withoutPlanModeQuestionTool(safeGetActiveTools());
359
+ if (!state.enabled) previousTools = withoutRequiredPlanModeTools(safeGetActiveTools());
306
360
  state = { ...state, enabled: true, awaitingAction: false };
307
361
  activatePlanModeTools();
308
362
  applyPlanThinkingLevel();
@@ -321,10 +375,12 @@ export default function planMode(pi: ExtensionAPI) {
321
375
 
322
376
  function exitPlanMode(ctx: ExtensionContext) {
323
377
  const wasEnabled = state.enabled;
378
+ readyPresentationIntent = undefined;
324
379
  state = {
325
380
  ...state,
326
381
  enabled: false,
327
382
  latestPlan: undefined,
383
+ latestPlanSource: undefined,
328
384
  awaitingAction: false,
329
385
  manualThinkingLevel: undefined,
330
386
  };
@@ -349,17 +405,82 @@ export default function planMode(pi: ExtensionAPI) {
349
405
  }
350
406
  }
351
407
 
352
- function scheduleAfterCurrentAgentRun(task: () => Promise<void> | void) {
353
- setTimeout(() => {
354
- void Promise.resolve(task()).catch((error: unknown) => {
355
- const message = error instanceof Error ? error.message : String(error);
356
- console.error(`Plan mode follow-up failed: ${message}`);
357
- });
358
- }, 0);
408
+ function acceptCompletedPlan(
409
+ plan: string,
410
+ source: PlanCompletionSource,
411
+ ctx: ExtensionContext,
412
+ ) {
413
+ if (
414
+ state.enabled &&
415
+ state.awaitingAction &&
416
+ state.latestPlan === plan &&
417
+ state.latestPlanSource === source
418
+ ) {
419
+ return;
420
+ }
421
+ state = {
422
+ ...state,
423
+ latestPlan: plan,
424
+ latestPlanSource: source,
425
+ awaitingAction: true,
426
+ };
427
+ readyPresentationIntent = {
428
+ nonce: ++nextReadyPresentationNonce,
429
+ plan,
430
+ source,
431
+ };
432
+ persistState();
433
+ updateUi(ctx);
434
+ }
435
+
436
+ function completedPlanIsCurrent(intent: ReadyPresentationIntent) {
437
+ return (
438
+ state.enabled &&
439
+ state.awaitingAction &&
440
+ state.latestPlan === intent.plan &&
441
+ state.latestPlanSource === intent.source
442
+ );
443
+ }
444
+
445
+ function readyPresentationIsCurrent(intent: ReadyPresentationIntent) {
446
+ return completedPlanIsCurrent(intent) && readyPresentationIntent?.nonce === intent.nonce;
447
+ }
448
+
449
+ function showStoredPlan(ctx: ExtensionContext) {
450
+ const plan = state.latestPlan?.trim();
451
+ if (!state.enabled || !plan) {
452
+ ctx.ui.notify("No completed plan is available. Use /plan finalize when planning is complete.", "info");
453
+ return;
454
+ }
455
+ try {
456
+ pi.sendMessage(
457
+ {
458
+ customType: PROPOSED_PLAN_MESSAGE_TYPE,
459
+ content: `**Proposed Plan**\n\n${plan}`,
460
+ display: true,
461
+ },
462
+ { triggerTurn: false },
463
+ );
464
+ } catch (error: unknown) {
465
+ const detail = error instanceof Error ? error.message : String(error);
466
+ ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
467
+ }
468
+ }
469
+
470
+ function requestFinalPlan(ctx: ExtensionContext) {
471
+ if (!state.enabled) {
472
+ ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
473
+ return;
474
+ }
475
+ sendPlanModeUserMessage(
476
+ "Finalize the current implementation plan now. If any material decision remains, use plan_mode_question instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.",
477
+ ctx,
478
+ );
359
479
  }
360
480
 
361
481
  function startImplementation(ctx: ExtensionContext) {
362
482
  const plan = state.latestPlan?.trim();
483
+ const source = state.latestPlanSource;
363
484
  exitPlanMode(ctx);
364
485
 
365
486
  if (!plan) {
@@ -373,7 +494,7 @@ export default function planMode(pi: ExtensionAPI) {
373
494
  );
374
495
  if (!sent) {
375
496
  enterPlanMode(ctx);
376
- state = { ...state, latestPlan: plan, awaitingAction: true };
497
+ state = { ...state, latestPlan: plan, latestPlanSource: source, awaitingAction: true };
377
498
  persistState();
378
499
  updateUi(ctx);
379
500
  }
@@ -393,10 +514,19 @@ export default function planMode(pi: ExtensionAPI) {
393
514
  "Stay in Plan mode",
394
515
  "Exit Plan mode",
395
516
  ]
396
- : ["Configure Plan-mode tools", "Stay in Plan mode", "Exit Plan mode"];
517
+ : [
518
+ "Request final plan",
519
+ "Configure Plan-mode tools",
520
+ "Stay in Plan mode",
521
+ "Exit Plan mode",
522
+ ];
397
523
  const choice = await ctx.ui.select(planStatusText(), choices);
398
524
  if (choice === "Show latest proposed plan") {
399
- ctx.ui.notify(state.latestPlan ?? "No proposed plan yet.", "info");
525
+ showStoredPlan(ctx);
526
+ return;
527
+ }
528
+ if (choice === "Request final plan") {
529
+ requestFinalPlan(ctx);
400
530
  return;
401
531
  }
402
532
  if (choice === "Implement this plan") {
@@ -497,7 +627,7 @@ export default function planMode(pi: ExtensionAPI) {
497
627
  }
498
628
 
499
629
  function activatePlanModeTools() {
500
- previousTools ??= withoutPlanModeQuestionTool(safeGetActiveTools());
630
+ previousTools ??= withoutRequiredPlanModeTools(safeGetActiveTools());
501
631
  applyPlanModeTools();
502
632
  }
503
633
 
@@ -507,7 +637,9 @@ export default function planMode(pi: ExtensionAPI) {
507
637
 
508
638
  function planModeToolNames() {
509
639
  const tools = selectableTools();
510
- if (tools.length === 0) return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME];
640
+ if (tools.length === 0) {
641
+ return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME, PLAN_MODE_COMPLETE_TOOL_NAME];
642
+ }
511
643
 
512
644
  const selectedNames = planModeSelectedNames(tools);
513
645
  return withRequiredPlanModeTools(
@@ -549,7 +681,10 @@ export default function planMode(pi: ExtensionAPI) {
549
681
 
550
682
  function selectableTools() {
551
683
  return safeGetAllTools()
552
- .filter((tool) => tool.name !== PLAN_MODE_QUESTION_TOOL_NAME)
684
+ .filter(
685
+ (tool) =>
686
+ tool.name !== PLAN_MODE_QUESTION_TOOL_NAME && tool.name !== PLAN_MODE_COMPLETE_TOOL_NAME,
687
+ )
553
688
  .sort(compareTools);
554
689
  }
555
690
 
@@ -567,7 +702,7 @@ export default function planMode(pi: ExtensionAPI) {
567
702
 
568
703
  function restoreTools() {
569
704
  const restoredTools = previousTools ?? DEFAULT_TOOLS;
570
- pi.setActiveTools(withoutPlanModeQuestionTool(restoredTools));
705
+ pi.setActiveTools(withoutRequiredPlanModeTools(restoredTools));
571
706
  previousTools = undefined;
572
707
  }
573
708
 
@@ -620,7 +755,7 @@ export default function planMode(pi: ExtensionAPI) {
620
755
 
621
756
  function deactivatePlanModeQuestionTool() {
622
757
  const activeTools = safeGetActiveTools();
623
- const filteredTools = withoutPlanModeQuestionTool(activeTools);
758
+ const filteredTools = withoutRequiredPlanModeTools(activeTools);
624
759
  if (filteredTools.length !== activeTools.length) {
625
760
  pi.setActiveTools(filteredTools);
626
761
  }
@@ -639,30 +774,7 @@ export default function planMode(pi: ExtensionAPI) {
639
774
  }
640
775
 
641
776
  function restoreState(ctx: ExtensionContext) {
642
- state = { enabled: false, awaitingAction: false };
643
- const entries = ctx.sessionManager.getEntries() as SessionEntry[];
644
- const entry = entries
645
- .filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
646
- .pop();
647
- if (!isRecord(entry?.data)) return;
648
- const enabled = entry.data.enabled === true;
649
- state = {
650
- enabled,
651
- latestPlan:
652
- enabled && typeof entry.data.latestPlan === "string" ? entry.data.latestPlan : undefined,
653
- awaitingAction: enabled && entry.data.awaitingAction === true,
654
- selectedToolNames: stringArray(entry.data.selectedToolNames),
655
- selectedToolKeys: stringArray(entry.data.selectedToolKeys),
656
- previousThinkingLevel: enabled
657
- ? fixedThinkingLevel(entry.data.previousThinkingLevel)
658
- : undefined,
659
- appliedThinkingLevel: enabled
660
- ? fixedThinkingLevel(entry.data.appliedThinkingLevel)
661
- : undefined,
662
- manualThinkingLevel: enabled
663
- ? fixedThinkingLevel(entry.data.manualThinkingLevel)
664
- : undefined,
665
- };
777
+ state = restorePlanModeState(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
666
778
  }
667
779
 
668
780
  function updateUi(ctx: ExtensionContext) {
@@ -676,7 +788,7 @@ export default function planMode(pi: ExtensionAPI) {
676
788
  ctx.ui.setWidget(PLAN_WIDGET_KEY, [
677
789
  "Plan mode: planning",
678
790
  formatToolSummary(),
679
- "Produce a <proposed_plan> block.",
791
+ "Finish with plan_mode_complete when decision-ready.",
680
792
  ]);
681
793
  } else {
682
794
  ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
@@ -697,7 +809,7 @@ export default function planMode(pi: ExtensionAPI) {
697
809
  function planStatusText() {
698
810
  if (!state.enabled) return "Plan mode is off.";
699
811
  if (state.latestPlan) return `Plan mode is active and a proposed plan is ready. ${formatToolSummary()}`;
700
- return `Plan mode is active. ${formatToolSummary()} Explore, ask, and produce a <proposed_plan> block.`;
812
+ return `Plan mode is active. ${formatToolSummary()} Explore, ask, and finish with plan_mode_complete when decision-ready.`;
701
813
  }
702
814
 
703
815
  function formatToolSummary() {
@@ -761,32 +873,33 @@ function unique(values: string[]) {
761
873
  return Array.from(new Set(values));
762
874
  }
763
875
 
764
- function isRecord(value: unknown): value is Record<string, unknown> {
765
- return typeof value === "object" && value !== null && !Array.isArray(value);
766
- }
767
-
768
- function stringArray(value: unknown) {
769
- return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
770
- ? unique(value)
771
- : undefined;
772
- }
773
-
774
- function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
775
- return typeof value === "string" &&
776
- value !== "inherit" &&
777
- PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
778
- ? (value as PlanModeFixedThinkingLevel)
779
- : undefined;
876
+ function isStaleExtensionContextError(error: unknown) {
877
+ return (
878
+ error instanceof Error &&
879
+ (error.message.includes("This extension ctx is stale after session replacement or reload") ||
880
+ error.message.includes("Extension context is no longer active"))
881
+ );
780
882
  }
781
883
 
782
884
  export function withRequiredPlanModeTools(toolNames: string[]) {
783
- return unique([...withoutPlanModeQuestionTool(toolNames), PLAN_MODE_QUESTION_TOOL_NAME]);
885
+ return unique([
886
+ ...withoutRequiredPlanModeTools(toolNames),
887
+ PLAN_MODE_QUESTION_TOOL_NAME,
888
+ PLAN_MODE_COMPLETE_TOOL_NAME,
889
+ ]);
784
890
  }
785
891
 
786
892
  export function withoutPlanModeQuestionTool(toolNames: string[]) {
787
893
  return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
788
894
  }
789
895
 
896
+ function withoutRequiredPlanModeTools(toolNames: string[]) {
897
+ return toolNames.filter(
898
+ (toolName) =>
899
+ toolName !== PLAN_MODE_QUESTION_TOOL_NAME && toolName !== PLAN_MODE_COMPLETE_TOOL_NAME,
900
+ );
901
+ }
902
+
790
903
  function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
791
904
  const detail = {
792
905
  empty: "the block is empty",
package/src/prompt.ts CHANGED
@@ -32,27 +32,28 @@ You are in Plan Mode, a Codex-like collaboration mode for producing a decision-c
32
32
  - Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options.
33
33
  - If plan_mode_question returns cancelled or ui_unavailable, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
34
34
 
35
- ## Finalization rule
35
+ ## Ending each turn
36
36
 
37
- Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
37
+ Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
38
38
 
39
- <proposed_plan>
40
- # Title
39
+ - If a material decision remains, use plan_mode_question. If interactive UI is unavailable, ask one concise plain-text question instead.
40
+ - If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
41
41
 
42
- ## Summary
43
- ...
42
+ If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
44
43
 
45
- ## Key Changes
46
- ...
44
+ Never end with prose that merely announces you are about to present, write, or finalize the plan. Submit the actual plan with plan_mode_complete in that turn.
47
45
 
48
- ## Test Plan
49
- ...
46
+ ## Completion rule
50
47
 
51
- ## Assumptions
52
- ...
53
- </proposed_plan>
48
+ Only call plan_mode_complete when the plan leaves no implementation decisions unresolved. Pass the complete plan as Markdown with:
54
49
 
55
- Keep the proposed plan concise, human and agent digestible, and free of open decisions. Prefer grouped behavior-level changes over file-by-file or symbol-by-symbol inventories. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.
50
+ - A clear title
51
+ - A brief summary
52
+ - Important changes to behavior, public APIs, interfaces, or types
53
+ - Test cases and verification scenarios
54
+ - Explicit assumptions and defaults chosen where needed
56
55
 
57
- Produce at most one <proposed_plan> block per turn. If the user requests revisions after a prior proposed plan, any new block must be a complete replacement. If there is not enough information for a complete replacement, continue planning without a block. If a follow-up only asks for clarification and does not change or challenge the plan, answer it and then reproduce the prior proposed plan unchanged.`;
56
+ Keep the plan concise, human and agent digestible, and free of open decisions. Prefer grouped behavior-level changes over file-by-file or symbol-by-symbol inventories. Do not ask "should I proceed?"; plan_mode_complete opens the Plan-mode ready flow.
57
+
58
+ If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, continue planning with plan_mode_question instead of calling plan_mode_complete.`;
58
59
  }
package/src/settings.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { randomUUID } from "node:crypto";
2
+ import { access, link, lstat, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
3
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
5
 
5
- export const PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
6
+ export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
7
+ const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
6
8
  export const PLAN_MODE_THINKING_LEVELS = [
7
9
  "inherit",
8
10
  "off",
@@ -11,6 +13,7 @@ export const PLAN_MODE_THINKING_LEVELS = [
11
13
  "medium",
12
14
  "high",
13
15
  "xhigh",
16
+ "max",
14
17
  ] as const;
15
18
 
16
19
  export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
@@ -19,9 +22,9 @@ export interface PlanModeSettings {
19
22
  thinkingLevel: PlanModeThinkingLevel;
20
23
  }
21
24
  export type PlanModeSettingsLoadResult =
22
- | { kind: "missing" }
23
- | { kind: "invalid"; reason: string }
24
- | { kind: "loaded"; settings: PlanModeSettings };
25
+ | { kind: "missing"; notice?: string }
26
+ | { kind: "invalid"; reason: string; notice?: string }
27
+ | { kind: "loaded"; settings: PlanModeSettings; notice?: string };
25
28
 
26
29
  export function normalizePlanModeSettings(value: unknown): PlanModeSettings | undefined {
27
30
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
@@ -34,22 +37,127 @@ export function normalizePlanModeSettings(value: unknown): PlanModeSettings | un
34
37
  }
35
38
 
36
39
  export async function readPlanModeSettings(
37
- settingsPath = join(getAgentDir(), PLAN_MODE_SETTINGS_FILE),
40
+ settingsPath?: string,
38
41
  ): Promise<PlanModeSettingsLoadResult> {
42
+ if (settingsPath) return readSettingsFile(settingsPath);
43
+ const canonicalPath = join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
44
+ const canonical = await readSettingsFile(canonicalPath);
45
+ const legacyPath = join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
46
+ if (canonical.kind !== "missing") {
47
+ return (await exists(legacyPath))
48
+ ? {
49
+ ...canonical,
50
+ notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
51
+ }
52
+ : canonical;
53
+ }
54
+
55
+ const legacySnapshot = await readSettingsSnapshot(legacyPath);
56
+ const legacy = legacySnapshot.result;
57
+ const raced = await readSettingsFile(canonicalPath);
58
+ if (raced.kind !== "missing") return raced;
59
+ if (legacy.kind !== "loaded") return legacy;
60
+ let installedIdentity: FileIdentity;
61
+ try {
62
+ installedIdentity = await installFileExclusively(
63
+ canonicalPath,
64
+ legacySnapshot.contents ?? "",
65
+ );
66
+ } catch (error) {
67
+ const created = await readSettingsFile(canonicalPath);
68
+ if (created.kind !== "missing") {
69
+ return {
70
+ ...created,
71
+ notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} was created concurrently.`,
72
+ };
73
+ }
74
+ return {
75
+ ...legacy,
76
+ notice: `Plan-mode settings migration failed: ${formatError(error)}. The legacy file was used for this session.`,
77
+ };
78
+ }
79
+ if (!(await fileContentsEqual(legacyPath, legacySnapshot.contents ?? ""))) {
80
+ const removed = await removeFileIfIdentityMatches(
81
+ canonicalPath,
82
+ installedIdentity,
83
+ legacySnapshot.contents ?? "",
84
+ );
85
+ return {
86
+ ...legacy,
87
+ notice: removed
88
+ ? `${LEGACY_PLAN_MODE_SETTINGS_FILE} changed during migration; the stale ${PLAN_MODE_SETTINGS_FILE} snapshot was removed.`
89
+ : `${LEGACY_PLAN_MODE_SETTINGS_FILE} changed during migration, but ${PLAN_MODE_SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`,
90
+ };
91
+ }
92
+ try {
93
+ await rm(legacyPath);
94
+ return {
95
+ ...legacy,
96
+ notice: `Plan-mode settings migrated from ${LEGACY_PLAN_MODE_SETTINGS_FILE} to ${PLAN_MODE_SETTINGS_FILE}.`,
97
+ };
98
+ } catch (error) {
99
+ return {
100
+ ...legacy,
101
+ notice: `Plan-mode settings migrated to ${PLAN_MODE_SETTINGS_FILE}, but ${LEGACY_PLAN_MODE_SETTINGS_FILE} could not be removed: ${formatError(error)}.`,
102
+ };
103
+ }
104
+ }
105
+
106
+ type FileIdentity = { dev: number; ino: number };
107
+
108
+ async function installFileExclusively(filePath: string, contents: string): Promise<FileIdentity> {
109
+ const tempFile = join(dirname(filePath), `.${PLAN_MODE_SETTINGS_FILE}.${randomUUID()}.tmp`);
110
+ try {
111
+ await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
112
+ const identity = await lstat(tempFile);
113
+ await link(tempFile, filePath);
114
+ return { dev: identity.dev, ino: identity.ino };
115
+ } finally {
116
+ await rm(tempFile, { force: true }).catch(() => undefined);
117
+ }
118
+ }
119
+
120
+ async function removeFileIfIdentityMatches(
121
+ filePath: string,
122
+ expected: FileIdentity,
123
+ expectedContents: string,
124
+ ) {
125
+ try {
126
+ const current = await lstat(filePath);
127
+ if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
128
+ if ((await readFile(filePath, "utf8")) !== expectedContents) return false;
129
+ await rm(filePath);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ async function readSettingsFile(settingsPath: string): Promise<PlanModeSettingsLoadResult> {
137
+ return (await readSettingsSnapshot(settingsPath)).result;
138
+ }
139
+
140
+ async function readSettingsSnapshot(settingsPath: string): Promise<{
141
+ result: PlanModeSettingsLoadResult;
142
+ contents?: string;
143
+ }> {
39
144
  let contents: string;
40
145
  try {
41
146
  contents = await readFile(settingsPath, "utf8");
42
147
  } catch (error: unknown) {
43
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
44
- return { kind: "invalid", reason: formatError(error) };
148
+ if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
149
+ return { result: { kind: "invalid", reason: formatError(error) } };
45
150
  }
46
151
  try {
47
152
  const settings = normalizePlanModeSettings(JSON.parse(contents) as unknown);
48
- return settings
49
- ? { kind: "loaded", settings }
50
- : { kind: "invalid", reason: "invalid settings shape" };
153
+ return {
154
+ contents,
155
+ result: settings
156
+ ? { kind: "loaded", settings }
157
+ : { kind: "invalid", reason: "invalid settings shape" },
158
+ };
51
159
  } catch (error: unknown) {
52
- return { kind: "invalid", reason: formatError(error) };
160
+ return { contents, result: { kind: "invalid", reason: formatError(error) } };
53
161
  }
54
162
  }
55
163
 
@@ -59,6 +167,23 @@ export function configuredThinkingLevel(
59
167
  return settings.thinkingLevel === "inherit" ? undefined : settings.thinkingLevel;
60
168
  }
61
169
 
170
+ async function fileContentsEqual(path: string, expected: string) {
171
+ try {
172
+ return (await readFile(path, "utf8")) === expected;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ async function exists(path: string) {
179
+ try {
180
+ await access(path);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
62
187
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
63
188
  return error instanceof Error && "code" in error;
64
189
  }
package/src/state.ts ADDED
@@ -0,0 +1,128 @@
1
+ import {
2
+ PLAN_MODE_COMPLETE_TOOL_NAME,
3
+ normalizePlanModeCompletion,
4
+ planFromCompletionDetails,
5
+ } from "./completion-tool.js";
6
+ import {
7
+ PLAN_MODE_THINKING_LEVELS,
8
+ type PlanModeFixedThinkingLevel,
9
+ } from "./settings.js";
10
+
11
+ export type PlanCompletionSource =
12
+ | typeof PLAN_MODE_COMPLETE_TOOL_NAME
13
+ | "legacy_proposed_plan";
14
+
15
+ export interface PlanModeState {
16
+ enabled: boolean;
17
+ latestPlan?: string;
18
+ latestPlanSource?: PlanCompletionSource;
19
+ awaitingAction: boolean;
20
+ selectedToolNames?: string[];
21
+ selectedToolKeys?: string[];
22
+ previousThinkingLevel?: PlanModeFixedThinkingLevel;
23
+ appliedThinkingLevel?: PlanModeFixedThinkingLevel;
24
+ manualThinkingLevel?: PlanModeFixedThinkingLevel;
25
+ }
26
+
27
+ type SessionEntry = {
28
+ type?: string;
29
+ customType?: string;
30
+ data?: unknown;
31
+ message?: {
32
+ role?: string;
33
+ toolName?: string;
34
+ details?: unknown;
35
+ };
36
+ };
37
+
38
+ export function restorePlanModeState(entries: unknown[], stateEntryType: string): PlanModeState {
39
+ const branch = entries as SessionEntry[];
40
+ let stateEntryIndex = -1;
41
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
42
+ const candidate = branch[index];
43
+ if (candidate?.type === "custom" && candidate.customType === stateEntryType) {
44
+ stateEntryIndex = index;
45
+ break;
46
+ }
47
+ }
48
+ const entry = branch[stateEntryIndex];
49
+ if (!isRecord(entry?.data)) return { enabled: false, awaitingAction: false };
50
+
51
+ const enabled = entry.data.enabled === true;
52
+ const persistedSource = enabled
53
+ ? planCompletionSource(entry.data.latestPlanSource)
54
+ : undefined;
55
+ const persistedPlan = enabled
56
+ ? normalizePersistedPlan(entry.data.latestPlan, persistedSource)
57
+ : undefined;
58
+ const recoveredPlan =
59
+ enabled && !persistedPlan
60
+ ? latestCompletionPlan(branch.slice(stateEntryIndex + 1))
61
+ : undefined;
62
+ const latestPlan = persistedPlan ?? recoveredPlan;
63
+ return {
64
+ enabled,
65
+ latestPlan,
66
+ latestPlanSource: enabled
67
+ ? (persistedPlan ? persistedSource : undefined) ??
68
+ (recoveredPlan ? PLAN_MODE_COMPLETE_TOOL_NAME : undefined)
69
+ : undefined,
70
+ awaitingAction: enabled && latestPlan !== undefined,
71
+ selectedToolNames: stringArray(entry.data.selectedToolNames),
72
+ selectedToolKeys: stringArray(entry.data.selectedToolKeys),
73
+ previousThinkingLevel: enabled
74
+ ? fixedThinkingLevel(entry.data.previousThinkingLevel)
75
+ : undefined,
76
+ appliedThinkingLevel: enabled
77
+ ? fixedThinkingLevel(entry.data.appliedThinkingLevel)
78
+ : undefined,
79
+ manualThinkingLevel: enabled
80
+ ? fixedThinkingLevel(entry.data.manualThinkingLevel)
81
+ : undefined,
82
+ };
83
+ }
84
+
85
+ function normalizePersistedPlan(value: unknown, source: PlanCompletionSource | undefined) {
86
+ if (typeof value !== "string") return undefined;
87
+ if (source === PLAN_MODE_COMPLETE_TOOL_NAME) {
88
+ const normalized = normalizePlanModeCompletion({ plan: value });
89
+ return normalized.ok ? normalized.plan : undefined;
90
+ }
91
+ return value.trim() || undefined;
92
+ }
93
+
94
+ function latestCompletionPlan(entries: SessionEntry[]) {
95
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
96
+ const message = entries[index]?.message;
97
+ if (message?.role !== "toolResult" || message.toolName !== PLAN_MODE_COMPLETE_TOOL_NAME) {
98
+ continue;
99
+ }
100
+ const plan = planFromCompletionDetails(message.details);
101
+ if (plan) return plan;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function planCompletionSource(value: unknown): PlanCompletionSource | undefined {
107
+ return value === PLAN_MODE_COMPLETE_TOOL_NAME || value === "legacy_proposed_plan"
108
+ ? value
109
+ : undefined;
110
+ }
111
+
112
+ function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
113
+ return typeof value === "string" &&
114
+ value !== "inherit" &&
115
+ PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
116
+ ? (value as PlanModeFixedThinkingLevel)
117
+ : undefined;
118
+ }
119
+
120
+ function stringArray(value: unknown) {
121
+ return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
122
+ ? Array.from(new Set(value))
123
+ : undefined;
124
+ }
125
+
126
+ function isRecord(value: unknown): value is Record<string, unknown> {
127
+ return typeof value === "object" && value !== null && !Array.isArray(value);
128
+ }