@mariozechner/pi-coding-agent 0.32.3 → 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 (102) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +76 -3
  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/export-html/template.css +34 -4
  18. package/dist/core/export-html/template.js +17 -4
  19. package/dist/core/hooks/index.d.ts +1 -1
  20. package/dist/core/hooks/index.d.ts.map +1 -1
  21. package/dist/core/hooks/index.js.map +1 -1
  22. package/dist/core/hooks/loader.d.ts +56 -1
  23. package/dist/core/hooks/loader.d.ts.map +1 -1
  24. package/dist/core/hooks/loader.js +54 -2
  25. package/dist/core/hooks/loader.js.map +1 -1
  26. package/dist/core/hooks/runner.d.ts +33 -5
  27. package/dist/core/hooks/runner.d.ts.map +1 -1
  28. package/dist/core/hooks/runner.js +100 -9
  29. package/dist/core/hooks/runner.js.map +1 -1
  30. package/dist/core/hooks/types.d.ts +135 -3
  31. package/dist/core/hooks/types.d.ts.map +1 -1
  32. package/dist/core/hooks/types.js.map +1 -1
  33. package/dist/core/keybindings.d.ts +59 -0
  34. package/dist/core/keybindings.d.ts.map +1 -0
  35. package/dist/core/keybindings.js +149 -0
  36. package/dist/core/keybindings.js.map +1 -0
  37. package/dist/core/sdk.d.ts +3 -0
  38. package/dist/core/sdk.d.ts.map +1 -1
  39. package/dist/core/sdk.js +102 -27
  40. package/dist/core/sdk.js.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/main.d.ts.map +1 -1
  46. package/dist/main.js +32 -7
  47. package/dist/main.js.map +1 -1
  48. package/dist/modes/interactive/components/custom-editor.d.ts +13 -12
  49. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  50. package/dist/modes/interactive/components/custom-editor.js +50 -68
  51. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  52. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -1
  53. package/dist/modes/interactive/components/hook-editor.js +5 -4
  54. package/dist/modes/interactive/components/hook-editor.js.map +1 -1
  55. package/dist/modes/interactive/components/hook-input.d.ts.map +1 -1
  56. package/dist/modes/interactive/components/hook-input.js +4 -3
  57. package/dist/modes/interactive/components/hook-input.js.map +1 -1
  58. package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -1
  59. package/dist/modes/interactive/components/hook-selector.js +6 -5
  60. package/dist/modes/interactive/components/hook-selector.js.map +1 -1
  61. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  62. package/dist/modes/interactive/components/model-selector.js +6 -5
  63. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  64. package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/oauth-selector.js +6 -5
  66. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  67. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  68. package/dist/modes/interactive/components/session-selector.js +6 -9
  69. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  70. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  71. package/dist/modes/interactive/components/tree-selector.js +14 -15
  72. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  73. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  74. package/dist/modes/interactive/components/user-message-selector.js +6 -11
  75. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  76. package/dist/modes/interactive/interactive-mode.d.ts +34 -1
  77. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  78. package/dist/modes/interactive/interactive-mode.js +300 -64
  79. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  80. package/dist/modes/interactive/theme/theme.d.ts +1 -0
  81. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  82. package/dist/modes/interactive/theme/theme.js +3 -0
  83. package/dist/modes/interactive/theme/theme.js.map +1 -1
  84. package/dist/modes/print-mode.d.ts.map +1 -1
  85. package/dist/modes/print-mode.js +3 -0
  86. package/dist/modes/print-mode.js.map +1 -1
  87. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  88. package/dist/modes/rpc/rpc-mode.js +16 -0
  89. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  90. package/dist/modes/rpc/rpc-types.d.ts +6 -0
  91. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  92. package/dist/modes/rpc/rpc-types.js.map +1 -1
  93. package/docs/hooks.md +114 -4
  94. package/docs/tui.md +18 -15
  95. package/examples/custom-tools/subagent/README.md +2 -2
  96. package/examples/hooks/README.md +3 -0
  97. package/examples/hooks/pirate.ts +44 -0
  98. package/examples/hooks/plan-mode.ts +548 -0
  99. package/examples/hooks/snake.ts +7 -7
  100. package/examples/hooks/todo/index.ts +2 -2
  101. package/examples/hooks/tools.ts +145 -0
  102. package/package.json +5 -4
package/docs/tui.md CHANGED
@@ -130,27 +130,30 @@ const image = new Image(
130
130
 
131
131
  ## Keyboard Input
132
132
 
133
- Use key detection helpers:
133
+ Use `matchesKey()` for key detection:
134
134
 
135
135
  ```typescript
136
- import {
137
- isEnter, isEscape, isTab,
138
- isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
139
- isCtrlC, isCtrlO, isBackspace, isDelete,
140
- // ... and more
141
- } from "@mariozechner/pi-tui";
136
+ import { matchesKey, Key } from "@mariozechner/pi-tui";
142
137
 
143
138
  handleInput(data: string) {
144
- if (isArrowUp(data)) {
139
+ if (matchesKey(data, Key.up)) {
145
140
  this.selectedIndex--;
146
- } else if (isEnter(data)) {
141
+ } else if (matchesKey(data, Key.enter)) {
147
142
  this.onSelect?.(this.selectedIndex);
148
- } else if (isEscape(data)) {
143
+ } else if (matchesKey(data, Key.escape)) {
149
144
  this.onCancel?.();
145
+ } else if (matchesKey(data, Key.ctrl("c"))) {
146
+ // Ctrl+C
150
147
  }
151
148
  }
152
149
  ```
153
150
 
151
+ **Key identifiers** (use `Key.*` for autocomplete, or string literals):
152
+ - Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
153
+ - Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
154
+ - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
155
+ - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
156
+
154
157
  ## Line Width
155
158
 
156
159
  **Critical:** Each line from `render()` must not exceed the `width` parameter.
@@ -175,7 +178,7 @@ Example: Interactive selector
175
178
 
176
179
  ```typescript
177
180
  import {
178
- isEnter, isEscape, isArrowUp, isArrowDown,
181
+ matchesKey, Key,
179
182
  truncateToWidth, visibleWidth
180
183
  } from "@mariozechner/pi-tui";
181
184
 
@@ -193,15 +196,15 @@ class MySelector {
193
196
  }
194
197
 
195
198
  handleInput(data: string): void {
196
- if (isArrowUp(data) && this.selected > 0) {
199
+ if (matchesKey(data, Key.up) && this.selected > 0) {
197
200
  this.selected--;
198
201
  this.invalidate();
199
- } else if (isArrowDown(data) && this.selected < this.items.length - 1) {
202
+ } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
200
203
  this.selected++;
201
204
  this.invalidate();
202
- } else if (isEnter(data)) {
205
+ } else if (matchesKey(data, Key.enter)) {
203
206
  this.onSelect?.(this.items[this.selected]);
204
- } else if (isEscape(data)) {
207
+ } else if (matchesKey(data, Key.escape)) {
205
208
  this.onCancel?.();
206
209
  }
207
210
  }
@@ -16,7 +16,7 @@ Delegate tasks to specialized subagents with isolated context windows.
16
16
  ```
17
17
  subagent/
18
18
  ├── README.md # This file
19
- ├── subagent.ts # The custom tool (entry point)
19
+ ├── index.ts # The custom tool (entry point)
20
20
  ├── agents.ts # Agent discovery logic
21
21
  ├── agents/ # Sample agent definitions
22
22
  │ ├── scout.md # Fast recon, returns compressed context
@@ -36,7 +36,7 @@ From the repository root, symlink the files:
36
36
  ```bash
37
37
  # Symlink the tool (must be in a subdirectory with index.ts)
38
38
  mkdir -p ~/.pi/agent/tools/subagent
39
- ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/subagent.ts" ~/.pi/agent/tools/subagent/index.ts
39
+ ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/index.ts" ~/.pi/agent/tools/subagent/index.ts
40
40
  ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/agents.ts" ~/.pi/agent/tools/subagent/agents.ts
41
41
 
42
42
  # Symlink agents
@@ -16,6 +16,9 @@ cp permission-gate.ts ~/.pi/agent/hooks/
16
16
 
17
17
  | Hook | Description |
18
18
  |------|-------------|
19
+ | `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
20
+ | `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
21
+ | `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
19
22
  | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
20
23
  | `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
21
24
  | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
@@ -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
+ }