@prjct.app/pi-plan 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Set the npm package identity to `@prjct.app/pi-plan`.
6
+ - Clarify installation, project scope, updates, removal, usage, and limitations.
7
+ - Document resource discovery and dependencies against the official Pi 0.85.1 guides.
8
+ - Include contribution and package documentation in the release file list.
9
+
10
+ Initial npm release. The documentation and naming changes preserve the existing extension runtime behavior.
@@ -0,0 +1,14 @@
1
+ # Contributing
2
+
3
+ - Integration branch: `main`. Create a feature branch from `main`.
4
+ - Deliver changes through a pull request using `.github/pull_request_template.md`.
5
+ - Use English for code, documentation, tests, issues, and pull requests.
6
+ - Use strict TypeScript and only APIs documented by Pi 0.85.1.
7
+ - Do not import host internals, monkey-patch prototypes, or access real credentials, sessions, or user configuration in tests.
8
+ - Keep runtime dependencies in `dependencies`; list Pi-provided packages in `peerDependencies` with a `*` range.
9
+ - Run `npm run check`, `npm test`, and `npm pack --dry-run` before review.
10
+ - Never push, open or merge a pull request, publish, or deploy without explicit authorization.
11
+
12
+ ## Package documentation
13
+
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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prjct
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,78 @@
1
+ # pi-plan
2
+
3
+ Read-only planning with an approval step and tracked execution in Pi.
4
+
5
+ `@prjct.app/pi-plan` · Planning commands and progress UI; one extension.
6
+
7
+ ## Install
8
+
9
+ 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.
10
+
11
+ Install with Pi's package manager:
12
+
13
+ ```sh
14
+ pi install npm:@prjct.app/pi-plan
15
+ ```
16
+
17
+ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-plan`. Restart Pi after installation. Do not install the same extension from both GitHub and npm: Pi treats those as different package identities.
18
+
19
+ ## Usage
20
+
21
+ | Entry point | Behavior |
22
+ | --- | --- |
23
+ | `/plan` | Toggle planning mode |
24
+ | `Ctrl+Alt+P` | Toggle planning mode from the keyboard |
25
+ | `/todos` | Show the current plan's steps |
26
+ | `pi --plan` | Start with planning enabled after the extension is installed |
27
+
28
+ Run `/plan`, then ask Pi to investigate a concrete task. Numbered steps under a `Plan:` heading become a progress list. Choose whether to execute when prompted. During execution, `[DONE:n]` markers update completed steps.
29
+
30
+ 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.
31
+
32
+ Install Pi Workflows separately if you also want `/work`, `/spec`, and the other workflow commands. Pi Plan works independently. Its integration event remains `plan-mode:enable` and its status key remains `plan-mode`, preserving compatibility with existing integrations.
33
+
34
+
35
+ ## Manage the package
36
+
37
+ For an npm installation:
38
+
39
+ ```sh
40
+ pi list
41
+ pi update npm:@prjct.app/pi-plan
42
+ pi remove npm:@prjct.app/pi-plan
43
+ ```
44
+
45
+ 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.
46
+
47
+ To pin version 0.1.0, use `pi install npm:@prjct.app/pi-plan@0.1.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-mode` source instead of the npm source.
48
+
49
+ When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
50
+
51
+ ## Troubleshooting
52
+
53
+ If `/work` is unknown, install Pi Workflows too. If `/todos` is empty, ask for numbered steps under a `Plan:` heading. Restart after installation so the extension and CLI flag are registered.
54
+
55
+ ## Package and API documentation
56
+
57
+ Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, and status/widget APIs.
58
+
59
+ 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.
60
+
61
+ ## Development
62
+
63
+ From a repository checkout:
64
+
65
+ ```sh
66
+ npm ci --ignore-scripts
67
+ npm run check
68
+ npm test
69
+ npm run check:package
70
+ ```
71
+
72
+ 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.
73
+
74
+ ## License
75
+
76
+ [MIT](LICENSE).
77
+
78
+ Third-party code or assets are credited in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md); the accompanying licenses are included.
@@ -0,0 +1,5 @@
1
+ # Third-party notices
2
+
3
+ The plan-mode foundation is adapted from the Pi 0.85.1 extension examples in [earendil-works/pi](https://github.com/earendil-works/pi). Pi's MIT license is reproduced in [licenses/pi-MIT.txt](licenses/pi-MIT.txt).
4
+
5
+ This is an independent community package and not an official Pi release.
@@ -0,0 +1,54 @@
1
+ # Package structure and compatibility
2
+
3
+ ## Identity
4
+
5
+ - npm name: `@prjct.app/pi-plan`.
6
+ - Initial version: `0.1.0`.
7
+ - Source repository: [prjct-app/pi-plan-mode](https://github.com/prjct-app/pi-plan-mode).
8
+ - Tested host: Pi `0.85.1`; Node.js `22.19+`.
9
+
10
+ The npm name and repository name may differ. Repository URLs remain unchanged. Existing runtime command names, event names, persisted entry types, and settings keys are unchanged by the package rename.
11
+
12
+ ## Resource manifest
13
+
14
+ ```json
15
+ {
16
+ "name": "@prjct.app/pi-plan",
17
+ "keywords": [
18
+ "pi-package"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ }
25
+ }
26
+ ```
27
+
28
+ The `pi-package` keyword makes the package discoverable. Manifest paths are relative to the package root. The extension entry point is shipped as TypeScript because Pi loads it directly. There is no CLI binary or JavaScript build artifact to install separately.
29
+
30
+ ## Dependencies
31
+
32
+ Pi-provided libraries imported by this package are declared in `peerDependencies` with `*`, as required by Pi's package guide. They are not bundled. Exact Pi 0.85.1 development dependencies establish the tested baseline; the peer wildcard is not a claim that every Pi release is supported.
33
+
34
+ Third-party runtime dependencies belong in `dependencies`. Companion extensions are installed separately only when communication uses Pi's event bus; this package does not import code from a separately installed companion. A package that directly imports another Pi package's resources must instead bundle it following the official guide.
35
+
36
+ ## Public interfaces
37
+
38
+ Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, and status/widget APIs.
39
+
40
+ ## Published contents
41
+
42
+ The `files` allowlist includes runtime resources, user documentation, and license files. Development tests, dependency folders, repository settings, and Git history are excluded. npm also includes `package.json` automatically. The npm lockfile remains in the repository for repeatable development installs.
43
+
44
+ Run `npm run check:package` to inspect the exact prospective tarball before release. Check that each manifest entry and each referenced local document exists in the packed file list. Only claim npm availability after verifying a successful registry publication.
45
+
46
+ ## Official references
47
+
48
+ These links are pinned to the tested Pi version rather than the moving main branch:
49
+
50
+ - [Packages: manifest, sources, dependencies, filtering, and deduplication](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md).
51
+ - [Extensions: lifecycle, commands, tools, messages, and UI APIs](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md).
52
+ - [TUI: components, rendering, terminal widths, and image support](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/tui.md).
53
+
54
+ The installed `@earendil-works/pi-coding-agent@0.85.1` package ships the same guides under `docs/`. The [current official guide](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) may describe changes beyond this tested baseline.
package/index.ts ADDED
@@ -0,0 +1,3 @@
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";
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
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/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@prjct.app/pi-plan",
3
+ "version": "0.1.0",
4
+ "description": "Read-only planning with an approval step and tracked execution in Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "plan",
10
+ "planning",
11
+ "workflow",
12
+ "terminal"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "scripts": {
20
+ "check": "tsc --noEmit",
21
+ "test": "node --import tsx --test tests/*.test.mjs",
22
+ "check:package": "npm pack --dry-run --ignore-scripts"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-agent-core": "*",
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-coding-agent": "*",
31
+ "@earendil-works/pi-tui": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-agent-core": "0.85.1",
35
+ "@earendil-works/pi-ai": "0.85.1",
36
+ "@earendil-works/pi-coding-agent": "0.85.1",
37
+ "@earendil-works/pi-tui": "0.85.1",
38
+ "@types/node": "^22.19.0",
39
+ "tsx": "^4.20.0",
40
+ "typescript": "^5.9.3"
41
+ },
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/prjct-app/pi-plan-mode.git"
46
+ },
47
+ "homepage": "https://github.com/prjct-app/pi-plan-mode#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/prjct-app/pi-plan-mode/issues"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "files": [
55
+ "index.ts",
56
+ "src",
57
+ "README.md",
58
+ "LICENSE",
59
+ "THIRD_PARTY_NOTICES.md",
60
+ "licenses",
61
+ "CONTRIBUTING.md",
62
+ "CHANGELOG.md",
63
+ "docs/package.md"
64
+ ]
65
+ }
package/src/events.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const PLAN_MODE_ENABLE_EVENT = "plan-mode:enable";
4
+
5
+ export interface PlanModeEnableRequest {
6
+ ctx: ExtensionContext;
7
+ source: string;
8
+ handled?: boolean;
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Plan Mode Extension
3
+ *
4
+ * Read-only exploration mode for safe code analysis.
5
+ * When enabled, built-in write tools are disabled.
6
+ *
7
+ * Features:
8
+ * - /plan command or Ctrl+Alt+P to toggle
9
+ * - Bash restricted to allowlisted read-only commands
10
+ * - Extracts numbered plan steps from "Plan:" sections
11
+ * - [DONE:n] markers to complete steps during execution
12
+ * - Progress tracking widget during execution
13
+ */
14
+
15
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
16
+ import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { Key } from "@earendil-works/pi-tui";
19
+ import { PLAN_MODE_ENABLE_EVENT, type PlanModeEnableRequest } from "./events.ts";
20
+ import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts";
21
+
22
+ // Tools
23
+ const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "ask_user"];
24
+ const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
25
+ const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
26
+ const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
27
+
28
+ interface PlanModeState {
29
+ enabled: boolean;
30
+ todos?: TodoItem[];
31
+ executing?: boolean;
32
+ toolsBeforePlanMode?: string[];
33
+ }
34
+
35
+ // Type guard for assistant messages
36
+ function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
37
+ return m.role === "assistant" && Array.isArray(m.content);
38
+ }
39
+
40
+ // Extract text content from an assistant message
41
+ function getTextContent(message: AssistantMessage): string {
42
+ return message.content
43
+ .filter((block): block is TextContent => block.type === "text")
44
+ .map((block) => block.text)
45
+ .join("\n");
46
+ }
47
+
48
+ export default function planModeExtension(pi: ExtensionAPI): void {
49
+ let planModeEnabled = false;
50
+ let executionMode = false;
51
+ let todoItems: TodoItem[] = [];
52
+ let toolsBeforePlanMode: string[] | undefined;
53
+
54
+ pi.registerFlag("plan", {
55
+ description: "Start in plan mode (read-only exploration)",
56
+ type: "boolean",
57
+ default: false,
58
+ });
59
+
60
+ function updateStatus(ctx: ExtensionContext): void {
61
+ // Footer status
62
+ if (executionMode && todoItems.length > 0) {
63
+ const completed = todoItems.filter((t) => t.completed).length;
64
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
65
+ } else if (planModeEnabled) {
66
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
67
+ } else {
68
+ ctx.ui.setStatus("plan-mode", undefined);
69
+ }
70
+
71
+ // Widget showing todo list
72
+ if (executionMode && todoItems.length > 0) {
73
+ const lines = todoItems.map((item) => {
74
+ if (item.completed) {
75
+ return (
76
+ ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
77
+ );
78
+ }
79
+ return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
80
+ });
81
+ ctx.ui.setWidget("plan-todos", lines);
82
+ } else {
83
+ ctx.ui.setWidget("plan-todos", undefined);
84
+ }
85
+ }
86
+
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
+ function enablePlanModeTools(): void {
103
+ if (toolsBeforePlanMode === undefined) {
104
+ toolsBeforePlanMode = pi.getActiveTools();
105
+ }
106
+ pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
107
+ }
108
+
109
+ function restoreNormalModeTools(): void {
110
+ pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
111
+ toolsBeforePlanMode = undefined;
112
+ }
113
+
114
+ function persistState(): void {
115
+ pi.appendEntry("plan-mode", {
116
+ enabled: planModeEnabled,
117
+ todos: todoItems,
118
+ executing: executionMode,
119
+ toolsBeforePlanMode,
120
+ });
121
+ }
122
+
123
+ function enablePlanMode(ctx: ExtensionContext, source?: string): void {
124
+ planModeEnabled = true;
125
+ executionMode = false;
126
+ todoItems = [];
127
+ enablePlanModeTools();
128
+ ctx.ui.notify(
129
+ source
130
+ ? `Plan mode enabled by ${source}. Built-in write tools disabled.`
131
+ : "Plan mode enabled. Built-in write tools disabled.",
132
+ );
133
+ updateStatus(ctx);
134
+ persistState();
135
+ }
136
+
137
+ function disablePlanMode(ctx: ExtensionContext): void {
138
+ planModeEnabled = false;
139
+ executionMode = false;
140
+ todoItems = [];
141
+ restoreNormalModeTools();
142
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
143
+ updateStatus(ctx);
144
+ persistState();
145
+ }
146
+
147
+ function togglePlanMode(ctx: ExtensionContext): void {
148
+ if (planModeEnabled) {
149
+ disablePlanMode(ctx);
150
+ } else {
151
+ enablePlanMode(ctx);
152
+ }
153
+ }
154
+
155
+ pi.events.on(PLAN_MODE_ENABLE_EVENT, (data) => {
156
+ const request = data as PlanModeEnableRequest;
157
+ if (!request?.ctx || typeof request.source !== "string") return;
158
+
159
+ enablePlanMode(request.ctx, request.source);
160
+ request.handled = true;
161
+ });
162
+
163
+ pi.registerCommand("plan", {
164
+ description: "Toggle plan mode (read-only exploration)",
165
+ handler: async (_args, ctx) => togglePlanMode(ctx),
166
+ });
167
+
168
+ pi.registerCommand("todos", {
169
+ description: "Show current plan todo list",
170
+ handler: async (_args, ctx) => {
171
+ if (todoItems.length === 0) {
172
+ ctx.ui.notify("No todos. Create a plan first with /plan", "info");
173
+ return;
174
+ }
175
+ const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
176
+ ctx.ui.notify(`Plan Progress:\n${list}`, "info");
177
+ },
178
+ });
179
+
180
+ pi.registerShortcut(Key.ctrlAlt("p"), {
181
+ description: "Toggle plan mode",
182
+ handler: async (ctx) => togglePlanMode(ctx),
183
+ });
184
+
185
+ // Block destructive bash commands in plan mode
186
+ pi.on("tool_call", async (event) => {
187
+ if (!planModeEnabled || event.toolName !== "bash") return;
188
+
189
+ const command = event.input.command as string;
190
+ if (!isSafeCommand(command)) {
191
+ return {
192
+ block: true,
193
+ reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
194
+ };
195
+ }
196
+ });
197
+
198
+ // Filter out stale plan mode context when not in plan mode
199
+ pi.on("context", async (event) => {
200
+ if (planModeEnabled) return;
201
+
202
+ return {
203
+ messages: event.messages.filter((m) => {
204
+ const msg = m as AgentMessage & { customType?: string };
205
+ if (msg.customType === "plan-mode-context") return false;
206
+ if (msg.role !== "user") return true;
207
+
208
+ const content = msg.content;
209
+ if (typeof content === "string") {
210
+ return !content.includes("[PLAN MODE ACTIVE]");
211
+ }
212
+ if (Array.isArray(content)) {
213
+ return !content.some(
214
+ (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
215
+ );
216
+ }
217
+ return true;
218
+ }),
219
+ };
220
+ });
221
+
222
+ // Inject plan/execution context before agent starts
223
+ pi.on("before_agent_start", async () => {
224
+ if (planModeEnabled) {
225
+ return {
226
+ message: {
227
+ customType: "plan-mode-context",
228
+ content: `[PLAN MODE ACTIVE]
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.`,
247
+ display: false,
248
+ },
249
+ };
250
+ }
251
+
252
+ 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
+ return {
256
+ message: {
257
+ customType: "plan-execution-context",
258
+ content: `[EXECUTING PLAN - Full tool access enabled]
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.`,
265
+ display: false,
266
+ },
267
+ };
268
+ }
269
+ });
270
+
271
+ // Track progress after each turn
272
+ pi.on("turn_end", async (event, ctx) => {
273
+ if (!executionMode || todoItems.length === 0) return;
274
+ if (!isAssistantMessage(event.message)) return;
275
+
276
+ const text = getTextContent(event.message);
277
+ if (markCompletedSteps(text, todoItems) > 0) {
278
+ updateStatus(ctx);
279
+ }
280
+ persistState();
281
+ });
282
+
283
+ // Handle plan completion and plan mode UI
284
+ pi.on("agent_end", async (event, ctx) => {
285
+ // Check if execution is complete
286
+ if (executionMode && todoItems.length > 0) {
287
+ if (todoItems.every((t) => t.completed)) {
288
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
289
+ pi.sendMessage(
290
+ { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
291
+ { triggerTurn: false },
292
+ );
293
+ executionMode = false;
294
+ todoItems = [];
295
+ updateStatus(ctx);
296
+ persistState(); // Save cleared state so resume doesn't restore old execution mode
297
+ }
298
+ return;
299
+ }
300
+
301
+ if (!planModeEnabled || !ctx.hasUI) return;
302
+
303
+ // Extract todos from last assistant message
304
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
305
+ if (lastAssistant) {
306
+ const extracted = extractTodoItems(getTextContent(lastAssistant));
307
+ if (extracted.length > 0) {
308
+ todoItems = extracted;
309
+ }
310
+ }
311
+
312
+ if (todoItems.length === 0) return;
313
+ persistState();
314
+
315
+ // Show plan steps and prompt for next action
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
+ };
322
+
323
+ const choice = await ctx.ui.select("Plan mode - what next?", [
324
+ "Execute the plan (track progress)",
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;
332
+
333
+ planModeEnabled = false;
334
+ executionMode = true;
335
+ restoreNormalModeTools();
336
+ updateStatus(ctx);
337
+ persistState();
338
+
339
+ const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
340
+ const execMessage = `Execute the plan.
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" });
348
+ pi.sendMessage(
349
+ { customType: "plan-mode-execute", content: execMessage, display: true },
350
+ { triggerTurn: true, deliverAs: "followUp" },
351
+ );
352
+ } else if (choice === "Refine the plan") {
353
+ const refinement = await ctx.ui.editor("Refine the plan:", "");
354
+ if (refinement?.trim()) {
355
+ pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
356
+ pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
357
+ }
358
+ }
359
+ });
360
+
361
+ pi.on("session_shutdown", async (_event, ctx) => {
362
+ ctx.ui.setStatus("plan-mode", undefined);
363
+ ctx.ui.setWidget("plan-todos", undefined);
364
+ });
365
+
366
+ // Restore state on session start/resume
367
+ pi.on("session_start", async (_event, ctx) => {
368
+ if (pi.getFlag("plan") === true) {
369
+ planModeEnabled = true;
370
+ }
371
+
372
+ const entries = ctx.sessionManager.getEntries();
373
+
374
+ // Restore persisted state
375
+ const planModeEntry = entries
376
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
377
+ .pop() as { data?: PlanModeState } | undefined;
378
+
379
+ if (planModeEntry?.data) {
380
+ planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
381
+ todoItems = planModeEntry.data.todos ?? todoItems;
382
+ executionMode = planModeEntry.data.executing ?? executionMode;
383
+ toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
384
+ }
385
+
386
+ // On resume: re-scan messages to rebuild completion state
387
+ // Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
388
+ const isResume = planModeEntry !== undefined;
389
+ if (isResume && executionMode && todoItems.length > 0) {
390
+ // Find the index of the last plan-mode-execute entry (marks when current execution started)
391
+ let executeIndex = -1;
392
+ for (let i = entries.length - 1; i >= 0; i--) {
393
+ const entry = entries[i] as { type: string; customType?: string };
394
+ if (entry.customType === "plan-mode-execute") {
395
+ executeIndex = i;
396
+ break;
397
+ }
398
+ }
399
+
400
+ // Only scan messages after the execute marker
401
+ const messages: AssistantMessage[] = [];
402
+ for (let i = executeIndex + 1; i < entries.length; i++) {
403
+ const entry = entries[i];
404
+ if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
405
+ messages.push(entry.message as AssistantMessage);
406
+ }
407
+ }
408
+ const allText = messages.map(getTextContent).join("\n");
409
+ markCompletedSteps(allText, todoItems);
410
+ }
411
+
412
+ if (planModeEnabled) {
413
+ enablePlanModeTools();
414
+ }
415
+ updateStatus(ctx);
416
+ });
417
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pure utility functions for plan mode.
3
+ * Extracted for testability.
4
+ */
5
+
6
+ // Destructive commands blocked in plan mode
7
+ const DESTRUCTIVE_PATTERNS = [
8
+ /\brm\b/i,
9
+ /\brmdir\b/i,
10
+ /\bmv\b/i,
11
+ /\bcp\b/i,
12
+ /\bmkdir\b/i,
13
+ /\btouch\b/i,
14
+ /\bchmod\b/i,
15
+ /\bchown\b/i,
16
+ /\bchgrp\b/i,
17
+ /\bln\b/i,
18
+ /\btee\b/i,
19
+ /\btruncate\b/i,
20
+ /\bdd\b/i,
21
+ /\bshred\b/i,
22
+ /(^|[^<])>(?!>)/,
23
+ />>/,
24
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
25
+ /\byarn\s+(add|remove|install|publish)/i,
26
+ /\bpnpm\s+(add|remove|install|publish)/i,
27
+ /\bpip\s+(install|uninstall)/i,
28
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
+ /\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,
31
+ /\bsudo\b/i,
32
+ /\bsu\b/i,
33
+ /\bkill\b/i,
34
+ /\bpkill\b/i,
35
+ /\bkillall\b/i,
36
+ /\breboot\b/i,
37
+ /\bshutdown\b/i,
38
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
39
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
40
+ /\b(vim?|nano|emacs|code|subl)\b/i,
41
+ ];
42
+
43
+ // Safe read-only commands allowed in plan mode
44
+ const SAFE_PATTERNS = [
45
+ /^\s*cat\b/,
46
+ /^\s*head\b/,
47
+ /^\s*tail\b/,
48
+ /^\s*less\b/,
49
+ /^\s*more\b/,
50
+ /^\s*grep\b/,
51
+ /^\s*find\b/,
52
+ /^\s*ls\b/,
53
+ /^\s*pwd\b/,
54
+ /^\s*echo\b/,
55
+ /^\s*printf\b/,
56
+ /^\s*wc\b/,
57
+ /^\s*sort\b/,
58
+ /^\s*uniq\b/,
59
+ /^\s*diff\b/,
60
+ /^\s*file\b/,
61
+ /^\s*stat\b/,
62
+ /^\s*du\b/,
63
+ /^\s*df\b/,
64
+ /^\s*tree\b/,
65
+ /^\s*which\b/,
66
+ /^\s*whereis\b/,
67
+ /^\s*type\b/,
68
+ /^\s*env\b/,
69
+ /^\s*printenv\b/,
70
+ /^\s*uname\b/,
71
+ /^\s*whoami\b/,
72
+ /^\s*id\b/,
73
+ /^\s*date\b/,
74
+ /^\s*cal\b/,
75
+ /^\s*uptime\b/,
76
+ /^\s*ps\b/,
77
+ /^\s*top\b/,
78
+ /^\s*htop\b/,
79
+ /^\s*free\b/,
80
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
81
+ /^\s*git\s+ls-/i,
82
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
83
+ /^\s*yarn\s+(list|info|why|audit)/i,
84
+ /^\s*node\s+--version/i,
85
+ /^\s*python\s+--version/i,
86
+ /^\s*curl\s/i,
87
+ /^\s*wget\s+-O\s*-/i,
88
+ /^\s*jq\b/,
89
+ /^\s*sed\s+-n/i,
90
+ /^\s*awk\b/,
91
+ /^\s*rg\b/,
92
+ /^\s*fd\b/,
93
+ /^\s*bat\b/,
94
+ /^\s*eza\b/,
95
+ ];
96
+
97
+ export function isSafeCommand(command: string): boolean {
98
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
99
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
100
+ return !isDestructive && isSafe;
101
+ }
102
+
103
+ export interface TodoItem {
104
+ step: number;
105
+ text: string;
106
+ completed: boolean;
107
+ }
108
+
109
+ export function cleanStepText(text: string): string {
110
+ let cleaned = text
111
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
112
+ .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
+ .replace(/\s+/g, " ")
118
+ .trim();
119
+
120
+ if (cleaned.length > 0) {
121
+ cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
122
+ }
123
+ if (cleaned.length > 50) {
124
+ cleaned = `${cleaned.slice(0, 47)}...`;
125
+ }
126
+ return cleaned;
127
+ }
128
+
129
+ export function extractTodoItems(message: string): TodoItem[] {
130
+ const items: TodoItem[] = [];
131
+ const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
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;
136
+
137
+ for (const match of planSection.matchAll(numberedPattern)) {
138
+ const text = match[2]
139
+ .trim()
140
+ .replace(/\*{1,2}$/, "")
141
+ .trim();
142
+ if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
143
+ const cleaned = cleanStepText(text);
144
+ if (cleaned.length > 3) {
145
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
146
+ }
147
+ }
148
+ }
149
+ return items;
150
+ }
151
+
152
+ export function extractDoneSteps(message: string): number[] {
153
+ const steps: number[] = [];
154
+ for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
155
+ const step = Number(match[1]);
156
+ if (Number.isFinite(step)) steps.push(step);
157
+ }
158
+ return steps;
159
+ }
160
+
161
+ export function markCompletedSteps(text: string, items: TodoItem[]): number {
162
+ const doneSteps = extractDoneSteps(text);
163
+ for (const step of doneSteps) {
164
+ const item = items.find((t) => t.step === step);
165
+ if (item) item.completed = true;
166
+ }
167
+ return doneSteps.length;
168
+ }