@foolsecret/pi-planmode 1.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.
@@ -0,0 +1,25 @@
1
+ # 发布到 npm:推送 v* tag 时自动执行
2
+ # 认证:GitHub Secret NPM_TOKEN(granular token,勾选 bypass 2FA)
3
+ # 更换 token:gh secret set NPM_TOKEN(本地 gh CLI 已登录)
4
+ name: Publish to npm
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 22
20
+ registry-url: https://registry.npmjs.org/
21
+ - name: Check version matches tag
22
+ run: node -e "const v=require('./package.json').version; const t=process.env.GITHUB_REF_NAME.slice(1); if(v!==t){console.error(`package.json ${v} != tag v${t}`);process.exit(1)}"
23
+ - run: npm publish --access public
24
+ env:
25
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Titor-Z
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,82 @@
1
+ <div align="center">
2
+
3
+ # pi-planning
4
+
5
+ **Plan mode for Pi Agent — read-only exploration, plan extraction & execution progress tracking.**
6
+
7
+ 计划模式:只读探索 → 生成计划 → 全量执行 → 进度追踪,一站式完成。
8
+
9
+ </div>
10
+
11
+ ---
12
+
13
+ ## ✨ 它是什么?
14
+
15
+ 一个开关,让 AI 先想清楚再动手:
16
+
17
+ - 🔒 **只读探索**:禁用 edit/write,Bash 限制在只读命令白名单内,安全分析代码
18
+ - 📋 **计划提取**:AI 在 `Plan:` 标题下输出编号步骤,自动提取为待办清单
19
+ - 🚀 **全量执行**:确认后恢复全部工具,按步骤执行
20
+ - ☑️ **进度追踪**:AI 每完成一步输出 `[DONE:n]`,侧边 widget 实时勾选进度
21
+
22
+ ### 使用方式
23
+
24
+ | 入口 | 说明 |
25
+ |---|---|
26
+ | `/plan` | 切换计划模式(只读 ⇄ 正常) |
27
+ | `Ctrl+Alt+P` | 同上,快捷键 |
28
+ | `pi --plan` | 启动时直接进入计划模式 |
29
+ | `/todos` | 查看当前计划进度 |
30
+ | 计划确认弹窗 | 执行计划 / 继续探索 / 修改计划 |
31
+
32
+ ### 视觉反馈
33
+
34
+ - Plan 模式:输入框边框变为半透明橙色,footer 显示 `[ PLAN ]` 徽章
35
+ - 执行模式:widget 显示 `📋 2/5` 式进度,已完成步骤划线勾选
36
+
37
+ ## 📦 安装
38
+
39
+ ```bash
40
+ pi install npm:@foolsecret/pi-planmode
41
+ # 或从 GitHub
42
+ pi install git:github.com/Titor-Z/pi-planmode
43
+ ```
44
+
45
+ ## 🧠 工作原理
46
+
47
+ 1. **激活**:`/plan` 后注入只读上下文,收窄工具集(保留 read/bash/grep 等,移除 edit/write)
48
+ 2. **防护**:`tool_call` 拦截非白名单 Bash 命令(写文件、包管理、git 写操作、系统命令等 60+ 模式)
49
+ 3. **提取**:回合结束从 AI 回复的 `Plan:` 段提取编号步骤
50
+ 4. **执行**:用户确认后恢复完整工具集,逐步执行;`turn_end` 扫描 `[DONE:n]` 标记更新进度
51
+ 5. **持久化**:状态写入会话(custom entry),`--resume` 恢复会话时自动还原进度
52
+
53
+ ## 📁 结构
54
+
55
+ ```
56
+ extensions/index.ts 薄入口:命令/快捷键/事件注册
57
+ extensions/mode-editor.ts 输入框边框变色(独立扩展,与主扩展共享状态)
58
+ src/state.ts globalThis 共享状态(跨扩展通信)
59
+ src/utils.ts 纯函数:命令白名单、计划提取、进度标记
60
+ src/prompt.ts 注入给模型的提示词常量
61
+ ```
62
+
63
+ ## ⚠️ 说明
64
+
65
+ - npm 包名为 `@foolsecret/pi-planmode`(scope 保住了 planmode 原名);本 GitHub 仓库名为 `pi-planmode`(仓库名不受
66
+ npm 规则约束)
67
+
68
+ - npm 上的 `pi-plan-mode`(qmx 维护)为无关项目;npm 名称混淆保护也不允许
69
+ 发布 `pi-planmode`(与 pi-plan-mode 过似),故定名 `pi-planning`
70
+ - Bash 白名单为保守设计:不在白名单内一律拦截,宁严勿松
71
+
72
+ ## English
73
+
74
+ **@foolsecret/pi-planmode** adds plan mode to Pi Agent: a toggle that switches the agent into read-only exploration (write tools disabled, bash restricted to a safe allowlist), extracts numbered plan steps from the AI's "Plan:" section, then executes with full tool access while tracking progress via `[DONE:n]` markers. Includes an input-box border tint and progress widget. Install with `pi install npm:@foolsecret/pi-planmode`, toggle with `/plan` or Ctrl+Alt+P.
75
+
76
+ ## License
77
+
78
+ MIT © 2026 Titor-Z
79
+
80
+ ---
81
+
82
+ **Star 🌟 让每一次执行都先有计划。**
@@ -0,0 +1,364 @@
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 { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "../src/utils.ts";
20
+ import { setPlanMode, PLAN_BADGE } from "../src/state.ts";
21
+ import { PLAN_MODE_PROMPT, executionPrompt, executionReminder } from "../src/prompt.ts";
22
+
23
+ // Tools
24
+ const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
25
+ const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
26
+ const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
27
+ const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
28
+
29
+ interface PlanModeState {
30
+ enabled: boolean;
31
+ todos?: TodoItem[];
32
+ executing?: boolean;
33
+ toolsBeforePlanMode?: string[];
34
+ }
35
+
36
+ // Type guard for assistant messages
37
+ function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
38
+ return m.role === "assistant" && Array.isArray(m.content);
39
+ }
40
+
41
+ // Extract text content from an assistant message
42
+ function getTextContent(message: AssistantMessage): string {
43
+ return message.content
44
+ .filter((block): block is TextContent => block.type === "text")
45
+ .map((block) => block.text)
46
+ .join("\n");
47
+ }
48
+
49
+ export default function planModeExtension(pi: ExtensionAPI): void {
50
+ let planModeEnabled = false;
51
+ let executionMode = false;
52
+ let todoItems: TodoItem[] = [];
53
+ let toolsBeforePlanMode: string[] | undefined;
54
+
55
+ pi.registerFlag("plan", {
56
+ description: "Start in plan mode (read-only exploration)",
57
+ type: "boolean",
58
+ default: false,
59
+ });
60
+
61
+ function updateStatus(ctx: ExtensionContext): void {
62
+ // Footer status
63
+ if (executionMode && todoItems.length > 0) {
64
+ const completed = todoItems.filter((t) => t.completed).length;
65
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
66
+ } else if (planModeEnabled) {
67
+ ctx.ui.setStatus("plan-mode", PLAN_BADGE);
68
+ } else {
69
+ ctx.ui.setStatus("plan-mode", undefined);
70
+ }
71
+
72
+ // Widget showing todo list
73
+ if (executionMode && todoItems.length > 0) {
74
+ const lines = todoItems.map((item) => {
75
+ if (item.completed) {
76
+ return (
77
+ ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
78
+ );
79
+ }
80
+ return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
81
+ });
82
+ ctx.ui.setWidget("plan-todos", lines);
83
+ } else {
84
+ ctx.ui.setWidget("plan-todos", undefined);
85
+ }
86
+ }
87
+
88
+ function uniqueToolNames(toolNames: string[]): string[] {
89
+ return [...new Set(toolNames)];
90
+ }
91
+
92
+ function getPlanModeTools(activeToolNames: string[]): string[] {
93
+ return uniqueToolNames([
94
+ ...activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)),
95
+ ...PLAN_MODE_TOOLS,
96
+ ]);
97
+ }
98
+
99
+ function getNormalModeTools(activeToolNames: string[]): string[] {
100
+ return uniqueToolNames([
101
+ ...NORMAL_MODE_TOOLS,
102
+ ...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
103
+ ]);
104
+ }
105
+
106
+ function enablePlanModeTools(): void {
107
+ const previous = toolsBeforePlanMode ?? (toolsBeforePlanMode = pi.getActiveTools());
108
+ pi.setActiveTools(getPlanModeTools(previous));
109
+ }
110
+
111
+ function restoreNormalModeTools(): void {
112
+ pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
113
+ toolsBeforePlanMode = undefined;
114
+ }
115
+
116
+ function persistState(): void {
117
+ pi.appendEntry("plan-mode", {
118
+ enabled: planModeEnabled,
119
+ todos: todoItems,
120
+ executing: executionMode,
121
+ toolsBeforePlanMode,
122
+ });
123
+ }
124
+
125
+ function togglePlanMode(ctx: ExtensionContext): void {
126
+ planModeEnabled = !planModeEnabled;
127
+ executionMode = false;
128
+ todoItems = [];
129
+ setPlanMode(planModeEnabled);
130
+
131
+ if (planModeEnabled) {
132
+ enablePlanModeTools();
133
+ ctx.ui.notify("Plan mode enabled. Built-in write tools disabled.");
134
+ } else {
135
+ restoreNormalModeTools();
136
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
137
+ }
138
+ updateStatus(ctx);
139
+ persistState();
140
+ }
141
+
142
+ pi.registerCommand("plan", {
143
+ description: "Toggle plan mode (read-only exploration)",
144
+ handler: async (_args, ctx) => togglePlanMode(ctx),
145
+ });
146
+
147
+ pi.registerCommand("todos", {
148
+ description: "Show current plan todo list",
149
+ handler: async (_args, ctx) => {
150
+ if (todoItems.length === 0) {
151
+ ctx.ui.notify("No todos. Create a plan first with /plan", "info");
152
+ return;
153
+ }
154
+ const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
155
+ ctx.ui.notify(`Plan Progress:\n${list}`, "info");
156
+ },
157
+ });
158
+
159
+ pi.registerShortcut(Key.ctrlAlt("p"), {
160
+ description: "Toggle plan mode",
161
+ handler: async (ctx) => togglePlanMode(ctx),
162
+ });
163
+
164
+ // Block destructive bash commands in plan mode
165
+ pi.on("tool_call", async (event) => {
166
+ if (!planModeEnabled || event.toolName !== "bash") return;
167
+
168
+ const command = event.input.command as string;
169
+ if (!isSafeCommand(command)) {
170
+ return {
171
+ block: true,
172
+ reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
173
+ };
174
+ }
175
+ });
176
+
177
+ // Filter out stale plan mode context when not in plan mode
178
+ pi.on("context", async (event) => {
179
+ if (planModeEnabled) return;
180
+
181
+ return {
182
+ messages: event.messages.filter((m) => {
183
+ const msg = m as AgentMessage & { customType?: string };
184
+ if (msg.customType === "plan-mode-context") return false;
185
+ if (msg.role !== "user") return true;
186
+
187
+ const content = msg.content;
188
+ if (typeof content === "string") {
189
+ return !content.includes("[PLAN MODE ACTIVE]");
190
+ }
191
+ if (Array.isArray(content)) {
192
+ return !content.some(
193
+ (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
194
+ );
195
+ }
196
+ return true;
197
+ }),
198
+ };
199
+ });
200
+
201
+ // Inject plan/execution context before agent starts
202
+ pi.on("before_agent_start", async () => {
203
+ if (planModeEnabled) {
204
+ return {
205
+ message: {
206
+ customType: "plan-mode-context",
207
+ content: PLAN_MODE_PROMPT,
208
+ display: false,
209
+ },
210
+ };
211
+ }
212
+
213
+ if (executionMode && todoItems.length > 0) {
214
+ const remaining = todoItems.filter((t) => !t.completed);
215
+ const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
216
+ return {
217
+ message: {
218
+ customType: "plan-execution-context",
219
+ content: executionReminder(todoList),
220
+ display: false,
221
+ },
222
+ };
223
+ }
224
+ });
225
+
226
+ // Track progress after each turn
227
+ pi.on("turn_end", async (event, ctx) => {
228
+ if (!executionMode || todoItems.length === 0) return;
229
+ if (!isAssistantMessage(event.message)) return;
230
+
231
+ const text = getTextContent(event.message);
232
+ if (markCompletedSteps(text, todoItems) > 0) {
233
+ updateStatus(ctx);
234
+ }
235
+ persistState();
236
+ });
237
+
238
+ // Handle plan completion and plan mode UI
239
+ pi.on("agent_end", async (event, ctx) => {
240
+ // Check if execution is complete
241
+ if (executionMode && todoItems.length > 0) {
242
+ if (todoItems.every((t) => t.completed)) {
243
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
244
+ pi.sendMessage(
245
+ { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
246
+ { triggerTurn: false },
247
+ );
248
+ executionMode = false;
249
+ todoItems = [];
250
+ updateStatus(ctx);
251
+ persistState(); // Save cleared state so resume doesn't restore old execution mode
252
+ }
253
+ return;
254
+ }
255
+
256
+ if (!planModeEnabled || !ctx.hasUI) return;
257
+
258
+ // Extract todos from last assistant message
259
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
260
+ if (lastAssistant) {
261
+ const extracted = extractTodoItems(getTextContent(lastAssistant));
262
+ if (extracted.length > 0) {
263
+ todoItems = extracted;
264
+ }
265
+ }
266
+
267
+ if (todoItems.length === 0) return;
268
+ persistState();
269
+
270
+ // Show plan steps and prompt for next action
271
+ const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
272
+ const planTodoListMessage = {
273
+ customType: "plan-todo-list",
274
+ content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
275
+ display: true,
276
+ };
277
+
278
+ const choice = await ctx.ui.select("Plan mode - what next?", [
279
+ "Execute the plan (track progress)",
280
+ "Stay in plan mode",
281
+ "Refine the plan",
282
+ ]);
283
+
284
+ if (choice?.startsWith("Execute")) {
285
+ const firstTodoItem = todoItems[0];
286
+ if (!firstTodoItem) return;
287
+
288
+ planModeEnabled = false;
289
+ executionMode = true;
290
+ setPlanMode(false);
291
+ restoreNormalModeTools();
292
+ updateStatus(ctx);
293
+ persistState();
294
+
295
+ const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
296
+ const execMessage = executionPrompt(remainingList, firstTodoItem.text);
297
+ pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
298
+ pi.sendMessage(
299
+ { customType: "plan-mode-execute", content: execMessage, display: true },
300
+ { triggerTurn: true, deliverAs: "followUp" },
301
+ );
302
+ } else if (choice === "Refine the plan") {
303
+ const refinement = await ctx.ui.editor("Refine the plan:", "");
304
+ if (refinement?.trim()) {
305
+ pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
306
+ pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
307
+ }
308
+ }
309
+ });
310
+
311
+ // Restore state on session start/resume
312
+ pi.on("session_start", async (_event, ctx) => {
313
+ if (pi.getFlag("plan") === true) {
314
+ planModeEnabled = true;
315
+ }
316
+
317
+ const entries = ctx.sessionManager.getEntries();
318
+
319
+ // Restore persisted state
320
+ const planModeEntry = entries
321
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
322
+ .pop() as { data?: PlanModeState } | undefined;
323
+
324
+ if (planModeEntry?.data) {
325
+ planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
326
+ todoItems = planModeEntry.data.todos ?? todoItems;
327
+ executionMode = planModeEntry.data.executing ?? executionMode;
328
+ toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
329
+ }
330
+
331
+ // On resume: re-scan messages to rebuild completion state
332
+ // Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
333
+ const isResume = planModeEntry !== undefined;
334
+ if (isResume && executionMode && todoItems.length > 0) {
335
+ // Find the index of the last plan-mode-execute entry (marks when current execution started)
336
+ let executeIndex = -1;
337
+ for (let i = entries.length - 1; i >= 0; i--) {
338
+ const entry = entries[i] as { type: string; customType?: string };
339
+ if (entry.customType === "plan-mode-execute") {
340
+ executeIndex = i;
341
+ break;
342
+ }
343
+ }
344
+
345
+ // Only scan messages after the execute marker
346
+ const messages: AssistantMessage[] = [];
347
+ for (let i = executeIndex + 1; i < entries.length; i++) {
348
+ const entry = entries[i];
349
+ if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
350
+ messages.push(entry.message as AssistantMessage);
351
+ }
352
+ }
353
+ const allText = messages.map(getTextContent).join("\n");
354
+ markCompletedSteps(allText, todoItems);
355
+ }
356
+
357
+ setPlanMode(planModeEnabled);
358
+
359
+ if (planModeEnabled) {
360
+ enablePlanModeTools();
361
+ }
362
+ updateStatus(ctx);
363
+ });
364
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Mode Editor —— 让输入框边框随模式变色(类似 opencode 的模式切换视觉反馈)
3
+ *
4
+ * - plan 模式: 半透明橙边框([ PLAN ] 徽章统一显示在 DeepSeek footer 左侧)
5
+ * - 正常/执行模式: 主题默认边框
6
+ *
7
+ * 通过 src/state.ts(globalThis 共享)与 plan-mode 扩展联动:
8
+ * /plan、Ctrl+Alt+P、--plan 都会实时反映到输入框上。
9
+ */
10
+
11
+ import { CustomEditor, type ExtensionAPI, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
12
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
13
+ import { isPlanMode, onPlanModeChange, PLAN_BORDER } from "../src/state.ts";
14
+
15
+ class ModeEditor extends CustomEditor {
16
+ private lastPlanMode: boolean | null = null;
17
+ private defaultBorder: (text: string) => string;
18
+
19
+ constructor(
20
+ tui: TUI,
21
+ theme: EditorTheme,
22
+ keybindings: KeybindingsManager,
23
+ private planBorder: (text: string) => string,
24
+ ) {
25
+ super(tui, theme, keybindings);
26
+ // 保存默认边框色。注意:pi 的 setCustomEditorComponent 在工厂返回后会把
27
+ // borderColor 强制覆盖为默认值,因此这里只保存引用,真正的换色在 render()
28
+ // 每次渲染前重新安装(见 render)。
29
+ this.defaultBorder = theme.borderColor;
30
+ }
31
+
32
+ render(width: number): string[] {
33
+ // pi 会覆盖构造时设置的 borderColor,所以每次渲染前重新安装我们的着色函数
34
+ this.borderColor = isPlanMode() ? this.planBorder : this.defaultBorder;
35
+
36
+ const planMode = isPlanMode();
37
+ if (planMode !== this.lastPlanMode) {
38
+ this.lastPlanMode = planMode;
39
+ // 模式翻转时主动请求一次重绘,保证颜色立即生效
40
+ this.tui.requestRender();
41
+ }
42
+
43
+ return super.render(width);
44
+ }
45
+ }
46
+
47
+ export default function (pi: ExtensionAPI) {
48
+ pi.on("session_start", (_event, ctx) => {
49
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
50
+ // 状态变化(含退出 plan)时立即重绘,保证颜色即时生效
51
+ onPlanModeChange(() => tui.requestRender());
52
+ return new ModeEditor(tui, theme, keybindings, PLAN_BORDER);
53
+ });
54
+ });
55
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@foolsecret/pi-planmode",
3
+ "version": "1.1.0",
4
+ "description": "为 Pi Agent 打造的计划模式 — 只读探索、计划提取与执行进度追踪 | Plan mode for Pi Agent — read-only exploration, plan extraction & execution progress tracking",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "plan-mode",
9
+ "planning",
10
+ "agent",
11
+ "read-only"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Titor-Z",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Titor-Z/pi-planmode.git"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions/index.ts",
22
+ "./extensions/mode-editor.ts"
23
+ ]
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-agent-core": "*",
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-tui": "*"
30
+ }
31
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Plan mode 注入给模型的提示词常量。
3
+ * 抽离为独立模块便于调优与审阅,避免散落在扩展主逻辑中。
4
+ */
5
+
6
+ /** plan 模式激活时注入的只读探索上下文 */
7
+ export const PLAN_MODE_PROMPT = `[PLAN MODE ACTIVE]
8
+ You are in plan mode - a read-only exploration mode for safe code analysis.
9
+
10
+ Restrictions:
11
+ - Built-in edit and write tools are disabled
12
+ - Other currently active tools remain available
13
+ - Bash is restricted to an allowlist of read-only commands
14
+
15
+ Ask clarifying questions using the questionnaire tool.
16
+ Use brave-search skill via bash for web research.
17
+
18
+ Create a detailed numbered plan under a "Plan:" header:
19
+
20
+ Plan:
21
+ 1. First step description
22
+ 2. Second step description
23
+ ...
24
+
25
+ Do NOT attempt to make changes - just describe what you would do.`;
26
+
27
+ /** 执行模式开始时注入的执行上下文(含剩余步骤清单) */
28
+ export function executionPrompt(remainingList: string, firstStep: string): string {
29
+ return `Execute the plan.
30
+
31
+ Remaining steps:
32
+ ${remainingList}
33
+
34
+ Start with: ${firstStep}
35
+ After completing a step, include a [DONE:n] tag in your response.`;
36
+ }
37
+
38
+ /** 执行过程中每轮注入的剩余步骤提醒 */
39
+ export function executionReminder(todoList: string): string {
40
+ return `[EXECUTING PLAN - Full tool access enabled]
41
+
42
+ Remaining steps:
43
+ ${todoList}
44
+
45
+ Execute each step in order.
46
+ After completing a step, include a [DONE:n] tag in your response.`;
47
+ }
package/src/state.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * plan 模式共享状态与样式常量(跨扩展通信)。
3
+ *
4
+ * 重要:pi 的扩展加载器用 moduleCache:false 的 jiti 加载扩展,
5
+ * 同一个模块在不同扩展里会被求值成不同实例。
6
+ * 因此状态必须挂到 globalThis 上,才能保证 plan-mode 写入、
7
+ * mode-editor 读取到的是同一个对象。
8
+ * (常量是字符串/纯函数,各实例自带一份相同值即可,无影响。)
9
+ */
10
+
11
+ interface PlanModeSharedState {
12
+ enabled: boolean;
13
+ listeners: Set<() => void>;
14
+ }
15
+
16
+ const GLOBAL_KEY = "__pi_plan_mode_shared_state__";
17
+
18
+ function getState(): PlanModeSharedState {
19
+ const g = globalThis as Record<string, unknown>;
20
+ const existing = g[GLOBAL_KEY] as PlanModeSharedState | undefined;
21
+ if (existing) return existing;
22
+ const state: PlanModeSharedState = { enabled: false, listeners: new Set() };
23
+ g[GLOBAL_KEY] = state;
24
+ return state;
25
+ }
26
+
27
+ export function setPlanMode(enabled: boolean): void {
28
+ const s = getState();
29
+ s.enabled = enabled;
30
+ for (const listener of s.listeners) listener();
31
+ }
32
+
33
+ export function isPlanMode(): boolean {
34
+ return getState().enabled;
35
+ }
36
+
37
+ /** 订阅模式变化,返回取消订阅函数。用于触发 UI 重绘。 */
38
+ export function onPlanModeChange(listener: () => void): () => void {
39
+ const s = getState();
40
+ s.listeners.add(listener);
41
+ return () => {
42
+ s.listeners.delete(listener);
43
+ };
44
+ }
45
+
46
+ /** 橙色背景 + 白色字体的 [ PLAN ] 徽章(ANSI 256 色 208 = 橙色,97 = 亮白) */
47
+ export const PLAN_BADGE = "\x1b[48;5;208m\x1b[97m PLAN \x1b[0m";
48
+
49
+ /**
50
+ * 半透明橙色边框(dim 橙色,模拟半透明质感;不支持 dim 的终端会回退为亮橙)。
51
+ * 用在输入框边框上,与 footer 里的实心橙色徽章区分层次。
52
+ */
53
+ export const PLAN_BORDER = (text: string): string => `\x1b[2m\x1b[38;5;208m${text}\x1b[0m`;
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
+ }