@bacnh85/pi-plan 0.1.9 → 0.3.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 (3) hide show
  1. package/index.ts +216 -130
  2. package/lib/bash-gating.ts +154 -0
  3. package/package.json +12 -2
package/index.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { isToolCallEventType, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
5
  import { Type } from "typebox";
4
6
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
7
  import os from "node:os";
6
8
  import path from "node:path";
9
+ import { isDestructiveBash, isReadOnlyBash } from "./lib/bash-gating";
7
10
 
8
11
  const STATUS_KEY = "pi-plan";
9
12
  const PLAN_DIR = path.join(".agents", "plans");
@@ -11,7 +14,29 @@ const PLAN_TOOL = "write_plan";
11
14
  const PLAN_QUESTION_TOOL = "ask_plan_question";
12
15
  const PLAN_EXECUTE_COMMAND = "plan-execute";
13
16
  const PREFERENCES_FILE = path.join(os.homedir(), ".pi", "agent", "pi-plan", "preferences.json");
14
- const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", "searxng_search", "brave_search", "brave_content", "firecrawl_search", "firecrawl_scrape", "firecrawl_map", "firecrawl_crawl", "web_status", PLAN_TOOL, PLAN_QUESTION_TOOL];
17
+ const SERENA_PLAN_TOOLS = [
18
+ "serena_status",
19
+ "serena_list_tools",
20
+ "serena_get_symbols_overview",
21
+ "serena_find_symbol",
22
+ "serena_find_referencing_symbols",
23
+ "serena_find_declaration",
24
+ "serena_find_implementations",
25
+ "serena_search_for_pattern",
26
+ "serena_get_current_config",
27
+ "serena_get_diagnostics_for_file",
28
+ "serena_list_memories",
29
+ "serena_read_memory",
30
+ ];
31
+
32
+ const DEFAULT_PLAN_TOOLS = [
33
+ "read", "bash", "grep", "find", "ls",
34
+ "searxng_search", "brave_search", "brave_content",
35
+ "firecrawl_search", "firecrawl_scrape", "firecrawl_map", "firecrawl_crawl",
36
+ "web_status",
37
+ ...SERENA_PLAN_TOOLS,
38
+ PLAN_TOOL, PLAN_QUESTION_TOOL,
39
+ ];
15
40
  const PLAN_ALLOWED_TOOLS = new Set(DEFAULT_PLAN_TOOLS);
16
41
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
17
42
 
@@ -52,28 +77,6 @@ interface PlanQuestionParams {
52
77
  allowOther?: boolean;
53
78
  }
54
79
 
55
- const DESTRUCTIVE_BASH_PATTERNS = [
56
- /\brm\b/i,
57
- /\brmdir\b/i,
58
- /\bmv\b/i,
59
- /\bcp\b/i,
60
- /\bmkdir\b/i,
61
- /\btouch\b/i,
62
- /\bchmod\b/i,
63
- /\bchown\b/i,
64
- /\btee\b/i,
65
- /(^|[^<])>(?!>)/,
66
- />>/,
67
- /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
68
- /\b(yarn|pnpm)\s+(add|remove|install|publish)/i,
69
- /\bpip\s+(install|uninstall)/i,
70
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i,
71
- /\bsudo\b/i,
72
- /\bkill(all)?\b/i,
73
- /\b(pk|re)?kill\b/i,
74
- /\b(vim?|nano|emacs|code|subl)\b/i,
75
- ];
76
-
77
80
  function isThinkingLevel(value: string): value is ThinkingLevel {
78
81
  return (THINKING_LEVELS as readonly string[]).includes(value);
79
82
  }
@@ -169,91 +172,6 @@ async function savePreferences(preferences: PlanPreferences): Promise<void> {
169
172
  await rename(temporaryPath, PREFERENCES_FILE);
170
173
  }
171
174
 
172
- function isDestructiveBash(command: string): boolean {
173
- return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
174
- }
175
-
176
- function tokenizeSimpleCommand(command: string): string[] | undefined {
177
- const trimmed = command.trim();
178
- if (!trimmed) return [];
179
- if (/[;&|`$(){}<>]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return undefined;
180
- if (/['"]/.test(trimmed)) return undefined;
181
- return trimmed.split(/\s+/).filter(Boolean);
182
- }
183
-
184
- function sanitizeCommand(command: string): string[] {
185
- let sanitized = command;
186
-
187
- // Strip /dev/null redirects: 2>/dev/null, >/dev/null, >>/dev/null, &>/dev/null
188
- sanitized = sanitized.replace(/\d*>>?\s*\/dev\/null/g, "");
189
- sanitized = sanitized.replace(/&>\s*\/dev\/null/g, "");
190
- // Strip fd redirections: 2>&1, 1>&2, etc.
191
- sanitized = sanitized.replace(/\s*\d*>&\d+\s*/g, " ");
192
-
193
- // Strip cd <path> && / cd <path> ; prefix
194
- sanitized = sanitized.replace(/^cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;)\s*/i, "").trim();
195
-
196
- // If command contains pipes, split into segments and validate each
197
- if (sanitized.includes("|")) {
198
- return sanitized.split("|").map((s) => s.trim()).filter(Boolean);
199
- }
200
-
201
- return [sanitized.trim()];
202
- }
203
-
204
- function hasOptionValue(tokens: string[], index: number): boolean {
205
- return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
206
- }
207
-
208
- function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
209
- if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
210
- let hasSpec = false;
211
- for (let index = 2; index < tokens.length; index += 1) {
212
- const token = tokens[index];
213
- if (["--registry", "--tag"].includes(token)) {
214
- if (!hasOptionValue(tokens, index)) return false;
215
- index += 1;
216
- continue;
217
- }
218
- if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
219
- if (token.startsWith("-")) return false;
220
- hasSpec = true;
221
- }
222
- return hasSpec;
223
- }
224
-
225
- function isAllowedGitCommand(tokens: string[]): boolean {
226
- if (tokens[0] !== "git" || !tokens[1]) return false;
227
- const subcommand = tokens[1];
228
- const args = tokens.slice(2);
229
- if (["add", "commit", "push", "pull", "merge", "rebase", "reset", "checkout", "switch", "restore", "stash", "cherry-pick", "revert", "tag", "init", "clone", "fetch", "remote", "config", "worktree"].includes(subcommand)) return false;
230
-
231
- if (subcommand === "status") return args.every((arg) => ["--short", "-s", "--porcelain", "--porcelain=v1", "--porcelain=v2", "--branch", "-b", "--ignored", "--ignored=matching", "--ignored=traditional", "--ignored=no", "--untracked-files", "--untracked-files=no", "--untracked-files=normal", "--untracked-files=all", "-uno", "-unormal", "-uall"].includes(arg));
232
- if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
233
- if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
234
- if (subcommand === "branch") {
235
- const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
236
- return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
237
- }
238
- return false;
239
- }
240
-
241
- export function isReadOnlyBash(command: string): boolean {
242
- const segments = sanitizeCommand(command);
243
- if (segments.length === 0) return true;
244
-
245
- return segments.every((segment) => {
246
- const tokens = tokenizeSimpleCommand(segment);
247
- if (!tokens) return false;
248
- if (tokens.length === 0) return true;
249
- const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
250
- if (normalized.length === 0) return false;
251
- if (isAllowedGitCommand(normalized)) return true;
252
- if (isAllowedNpmMetadataCommand(normalized)) return true;
253
- return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut|read)$/.test(normalized[0]);
254
- });
255
- }
256
-
257
175
  export default function piPlanExtension(pi: ExtensionAPI): void {
258
176
  let planModeEnabled = false;
259
177
  let executionMode = false;
@@ -265,22 +183,168 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
265
183
  let lastPlanStatus: PlanStatus | undefined;
266
184
  let applyingStoredThinking = false;
267
185
 
268
- function setStatus(ctx: ExtensionContext): void {
269
- if (planModeEnabled) {
270
- ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", `plan:${planThinking}`));
271
- return;
272
- }
273
- if (executionMode) {
274
- ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `exec:${normalThinking}`));
275
- return;
276
- }
277
- ctx.ui.setStatus(STATUS_KEY, undefined);
278
- }
279
-
280
186
  function clearPlanWidget(ctx: ExtensionContext): void {
281
187
  ctx.ui.setWidget(STATUS_KEY, undefined);
282
188
  }
283
-
189
+ function updateFooter(ctx: ExtensionContext): void {
190
+ if (planModeEnabled) {
191
+ ctx.ui.setFooter((tui, theme, footerData) => {
192
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
193
+ const sanitizeStatusText = (text: string) =>
194
+ text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
195
+ return {
196
+ dispose: unsub,
197
+ invalidate() {},
198
+ render(width: number): string[] {
199
+ const lines: string[] = [];
200
+
201
+ // Line 1: cwd (branch) • session name
202
+ const home = process.env.HOME || process.env.USERPROFILE;
203
+ const resolvedCwd = path.resolve(ctx.sessionManager.getCwd());
204
+ let pwd = resolvedCwd;
205
+ if (home) {
206
+ const resolvedHome = path.resolve(home);
207
+ const rel = path.relative(resolvedHome, resolvedCwd);
208
+ if (rel === "" || (!rel.startsWith(`..`) && !path.isAbsolute(rel))) {
209
+ pwd = rel === "" ? "~" : `~${path.sep}${rel}`;
210
+ }
211
+ }
212
+ const branch = footerData.getGitBranch();
213
+ if (branch) pwd = `${pwd} (${branch})`;
214
+ const sessionName = ctx.sessionManager.getSessionName();
215
+ if (sessionName) pwd = `${pwd} • ${sessionName}`;
216
+ lines.push(truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")));
217
+
218
+ // Calculate cumulative usage from ALL session entries
219
+ const fmtTokens = (n: number) =>
220
+ n < 1000 ? `${n}` :
221
+ n < 10000 ? `${(n / 1000).toFixed(1)}k` :
222
+ n < 1000000 ? `${Math.round(n / 1000)}k` :
223
+ n < 10000000 ? `${(n / 1000000).toFixed(1)}M` :
224
+ `${Math.round(n / 1000000)}M`;
225
+
226
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0, totalCost = 0;
227
+ let latestCacheHitRate: number | undefined;
228
+ for (const e of ctx.sessionManager.getEntries()) {
229
+ if (e.type === "message" && e.message.role === "assistant") {
230
+ const m = e.message as AssistantMessage;
231
+ totalInput += m.usage.input;
232
+ totalOutput += m.usage.output;
233
+ totalCacheRead += m.usage.cacheRead;
234
+ totalCacheWrite += m.usage.cacheWrite;
235
+ totalCost += m.usage.cost.total;
236
+ const latestPromptTokens = m.usage.input + m.usage.cacheRead + m.usage.cacheWrite;
237
+ latestCacheHitRate = latestPromptTokens > 0
238
+ ? (m.usage.cacheRead / latestPromptTokens) * 100
239
+ : undefined;
240
+ }
241
+ }
242
+
243
+ const contextUsage = ctx.getContextUsage();
244
+ const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
245
+ const contextPercentValue = contextUsage?.percent ?? 0;
246
+ const contextPercent = contextUsage?.percent !== null
247
+ ? contextPercentValue.toFixed(1)
248
+ : "?";
249
+
250
+ // Build left stats
251
+ const parts: string[] = [];
252
+ if (totalInput) parts.push(`↑${fmtTokens(totalInput)}`);
253
+ if (totalOutput) parts.push(`↓${fmtTokens(totalOutput)}`);
254
+ if (totalCacheRead) parts.push(`R${fmtTokens(totalCacheRead)}`);
255
+ if (totalCacheWrite) parts.push(`W${fmtTokens(totalCacheWrite)}`);
256
+ if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) {
257
+ parts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
258
+ }
259
+ const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
260
+ if (totalCost || usingSubscription) {
261
+ parts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
262
+ }
263
+ const autoIndicator = " (auto)";
264
+ const contextPercentDisplay = contextPercent === "?"
265
+ ? `?/${fmtTokens(contextWindow)}${autoIndicator}`
266
+ : `${contextPercent}%/${fmtTokens(contextWindow)}${autoIndicator}`;
267
+ let contextPercentStr: string;
268
+ if (contextPercentValue > 90) {
269
+ contextPercentStr = theme.fg("error", contextPercentDisplay);
270
+ } else if (contextPercentValue > 70) {
271
+ contextPercentStr = theme.fg("warning", contextPercentDisplay);
272
+ } else {
273
+ contextPercentStr = contextPercentDisplay;
274
+ }
275
+ parts.push(contextPercentStr);
276
+
277
+ const statsLeft = parts.join(" ");
278
+ const statsLeftWidth = visibleWidth(statsLeft);
279
+
280
+ // Right side: model info (dimmed) + highlighted plan mode indicator
281
+ const modelName = ctx.model?.id || "no-model";
282
+ let rightModelInfo = modelName;
283
+ if (ctx.model?.reasoning) {
284
+ const thinkingLevel = pi.getThinkingLevel() || "off";
285
+ rightModelInfo = thinkingLevel === "off"
286
+ ? `${modelName} • thinking off`
287
+ : `${modelName} • ${thinkingLevel}`;
288
+ }
289
+ if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
290
+ const withProvider = `(${ctx.model.provider}) ${rightModelInfo}`;
291
+ const minPad = 2;
292
+ if (statsLeftWidth + minPad + visibleWidth(withProvider) + visibleWidth(" | Plan mode") <= width) {
293
+ rightModelInfo = withProvider;
294
+ }
295
+ }
296
+
297
+ const planText = " | Plan mode";
298
+ const rightModelWidth = visibleWidth(rightModelInfo);
299
+ const planWidth = visibleWidth(planText);
300
+ const minPadding = 2;
301
+ const totalNeeded = statsLeftWidth + minPadding + rightModelWidth + planWidth;
302
+
303
+ let statsLine: string;
304
+ if (totalNeeded <= width) {
305
+ const padding = " ".repeat(width - statsLeftWidth - rightModelWidth - planWidth);
306
+ // Dim statsLeft and model info separately (statsLeft may have color codes from context %)
307
+ statsLine = theme.fg("dim", statsLeft) + theme.fg("dim", padding + rightModelInfo) + theme.fg("warning", planText);
308
+ } else {
309
+ const availableForRight = width - statsLeftWidth - minPadding;
310
+ if (availableForRight > 0) {
311
+ if (availableForRight <= planWidth) {
312
+ // Very tight: show only (part of) plan indicator
313
+ const trimmed = truncateToWidth(theme.fg("warning", planText), availableForRight, "");
314
+ const pad = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(trimmed)));
315
+ statsLine = theme.fg("dim", statsLeft) + pad + trimmed;
316
+ } else {
317
+ // Show model info (truncated if needed) + plan indicator
318
+ const availModel = availableForRight - planWidth;
319
+ const modelDisplay = truncateToWidth(rightModelInfo, availModel, "");
320
+ const pad = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(modelDisplay) - planWidth));
321
+ statsLine = theme.fg("dim", statsLeft) + theme.fg("dim", pad + modelDisplay) + theme.fg("warning", planText);
322
+ }
323
+ } else {
324
+ statsLine = theme.fg("dim", statsLeft);
325
+ }
326
+ }
327
+
328
+ lines.push(statsLine);
329
+
330
+ // Line 3: extension statuses (from setStatus calls by other extensions)
331
+ const extensionStatuses = footerData.getExtensionStatuses();
332
+ if (extensionStatuses.size > 0) {
333
+ const sortedStatuses = Array.from(extensionStatuses.entries())
334
+ .sort(([a], [b]) => a.localeCompare(b))
335
+ .map(([, text]) => sanitizeStatusText(text));
336
+ const statusLine = sortedStatuses.join(" ");
337
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
338
+ }
339
+
340
+ return lines;
341
+ },
342
+ };
343
+ });
344
+ } else {
345
+ ctx.ui.setFooter(undefined);
346
+ }
347
+ }
284
348
  function persistState(): void {
285
349
  pi.appendEntry("pi-plan", {
286
350
  enabled: planModeEnabled,
@@ -332,7 +396,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
332
396
  if (key && preferences) {
333
397
  preferences.perModel[key] = { planThinking, normalThinking };
334
398
  }
335
- setStatus(ctx);
399
+ updateFooter(ctx);
336
400
  persistState();
337
401
  persistPreferences();
338
402
  }
@@ -342,10 +406,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
342
406
  executionMode = false;
343
407
  enablePlanTools();
344
408
  applyThinking(planThinking);
345
- setStatus(ctx);
409
+ updateFooter(ctx);
346
410
  clearPlanWidget(ctx);
347
411
  persistState();
348
- ctx.ui.notify(`Plan mode enabled. Thinking=${planThinking}. Plans will be written to ${PLAN_DIR}/`, "info");
412
+ ctx.ui.notify(`Plan mode enabled. Plans will be written to ${PLAN_DIR}/`, "info");
349
413
  }
350
414
 
351
415
  function leavePlanMode(ctx: ExtensionContext, restoreThinking = true): void {
@@ -353,10 +417,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
353
417
  executionMode = false;
354
418
  restoreTools();
355
419
  if (restoreThinking) applyThinking(normalThinking);
356
- setStatus(ctx);
420
+ updateFooter(ctx);
357
421
  clearPlanWidget(ctx);
358
422
  persistState();
359
- ctx.ui.notify(`Plan mode disabled. Thinking=${pi.getThinkingLevel()}.`, "info");
423
+ ctx.ui.notify("Plan mode disabled.", "info");
360
424
  }
361
425
 
362
426
  async function handlePlanCommand(args: string, ctx: ExtensionContext): Promise<void> {
@@ -374,7 +438,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
374
438
  lastPlanStatus = "approved";
375
439
  restoreTools();
376
440
  applyThinking(normalThinking);
377
- setStatus(ctx);
441
+ updateFooter(ctx);
378
442
  clearPlanWidget(ctx);
379
443
  persistState();
380
444
  persistPreferences();
@@ -584,7 +648,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
584
648
  } else if (executionMode) {
585
649
  applyThinking(normalThinking);
586
650
  }
587
- setStatus(ctx);
651
+ updateFooter(ctx);
588
652
  clearPlanWidget(ctx);
589
653
  });
590
654
 
@@ -603,7 +667,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
603
667
  applyThinking(normalThinking);
604
668
  }
605
669
  }
606
- setStatus(ctx);
670
+ updateFooter(ctx);
607
671
  persistState();
608
672
  });
609
673
 
@@ -624,6 +688,28 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
624
688
  }
625
689
  });
626
690
 
691
+ pi.on("context", async (event) => {
692
+ // When not in plan mode or execution mode, filter out stale context messages
693
+ if (!planModeEnabled && !executionMode) {
694
+ return {
695
+ messages: event.messages.filter((m) => {
696
+ const msg = m as { customType?: string };
697
+ return msg.customType !== "pi-plan-context" && msg.customType !== "pi-plan-execution-context";
698
+ }),
699
+ };
700
+ }
701
+ // In execution mode, filter out stale plan mode context messages
702
+ if (executionMode && !planModeEnabled) {
703
+ return {
704
+ messages: event.messages.filter((m) => {
705
+ const msg = m as { customType?: string };
706
+ return msg.customType !== "pi-plan-context";
707
+ }),
708
+ };
709
+ }
710
+ // In plan mode, let all messages through
711
+ });
712
+
627
713
  pi.on("before_agent_start", async (_event, ctx) => {
628
714
  if (planModeEnabled) {
629
715
  const relativePlan = lastPlanPath ? relativeToCwd(ctx.cwd, lastPlanPath) : `${PLAN_DIR}/<timestamp>-<title>.md`;
@@ -673,7 +759,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
673
759
  lastPlanStatus = "approved";
674
760
  restoreTools();
675
761
  applyThinking(normalThinking);
676
- setStatus(ctx);
762
+ updateFooter(ctx);
677
763
  clearPlanWidget(ctx);
678
764
  persistState();
679
765
  persistPreferences();
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Bash command gating logic for pi-plan's read-only plan mode.
3
+ * Extracted from index.ts so it can be tested without importing @earendil-works/pi-coding-agent.
4
+ */
5
+
6
+ export const DESTRUCTIVE_BASH_PATTERNS = [
7
+ // File-system modifying commands — match only at command start (^) or after
8
+ // command separators (; && || | &), not after plain spaces within arguments.
9
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee)\b/i,
10
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))sudo\b/i,
11
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:kill(?:all)?|pkill|rekill)\b/i,
12
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:vim?|nano|emacs|code|subl)\b/i,
13
+ // File output redirects >, >>, 1>, 2> (NOT fd duplication like 2>&1)
14
+ // Matches after whitespace or command separators since that's how shell redirects work
15
+ /(?:^|[\s;|&])[0-9]*>(?!>|&\d)/,
16
+ />>/,
17
+ // Package manager commands (\b prefix correctly checks first word)
18
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
19
+ /\b(yarn|pnpm)\s+(add|remove|install|publish)/i,
20
+ /\bpip\s+(install|uninstall)/i,
21
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i,
22
+ ];
23
+
24
+ export function isDestructiveBash(command: string): boolean {
25
+ return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
26
+ }
27
+
28
+ export function tokenizeSimpleCommand(command: string): string[] | undefined {
29
+ const trimmed = command.trim();
30
+ if (!trimmed) return [];
31
+ if (/[;&|`$(){}<>]/.test(trimmed)) return undefined;
32
+ // Only check the first word for interpreter names, not arguments.
33
+ // e.g., "which node" is read-only (node is an argument), "node script.js" is not.
34
+ const firstWord = trimmed.split(/\s+/)[0];
35
+ if (firstWord && /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(firstWord)) return undefined;
36
+ if (/['"]/.test(trimmed)) {
37
+ // Parse tokens respecting quotes so quoted paths like ls "C:\\path" work.
38
+ const tokens: string[] = [];
39
+ let current = "";
40
+ let inQuote: '"' | "'" | null = null;
41
+ for (const ch of trimmed) {
42
+ if (inQuote) {
43
+ if (ch === inQuote) {
44
+ inQuote = null;
45
+ } else {
46
+ current += ch;
47
+ }
48
+ } else if (ch === '"' || ch === "'") {
49
+ inQuote = ch;
50
+ } else if (/\s/.test(ch)) {
51
+ if (current) {
52
+ tokens.push(current);
53
+ current = "";
54
+ }
55
+ } else {
56
+ current += ch;
57
+ }
58
+ }
59
+ if (current) tokens.push(current);
60
+ return tokens;
61
+ }
62
+ return trimmed.split(/\s+/).filter(Boolean);
63
+ }
64
+
65
+ export function sanitizeCommand(command: string): string[] {
66
+ let sanitized = command;
67
+
68
+ // Strip /dev/null redirects: 2>/dev/null, >/dev/null, >>/dev/null, &>/dev/null
69
+ sanitized = sanitized.replace(/\d*>>?\s*\/dev\/null/g, "");
70
+ sanitized = sanitized.replace(/&>\s*\/dev\/null/g, "");
71
+ // Strip Windows nul redirects: 2>nul, >nul, >>nul, &>nul
72
+ sanitized = sanitized.replace(/\d*>>?\s*nul\b/g, "");
73
+ sanitized = sanitized.replace(/&>\s*nul\b/g, "");
74
+ // Strip fd redirections: 2>&1, 1>&2, etc.
75
+ sanitized = sanitized.replace(/\s*\d*>&\d+\s*/g, " ");
76
+
77
+ // Strip cd <path> && / cd <path> ; prefix
78
+ sanitized = sanitized.replace(/^cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;)\s*/i, "").trim();
79
+
80
+ // Split on && and ; (chaining operators) to validate each segment independently.
81
+ // || remains blocked because it introduces conditional/fallback execution paths.
82
+ const chainSegments = sanitized.split(/\s*&&\s*|\s*;\s*/).map((s) => s.trim()).filter(Boolean);
83
+ if (chainSegments.length === 0) return [];
84
+
85
+ // Within each chain segment, further split on pipes for per-segment validation
86
+ const allSegments: string[] = [];
87
+ for (const segment of chainSegments) {
88
+ if (segment.includes("|")) {
89
+ allSegments.push(...segment.split("|").map((s) => s.trim()).filter(Boolean));
90
+ } else {
91
+ allSegments.push(segment);
92
+ }
93
+ }
94
+
95
+ return allSegments;
96
+ }
97
+
98
+ function hasOptionValue(tokens: string[], index: number): boolean {
99
+ return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
100
+ }
101
+
102
+ function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
103
+ if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
104
+ let hasSpec = false;
105
+ for (let index = 2; index < tokens.length; index += 1) {
106
+ const token = tokens[index];
107
+ if (["--registry", "--tag"].includes(token)) {
108
+ if (!hasOptionValue(tokens, index)) return false;
109
+ index += 1;
110
+ continue;
111
+ }
112
+ if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
113
+ if (token.startsWith("-")) return false;
114
+ hasSpec = true;
115
+ }
116
+ return hasSpec;
117
+ }
118
+
119
+ function isAllowedGitCommand(tokens: string[]): boolean {
120
+ if (tokens[0] !== "git" || !tokens[1]) return false;
121
+ const subcommand = tokens[1];
122
+ // -- is a standard POSIX argument separator; filter it out before subcommand-specific checks
123
+ const args = tokens.slice(2).filter((arg) => arg !== "--");
124
+ if (["add", "commit", "push", "pull", "merge", "rebase", "reset", "checkout", "switch", "restore", "stash", "cherry-pick", "revert", "tag", "init", "clone", "fetch", "remote", "config", "worktree"].includes(subcommand)) return false;
125
+
126
+ if (subcommand === "status") {
127
+ // Flags must match the allowlist; positional args (paths) are always read-only
128
+ const flags = args.filter((arg) => arg.startsWith("-"));
129
+ return flags.every((arg) => ["--short", "-s", "--porcelain", "--porcelain=v1", "--porcelain=v2", "--branch", "-b", "--ignored", "--ignored=matching", "--ignored=traditional", "--ignored=no", "--untracked-files", "--untracked-files=no", "--untracked-files=normal", "--untracked-files=all", "-uno", "-unormal", "-uall"].includes(arg));
130
+ }
131
+ if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
132
+ if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
133
+ if (subcommand === "branch") {
134
+ const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
135
+ return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
136
+ }
137
+ return false;
138
+ }
139
+
140
+ export function isReadOnlyBash(command: string): boolean {
141
+ const segments = sanitizeCommand(command);
142
+ if (segments.length === 0) return true;
143
+
144
+ return segments.every((segment) => {
145
+ const tokens = tokenizeSimpleCommand(segment);
146
+ if (!tokens) return false;
147
+ if (tokens.length === 0) return true;
148
+ const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
149
+ if (normalized.length === 0) return false;
150
+ if (isAllowedGitCommand(normalized)) return true;
151
+ if (isAllowedNpmMetadataCommand(normalized)) return true;
152
+ return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut|echo|read|where|which|findstr|type)$/.test(normalized[0]);
153
+ });
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension that adds a plan mode with workspace markdown plans and thinking-level presets.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -24,15 +24,25 @@
24
24
  ],
25
25
  "files": [
26
26
  "README.md",
27
- "index.ts"
27
+ "index.ts",
28
+ "lib/"
28
29
  ],
29
30
  "pi": {
30
31
  "extensions": [
31
32
  "./index.ts"
32
33
  ]
33
34
  },
35
+ "scripts": {
36
+ "test": "mocha"
37
+ },
34
38
  "peerDependencies": {
35
39
  "@earendil-works/pi-coding-agent": "*",
36
40
  "typebox": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.19.43",
44
+ "chai": "^4.5.0",
45
+ "mocha": "^10.8.2",
46
+ "tsx": "^4.22.4"
37
47
  }
38
48
  }