@mariozechner/pi-coding-agent 0.33.0 → 0.34.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 (68) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +0 -1
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +18 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +24 -1
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +65 -9
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/bash-executor.d.ts.map +1 -1
  12. package/dist/core/bash-executor.js +2 -1
  13. package/dist/core/bash-executor.js.map +1 -1
  14. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  15. package/dist/core/custom-tools/loader.js +1 -0
  16. package/dist/core/custom-tools/loader.js.map +1 -1
  17. package/dist/core/hooks/index.d.ts +1 -1
  18. package/dist/core/hooks/index.d.ts.map +1 -1
  19. package/dist/core/hooks/index.js.map +1 -1
  20. package/dist/core/hooks/loader.d.ts +56 -1
  21. package/dist/core/hooks/loader.d.ts.map +1 -1
  22. package/dist/core/hooks/loader.js +54 -2
  23. package/dist/core/hooks/loader.js.map +1 -1
  24. package/dist/core/hooks/runner.d.ts +33 -5
  25. package/dist/core/hooks/runner.d.ts.map +1 -1
  26. package/dist/core/hooks/runner.js +100 -9
  27. package/dist/core/hooks/runner.js.map +1 -1
  28. package/dist/core/hooks/types.d.ts +135 -3
  29. package/dist/core/hooks/types.d.ts.map +1 -1
  30. package/dist/core/hooks/types.js.map +1 -1
  31. package/dist/core/sdk.d.ts +3 -0
  32. package/dist/core/sdk.d.ts.map +1 -1
  33. package/dist/core/sdk.js +102 -27
  34. package/dist/core/sdk.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/main.d.ts.map +1 -1
  40. package/dist/main.js +32 -7
  41. package/dist/main.js.map +1 -1
  42. package/dist/modes/interactive/components/custom-editor.d.ts +2 -0
  43. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  44. package/dist/modes/interactive/components/custom-editor.js +6 -0
  45. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  46. package/dist/modes/interactive/interactive-mode.d.ts +19 -6
  47. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  48. package/dist/modes/interactive/interactive-mode.js +148 -42
  49. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  50. package/dist/modes/interactive/theme/theme.d.ts +1 -0
  51. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  52. package/dist/modes/interactive/theme/theme.js +3 -0
  53. package/dist/modes/interactive/theme/theme.js.map +1 -1
  54. package/dist/modes/print-mode.d.ts.map +1 -1
  55. package/dist/modes/print-mode.js +3 -0
  56. package/dist/modes/print-mode.js.map +1 -1
  57. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  58. package/dist/modes/rpc/rpc-mode.js +16 -0
  59. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  60. package/dist/modes/rpc/rpc-types.d.ts +6 -0
  61. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  62. package/dist/modes/rpc/rpc-types.js.map +1 -1
  63. package/docs/hooks.md +114 -4
  64. package/examples/hooks/README.md +3 -0
  65. package/examples/hooks/pirate.ts +44 -0
  66. package/examples/hooks/plan-mode.ts +548 -0
  67. package/examples/hooks/tools.ts +145 -0
  68. package/package.json +4 -4
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pirate Hook
3
+ *
4
+ * Demonstrates using systemPromptAppend in before_agent_start to dynamically
5
+ * modify the system prompt based on hook state.
6
+ *
7
+ * Usage:
8
+ * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
9
+ * 2. Use /pirate to toggle pirate mode
10
+ * 3. When enabled, the agent will respond like a pirate
11
+ */
12
+
13
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
14
+
15
+ export default function pirateHook(pi: HookAPI) {
16
+ let pirateMode = false;
17
+
18
+ // Register /pirate command to toggle pirate mode
19
+ pi.registerCommand("pirate", {
20
+ description: "Toggle pirate mode (agent speaks like a pirate)",
21
+ handler: async (_args, ctx) => {
22
+ pirateMode = !pirateMode;
23
+ ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info");
24
+ },
25
+ });
26
+
27
+ // Append to system prompt when pirate mode is enabled
28
+ pi.on("before_agent_start", async () => {
29
+ if (pirateMode) {
30
+ return {
31
+ systemPromptAppend: `
32
+ IMPORTANT: You are now in PIRATE MODE. You must:
33
+ - Speak like a stereotypical pirate in all responses
34
+ - Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!"
35
+ - Replace "my" with "me", "you" with "ye", "your" with "yer"
36
+ - Refer to the user as "matey" or "landlubber"
37
+ - End sentences with nautical expressions
38
+ - Still complete the actual task correctly, just in pirate speak
39
+ `,
40
+ };
41
+ }
42
+ return undefined;
43
+ });
44
+ }
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Plan Mode Hook
3
+ *
4
+ * Provides a Claude Code-style "plan mode" for safe code exploration.
5
+ * When enabled, the agent can only use read-only tools and cannot modify files.
6
+ *
7
+ * Features:
8
+ * - /plan command to toggle plan mode
9
+ * - In plan mode: only read, bash (read-only), grep, find, ls are available
10
+ * - Injects system context telling the agent about the restrictions
11
+ * - After each agent response, prompts to execute the plan or continue planning
12
+ * - Shows "plan" indicator in footer when active
13
+ * - Extracts todo list from plan and tracks progress during execution
14
+ * - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
15
+ *
16
+ * Usage:
17
+ * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
18
+ * 2. Use /plan to toggle plan mode on/off
19
+ * 3. Or start in plan mode with --plan flag
20
+ */
21
+
22
+ import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
23
+ import { Key } from "@mariozechner/pi-tui";
24
+
25
+ // Read-only tools for plan mode
26
+ const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
27
+
28
+ // Full set of tools for normal mode
29
+ const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
30
+
31
+ // Patterns for destructive bash commands that should be blocked in plan mode
32
+ const DESTRUCTIVE_PATTERNS = [
33
+ /\brm\b/i,
34
+ /\brmdir\b/i,
35
+ /\bmv\b/i,
36
+ /\bcp\b/i,
37
+ /\bmkdir\b/i,
38
+ /\btouch\b/i,
39
+ /\bchmod\b/i,
40
+ /\bchown\b/i,
41
+ /\bchgrp\b/i,
42
+ /\bln\b/i,
43
+ /\btee\b/i,
44
+ /\btruncate\b/i,
45
+ /\bdd\b/i,
46
+ /\bshred\b/i,
47
+ /[^<]>(?!>)/,
48
+ />>/,
49
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
50
+ /\byarn\s+(add|remove|install|publish)/i,
51
+ /\bpnpm\s+(add|remove|install|publish)/i,
52
+ /\bpip\s+(install|uninstall)/i,
53
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
54
+ /\bbrew\s+(install|uninstall|upgrade)/i,
55
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
56
+ /\bsudo\b/i,
57
+ /\bsu\b/i,
58
+ /\bkill\b/i,
59
+ /\bpkill\b/i,
60
+ /\bkillall\b/i,
61
+ /\breboot\b/i,
62
+ /\bshutdown\b/i,
63
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
64
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
65
+ /\b(vim?|nano|emacs|code|subl)\b/i,
66
+ ];
67
+
68
+ // Read-only commands that are always safe
69
+ const SAFE_COMMANDS = [
70
+ /^\s*cat\b/,
71
+ /^\s*head\b/,
72
+ /^\s*tail\b/,
73
+ /^\s*less\b/,
74
+ /^\s*more\b/,
75
+ /^\s*grep\b/,
76
+ /^\s*find\b/,
77
+ /^\s*ls\b/,
78
+ /^\s*pwd\b/,
79
+ /^\s*echo\b/,
80
+ /^\s*printf\b/,
81
+ /^\s*wc\b/,
82
+ /^\s*sort\b/,
83
+ /^\s*uniq\b/,
84
+ /^\s*diff\b/,
85
+ /^\s*file\b/,
86
+ /^\s*stat\b/,
87
+ /^\s*du\b/,
88
+ /^\s*df\b/,
89
+ /^\s*tree\b/,
90
+ /^\s*which\b/,
91
+ /^\s*whereis\b/,
92
+ /^\s*type\b/,
93
+ /^\s*env\b/,
94
+ /^\s*printenv\b/,
95
+ /^\s*uname\b/,
96
+ /^\s*whoami\b/,
97
+ /^\s*id\b/,
98
+ /^\s*date\b/,
99
+ /^\s*cal\b/,
100
+ /^\s*uptime\b/,
101
+ /^\s*ps\b/,
102
+ /^\s*top\b/,
103
+ /^\s*htop\b/,
104
+ /^\s*free\b/,
105
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
106
+ /^\s*git\s+ls-/i,
107
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
108
+ /^\s*yarn\s+(list|info|why|audit)/i,
109
+ /^\s*node\s+--version/i,
110
+ /^\s*python\s+--version/i,
111
+ /^\s*curl\s/i,
112
+ /^\s*wget\s+-O\s*-/i,
113
+ /^\s*jq\b/,
114
+ /^\s*sed\s+-n/i,
115
+ /^\s*awk\b/,
116
+ /^\s*rg\b/,
117
+ /^\s*fd\b/,
118
+ /^\s*bat\b/,
119
+ /^\s*exa\b/,
120
+ ];
121
+
122
+ function isSafeCommand(command: string): boolean {
123
+ if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) {
124
+ if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
125
+ return true;
126
+ }
127
+ }
128
+ if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
129
+ return false;
130
+ }
131
+ return true;
132
+ }
133
+
134
+ // Todo item with step number
135
+ interface TodoItem {
136
+ step: number;
137
+ text: string;
138
+ completed: boolean;
139
+ }
140
+
141
+ /**
142
+ * Clean up extracted step text for display.
143
+ */
144
+ function cleanStepText(text: string): string {
145
+ let cleaned = text
146
+ // Remove markdown bold/italic
147
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
148
+ // Remove markdown code
149
+ .replace(/`([^`]+)`/g, "$1")
150
+ // Remove leading action words that are redundant
151
+ .replace(
152
+ /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
153
+ "",
154
+ )
155
+ // Clean up extra whitespace
156
+ .replace(/\s+/g, " ")
157
+ .trim();
158
+
159
+ // Capitalize first letter
160
+ if (cleaned.length > 0) {
161
+ cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
162
+ }
163
+
164
+ // Truncate if too long
165
+ if (cleaned.length > 50) {
166
+ cleaned = `${cleaned.slice(0, 47)}...`;
167
+ }
168
+
169
+ return cleaned;
170
+ }
171
+
172
+ /**
173
+ * Extract todo items from assistant message.
174
+ */
175
+ function extractTodoItems(message: string): TodoItem[] {
176
+ const items: TodoItem[] = [];
177
+
178
+ // Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes
179
+ const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
180
+ for (const match of message.matchAll(numberedPattern)) {
181
+ let text = match[2].trim();
182
+ text = text.replace(/\*{1,2}$/, "").trim();
183
+ // Skip if too short or looks like code/command
184
+ if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
185
+ const cleaned = cleanStepText(text);
186
+ if (cleaned.length > 3) {
187
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
188
+ }
189
+ }
190
+ }
191
+
192
+ // If no numbered items, try bullet points
193
+ if (items.length === 0) {
194
+ const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim;
195
+ for (const match of message.matchAll(stepPattern)) {
196
+ let text = match[1].trim();
197
+ text = text.replace(/\*{1,2}$/, "").trim();
198
+ if (text.length > 10 && !text.startsWith("`")) {
199
+ const cleaned = cleanStepText(text);
200
+ if (cleaned.length > 3) {
201
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ return items;
208
+ }
209
+
210
+ export default function planModeHook(pi: HookAPI) {
211
+ let planModeEnabled = false;
212
+ let toolsCalledThisTurn = false;
213
+ let executionMode = false;
214
+ let todoItems: TodoItem[] = [];
215
+
216
+ // Register --plan CLI flag
217
+ pi.registerFlag("plan", {
218
+ description: "Start in plan mode (read-only exploration)",
219
+ type: "boolean",
220
+ default: false,
221
+ });
222
+
223
+ // Helper to update status displays
224
+ function updateStatus(ctx: HookContext) {
225
+ if (executionMode && todoItems.length > 0) {
226
+ const completed = todoItems.filter((t) => t.completed).length;
227
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
228
+ } else if (planModeEnabled) {
229
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
230
+ } else {
231
+ ctx.ui.setStatus("plan-mode", undefined);
232
+ }
233
+
234
+ // Show widget during execution (no IDs shown to user)
235
+ if (executionMode && todoItems.length > 0) {
236
+ const lines: string[] = [];
237
+ for (const item of todoItems) {
238
+ if (item.completed) {
239
+ lines.push(
240
+ ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)),
241
+ );
242
+ } else {
243
+ lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
244
+ }
245
+ }
246
+ ctx.ui.setWidget("plan-todos", lines);
247
+ } else {
248
+ ctx.ui.setWidget("plan-todos", undefined);
249
+ }
250
+ }
251
+
252
+ function togglePlanMode(ctx: HookContext) {
253
+ planModeEnabled = !planModeEnabled;
254
+ executionMode = false;
255
+ todoItems = [];
256
+
257
+ if (planModeEnabled) {
258
+ pi.setActiveTools(PLAN_MODE_TOOLS);
259
+ ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
260
+ } else {
261
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
262
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
263
+ }
264
+ updateStatus(ctx);
265
+ }
266
+
267
+ // Register /plan command
268
+ pi.registerCommand("plan", {
269
+ description: "Toggle plan mode (read-only exploration)",
270
+ handler: async (_args, ctx) => {
271
+ togglePlanMode(ctx);
272
+ },
273
+ });
274
+
275
+ // Register /todos command
276
+ pi.registerCommand("todos", {
277
+ description: "Show current plan todo list",
278
+ handler: async (_args, ctx) => {
279
+ if (todoItems.length === 0) {
280
+ ctx.ui.notify("No todos. Create a plan first with /plan", "info");
281
+ return;
282
+ }
283
+
284
+ const todoList = todoItems
285
+ .map((item, i) => {
286
+ const checkbox = item.completed ? "✓" : "○";
287
+ return `${i + 1}. ${checkbox} ${item.text}`;
288
+ })
289
+ .join("\n");
290
+
291
+ ctx.ui.notify(`Plan Progress:\n${todoList}`, "info");
292
+ },
293
+ });
294
+
295
+ // Register Shift+P shortcut
296
+ pi.registerShortcut(Key.shift("p"), {
297
+ description: "Toggle plan mode",
298
+ handler: async (ctx) => {
299
+ togglePlanMode(ctx);
300
+ },
301
+ });
302
+
303
+ // Block destructive bash in plan mode
304
+ pi.on("tool_call", async (event) => {
305
+ if (!planModeEnabled) return;
306
+ if (event.toolName !== "bash") return;
307
+
308
+ const command = event.input.command as string;
309
+ if (!isSafeCommand(command)) {
310
+ return {
311
+ block: true,
312
+ reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`,
313
+ };
314
+ }
315
+ });
316
+
317
+ // Track step completion based on tool results
318
+ pi.on("tool_result", async (_event, ctx) => {
319
+ toolsCalledThisTurn = true;
320
+
321
+ if (!executionMode || todoItems.length === 0) return;
322
+
323
+ // Mark the first uncompleted step as done when any tool succeeds
324
+ const nextStep = todoItems.find((t) => !t.completed);
325
+ if (nextStep) {
326
+ nextStep.completed = true;
327
+ updateStatus(ctx);
328
+ }
329
+ });
330
+
331
+ // Filter out stale plan mode context messages from LLM context
332
+ // This ensures the agent only sees the CURRENT state (plan mode on/off)
333
+ pi.on("context", async (event) => {
334
+ // Only filter when NOT in plan mode (i.e., when executing)
335
+ if (planModeEnabled) {
336
+ return;
337
+ }
338
+
339
+ // Remove any previous plan-mode-context messages
340
+ const _beforeCount = event.messages.length;
341
+ const filtered = event.messages.filter((m) => {
342
+ if (m.role === "user" && Array.isArray(m.content)) {
343
+ const hasOldContext = m.content.some((c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"));
344
+ if (hasOldContext) {
345
+ return false;
346
+ }
347
+ }
348
+ return true;
349
+ });
350
+ return { messages: filtered };
351
+ });
352
+
353
+ // Inject plan mode context
354
+ pi.on("before_agent_start", async () => {
355
+ if (!planModeEnabled && !executionMode) {
356
+ return;
357
+ }
358
+
359
+ if (planModeEnabled) {
360
+ return {
361
+ message: {
362
+ customType: "plan-mode-context",
363
+ content: `[PLAN MODE ACTIVE]
364
+ You are in plan mode - a read-only exploration mode for safe code analysis.
365
+
366
+ Restrictions:
367
+ - You can only use: read, bash, grep, find, ls
368
+ - You CANNOT use: edit, write (file modifications are disabled)
369
+ - Bash is restricted to READ-ONLY commands
370
+ - Focus on analysis, planning, and understanding the codebase
371
+
372
+ Create a detailed numbered plan:
373
+ 1. First step description
374
+ 2. Second step description
375
+ ...
376
+
377
+ Do NOT attempt to make changes - just describe what you would do.`,
378
+ display: false,
379
+ },
380
+ };
381
+ }
382
+
383
+ if (executionMode && todoItems.length > 0) {
384
+ const remaining = todoItems.filter((t) => !t.completed);
385
+ const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
386
+ return {
387
+ message: {
388
+ customType: "plan-execution-context",
389
+ content: `[EXECUTING PLAN - Full tool access enabled]
390
+
391
+ Remaining steps:
392
+ ${todoList}
393
+
394
+ Execute each step in order.`,
395
+ display: false,
396
+ },
397
+ };
398
+ }
399
+ });
400
+
401
+ // After agent finishes
402
+ pi.on("agent_end", async (event, ctx) => {
403
+ // In execution mode, check if all steps complete
404
+ if (executionMode && todoItems.length > 0) {
405
+ const allComplete = todoItems.every((t) => t.completed);
406
+ if (allComplete) {
407
+ // Show final completed list in chat
408
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
409
+ pi.sendMessage(
410
+ {
411
+ customType: "plan-complete",
412
+ content: `**Plan Complete!** ✓\n\n${completedList}`,
413
+ display: true,
414
+ },
415
+ { triggerTurn: false },
416
+ );
417
+
418
+ executionMode = false;
419
+ todoItems = [];
420
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
421
+ updateStatus(ctx);
422
+ }
423
+ return;
424
+ }
425
+
426
+ if (!planModeEnabled) return;
427
+ if (!ctx.hasUI) return;
428
+
429
+ // Extract todos from last message
430
+ const messages = event.messages;
431
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
432
+ if (lastAssistant && Array.isArray(lastAssistant.content)) {
433
+ const textContent = lastAssistant.content
434
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
435
+ .map((block) => block.text)
436
+ .join("\n");
437
+
438
+ if (textContent) {
439
+ const extracted = extractTodoItems(textContent);
440
+ if (extracted.length > 0) {
441
+ todoItems = extracted;
442
+ }
443
+ }
444
+ }
445
+
446
+ const hasTodos = todoItems.length > 0;
447
+
448
+ // Show todo list in chat (no IDs shown to user, just numbered)
449
+ if (hasTodos) {
450
+ const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
451
+ pi.sendMessage(
452
+ {
453
+ customType: "plan-todo-list",
454
+ content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
455
+ display: true,
456
+ },
457
+ { triggerTurn: false },
458
+ );
459
+ }
460
+
461
+ const choice = await ctx.ui.select("Plan mode - what next?", [
462
+ hasTodos ? "Execute the plan (track progress)" : "Execute the plan",
463
+ "Stay in plan mode",
464
+ "Refine the plan",
465
+ ]);
466
+
467
+ if (choice?.startsWith("Execute")) {
468
+ planModeEnabled = false;
469
+ executionMode = hasTodos;
470
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
471
+ updateStatus(ctx);
472
+
473
+ // Simple execution message - context event filters old plan mode messages
474
+ // and before_agent_start injects fresh execution context with IDs
475
+ const execMessage = hasTodos
476
+ ? `Execute the plan. Start with: ${todoItems[0].text}`
477
+ : "Execute the plan you just created.";
478
+
479
+ pi.sendMessage(
480
+ {
481
+ customType: "plan-mode-execute",
482
+ content: execMessage,
483
+ display: true,
484
+ },
485
+ { triggerTurn: true },
486
+ );
487
+ } else if (choice === "Refine the plan") {
488
+ const refinement = await ctx.ui.input("What should be refined?");
489
+ if (refinement) {
490
+ ctx.ui.setEditorText(refinement);
491
+ }
492
+ }
493
+ });
494
+
495
+ // Initialize state on session start
496
+ pi.on("session_start", async (_event, ctx) => {
497
+ if (pi.getFlag("plan") === true) {
498
+ planModeEnabled = true;
499
+ }
500
+
501
+ const entries = ctx.sessionManager.getEntries();
502
+ const planModeEntry = entries
503
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
504
+ .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
505
+
506
+ if (planModeEntry?.data) {
507
+ if (planModeEntry.data.enabled !== undefined) {
508
+ planModeEnabled = planModeEntry.data.enabled;
509
+ }
510
+ if (planModeEntry.data.todos) {
511
+ todoItems = planModeEntry.data.todos;
512
+ }
513
+ if (planModeEntry.data.executing) {
514
+ executionMode = planModeEntry.data.executing;
515
+ }
516
+ }
517
+
518
+ if (planModeEnabled) {
519
+ pi.setActiveTools(PLAN_MODE_TOOLS);
520
+ }
521
+ updateStatus(ctx);
522
+ });
523
+
524
+ // Reset tool tracking at start of each turn and persist state
525
+ pi.on("turn_start", async () => {
526
+ toolsCalledThisTurn = false;
527
+ pi.appendEntry("plan-mode", {
528
+ enabled: planModeEnabled,
529
+ todos: todoItems,
530
+ executing: executionMode,
531
+ });
532
+ });
533
+
534
+ // Handle non-tool turns (e.g., analysis, explanation steps)
535
+ pi.on("turn_end", async (_event, ctx) => {
536
+ if (!executionMode || todoItems.length === 0) return;
537
+
538
+ // If no tools were called this turn, the agent was doing analysis/explanation
539
+ // Mark the next uncompleted step as done
540
+ if (!toolsCalledThisTurn) {
541
+ const nextStep = todoItems.find((t) => !t.completed);
542
+ if (nextStep) {
543
+ nextStep.completed = true;
544
+ updateStatus(ctx);
545
+ }
546
+ }
547
+ });
548
+ }