@prjct.app/pi-plan 0.1.3 → 0.2.1
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/CHANGELOG.md +7 -1
- package/CONTRIBUTING.md +4 -0
- package/README.md +20 -5
- package/docs/package.md +1 -1
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/index.ts +372 -133
- package/src/utils.ts +64 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
## [0.2.1](https://github.com/prjct-app/pi-plan/compare/v0.2.0...v0.2.1) (2026-09-10)
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/prjct-app/pi-plan/compare/v0.1.3...v0.2.0) (2026-09-10)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* richer plan TUI, structured planning, and hardened review flow ([582411f](https://github.com/prjct-app/pi-plan/commit/582411f6ab21e45f2d49858629e066b6efee5853))
|
|
2
8
|
|
|
3
9
|
## 0.1.3
|
|
4
10
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -12,3 +12,7 @@
|
|
|
12
12
|
## Package documentation
|
|
13
13
|
|
|
14
14
|
Follow [docs/package.md](docs/package.md) and its versioned official references. Keep README examples consistent with registered commands, distinguish tested behavior from unverified compatibility, and verify `npm run check:package` before release.
|
|
15
|
+
|
|
16
|
+
## Releases
|
|
17
|
+
|
|
18
|
+
Merging a releasable change into `main` automatically publishes to npm. Use conventional commit messages and read [Automatic releases](docs/releases.md) before merging. The workflow manages versions and authenticates with npm through OIDC.
|
package/README.md
CHANGED
|
@@ -6,6 +6,12 @@ Plan before editing in PI Agent with read-only tool restrictions, an approval st
|
|
|
6
6
|
|
|
7
7
|
`@prjct.app/pi-plan` · Planning commands and progress UI; one extension.
|
|
8
8
|
|
|
9
|
+
## Demo
|
|
10
|
+
|
|
11
|
+
[](https://github.com/prjct-app/pi-plan/raw/refs/heads/main/media/pi-plan-demo/pi-plan-demo.mp4)
|
|
12
|
+
|
|
13
|
+
[Watch or download the 40-second demo](https://github.com/prjct-app/pi-plan/raw/refs/heads/main/media/pi-plan-demo/pi-plan-demo.mp4). It shows read-only exploration, structured planning, human review, and tracked execution in PI Agent. The film follows the package's monochrome cover identity and uses an original instrumental soundtrack with no voice-over or external samples.
|
|
14
|
+
|
|
9
15
|
## Install
|
|
10
16
|
|
|
11
17
|
Requires Pi installed separately and Node.js **22.19 or later**. Compatibility is tested with **Pi 0.85.1**; newer versions are not yet verified. This is an independent community package.
|
|
@@ -24,10 +30,19 @@ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-plan`.
|
|
|
24
30
|
| --- | --- |
|
|
25
31
|
| `/plan` | Toggle planning mode |
|
|
26
32
|
| `Ctrl+Alt+P` | Toggle planning mode from the keyboard |
|
|
27
|
-
| `/todos` |
|
|
33
|
+
| `/todos` | Open the current plan dialog with steps, progress, and verification |
|
|
28
34
|
| `pi --plan` | Start with planning enabled after the extension is installed |
|
|
29
35
|
|
|
30
|
-
Run `/plan`, then ask Pi to investigate a concrete task.
|
|
36
|
+
Run `/plan`, then ask Pi to investigate a concrete task. The planner answers with a **Goal**, a short **Approach**, numbered steps under a `Plan:` heading that reference real files, a **Verify** section with the exact validation commands, and any **Risks**.
|
|
37
|
+
|
|
38
|
+
When a plan is ready, a review dialog offers four actions:
|
|
39
|
+
|
|
40
|
+
- **Execute the plan** — restore full tools and track step progress.
|
|
41
|
+
- **Refine the plan** — send feedback and keep planning.
|
|
42
|
+
- **Stay in plan mode** — keep read-only exploration.
|
|
43
|
+
- **Discard the plan** — exit plan mode and drop the steps.
|
|
44
|
+
|
|
45
|
+
During execution, a progress widget shows a completion bar, the current step, and the Verify command; `[DONE:n]` markers update completed steps, the footer shows `▸ plan n/total`, and the transcript renders the plan, kickoff, and completion with collapsed summaries expandable via `Ctrl+O`. After the final step, Pi runs the Verify commands and reports the result.
|
|
31
46
|
|
|
32
47
|
Planning disables the managed `edit` and `write` tools and checks Bash calls against a read-only allowlist. Other custom tools and external processes can retain write capabilities. Plan mode is a workflow policy, not a security sandbox.
|
|
33
48
|
|
|
@@ -46,7 +61,7 @@ pi remove npm:@prjct.app/pi-plan
|
|
|
46
61
|
|
|
47
62
|
Use `pi config` to enable or disable individual resources. Use `pi config -l` for project settings and add `-l` to removal when you installed locally.
|
|
48
63
|
|
|
49
|
-
To pin version 0.
|
|
64
|
+
To pin version 0.2.0, use `pi install npm:@prjct.app/pi-plan@0.2.0`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-plan` source instead of the npm source.
|
|
50
65
|
|
|
51
66
|
When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
|
|
52
67
|
|
|
@@ -56,7 +71,7 @@ If `/work` is unknown, install Pi Workflows too. If `/todos` is empty, ask for n
|
|
|
56
71
|
|
|
57
72
|
## Package and API documentation
|
|
58
73
|
|
|
59
|
-
Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`,
|
|
74
|
+
Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, status/widget APIs, custom message and entry renderers, and documented TUI components (`SelectList`, `DynamicBorder`, `Text`, `Container`). Plan progress records are display-only entries that never enter the model context; only the execution kickoff instruction does.
|
|
60
75
|
|
|
61
76
|
See [Package structure and compatibility](docs/package.md) for the manifest, dependency policy, shipped resources, and official references. This package follows the [official Pi package guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md) and [extension API guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md) for the tested version.
|
|
62
77
|
|
|
@@ -71,7 +86,7 @@ npm test
|
|
|
71
86
|
npm run check:package
|
|
72
87
|
```
|
|
73
88
|
|
|
74
|
-
Pi loads the TypeScript entry point directly; no build step is required. To try this checkout for one run, use `pi -e .`. Tests use isolated temporary state and do not call model APIs. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules and [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
89
|
+
Pi loads the TypeScript entry point directly; no build step is required. To try this checkout for one run, use `pi -e .`. Tests use isolated temporary state and do not call model APIs. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules, [Automatic releases](docs/releases.md) for publishing, and [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
75
90
|
|
|
76
91
|
## License
|
|
77
92
|
|
package/docs/package.md
CHANGED
|
@@ -35,7 +35,7 @@ Third-party runtime dependencies belong in `dependencies`. Companion extensions
|
|
|
35
35
|
|
|
36
36
|
## Public interfaces
|
|
37
37
|
|
|
38
|
-
Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`,
|
|
38
|
+
Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, status/widget APIs, custom message and entry renderers (`registerMessageRenderer()`, `registerEntryRenderer()`), and documented TUI components (`SelectList`, `DynamicBorder`, `Text`, `Container`) from `@earendil-works/pi-tui` and `@earendil-works/pi-coding-agent`. Plan progress records are display-only entries that never enter the model context; only the execution kickoff instruction does.
|
|
39
39
|
|
|
40
40
|
## Published contents
|
|
41
41
|
|
package/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { PLAN_MODE_ENABLE_EVENT } from "./src/events.ts";
|
|
2
|
-
export { default } from "./src/index.ts";
|
|
3
|
-
export { extractTodoItems, isSafeCommand, markCompletedSteps } from "./src/utils.ts";
|
|
2
|
+
export { default, installPlan } from "./src/index.ts";
|
|
3
|
+
export { extractTodoItems, extractVerification, isSafeCommand, markCompletedSteps } from "./src/utils.ts";
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,31 +7,125 @@
|
|
|
7
7
|
* Features:
|
|
8
8
|
* - /plan command or Ctrl+Alt+P to toggle
|
|
9
9
|
* - Bash restricted to allowlisted read-only commands
|
|
10
|
-
* -
|
|
10
|
+
* - Structured plans: Goal, Approach, numbered steps, Verify, Risks
|
|
11
|
+
* - Plan review dialog (execute / refine / stay / discard)
|
|
11
12
|
* - [DONE:n] markers to complete steps during execution
|
|
12
|
-
* - Progress
|
|
13
|
+
* - Progress widget with bar, current-step highlight, and footer status
|
|
14
|
+
* - Custom renderers for plan, execution, and completion messages
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
16
18
|
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
|
17
|
-
import type
|
|
18
|
-
import {
|
|
19
|
+
import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import {
|
|
21
|
+
type Component,
|
|
22
|
+
Container,
|
|
23
|
+
Key,
|
|
24
|
+
matchesKey,
|
|
25
|
+
SelectList,
|
|
26
|
+
type SelectItem,
|
|
27
|
+
Text,
|
|
28
|
+
truncateToWidth,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
19
30
|
import { PLAN_MODE_ENABLE_EVENT, type PlanModeEnableRequest } from "./events.ts";
|
|
20
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
extractTodoItems,
|
|
33
|
+
extractVerification,
|
|
34
|
+
isSafeCommand,
|
|
35
|
+
markCompletedSteps,
|
|
36
|
+
progressBar,
|
|
37
|
+
type TodoItem,
|
|
38
|
+
} from "./utils.ts";
|
|
21
39
|
|
|
22
40
|
// Tools
|
|
23
|
-
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "ask_user"];
|
|
24
|
-
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
|
25
41
|
const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
|
|
26
|
-
|
|
42
|
+
|
|
43
|
+
// Custom message types that carry instructions. They are only relevant while
|
|
44
|
+
// their mode is active and are filtered out of the model context afterwards.
|
|
45
|
+
const PLAN_INSTRUCTION_TYPE = "plan-mode-context";
|
|
27
46
|
|
|
28
47
|
interface PlanModeState {
|
|
29
48
|
enabled: boolean;
|
|
30
49
|
todos?: TodoItem[];
|
|
31
50
|
executing?: boolean;
|
|
51
|
+
verify?: string;
|
|
32
52
|
toolsBeforePlanMode?: string[];
|
|
33
53
|
}
|
|
34
54
|
|
|
55
|
+
interface PlanListDetails {
|
|
56
|
+
steps: TodoItem[];
|
|
57
|
+
verify?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ReviewChoice = "execute" | "refine" | "stay" | "discard";
|
|
61
|
+
|
|
62
|
+
const PLAN_MODE_PROMPT = `[PLAN MODE ACTIVE]
|
|
63
|
+
You are in plan mode: read-only exploration before any edit.
|
|
64
|
+
|
|
65
|
+
Restrictions:
|
|
66
|
+
- The edit and write tools are disabled.
|
|
67
|
+
- Bash is limited to an allowlist of read-only commands.
|
|
68
|
+
- Never attempt changes; describe exactly what you would change instead.
|
|
69
|
+
|
|
70
|
+
Process:
|
|
71
|
+
1. Investigate first: open the real files, symbols, and tests involved. Do not speculate about code you have not read.
|
|
72
|
+
2. If the request is ambiguous or has multiple viable approaches, ask the user (ask_user when available) before finalizing the plan.
|
|
73
|
+
3. Produce the plan in exactly this format:
|
|
74
|
+
|
|
75
|
+
**Goal:** one sentence describing the end state
|
|
76
|
+
**Approach:** 2-3 sentences on the strategy and key trade-offs
|
|
77
|
+
|
|
78
|
+
Plan:
|
|
79
|
+
1. Imperative step naming the concrete file(s)/symbol(s) and the change
|
|
80
|
+
2. ...
|
|
81
|
+
|
|
82
|
+
**Verify:** the exact command(s) that prove the plan succeeded
|
|
83
|
+
**Risks:** one line per risk, only when real risks exist
|
|
84
|
+
|
|
85
|
+
Rules:
|
|
86
|
+
- 3 to 8 steps, ordered by dependency, each independently checkable.
|
|
87
|
+
- Every step must reference real paths or symbols found during investigation.
|
|
88
|
+
- No filler steps such as "run tests" or "update docs" unless the user asked; the Verify section covers validation.
|
|
89
|
+
- Keep each step to one line; put file lists or notes as indented sub-bullets under its step.`;
|
|
90
|
+
|
|
91
|
+
function executionRules(verify: string | undefined): string {
|
|
92
|
+
const verification = verify
|
|
93
|
+
? `When every step is done, verify with: ${verify} — and report the actual result.`
|
|
94
|
+
: "When every step is done, run the project's verification commands and report the actual result.";
|
|
95
|
+
return `Rules:
|
|
96
|
+
- If a step is blocked, explain why and continue with the next unblocked step.
|
|
97
|
+
- Include a [DONE:n] tag in your response only after step n is actually applied.
|
|
98
|
+
- ${verification}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Kickoff message: the full approved plan, sent once when execution starts.
|
|
102
|
+
function executionKickoffPrompt(todos: TodoItem[], verify: string | undefined): string {
|
|
103
|
+
const stepList = todos.map((t) => `${t.step}. ${t.text}`).join("\n");
|
|
104
|
+
const first = todos.find((t) => !t.completed);
|
|
105
|
+
return `[EXECUTING PLAN - Full tool access restored]
|
|
106
|
+
|
|
107
|
+
Approved steps:
|
|
108
|
+
${stepList}
|
|
109
|
+
|
|
110
|
+
Complete the steps in order, starting with step ${first?.step ?? 1}${first ? `: ${first.text}` : ""}.
|
|
111
|
+
${executionRules(verify)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Per-start injection: progress plus only the remaining steps. Completed
|
|
115
|
+
// steps are already visible in the transcript and would be stale noise.
|
|
116
|
+
function executionContextPrompt(todos: TodoItem[], verify: string | undefined): string {
|
|
117
|
+
const doneCount = todos.filter((t) => t.completed).length;
|
|
118
|
+
const remaining = todos.filter((t) => !t.completed);
|
|
119
|
+
const stepList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
|
120
|
+
return `[EXECUTING PLAN - Full tool access restored]
|
|
121
|
+
|
|
122
|
+
Progress: ${doneCount}/${todos.length} steps complete.
|
|
123
|
+
Remaining steps:
|
|
124
|
+
${stepList}
|
|
125
|
+
|
|
126
|
+
${executionRules(verify)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
35
129
|
// Type guard for assistant messages
|
|
36
130
|
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
|
37
131
|
return m.role === "assistant" && Array.isArray(m.content);
|
|
@@ -45,10 +139,62 @@ function getTextContent(message: AssistantMessage): string {
|
|
|
45
139
|
.join("\n");
|
|
46
140
|
}
|
|
47
141
|
|
|
48
|
-
|
|
142
|
+
// Collapsed one-line transcript view (pi-team pattern).
|
|
143
|
+
function collapsed(text: string): Component {
|
|
144
|
+
return {
|
|
145
|
+
invalidate() {},
|
|
146
|
+
render(width: number) {
|
|
147
|
+
return [truncateToWidth(text, width)];
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatStep(item: TodoItem, theme: Theme, current: TodoItem | undefined): string {
|
|
153
|
+
if (item.completed) {
|
|
154
|
+
return theme.fg("success", "✓ ") + theme.fg("muted", theme.strikethrough(item.text));
|
|
155
|
+
}
|
|
156
|
+
if (item === current) {
|
|
157
|
+
return theme.fg("accent", "▸ ") + theme.fg("text", item.text);
|
|
158
|
+
}
|
|
159
|
+
return theme.fg("dim", "○ ") + theme.fg("muted", item.text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function planListView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
|
|
163
|
+
const steps = details?.steps ?? [];
|
|
164
|
+
const heading = `▸ Plan · ${steps.length} step${steps.length === 1 ? "" : "s"}`;
|
|
165
|
+
if (!expanded) return collapsed(`${heading} · Ctrl+O details`);
|
|
166
|
+
const lines = [theme.fg("accent", theme.bold(heading))];
|
|
167
|
+
for (const item of steps) lines.push(`${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`);
|
|
168
|
+
if (details?.verify) lines.push(theme.fg("muted", `Verify: ${details.verify}`));
|
|
169
|
+
return new Text(lines.join("\n"), 1, 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function planCompleteView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
|
|
173
|
+
const steps = details?.steps ?? [];
|
|
174
|
+
const heading = `▸ Plan complete ✓ · ${steps.length}/${steps.length}`;
|
|
175
|
+
if (!expanded) return collapsed(heading);
|
|
176
|
+
const lines = [theme.fg("success", theme.bold(heading))];
|
|
177
|
+
for (const item of steps) lines.push(theme.fg("muted", `✓ ${item.text}`));
|
|
178
|
+
if (details?.verify) lines.push(theme.fg("muted", `Verified with: ${details.verify}`));
|
|
179
|
+
return new Text(lines.join("\n"), 1, 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function planExecuteView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
|
|
183
|
+
const steps = details?.steps ?? [];
|
|
184
|
+
const first = steps.find((t) => !t.completed);
|
|
185
|
+
const heading = `▸ Execute plan · start at step ${first?.step ?? 1} of ${steps.length}`;
|
|
186
|
+
if (!expanded) return collapsed(`${heading} · Ctrl+O details`);
|
|
187
|
+
const lines = [theme.fg("accent", theme.bold(heading))];
|
|
188
|
+
for (const item of steps) lines.push(`${item.step}. ${item.text}`);
|
|
189
|
+
if (details?.verify) lines.push(theme.fg("muted", `Verify: ${details.verify}`));
|
|
190
|
+
return new Text(lines.join("\n"), 1, 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function installPlan(pi: ExtensionAPI): void {
|
|
49
194
|
let planModeEnabled = false;
|
|
50
195
|
let executionMode = false;
|
|
51
196
|
let todoItems: TodoItem[] = [];
|
|
197
|
+
let planVerify: string | undefined;
|
|
52
198
|
let toolsBeforePlanMode: string[] | undefined;
|
|
53
199
|
|
|
54
200
|
pi.registerFlag("plan", {
|
|
@@ -57,58 +203,60 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
57
203
|
default: false,
|
|
58
204
|
});
|
|
59
205
|
|
|
206
|
+
function uniqueToolNames(toolNames: string[]): string[] {
|
|
207
|
+
return [...new Set(toolNames)];
|
|
208
|
+
}
|
|
209
|
+
|
|
60
210
|
function updateStatus(ctx: ExtensionContext): void {
|
|
61
211
|
// Footer status
|
|
62
212
|
if (executionMode && todoItems.length > 0) {
|
|
63
213
|
const completed = todoItems.filter((t) => t.completed).length;
|
|
64
|
-
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent",
|
|
214
|
+
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `▸ plan ${completed}/${todoItems.length}`));
|
|
65
215
|
} else if (planModeEnabled) {
|
|
66
|
-
|
|
216
|
+
const drafted = todoItems.length > 0 ? ` · ${todoItems.length} steps ready` : "";
|
|
217
|
+
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", `⏸ plan${drafted}`));
|
|
67
218
|
} else {
|
|
68
219
|
ctx.ui.setStatus("plan-mode", undefined);
|
|
69
220
|
}
|
|
70
221
|
|
|
71
|
-
//
|
|
222
|
+
// Progress widget below the editor while executing
|
|
72
223
|
if (executionMode && todoItems.length > 0) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
224
|
+
ctx.ui.setWidget("plan-todos", (_tui, theme) => ({
|
|
225
|
+
invalidate() {},
|
|
226
|
+
render(width: number) {
|
|
227
|
+
const total = todoItems.length;
|
|
228
|
+
const doneCount = todoItems.filter((t) => t.completed).length;
|
|
229
|
+
const pct = Math.round((doneCount / total) * 100);
|
|
230
|
+
const barColor = doneCount === total ? "success" : "accent";
|
|
231
|
+
const current = todoItems.find((t) => !t.completed);
|
|
232
|
+
const lines = [
|
|
233
|
+
theme.fg("accent", theme.bold(`▸ Plan ${doneCount}/${total}`)) +
|
|
234
|
+
" " +
|
|
235
|
+
theme.fg(barColor, progressBar(doneCount, total)) +
|
|
236
|
+
theme.fg("muted", ` ${pct}%`),
|
|
237
|
+
];
|
|
238
|
+
for (const item of todoItems) lines.push(formatStep(item, theme, current));
|
|
239
|
+
if (planVerify) lines.push(theme.fg("dim", `Verify: ${planVerify}`));
|
|
240
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
82
243
|
} else {
|
|
83
244
|
ctx.ui.setWidget("plan-todos", undefined);
|
|
84
245
|
}
|
|
85
246
|
}
|
|
86
247
|
|
|
87
|
-
function uniqueToolNames(toolNames: string[]): string[] {
|
|
88
|
-
return [...new Set(toolNames)];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function getPlanModeTools(activeToolNames: string[]): string[] {
|
|
92
|
-
return uniqueToolNames(activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function getNormalModeTools(activeToolNames: string[]): string[] {
|
|
96
|
-
return uniqueToolNames([
|
|
97
|
-
...NORMAL_MODE_TOOLS,
|
|
98
|
-
...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
|
|
99
|
-
]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
248
|
function enablePlanModeTools(): void {
|
|
103
249
|
if (toolsBeforePlanMode === undefined) {
|
|
104
250
|
toolsBeforePlanMode = pi.getActiveTools();
|
|
105
251
|
}
|
|
106
|
-
pi.setActiveTools(
|
|
252
|
+
pi.setActiveTools(uniqueToolNames(toolsBeforePlanMode.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name))));
|
|
107
253
|
}
|
|
108
254
|
|
|
109
255
|
function restoreNormalModeTools(): void {
|
|
110
|
-
|
|
111
|
-
|
|
256
|
+
if (toolsBeforePlanMode !== undefined) {
|
|
257
|
+
pi.setActiveTools(toolsBeforePlanMode);
|
|
258
|
+
toolsBeforePlanMode = undefined;
|
|
259
|
+
}
|
|
112
260
|
}
|
|
113
261
|
|
|
114
262
|
function persistState(): void {
|
|
@@ -116,30 +264,34 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
116
264
|
enabled: planModeEnabled,
|
|
117
265
|
todos: todoItems,
|
|
118
266
|
executing: executionMode,
|
|
267
|
+
verify: planVerify,
|
|
119
268
|
toolsBeforePlanMode,
|
|
120
|
-
});
|
|
269
|
+
} satisfies PlanModeState);
|
|
121
270
|
}
|
|
122
271
|
|
|
123
272
|
function enablePlanMode(ctx: ExtensionContext, source?: string): void {
|
|
124
273
|
planModeEnabled = true;
|
|
125
274
|
executionMode = false;
|
|
126
275
|
todoItems = [];
|
|
276
|
+
planVerify = undefined;
|
|
127
277
|
enablePlanModeTools();
|
|
128
278
|
ctx.ui.notify(
|
|
129
279
|
source
|
|
130
|
-
? `Plan mode enabled by ${source}.
|
|
131
|
-
: "Plan mode enabled.
|
|
280
|
+
? `Plan mode enabled by ${source}. Write tools disabled; bash is read-only.`
|
|
281
|
+
: "Plan mode enabled. Write tools disabled; bash is read-only.",
|
|
282
|
+
"info",
|
|
132
283
|
);
|
|
133
284
|
updateStatus(ctx);
|
|
134
285
|
persistState();
|
|
135
286
|
}
|
|
136
287
|
|
|
137
|
-
function disablePlanMode(ctx: ExtensionContext): void {
|
|
288
|
+
function disablePlanMode(ctx: ExtensionContext, reason = "Plan mode disabled. Full access restored."): void {
|
|
138
289
|
planModeEnabled = false;
|
|
139
290
|
executionMode = false;
|
|
140
291
|
todoItems = [];
|
|
292
|
+
planVerify = undefined;
|
|
141
293
|
restoreNormalModeTools();
|
|
142
|
-
ctx.ui.notify(
|
|
294
|
+
ctx.ui.notify(reason, "info");
|
|
143
295
|
updateStatus(ctx);
|
|
144
296
|
persistState();
|
|
145
297
|
}
|
|
@@ -152,6 +304,81 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
152
304
|
}
|
|
153
305
|
}
|
|
154
306
|
|
|
307
|
+
// Bordered SelectList dialog (documented TUI Pattern 1).
|
|
308
|
+
function planReviewDialog(ctx: ExtensionContext): Promise<ReviewChoice | null> {
|
|
309
|
+
return ctx.ui.custom<ReviewChoice | null>((tui, theme, _kb, done) => {
|
|
310
|
+
const container = new Container();
|
|
311
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
312
|
+
container.addChild(
|
|
313
|
+
new Text(theme.fg("accent", theme.bold(`Plan ready · ${todoItems.length} steps`)), 1, 0),
|
|
314
|
+
);
|
|
315
|
+
container.addChild(
|
|
316
|
+
new Text(todoItems.map((t) => theme.fg("muted", ` ${t.step}. ${t.text}`)).join("\n"), 1, 0),
|
|
317
|
+
);
|
|
318
|
+
if (planVerify) container.addChild(new Text(theme.fg("dim", ` Verify: ${planVerify}`), 1, 0));
|
|
319
|
+
|
|
320
|
+
const items: SelectItem[] = [
|
|
321
|
+
{ value: "execute", label: "Execute the plan", description: "Restore full tools and track step progress" },
|
|
322
|
+
{ value: "refine", label: "Refine the plan", description: "Send feedback and keep planning" },
|
|
323
|
+
{ value: "stay", label: "Stay in plan mode", description: "Keep read-only exploration" },
|
|
324
|
+
{ value: "discard", label: "Discard the plan", description: "Exit plan mode and drop these steps" },
|
|
325
|
+
];
|
|
326
|
+
const list = new SelectList(items, items.length, {
|
|
327
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
328
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
329
|
+
description: (t) => theme.fg("muted", t),
|
|
330
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
331
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
332
|
+
});
|
|
333
|
+
list.onSelect = (item) => done(item.value as ReviewChoice);
|
|
334
|
+
list.onCancel = () => done(null);
|
|
335
|
+
container.addChild(list);
|
|
336
|
+
|
|
337
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc stay in plan mode"), 1, 0));
|
|
338
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
render: (w) => container.render(w),
|
|
342
|
+
invalidate: () => container.invalidate(),
|
|
343
|
+
handleInput: (data) => {
|
|
344
|
+
list.handleInput(data);
|
|
345
|
+
tui.requestRender();
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function showTodosDialog(ctx: ExtensionContext): Promise<null> {
|
|
352
|
+
const total = todoItems.length;
|
|
353
|
+
const doneCount = todoItems.filter((t) => t.completed).length;
|
|
354
|
+
return ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
355
|
+
const container = new Container();
|
|
356
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
357
|
+
const title = executionMode
|
|
358
|
+
? `Executing plan · ${doneCount}/${total} done`
|
|
359
|
+
: planModeEnabled
|
|
360
|
+
? `Draft plan · ${total} steps`
|
|
361
|
+
: `Plan · ${doneCount}/${total} done`;
|
|
362
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
363
|
+
const current = todoItems.find((t) => !t.completed);
|
|
364
|
+
container.addChild(
|
|
365
|
+
new Text(todoItems.map((item) => ` ${item.step}. ${formatStep(item, theme, current)}`).join("\n"), 1, 0),
|
|
366
|
+
);
|
|
367
|
+
if (planVerify) container.addChild(new Text(theme.fg("dim", ` Verify: ${planVerify}`), 1, 0));
|
|
368
|
+
container.addChild(new Text(theme.fg("dim", "esc/enter close"), 1, 0));
|
|
369
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
render: (w) => container.render(w),
|
|
373
|
+
invalidate: () => container.invalidate(),
|
|
374
|
+
handleInput: (data) => {
|
|
375
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) done(null);
|
|
376
|
+
tui.requestRender();
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
155
382
|
pi.events.on(PLAN_MODE_ENABLE_EVENT, (data) => {
|
|
156
383
|
const request = data as PlanModeEnableRequest;
|
|
157
384
|
if (!request?.ctx || typeof request.source !== "string") return;
|
|
@@ -166,14 +393,18 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
166
393
|
});
|
|
167
394
|
|
|
168
395
|
pi.registerCommand("todos", {
|
|
169
|
-
description: "Show current plan
|
|
396
|
+
description: "Show the current plan steps and progress",
|
|
170
397
|
handler: async (_args, ctx) => {
|
|
171
398
|
if (todoItems.length === 0) {
|
|
172
|
-
ctx.ui.notify("No
|
|
399
|
+
ctx.ui.notify("No plan steps. Create a plan first with /plan", "info");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!ctx.hasUI) {
|
|
403
|
+
const list = todoItems.map((item) => `${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
|
|
404
|
+
ctx.ui.notify(`Plan:\n${list}`, "info");
|
|
173
405
|
return;
|
|
174
406
|
}
|
|
175
|
-
|
|
176
|
-
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
|
|
407
|
+
await showTodosDialog(ctx);
|
|
177
408
|
},
|
|
178
409
|
});
|
|
179
410
|
|
|
@@ -182,9 +413,29 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
182
413
|
handler: async (ctx) => togglePlanMode(ctx),
|
|
183
414
|
});
|
|
184
415
|
|
|
185
|
-
|
|
416
|
+
pi.registerEntryRenderer<PlanListDetails>("plan-todo-list", (entry, { expanded }, theme) =>
|
|
417
|
+
planListView(entry.data, expanded, theme),
|
|
418
|
+
);
|
|
419
|
+
pi.registerEntryRenderer<PlanListDetails>("plan-complete", (entry, { expanded }, theme) =>
|
|
420
|
+
planCompleteView(entry.data, expanded, theme),
|
|
421
|
+
);
|
|
422
|
+
pi.registerMessageRenderer<PlanListDetails>("plan-mode-execute", (message, { expanded }, theme) =>
|
|
423
|
+
planExecuteView(message.details, expanded, theme),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// In plan mode, block writes defensively (even if another extension
|
|
427
|
+
// re-adds them) and restrict bash to the read-only allowlist.
|
|
186
428
|
pi.on("tool_call", async (event) => {
|
|
187
|
-
if (!planModeEnabled
|
|
429
|
+
if (!planModeEnabled) return;
|
|
430
|
+
|
|
431
|
+
if (PLAN_MODE_DISABLED_TOOLS.has(event.toolName)) {
|
|
432
|
+
return {
|
|
433
|
+
block: true,
|
|
434
|
+
reason: `Plan mode: ${event.toolName} is disabled. Present the plan and get approval before editing.`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (event.toolName !== "bash") return;
|
|
188
439
|
|
|
189
440
|
const command = event.input.command as string;
|
|
190
441
|
if (!isSafeCommand(command)) {
|
|
@@ -195,23 +446,37 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
195
446
|
}
|
|
196
447
|
});
|
|
197
448
|
|
|
198
|
-
// Filter
|
|
449
|
+
// Filter stale instruction messages so old mode prompts never accumulate
|
|
450
|
+
// in the model context. Each mode keeps only its LATEST injected copy;
|
|
451
|
+
// older duplicates are dropped. Display entries (plan list, completion)
|
|
452
|
+
// live outside the LLM context entirely (appendEntry, not sendMessage).
|
|
199
453
|
pi.on("context", async (event) => {
|
|
200
|
-
|
|
454
|
+
const messages = event.messages;
|
|
455
|
+
let lastPlanInstruction = -1;
|
|
456
|
+
let lastExecutionInstruction = -1;
|
|
457
|
+
messages.forEach((m, i) => {
|
|
458
|
+
const customType = (m as { customType?: string }).customType;
|
|
459
|
+
if (customType === PLAN_INSTRUCTION_TYPE) lastPlanInstruction = i;
|
|
460
|
+
if (customType === "plan-execution-context") lastExecutionInstruction = i;
|
|
461
|
+
});
|
|
201
462
|
|
|
202
463
|
return {
|
|
203
|
-
messages:
|
|
464
|
+
messages: messages.filter((m, i) => {
|
|
204
465
|
const msg = m as AgentMessage & { customType?: string };
|
|
205
|
-
if (msg.customType ===
|
|
466
|
+
if (msg.customType === PLAN_INSTRUCTION_TYPE) return planModeEnabled && i === lastPlanInstruction;
|
|
467
|
+
if (msg.customType === "plan-execution-context") return executionMode && i === lastExecutionInstruction;
|
|
468
|
+
if (msg.customType === "plan-mode-execute") return executionMode;
|
|
206
469
|
if (msg.role !== "user") return true;
|
|
207
470
|
|
|
471
|
+
// Legacy sessions stored the plan-mode prompt as plain user text.
|
|
208
472
|
const content = msg.content;
|
|
209
473
|
if (typeof content === "string") {
|
|
210
|
-
return !content.includes("[PLAN MODE ACTIVE]");
|
|
474
|
+
return planModeEnabled || !content.includes("[PLAN MODE ACTIVE]");
|
|
211
475
|
}
|
|
212
476
|
if (Array.isArray(content)) {
|
|
213
|
-
return
|
|
214
|
-
|
|
477
|
+
return (
|
|
478
|
+
planModeEnabled ||
|
|
479
|
+
!content.some((c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"))
|
|
215
480
|
);
|
|
216
481
|
}
|
|
217
482
|
return true;
|
|
@@ -224,44 +489,18 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
224
489
|
if (planModeEnabled) {
|
|
225
490
|
return {
|
|
226
491
|
message: {
|
|
227
|
-
customType:
|
|
228
|
-
content:
|
|
229
|
-
You are in plan mode - a read-only exploration mode for safe code analysis.
|
|
230
|
-
|
|
231
|
-
Restrictions:
|
|
232
|
-
- Built-in edit and write tools are disabled
|
|
233
|
-
- Other currently active tools remain available
|
|
234
|
-
- Bash is restricted to an allowlist of read-only commands
|
|
235
|
-
|
|
236
|
-
Ask clarifying questions using ask_user when available; otherwise ask the user directly.
|
|
237
|
-
Use an available web-search skill via bash for web research when needed.
|
|
238
|
-
|
|
239
|
-
Create a detailed numbered plan under a "Plan:" header:
|
|
240
|
-
|
|
241
|
-
Plan:
|
|
242
|
-
1. First step description
|
|
243
|
-
2. Second step description
|
|
244
|
-
...
|
|
245
|
-
|
|
246
|
-
Do NOT attempt to make changes - just describe what you would do.`,
|
|
492
|
+
customType: PLAN_INSTRUCTION_TYPE,
|
|
493
|
+
content: PLAN_MODE_PROMPT,
|
|
247
494
|
display: false,
|
|
248
495
|
},
|
|
249
496
|
};
|
|
250
497
|
}
|
|
251
498
|
|
|
252
499
|
if (executionMode && todoItems.length > 0) {
|
|
253
|
-
const remaining = todoItems.filter((t) => !t.completed);
|
|
254
|
-
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
|
255
500
|
return {
|
|
256
501
|
message: {
|
|
257
502
|
customType: "plan-execution-context",
|
|
258
|
-
content:
|
|
259
|
-
|
|
260
|
-
Remaining steps:
|
|
261
|
-
${todoList}
|
|
262
|
-
|
|
263
|
-
Execute each step in order.
|
|
264
|
-
After completing a step, include a [DONE:n] tag in your response.`,
|
|
503
|
+
content: executionContextPrompt(todoItems, planVerify),
|
|
265
504
|
display: false,
|
|
266
505
|
},
|
|
267
506
|
};
|
|
@@ -285,13 +524,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
285
524
|
// Check if execution is complete
|
|
286
525
|
if (executionMode && todoItems.length > 0) {
|
|
287
526
|
if (todoItems.every((t) => t.completed)) {
|
|
288
|
-
|
|
289
|
-
pi.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
);
|
|
527
|
+
// Display-only record; kept out of the LLM context on purpose.
|
|
528
|
+
pi.appendEntry("plan-complete", {
|
|
529
|
+
steps: todoItems,
|
|
530
|
+
verify: planVerify,
|
|
531
|
+
} satisfies PlanListDetails);
|
|
293
532
|
executionMode = false;
|
|
294
533
|
todoItems = [];
|
|
534
|
+
planVerify = undefined;
|
|
295
535
|
updateStatus(ctx);
|
|
296
536
|
persistState(); // Save cleared state so resume doesn't restore old execution mode
|
|
297
537
|
}
|
|
@@ -300,35 +540,25 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
300
540
|
|
|
301
541
|
if (!planModeEnabled || !ctx.hasUI) return;
|
|
302
542
|
|
|
303
|
-
// Extract
|
|
543
|
+
// Extract the plan from the last assistant message
|
|
304
544
|
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
|
305
545
|
if (lastAssistant) {
|
|
306
|
-
const
|
|
546
|
+
const text = getTextContent(lastAssistant);
|
|
547
|
+
const extracted = extractTodoItems(text);
|
|
307
548
|
if (extracted.length > 0) {
|
|
308
549
|
todoItems = extracted;
|
|
550
|
+
planVerify = extractVerification(text);
|
|
309
551
|
}
|
|
310
552
|
}
|
|
311
553
|
|
|
312
554
|
if (todoItems.length === 0) return;
|
|
555
|
+
updateStatus(ctx);
|
|
313
556
|
persistState();
|
|
314
557
|
|
|
315
|
-
|
|
316
|
-
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
|
317
|
-
const planTodoListMessage = {
|
|
318
|
-
customType: "plan-todo-list",
|
|
319
|
-
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
|
320
|
-
display: true,
|
|
321
|
-
};
|
|
558
|
+
const choice = await planReviewDialog(ctx);
|
|
322
559
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
"Stay in plan mode",
|
|
326
|
-
"Refine the plan",
|
|
327
|
-
]);
|
|
328
|
-
|
|
329
|
-
if (choice?.startsWith("Execute")) {
|
|
330
|
-
const firstTodoItem = todoItems[0];
|
|
331
|
-
if (!firstTodoItem) return;
|
|
560
|
+
if (choice === "execute") {
|
|
561
|
+
const details: PlanListDetails = { steps: todoItems, verify: planVerify };
|
|
332
562
|
|
|
333
563
|
planModeEnabled = false;
|
|
334
564
|
executionMode = true;
|
|
@@ -336,26 +566,28 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
336
566
|
updateStatus(ctx);
|
|
337
567
|
persistState();
|
|
338
568
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
Remaining steps:
|
|
343
|
-
${remainingList}
|
|
344
|
-
|
|
345
|
-
Start with: ${firstTodoItem.text}
|
|
346
|
-
After completing a step, include a [DONE:n] tag in your response.`;
|
|
347
|
-
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
|
|
569
|
+
// The step list is a display-only entry; only the kickoff instruction
|
|
570
|
+
// enters the LLM context.
|
|
571
|
+
pi.appendEntry("plan-todo-list", details);
|
|
348
572
|
pi.sendMessage(
|
|
349
|
-
{
|
|
573
|
+
{
|
|
574
|
+
customType: "plan-mode-execute",
|
|
575
|
+
content: executionKickoffPrompt(todoItems, planVerify),
|
|
576
|
+
display: true,
|
|
577
|
+
details,
|
|
578
|
+
},
|
|
350
579
|
{ triggerTurn: true, deliverAs: "followUp" },
|
|
351
580
|
);
|
|
352
|
-
} else if (choice === "
|
|
581
|
+
} else if (choice === "refine") {
|
|
353
582
|
const refinement = await ctx.ui.editor("Refine the plan:", "");
|
|
354
583
|
if (refinement?.trim()) {
|
|
355
|
-
pi.
|
|
584
|
+
pi.appendEntry("plan-todo-list", { steps: todoItems, verify: planVerify } satisfies PlanListDetails);
|
|
356
585
|
pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
|
|
357
586
|
}
|
|
587
|
+
} else if (choice === "discard") {
|
|
588
|
+
disablePlanMode(ctx, "Plan discarded. Full access restored.");
|
|
358
589
|
}
|
|
590
|
+
// "stay" or dialog cancelled: remain in plan mode.
|
|
359
591
|
});
|
|
360
592
|
|
|
361
593
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -369,18 +601,21 @@ After completing a step, include a [DONE:n] tag in your response.`;
|
|
|
369
601
|
planModeEnabled = true;
|
|
370
602
|
}
|
|
371
603
|
|
|
372
|
-
|
|
604
|
+
// getBranch() scopes restore to this branch, never a fork's copy.
|
|
605
|
+
const entries = ctx.sessionManager.getBranch();
|
|
373
606
|
|
|
374
607
|
// Restore persisted state
|
|
375
608
|
const planModeEntry = entries
|
|
376
|
-
.filter((e
|
|
377
|
-
.pop()
|
|
378
|
-
|
|
379
|
-
if (planModeEntry?.data) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
609
|
+
.filter((e) => e.type === "custom" && e.customType === "plan-mode")
|
|
610
|
+
.pop();
|
|
611
|
+
|
|
612
|
+
if (planModeEntry?.type === "custom" && planModeEntry.data) {
|
|
613
|
+
const data = planModeEntry.data as PlanModeState;
|
|
614
|
+
planModeEnabled = data.enabled ?? planModeEnabled;
|
|
615
|
+
todoItems = data.todos ?? todoItems;
|
|
616
|
+
executionMode = data.executing ?? executionMode;
|
|
617
|
+
planVerify = data.verify ?? planVerify;
|
|
618
|
+
toolsBeforePlanMode = data.toolsBeforePlanMode ?? toolsBeforePlanMode;
|
|
384
619
|
}
|
|
385
620
|
|
|
386
621
|
// On resume: re-scan messages to rebuild completion state
|
|
@@ -390,8 +625,8 @@ After completing a step, include a [DONE:n] tag in your response.`;
|
|
|
390
625
|
// Find the index of the last plan-mode-execute entry (marks when current execution started)
|
|
391
626
|
let executeIndex = -1;
|
|
392
627
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
393
|
-
const entry = entries[i]
|
|
394
|
-
if (entry.customType === "plan-mode-execute") {
|
|
628
|
+
const entry = entries[i];
|
|
629
|
+
if (entry.type === "custom_message" && entry.customType === "plan-mode-execute") {
|
|
395
630
|
executeIndex = i;
|
|
396
631
|
break;
|
|
397
632
|
}
|
|
@@ -401,7 +636,7 @@ After completing a step, include a [DONE:n] tag in your response.`;
|
|
|
401
636
|
const messages: AssistantMessage[] = [];
|
|
402
637
|
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
403
638
|
const entry = entries[i];
|
|
404
|
-
if (entry.type === "message" &&
|
|
639
|
+
if (entry.type === "message" && isAssistantMessage(entry.message as AgentMessage)) {
|
|
405
640
|
messages.push(entry.message as AssistantMessage);
|
|
406
641
|
}
|
|
407
642
|
}
|
|
@@ -415,3 +650,7 @@ After completing a step, include a [DONE:n] tag in your response.`;
|
|
|
415
650
|
updateStatus(ctx);
|
|
416
651
|
});
|
|
417
652
|
}
|
|
653
|
+
|
|
654
|
+
export default function planModeExtension(pi: ExtensionAPI): void {
|
|
655
|
+
installPlan(pi);
|
|
656
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -21,13 +21,20 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
21
21
|
/\bshred\b/i,
|
|
22
22
|
/(^|[^<])>(?!>)/,
|
|
23
23
|
/>>/,
|
|
24
|
+
// find/awk/sed/perl in-place or exec mutations
|
|
25
|
+
/\bfind\b[^|]*\s(-delete|-exec\b|-ok\b)/i,
|
|
26
|
+
/\bsed\s+-\w*i/i,
|
|
27
|
+
/\bawk\s+-i\s*inplace\b/i,
|
|
28
|
+
/\bperl\s+-\w*i/i,
|
|
29
|
+
/\bxargs\b/i,
|
|
30
|
+
/\bpatch\b/i,
|
|
24
31
|
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
25
32
|
/\byarn\s+(add|remove|install|publish)/i,
|
|
26
33
|
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
27
34
|
/\bpip\s+(install|uninstall)/i,
|
|
28
35
|
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
29
36
|
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
30
|
-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
37
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone|clean|restore|switch|worktree|rm|apply|am\b|reflog\s+expire)/i,
|
|
31
38
|
/\bsudo\b/i,
|
|
32
39
|
/\bsu\b/i,
|
|
33
40
|
/\bkill\b/i,
|
|
@@ -77,10 +84,15 @@ const SAFE_PATTERNS = [
|
|
|
77
84
|
/^\s*top\b/,
|
|
78
85
|
/^\s*htop\b/,
|
|
79
86
|
/^\s*free\b/,
|
|
80
|
-
/^\s*
|
|
81
|
-
/^\s*
|
|
87
|
+
/^\s*nl\b/,
|
|
88
|
+
/^\s*readlink\b/,
|
|
89
|
+
/^\s*realpath\b/,
|
|
90
|
+
/^\s*basename\b/,
|
|
91
|
+
/^\s*dirname\b/,
|
|
92
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|blame|grep|describe|rev-parse|ls-|config\s+--get)/i,
|
|
82
93
|
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
83
94
|
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
95
|
+
/^\s*pnpm\s+(list|ls|why|outdated|audit)/i,
|
|
84
96
|
/^\s*node\s+--version/i,
|
|
85
97
|
/^\s*python\s+--version/i,
|
|
86
98
|
/^\s*curl\s/i,
|
|
@@ -106,49 +118,74 @@ export interface TodoItem {
|
|
|
106
118
|
completed: boolean;
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
// Clean display text without changing its meaning: keep verbs such as
|
|
122
|
+
// "Delete" or "Remove" because stripping them inverts the step's intent.
|
|
109
123
|
export function cleanStepText(text: string): string {
|
|
110
124
|
let cleaned = text
|
|
111
125
|
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
|
|
112
126
|
.replace(/`([^`]+)`/g, "$1") // Remove code
|
|
113
|
-
.replace(
|
|
114
|
-
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
|
115
|
-
"",
|
|
116
|
-
)
|
|
117
127
|
.replace(/\s+/g, " ")
|
|
118
128
|
.trim();
|
|
119
129
|
|
|
120
130
|
if (cleaned.length > 0) {
|
|
121
131
|
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
122
132
|
}
|
|
123
|
-
if (cleaned.length >
|
|
124
|
-
cleaned = `${cleaned.slice(0,
|
|
133
|
+
if (cleaned.length > 100) {
|
|
134
|
+
cleaned = `${cleaned.slice(0, 97)}...`;
|
|
125
135
|
}
|
|
126
136
|
return cleaned;
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
// Header accepted as "Plan:", "**Plan:**", "## Plan:", and similar variants.
|
|
140
|
+
const PLAN_HEADER = /(?:^|\n)\s*(?:#{1,6}\s+)?\*{0,2}Plan\*{0,2}\s*:\*{0,2}\s*\n/i;
|
|
141
|
+
const NUMBERED_STEP = /^\s*(\d+)[.)]\s+(.+)$/;
|
|
142
|
+
const CONTINUATION = /^\s+\S/;
|
|
143
|
+
|
|
129
144
|
export function extractTodoItems(message: string): TodoItem[] {
|
|
130
145
|
const items: TodoItem[] = [];
|
|
131
|
-
const headerMatch = message.match(
|
|
132
|
-
if (!headerMatch) return items;
|
|
133
|
-
|
|
134
|
-
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
|
135
|
-
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
146
|
+
const headerMatch = message.match(PLAN_HEADER);
|
|
147
|
+
if (!headerMatch || headerMatch.index === undefined) return items;
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
|
143
|
-
const cleaned = cleanStepText(text);
|
|
149
|
+
const planSection = message.slice(headerMatch.index + headerMatch[0].length);
|
|
150
|
+
for (const line of planSection.split("\n")) {
|
|
151
|
+
const stepMatch = line.match(NUMBERED_STEP);
|
|
152
|
+
if (stepMatch) {
|
|
153
|
+
const cleaned = cleanStepText(stepMatch[2]);
|
|
144
154
|
if (cleaned.length > 3) {
|
|
145
155
|
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
146
156
|
}
|
|
157
|
+
continue;
|
|
147
158
|
}
|
|
159
|
+
// Indented sub-bullets belong to the previous step; skip them without
|
|
160
|
+
// ending the section. Any other line ends the numbered plan, so later
|
|
161
|
+
// numbered lists (e.g. verification steps) are never captured.
|
|
162
|
+
if (items.length > 0 && line.trim().length > 0 && !CONTINUATION.test(line)) break;
|
|
148
163
|
}
|
|
149
164
|
return items;
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
// Extract the "**Verify:**" section: the exact commands that prove the plan.
|
|
168
|
+
export function extractVerification(message: string): string | undefined {
|
|
169
|
+
const match = message.match(/(?:^|\n)\s*(?:#{1,6}\s+)?\*{0,2}Verify\*{0,2}\s*:\*{0,2}\s*/i);
|
|
170
|
+
if (!match || match.index === undefined) return undefined;
|
|
171
|
+
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
for (const line of message.slice(match.index + match[0].length).split("\n")) {
|
|
174
|
+
if (line.trim().length === 0) {
|
|
175
|
+
if (lines.length > 0) break;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Stop at the next section header such as "**Risks:**".
|
|
179
|
+
if (lines.length > 0 && /^\s*(?:#{1,6}\s|\*{0,2}[A-Z][A-Za-z ]*\*{0,2}\s*:)/.test(line)) break;
|
|
180
|
+
lines.push(line.trim().replace(/^[-*]\s+/, ""));
|
|
181
|
+
}
|
|
182
|
+
const text = lines
|
|
183
|
+
.join("; ")
|
|
184
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
185
|
+
.trim();
|
|
186
|
+
return text.length > 0 ? text.slice(0, 200) : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
152
189
|
export function extractDoneSteps(message: string): number[] {
|
|
153
190
|
const steps: number[] = [];
|
|
154
191
|
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
|
@@ -166,3 +203,9 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
|
166
203
|
}
|
|
167
204
|
return doneSteps.length;
|
|
168
205
|
}
|
|
206
|
+
|
|
207
|
+
export function progressBar(completed: number, total: number, width = 8): string {
|
|
208
|
+
const ratio = total <= 0 ? 0 : completed / total;
|
|
209
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
210
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
211
|
+
}
|