gsd-pi 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.
Files changed (128) hide show
  1. package/README.md +341 -0
  2. package/dist/app-paths.d.ts +4 -0
  3. package/dist/app-paths.js +6 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +35 -0
  6. package/dist/loader.d.ts +2 -0
  7. package/dist/loader.js +69 -0
  8. package/dist/modes/interactive/theme/dark.json +85 -0
  9. package/dist/modes/interactive/theme/light.json +84 -0
  10. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  11. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  12. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  13. package/dist/modes/interactive/theme/theme.js +949 -0
  14. package/dist/modes/interactive/theme/theme.js.map +1 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +48 -0
  17. package/dist/wizard.d.ts +20 -0
  18. package/dist/wizard.js +132 -0
  19. package/package.json +39 -0
  20. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  21. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  22. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  23. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  24. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  25. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  26. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  27. package/pkg/package.json +8 -0
  28. package/scripts/postinstall.js +10 -0
  29. package/src/resources/AGENTS.md +204 -0
  30. package/src/resources/GSD-WORKFLOW.md +661 -0
  31. package/src/resources/agents/researcher.md +29 -0
  32. package/src/resources/agents/scout.md +56 -0
  33. package/src/resources/agents/worker.md +31 -0
  34. package/src/resources/extensions/ask-user-questions.ts +200 -0
  35. package/src/resources/extensions/bg-shell/index.ts +2554 -0
  36. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  37. package/src/resources/extensions/browser-tools/core.js +1057 -0
  38. package/src/resources/extensions/browser-tools/index.ts +4916 -0
  39. package/src/resources/extensions/browser-tools/package.json +20 -0
  40. package/src/resources/extensions/context7/index.ts +428 -0
  41. package/src/resources/extensions/context7/package.json +11 -0
  42. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  43. package/src/resources/extensions/gsd/activity-log.ts +48 -0
  44. package/src/resources/extensions/gsd/auto.ts +2032 -0
  45. package/src/resources/extensions/gsd/commands.ts +292 -0
  46. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  47. package/src/resources/extensions/gsd/dashboard-overlay.ts +516 -0
  48. package/src/resources/extensions/gsd/docs/preferences-reference.md +103 -0
  49. package/src/resources/extensions/gsd/doctor.ts +683 -0
  50. package/src/resources/extensions/gsd/files.ts +730 -0
  51. package/src/resources/extensions/gsd/gitignore.ts +104 -0
  52. package/src/resources/extensions/gsd/guided-flow.ts +800 -0
  53. package/src/resources/extensions/gsd/index.ts +418 -0
  54. package/src/resources/extensions/gsd/metrics.ts +372 -0
  55. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  56. package/src/resources/extensions/gsd/package.json +11 -0
  57. package/src/resources/extensions/gsd/paths.ts +308 -0
  58. package/src/resources/extensions/gsd/preferences.ts +600 -0
  59. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  60. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  61. package/src/resources/extensions/gsd/prompts/complete-slice.md +27 -0
  62. package/src/resources/extensions/gsd/prompts/discuss.md +151 -0
  63. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  64. package/src/resources/extensions/gsd/prompts/execute-task.md +64 -0
  65. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  66. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  67. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  68. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  69. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  70. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  71. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  72. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  73. package/src/resources/extensions/gsd/prompts/plan-milestone.md +47 -0
  74. package/src/resources/extensions/gsd/prompts/plan-slice.md +63 -0
  75. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  76. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  77. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  78. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  79. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  80. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  81. package/src/resources/extensions/gsd/prompts/system.md +220 -0
  82. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  83. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  84. package/src/resources/extensions/gsd/state.ts +439 -0
  85. package/src/resources/extensions/gsd/templates/context.md +76 -0
  86. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  87. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  88. package/src/resources/extensions/gsd/templates/plan.md +133 -0
  89. package/src/resources/extensions/gsd/templates/preferences.md +15 -0
  90. package/src/resources/extensions/gsd/templates/project.md +31 -0
  91. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  92. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  93. package/src/resources/extensions/gsd/templates/research.md +46 -0
  94. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  95. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  96. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  97. package/src/resources/extensions/gsd/templates/state.md +19 -0
  98. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  99. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  100. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  101. package/src/resources/extensions/gsd/types.ts +159 -0
  102. package/src/resources/extensions/gsd/unit-runtime.ts +162 -0
  103. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  104. package/src/resources/extensions/gsd/worktree.ts +182 -0
  105. package/src/resources/extensions/plan-mode/README.md +65 -0
  106. package/src/resources/extensions/plan-mode/index.ts +521 -0
  107. package/src/resources/extensions/plan-mode/utils.ts +168 -0
  108. package/src/resources/extensions/search-the-web/cache.ts +70 -0
  109. package/src/resources/extensions/search-the-web/format.ts +134 -0
  110. package/src/resources/extensions/search-the-web/http.ts +147 -0
  111. package/src/resources/extensions/search-the-web/index.ts +46 -0
  112. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +374 -0
  113. package/src/resources/extensions/search-the-web/tool-search.ts +424 -0
  114. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  115. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  116. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  117. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  118. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  119. package/src/resources/extensions/shared/ui.ts +400 -0
  120. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  121. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  122. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  123. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  124. package/src/resources/extensions/slash-commands/gsd-run.ts +34 -0
  125. package/src/resources/extensions/slash-commands/index.ts +12 -0
  126. package/src/resources/extensions/subagent/agents.ts +126 -0
  127. package/src/resources/extensions/subagent/index.ts +1021 -0
  128. package/src/resources/extensions/worktree/index.ts +420 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Plan Mode Extension
3
+ *
4
+ * Read-only exploration mode for safe code analysis.
5
+ * When enabled, only read-only tools are available.
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 "@mariozechner/pi-agent-core";
16
+ import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
17
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
18
+ import { Key } from "@mariozechner/pi-tui";
19
+ import { Type } from "@sinclair/typebox";
20
+ import { Text } from "@mariozechner/pi-tui";
21
+ import { showInterviewRound, type Question } from "../shared/interview-ui.js";
22
+ import {
23
+ createProgressPanel,
24
+ type ProgressPanel,
25
+ type ProgressPanelModel,
26
+ type ProgressItem,
27
+ type ProgressItemStatus,
28
+ } from "../shared/progress-widget.js";
29
+ import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
30
+
31
+ // Tools
32
+ const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "plan_clarify"];
33
+ const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
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 panel: ProgressPanel | null = null;
53
+ let cmdCtx: ExtensionCommandContext | null = null;
54
+
55
+ // ── Progress panel model builder ──────────────────────────────────────────
56
+
57
+ function buildPlanModel(): ProgressPanelModel {
58
+ const allDone = todoItems.length > 0 && todoItems.every((t) => t.completed);
59
+
60
+ // Derive per-item status: first non-completed item is active
61
+ let foundActive = false;
62
+ const items: ProgressItem[] = todoItems.map((t) => {
63
+ let status: ProgressItemStatus;
64
+ if (t.completed) {
65
+ status = "done";
66
+ } else if (!foundActive) {
67
+ status = "active";
68
+ foundActive = true;
69
+ } else {
70
+ status = "pending";
71
+ }
72
+ return { label: t.text, status };
73
+ });
74
+
75
+ const completed = todoItems.filter((t) => t.completed).length;
76
+
77
+ return {
78
+ title: "Plan",
79
+ badge: allDone ? "DONE" : "EXECUTING",
80
+ badgeStatus: allDone ? "done" : "active",
81
+ subtitle: [`Step ${completed}/${todoItems.length}`],
82
+ items,
83
+ };
84
+ }
85
+
86
+ pi.registerFlag("plan", {
87
+ description: "Start in plan mode (read-only exploration)",
88
+ type: "boolean",
89
+ default: false,
90
+ });
91
+
92
+ function ensurePanel(ctx: ExtensionContext): void {
93
+ if (!panel) {
94
+ panel = createProgressPanel(ctx.ui, {
95
+ widgetKey: "plan-todos",
96
+ statusKey: "plan-mode",
97
+ statusPrefix: "plan",
98
+ });
99
+ }
100
+ }
101
+
102
+ function updateStatus(ctx: ExtensionContext): void {
103
+ if (executionMode && todoItems.length > 0) {
104
+ // Execution-mode: delegate to the shared progress panel
105
+ ensurePanel(ctx);
106
+ panel!.update(buildPlanModel());
107
+ } else if (planModeEnabled) {
108
+ // Idle plan-mode: simple footer status, no widget
109
+ panel?.dispose();
110
+ panel = null;
111
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
112
+ } else {
113
+ // Neither mode: clean up everything
114
+ panel?.dispose();
115
+ panel = null;
116
+ ctx.ui.setStatus("plan-mode", undefined);
117
+ }
118
+ }
119
+
120
+ function togglePlanMode(ctx: ExtensionContext): void {
121
+ planModeEnabled = !planModeEnabled;
122
+ executionMode = false;
123
+ todoItems = [];
124
+
125
+ if (planModeEnabled) {
126
+ pi.setActiveTools(PLAN_MODE_TOOLS);
127
+ ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
128
+ } else {
129
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
130
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
131
+ }
132
+ updateStatus(ctx);
133
+ }
134
+
135
+ function persistState(): void {
136
+ pi.appendEntry("plan-mode", {
137
+ enabled: planModeEnabled,
138
+ todos: todoItems,
139
+ executing: executionMode,
140
+ });
141
+ }
142
+
143
+ // ── plan_clarify tool — LLM-driven clarifying questions via interview UI ──
144
+
145
+ pi.registerTool({
146
+ name: "plan_clarify",
147
+ label: "Plan Clarify",
148
+ description: [
149
+ "Ask the user clarifying questions during plan mode exploration.",
150
+ "Use this to resolve ambiguities before committing to a plan.",
151
+ "Each question should have 2-4 concrete options. The user can also add notes.",
152
+ "Keep questions focused on intent, scope, and constraints — not implementation details.",
153
+ ].join("\n"),
154
+ parameters: Type.Object({
155
+ questions: Type.Array(
156
+ Type.Object({
157
+ id: Type.String({ description: "Stable snake_case identifier, e.g. 'scope'" }),
158
+ header: Type.String({ description: "Short label, 12 chars or fewer, shown in tab bar" }),
159
+ question: Type.String({ description: "Single sentence question for the user" }),
160
+ options: Type.Array(
161
+ Type.Object({
162
+ label: Type.String({ description: "2-5 word label" }),
163
+ description: Type.String({ description: "One sentence explaining this choice" }),
164
+ }),
165
+ { minItems: 2, maxItems: 4 },
166
+ ),
167
+ allowMultiple: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
168
+ }),
169
+ { description: "1-4 clarifying questions to present to the user" },
170
+ ),
171
+ }),
172
+
173
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
174
+ if (!ctx.hasUI) {
175
+ return {
176
+ content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
177
+ details: { cancelled: true, answers: {} },
178
+ };
179
+ }
180
+
181
+ const questions = params.questions as Question[];
182
+ if (questions.length === 0) {
183
+ return {
184
+ content: [{ type: "text", text: "Error: No questions provided" }],
185
+ details: { cancelled: true, answers: {} },
186
+ };
187
+ }
188
+
189
+ const result = await showInterviewRound(
190
+ questions,
191
+ { progress: "Plan Mode · Clarifying Questions" },
192
+ ctx,
193
+ );
194
+
195
+ if (!result.answers || Object.keys(result.answers).length === 0) {
196
+ return {
197
+ content: [{ type: "text", text: "User cancelled the clarification." }],
198
+ details: { cancelled: true, answers: {} },
199
+ };
200
+ }
201
+
202
+ const lines = Object.entries(result.answers).map(([id, ans]) => {
203
+ const q = questions.find((q) => q.id === id);
204
+ const label = q?.question ?? id;
205
+ const selected = Array.isArray(ans.selected) ? ans.selected.join(", ") : ans.selected;
206
+ return ans.notes
207
+ ? `${label}: ${selected} (note: ${ans.notes})`
208
+ : `${label}: ${selected}`;
209
+ });
210
+
211
+ return {
212
+ content: [{ type: "text", text: lines.join("\n") }],
213
+ details: { cancelled: false, answers: result.answers },
214
+ };
215
+ },
216
+
217
+ renderCall(args, theme) {
218
+ const qs = (args.questions as Question[]) || [];
219
+ let text = theme.fg("toolTitle", theme.bold("plan_clarify "));
220
+ text += theme.fg("muted", `${qs.length} question${qs.length !== 1 ? "s" : ""}`);
221
+ return new Text(text, 0, 0);
222
+ },
223
+
224
+ renderResult(result, _options, theme) {
225
+ const details = result.details as { cancelled?: boolean; answers?: Record<string, { selected: string | string[]; notes?: string }> } | undefined;
226
+ if (!details || details.cancelled) {
227
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
228
+ }
229
+ const lines = Object.entries(details.answers ?? {}).map(([id, ans]) => {
230
+ const selected = Array.isArray(ans.selected) ? ans.selected.join(", ") : ans.selected;
231
+ let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", id)}: ${selected}`;
232
+ if (ans.notes) line += ` ${theme.fg("muted", `[${ans.notes}]`)}`;
233
+ return line;
234
+ });
235
+ return new Text(lines.join("\n") || theme.fg("dim", "(no answers)"), 0, 0);
236
+ },
237
+ });
238
+
239
+ pi.registerCommand("plan", {
240
+ description: "Toggle plan mode (read-only exploration)",
241
+ handler: async (_args, ctx) => {
242
+ cmdCtx = ctx;
243
+ togglePlanMode(ctx);
244
+ },
245
+ });
246
+
247
+ pi.registerCommand("todos", {
248
+ description: "Show current plan todo list",
249
+ handler: async (_args, ctx) => {
250
+ if (todoItems.length === 0) {
251
+ ctx.ui.notify("No todos. Create a plan first with /plan", "info");
252
+ return;
253
+ }
254
+ const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
255
+ ctx.ui.notify(`Plan Progress:\n${list}`, "info");
256
+ },
257
+ });
258
+
259
+ pi.registerShortcut(Key.ctrlAlt("p"), {
260
+ description: "Toggle plan mode",
261
+ handler: async (ctx) => togglePlanMode(ctx),
262
+ });
263
+
264
+ // Block destructive bash commands in plan mode
265
+ pi.on("tool_call", async (event) => {
266
+ if (!planModeEnabled || event.toolName !== "bash") return;
267
+
268
+ const command = event.input.command as string;
269
+ if (!isSafeCommand(command)) {
270
+ return {
271
+ block: true,
272
+ reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
273
+ };
274
+ }
275
+ });
276
+
277
+ // Filter out stale plan mode context when not in plan mode
278
+ pi.on("context", async (event) => {
279
+ if (planModeEnabled) return;
280
+
281
+ return {
282
+ messages: event.messages.filter((m) => {
283
+ const msg = m as AgentMessage & { customType?: string };
284
+ if (msg.customType === "plan-mode-context") return false;
285
+ if (msg.role !== "user") return true;
286
+
287
+ const content = msg.content;
288
+ if (typeof content === "string") {
289
+ return !content.includes("[PLAN MODE ACTIVE]");
290
+ }
291
+ if (Array.isArray(content)) {
292
+ return !content.some(
293
+ (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
294
+ );
295
+ }
296
+ return true;
297
+ }),
298
+ };
299
+ });
300
+
301
+ // Inject plan/execution context before agent starts
302
+ pi.on("before_agent_start", async () => {
303
+ if (planModeEnabled) {
304
+ return {
305
+ message: {
306
+ customType: "plan-mode-context",
307
+ content: `[PLAN MODE ACTIVE]
308
+ You are in plan mode - a read-only exploration mode for safe code analysis.
309
+
310
+ Restrictions:
311
+ - You can only use: read, bash, grep, find, ls, plan_clarify
312
+ - You CANNOT use: edit, write (file modifications are disabled)
313
+ - Bash is restricted to an allowlist of read-only commands
314
+
315
+ Use the plan_clarify tool to ask the user clarifying questions before committing to a plan.
316
+
317
+ Create a detailed numbered plan under a "Plan:" header:
318
+
319
+ Plan:
320
+ 1. First step description
321
+ 2. Second step description
322
+ ...
323
+
324
+ Do NOT attempt to make changes - just describe what you would do.`,
325
+ display: false,
326
+ },
327
+ };
328
+ }
329
+
330
+ if (executionMode && todoItems.length > 0) {
331
+ if (panel) panel.startPulse();
332
+
333
+ const remaining = todoItems.filter((t) => !t.completed);
334
+ const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
335
+ return {
336
+ message: {
337
+ customType: "plan-execution-context",
338
+ content: `[EXECUTING PLAN - Full tool access enabled]
339
+
340
+ Remaining steps:
341
+ ${todoList}
342
+
343
+ Execute each step in order.
344
+ After completing a step, include a [DONE:n] tag in your response.`,
345
+ display: false,
346
+ },
347
+ };
348
+ }
349
+ });
350
+
351
+ // Track progress after each turn
352
+ pi.on("turn_end", async (event, ctx) => {
353
+ if (!executionMode || todoItems.length === 0) return;
354
+ if (!isAssistantMessage(event.message)) return;
355
+
356
+ const text = getTextContent(event.message);
357
+ if (markCompletedSteps(text, todoItems) > 0) {
358
+ updateStatus(ctx);
359
+ }
360
+ persistState();
361
+ });
362
+
363
+ // Handle plan completion and plan mode UI
364
+ pi.on("agent_end", async (event, ctx) => {
365
+ // Stop pulse animation (safe to call even if not pulsing)
366
+ if (panel) panel.stopPulse();
367
+
368
+ // Check if execution is complete
369
+ if (executionMode && todoItems.length > 0) {
370
+ if (todoItems.every((t) => t.completed)) {
371
+ // Final update to show DONE state before disposing
372
+ panel?.update(buildPlanModel());
373
+
374
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
375
+ pi.sendMessage(
376
+ { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
377
+ { triggerTurn: false },
378
+ );
379
+ executionMode = false;
380
+ todoItems = [];
381
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
382
+ updateStatus(ctx);
383
+ persistState();
384
+ }
385
+ return;
386
+ }
387
+
388
+ if (!planModeEnabled || !ctx.hasUI) return;
389
+
390
+ // Extract todos from last assistant message
391
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
392
+ if (lastAssistant) {
393
+ const extracted = extractTodoItems(getTextContent(lastAssistant));
394
+ if (extracted.length > 0) {
395
+ todoItems = extracted;
396
+ }
397
+ }
398
+
399
+ // Show plan steps and prompt for next action
400
+ if (todoItems.length > 0) {
401
+ const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
402
+ pi.sendMessage(
403
+ {
404
+ customType: "plan-todo-list",
405
+ content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
406
+ display: true,
407
+ },
408
+ { triggerTurn: false },
409
+ );
410
+ }
411
+
412
+ const choice = await ctx.ui.select("Plan mode - what next?", [
413
+ todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
414
+ "Stay in plan mode",
415
+ "Refine the plan",
416
+ ]);
417
+
418
+ if (choice?.startsWith("Execute")) {
419
+ planModeEnabled = false;
420
+ executionMode = todoItems.length > 0;
421
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
422
+ updateStatus(ctx);
423
+
424
+ // Build the execution prompt — include the full plan so the fresh
425
+ // session has everything it needs without relying on prior context.
426
+ const todoListText =
427
+ todoItems.length > 0
428
+ ? todoItems.map((t) => `${t.step}. ${t.text}`).join("\n")
429
+ : "";
430
+ const execMessage =
431
+ todoItems.length > 0
432
+ ? [
433
+ `Execute the following plan. Start with step 1.`,
434
+ ``,
435
+ `Plan:`,
436
+ todoListText,
437
+ ``,
438
+ `After completing each step, include a [DONE:n] tag in your response.`,
439
+ ].join("\n")
440
+ : "Execute the plan you just created.";
441
+
442
+ // Clear context before executing so the LLM starts fresh.
443
+ if (cmdCtx) {
444
+ const result = await cmdCtx.newSession();
445
+ if (result.cancelled) {
446
+ // User cancelled the session switch — restore plan mode.
447
+ planModeEnabled = true;
448
+ executionMode = false;
449
+ pi.setActiveTools(PLAN_MODE_TOOLS);
450
+ updateStatus(ctx);
451
+ return;
452
+ }
453
+ }
454
+
455
+ pi.sendMessage(
456
+ { customType: "plan-mode-execute", content: execMessage, display: true },
457
+ { triggerTurn: true },
458
+ );
459
+ } else if (choice === "Refine the plan") {
460
+ const refinement = await ctx.ui.editor("Refine the plan:", "");
461
+ if (refinement?.trim()) {
462
+ pi.sendUserMessage(refinement.trim());
463
+ }
464
+ }
465
+ });
466
+
467
+ // Restore state on session start/resume
468
+ pi.on("session_start", async (_event, ctx) => {
469
+ if (pi.getFlag("plan") === true) {
470
+ planModeEnabled = true;
471
+ }
472
+
473
+ const entries = ctx.sessionManager.getEntries();
474
+
475
+ // Restore persisted state
476
+ const planModeEntry = entries
477
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
478
+ .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
479
+
480
+ if (planModeEntry?.data) {
481
+ planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
482
+ todoItems = planModeEntry.data.todos ?? todoItems;
483
+ executionMode = planModeEntry.data.executing ?? executionMode;
484
+ }
485
+
486
+ // On resume: re-scan messages to rebuild completion state
487
+ const isResume = planModeEntry !== undefined;
488
+ if (isResume && executionMode && todoItems.length > 0) {
489
+ let executeIndex = -1;
490
+ for (let i = entries.length - 1; i >= 0; i--) {
491
+ const entry = entries[i] as { type: string; customType?: string };
492
+ if (entry.customType === "plan-mode-execute") {
493
+ executeIndex = i;
494
+ break;
495
+ }
496
+ }
497
+
498
+ const messages: AssistantMessage[] = [];
499
+ for (let i = executeIndex + 1; i < entries.length; i++) {
500
+ const entry = entries[i];
501
+ if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
502
+ messages.push(entry.message as AssistantMessage);
503
+ }
504
+ }
505
+ const allText = messages.map(getTextContent).join("\n");
506
+ markCompletedSteps(allText, todoItems);
507
+ }
508
+
509
+ if (planModeEnabled) {
510
+ pi.setActiveTools(PLAN_MODE_TOOLS);
511
+ }
512
+
513
+ // Re-initialize the progress panel if resuming an active execution
514
+ if (executionMode && todoItems.length > 0) {
515
+ ensurePanel(ctx);
516
+ panel!.update(buildPlanModel());
517
+ }
518
+
519
+ updateStatus(ctx);
520
+ });
521
+ }
@@ -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*exa\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
+ }