@narumitw/pi-plan-mode 0.1.17
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/LICENSE +21 -0
- package/README.md +110 -0
- package/package.json +40 -0
- package/src/plan-mode.ts +374 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# 🧭 pi-plan-mode — Codex-like Plan Mode for Pi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-plan-mode) [](https://pi.dev) [](./LICENSE)
|
|
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.
|
|
6
|
+
|
|
7
|
+
Pi core intentionally does not ship a built-in plan mode; this package provides one as an independently installable extension.
|
|
8
|
+
|
|
9
|
+
## ✨ Features
|
|
10
|
+
|
|
11
|
+
- Adds `/plan` to enter or manage Plan mode.
|
|
12
|
+
- Adds `--plan` to start a session in Plan mode.
|
|
13
|
+
- Restricts active tools to read-only tools while Plan mode is active.
|
|
14
|
+
- Blocks mutating bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
|
|
15
|
+
- Injects Codex-like Plan mode instructions: explore first, ask only non-discoverable questions, do not mutate files, and finish with `<proposed_plan>`.
|
|
16
|
+
- Detects proposed plan blocks and prompts you to implement, revise, or stay in Plan mode.
|
|
17
|
+
- Persists Plan mode state in the Pi session so resume restores the mode.
|
|
18
|
+
|
|
19
|
+
## 📦 Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:@narumitw/pi-plan-mode
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Try without installing permanently:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi -e npm:@narumitw/pi-plan-mode
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Try this package locally from the repository root:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi -e ./extensions/pi-plan-mode
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 🚀 Usage
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
/plan
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
A complete Plan mode answer should include exactly one block like this:
|
|
46
|
+
|
|
47
|
+
```xml
|
|
48
|
+
<proposed_plan>
|
|
49
|
+
# Title
|
|
50
|
+
|
|
51
|
+
## Summary
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
## Key Changes
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
## Test Plan
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
## Assumptions
|
|
61
|
+
...
|
|
62
|
+
</proposed_plan>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
You can also exit directly:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
/plan exit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 🧠 Codex-like behavior
|
|
74
|
+
|
|
75
|
+
This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
76
|
+
|
|
77
|
+
- Plan mode is a conversational collaboration mode, not TODO/progress tracking.
|
|
78
|
+
- `update_plan`-style checklist use is discouraged while Plan mode is active.
|
|
79
|
+
- The implementation boundary is explicit: Plan mode restores tools only after you choose to leave it.
|
|
80
|
+
- Pi extension safety is approximated with active-tool restriction plus bash filtering, so it may be stricter or looser than Codex core in edge cases.
|
|
81
|
+
|
|
82
|
+
## 🗂️ Package layout
|
|
83
|
+
|
|
84
|
+
```txt
|
|
85
|
+
extensions/pi-plan-mode/
|
|
86
|
+
├── src/
|
|
87
|
+
│ └── plan-mode.ts
|
|
88
|
+
├── README.md
|
|
89
|
+
├── LICENSE
|
|
90
|
+
├── tsconfig.json
|
|
91
|
+
└── package.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The package exposes its Pi extension through `package.json`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"pi": {
|
|
99
|
+
"extensions": ["./src/plan-mode.ts"]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 🔎 Keywords
|
|
105
|
+
|
|
106
|
+
Pi extension, Pi coding agent, plan mode, Codex-like plan mode, AI coding workflow, read-only planning, implementation plan.
|
|
107
|
+
|
|
108
|
+
## 📄 License
|
|
109
|
+
|
|
110
|
+
MIT. See [`LICENSE`](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-plan-mode",
|
|
3
|
+
"version": "0.1.17",
|
|
4
|
+
"description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"plan-mode",
|
|
13
|
+
"planning"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./src/plan-mode.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "biome check . && npm run typecheck",
|
|
27
|
+
"format": "biome check --write .",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@biomejs/biome": "2.4.14",
|
|
32
|
+
"@mariozechner/pi-coding-agent": "0.73.0",
|
|
33
|
+
"typescript": "6.0.3"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
38
|
+
"directory": "extensions/pi-plan-mode"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/plan-mode.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
4
|
+
const STATUS_KEY = "plan-mode";
|
|
5
|
+
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
6
|
+
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
7
|
+
const READ_ONLY_TOOLS = ["read", "bash"];
|
|
8
|
+
const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
|
9
|
+
const MUTATING_TOOLS = new Set(["edit", "write"]);
|
|
10
|
+
const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
|
|
11
|
+
|
|
12
|
+
interface PlanModeState {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
latestPlan?: string;
|
|
15
|
+
awaitingAction: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type SessionEntry = {
|
|
19
|
+
type?: string;
|
|
20
|
+
customType?: string;
|
|
21
|
+
data?: Partial<PlanModeState>;
|
|
22
|
+
message?: SessionMessage;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SessionMessage = {
|
|
26
|
+
role?: string;
|
|
27
|
+
content?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TextBlock = {
|
|
31
|
+
type?: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const MUTATING_BASH_PATTERNS = [
|
|
36
|
+
/\brm\b/i,
|
|
37
|
+
/\brmdir\b/i,
|
|
38
|
+
/\bmv\b/i,
|
|
39
|
+
/\bcp\b/i,
|
|
40
|
+
/\bmkdir\b/i,
|
|
41
|
+
/\btouch\b/i,
|
|
42
|
+
/\bchmod\b/i,
|
|
43
|
+
/\bchown\b/i,
|
|
44
|
+
/\bchgrp\b/i,
|
|
45
|
+
/\bln\b/i,
|
|
46
|
+
/\btee\b/i,
|
|
47
|
+
/\btruncate\b/i,
|
|
48
|
+
/\bdd\b/i,
|
|
49
|
+
/(^|[^<])>(?!>)/,
|
|
50
|
+
/>>/,
|
|
51
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish|version)\b/i,
|
|
52
|
+
/\byarn\s+(add|remove|install|publish|upgrade)\b/i,
|
|
53
|
+
/\bpnpm\s+(add|remove|install|publish|update)\b/i,
|
|
54
|
+
/\bbun\s+(add|remove|install|update|publish)\b/i,
|
|
55
|
+
/\bpip\s+(install|uninstall)\b/i,
|
|
56
|
+
/\buv\s+(add|remove|sync|lock|pip\s+install)\b/i,
|
|
57
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|stash|cherry-pick|revert|tag|init|clone)\b/i,
|
|
58
|
+
/\bsudo\b/i,
|
|
59
|
+
/\bsu\b/i,
|
|
60
|
+
/\bkill\b/i,
|
|
61
|
+
/\bpkill\b/i,
|
|
62
|
+
/\bkillall\b/i,
|
|
63
|
+
/\breboot\b/i,
|
|
64
|
+
/\bshutdown\b/i,
|
|
65
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
|
|
66
|
+
/\bservice\s+\S+\s+(start|stop|restart)\b/i,
|
|
67
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const SAFE_BASH_PATTERNS = [
|
|
71
|
+
/^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|jq|awk|rg|fd|bat|eza)\b/i,
|
|
72
|
+
/^\s*sed\s+-n\b/i,
|
|
73
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
|
|
74
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
|
|
75
|
+
/^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export default function planMode(pi: ExtensionAPI) {
|
|
79
|
+
let state: PlanModeState = { enabled: false, awaitingAction: false };
|
|
80
|
+
let previousTools: string[] | undefined;
|
|
81
|
+
|
|
82
|
+
pi.registerFlag("plan", {
|
|
83
|
+
description: "Start in Codex-like Plan mode",
|
|
84
|
+
type: "boolean",
|
|
85
|
+
default: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerCommand("plan", {
|
|
89
|
+
description: "Enter or manage Codex-like Plan mode",
|
|
90
|
+
handler: async (args, ctx) => {
|
|
91
|
+
const command = args.trim().toLowerCase();
|
|
92
|
+
if (command === "exit" || command === "off") {
|
|
93
|
+
exitPlanMode(ctx);
|
|
94
|
+
ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!state.enabled) {
|
|
98
|
+
enterPlanMode(ctx);
|
|
99
|
+
ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await showPlanMenu(ctx);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.on("session_start", (_event, ctx) => {
|
|
107
|
+
restoreState(ctx);
|
|
108
|
+
if (pi.getFlag("plan") === true) state.enabled = true;
|
|
109
|
+
if (state.enabled) activateReadOnlyTools();
|
|
110
|
+
updateUi(ctx);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
114
|
+
persistState();
|
|
115
|
+
clearUi(ctx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("tool_call", async (event) => {
|
|
119
|
+
if (!state.enabled) return;
|
|
120
|
+
if (MUTATING_TOOLS.has(event.toolName)) {
|
|
121
|
+
return {
|
|
122
|
+
block: true,
|
|
123
|
+
reason: `Plan mode blocks mutating tool '${event.toolName}'. Use /plan and choose implementation when the plan is ready.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (event.toolName !== "bash") return;
|
|
127
|
+
|
|
128
|
+
const command = readCommand(event.input);
|
|
129
|
+
if (!isSafeCommand(command)) {
|
|
130
|
+
return {
|
|
131
|
+
block: true,
|
|
132
|
+
reason: `Plan mode blocks mutating or non-allowlisted bash commands.\nCommand: ${command}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("context", async (event) => {
|
|
138
|
+
if (state.enabled) return;
|
|
139
|
+
return {
|
|
140
|
+
messages: event.messages.filter((message: unknown) => !messageContainsPlanModeContext(message)),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.on("before_agent_start", () => {
|
|
145
|
+
if (!state.enabled) return;
|
|
146
|
+
return {
|
|
147
|
+
message: {
|
|
148
|
+
customType: "plan-mode-context",
|
|
149
|
+
content: buildPlanModePrompt(),
|
|
150
|
+
display: false,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
156
|
+
if (!state.enabled) return;
|
|
157
|
+
|
|
158
|
+
const text = latestAssistantText(event.messages);
|
|
159
|
+
const proposedPlan = extractProposedPlan(text);
|
|
160
|
+
if (!proposedPlan) {
|
|
161
|
+
persistState();
|
|
162
|
+
updateUi(ctx);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
|
|
167
|
+
persistState();
|
|
168
|
+
updateUi(ctx);
|
|
169
|
+
pi.sendMessage(
|
|
170
|
+
{
|
|
171
|
+
customType: "proposed-plan",
|
|
172
|
+
content: `**Proposed Plan**\n\n${proposedPlan}`,
|
|
173
|
+
display: true,
|
|
174
|
+
},
|
|
175
|
+
{ triggerTurn: false },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (ctx.hasUI) await showPlanReadyMenu(ctx);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
function enterPlanMode(ctx: ExtensionContext) {
|
|
182
|
+
if (!state.enabled) previousTools = safeGetActiveTools();
|
|
183
|
+
state = { ...state, enabled: true, awaitingAction: false };
|
|
184
|
+
activateReadOnlyTools();
|
|
185
|
+
persistState();
|
|
186
|
+
updateUi(ctx);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function exitPlanMode(ctx: ExtensionContext) {
|
|
190
|
+
state = { ...state, enabled: false, awaitingAction: false };
|
|
191
|
+
restoreTools();
|
|
192
|
+
persistState();
|
|
193
|
+
updateUi(ctx);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function showPlanMenu(ctx: ExtensionContext) {
|
|
197
|
+
if (!ctx.hasUI) {
|
|
198
|
+
ctx.ui.notify(planStatusText(), "info");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const choices = state.latestPlan
|
|
203
|
+
? ["Show latest proposed plan", "Implement this plan", "Stay in Plan mode", "Exit Plan mode"]
|
|
204
|
+
: ["Stay in Plan mode", "Exit Plan mode"];
|
|
205
|
+
const choice = await ctx.ui.select(planStatusText(), choices);
|
|
206
|
+
if (choice === "Show latest proposed plan") {
|
|
207
|
+
ctx.ui.notify(state.latestPlan ?? "No proposed plan yet.", "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (choice === "Implement this plan" || choice === "Exit Plan mode") {
|
|
211
|
+
exitPlanMode(ctx);
|
|
212
|
+
ctx.ui.notify("Plan mode disabled. Full tool access restored.", "info");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
updateUi(ctx);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function showPlanReadyMenu(ctx: ExtensionContext) {
|
|
219
|
+
const choice = await ctx.ui.select("Proposed plan ready. What next?", [
|
|
220
|
+
"Implement this plan",
|
|
221
|
+
"Revise plan",
|
|
222
|
+
"Stay in Plan mode",
|
|
223
|
+
]);
|
|
224
|
+
if (choice === "Implement this plan") {
|
|
225
|
+
exitPlanMode(ctx);
|
|
226
|
+
ctx.ui.notify("Plan mode disabled. Ask me to implement the proposed plan when ready.", "info");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (choice === "Revise plan") {
|
|
230
|
+
const refinement = await ctx.ui.editor("Revise the plan", "");
|
|
231
|
+
if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function activateReadOnlyTools() {
|
|
236
|
+
previousTools ??= safeGetActiveTools();
|
|
237
|
+
pi.setActiveTools(READ_ONLY_TOOLS);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function restoreTools() {
|
|
241
|
+
pi.setActiveTools(previousTools && previousTools.length > 0 ? previousTools : DEFAULT_TOOLS);
|
|
242
|
+
previousTools = undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function safeGetActiveTools() {
|
|
246
|
+
try {
|
|
247
|
+
return pi.getActiveTools();
|
|
248
|
+
} catch {
|
|
249
|
+
return DEFAULT_TOOLS;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function persistState() {
|
|
254
|
+
pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function restoreState(ctx: ExtensionContext) {
|
|
258
|
+
const entries = ctx.sessionManager.getEntries() as SessionEntry[];
|
|
259
|
+
const entry = entries
|
|
260
|
+
.filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
|
|
261
|
+
.pop();
|
|
262
|
+
if (!entry?.data) return;
|
|
263
|
+
state = {
|
|
264
|
+
enabled: entry.data.enabled ?? false,
|
|
265
|
+
latestPlan: entry.data.latestPlan,
|
|
266
|
+
awaitingAction: entry.data.awaitingAction ?? false,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function updateUi(ctx: ExtensionContext) {
|
|
271
|
+
ctx.ui.setStatus(STATUS_KEY, state.enabled ? "plan: active" : undefined);
|
|
272
|
+
if (state.enabled && state.latestPlan) {
|
|
273
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
274
|
+
"Proposed plan ready",
|
|
275
|
+
"Use /plan to implement, revise, or exit Plan mode.",
|
|
276
|
+
]);
|
|
277
|
+
} else if (state.enabled) {
|
|
278
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, ["Plan mode: read-only", "Produce a <proposed_plan> block."]);
|
|
279
|
+
} else {
|
|
280
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function clearUi(ctx: ExtensionContext) {
|
|
285
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
286
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function planStatusText() {
|
|
290
|
+
if (!state.enabled) return "Plan mode is off.";
|
|
291
|
+
if (state.latestPlan) return "Plan mode is active and a proposed plan is ready.";
|
|
292
|
+
return "Plan mode is active. Explore, ask, and produce a <proposed_plan> block.";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function buildPlanModePrompt() {
|
|
297
|
+
return `${PLAN_CONTEXT_MARKER}
|
|
298
|
+
You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan.
|
|
299
|
+
|
|
300
|
+
Mode rules:
|
|
301
|
+
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
302
|
+
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
303
|
+
- Use non-mutating exploration first: read files, search, inspect configuration, run read-only checks, and resolve discoverable facts before asking the user.
|
|
304
|
+
- Ask the user only for preferences or tradeoffs that cannot be discovered from the repository.
|
|
305
|
+
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
306
|
+
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
307
|
+
- When the plan is decision-complete, output exactly one proposed plan block using:
|
|
308
|
+
<proposed_plan>
|
|
309
|
+
# Title
|
|
310
|
+
|
|
311
|
+
## Summary
|
|
312
|
+
...
|
|
313
|
+
|
|
314
|
+
## Key Changes
|
|
315
|
+
...
|
|
316
|
+
|
|
317
|
+
## Test Plan
|
|
318
|
+
...
|
|
319
|
+
|
|
320
|
+
## Assumptions
|
|
321
|
+
...
|
|
322
|
+
</proposed_plan>
|
|
323
|
+
- Keep the proposed plan concise, implementation-ready, and free of open decisions.`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function readCommand(input: unknown) {
|
|
327
|
+
const command = input as { command?: unknown } | undefined;
|
|
328
|
+
return typeof command?.command === "string" ? command.command : "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isSafeCommand(command: string) {
|
|
332
|
+
const trimmed = command.trim();
|
|
333
|
+
if (!trimmed) return false;
|
|
334
|
+
if (MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
|
|
335
|
+
return SAFE_BASH_PATTERNS.some((pattern) => pattern.test(trimmed));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function extractProposedPlan(text: string) {
|
|
339
|
+
const match = PROPOSED_PLAN_PATTERN.exec(text);
|
|
340
|
+
return match?.[1]?.trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function latestAssistantText(messages: unknown) {
|
|
344
|
+
if (!Array.isArray(messages)) return "";
|
|
345
|
+
for (const entry of [...messages].reverse()) {
|
|
346
|
+
const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
|
|
347
|
+
if (message?.role !== "assistant") continue;
|
|
348
|
+
const text = messageText(message);
|
|
349
|
+
if (text) return text;
|
|
350
|
+
}
|
|
351
|
+
return "";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function messageContainsPlanModeContext(message: unknown) {
|
|
355
|
+
const candidate = message as { customType?: string; content?: unknown };
|
|
356
|
+
if (candidate.customType === "plan-mode-context") return true;
|
|
357
|
+
return contentText(candidate.content).includes(PLAN_CONTEXT_MARKER);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function messageText(message: SessionMessage) {
|
|
361
|
+
return contentText(message.content);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function contentText(content: unknown): string {
|
|
365
|
+
if (typeof content === "string") return content;
|
|
366
|
+
if (!Array.isArray(content)) return "";
|
|
367
|
+
return content
|
|
368
|
+
.map((block) => {
|
|
369
|
+
const textBlock = block as TextBlock;
|
|
370
|
+
return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
|
|
371
|
+
})
|
|
372
|
+
.filter(Boolean)
|
|
373
|
+
.join("\n");
|
|
374
|
+
}
|