@bacnh85/pi-plan 0.1.5 → 0.1.7

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/README.md +17 -0
  2. package/index.ts +120 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,6 +58,23 @@ pi --plan
58
58
 
59
59
  Agents should ask blocking, user-answerable planning questions with `ask_plan_question` before finalizing a plan. Final plans may still list non-blocking uncertainties or implementation-time checks, but should not leave consequential user decisions unresolved.
60
60
 
61
+ ## Plan-mode bash allowlist
62
+
63
+ Plan mode allows only a narrow read-only subset of shell commands. Examples include:
64
+
65
+ ```bash
66
+ git status --short
67
+ git diff
68
+ git show HEAD -- package.json
69
+ git log --oneline -5
70
+ git branch --show-current
71
+ git rev-parse --show-toplevel
72
+ npm view <package> --json
73
+ npm info <package> version
74
+ ```
75
+
76
+ `npm view` and `npm info` are allowed only for npm registry metadata lookup. They may contact the network and disclose the queried package name, but pi-plan does not allow npm install/update/publish/run/exec/auth/config style commands in plan mode.
77
+
61
78
  ## Reasoning levels
62
79
 
63
80
  pi-plan remembers two reasoning levels:
package/index.ts CHANGED
@@ -30,9 +30,9 @@ interface PlanState {
30
30
  }
31
31
 
32
32
  interface PlanPreferences {
33
- version: 1;
34
- planThinking: ThinkingLevel;
35
- normalThinking: ThinkingLevel;
33
+ version: 2;
34
+ defaults: { planThinking: ThinkingLevel; normalThinking: ThinkingLevel };
35
+ perModel: Record<string, { planThinking: ThinkingLevel; normalThinking: ThinkingLevel }>;
36
36
  }
37
37
 
38
38
  interface WritePlanParams {
@@ -128,14 +128,35 @@ function hasOpenQuestionWarning(content: string): boolean {
128
128
  return /(^|\n)#{1,6}\s+.*open questions?.*\n[\s\S]*\?/i.test(content);
129
129
  }
130
130
 
131
+ function modelKey(model: { provider?: string; id?: string } | undefined): string | undefined {
132
+ if (!model?.provider || !model?.id) return undefined;
133
+ return `${model.provider}/${model.id}`;
134
+ }
135
+
136
+ function getEffectiveThinking(prefs: PlanPreferences, model: { provider?: string; id?: string } | undefined): { plan: ThinkingLevel; normal: ThinkingLevel } {
137
+ const key = modelKey(model);
138
+ const stored = key ? prefs.perModel[key] : undefined;
139
+ return {
140
+ plan: stored?.planThinking ?? prefs.defaults.planThinking,
141
+ normal: stored?.normalThinking ?? prefs.defaults.normalThinking,
142
+ };
143
+ }
144
+
131
145
  async function loadPreferences(): Promise<PlanPreferences | undefined> {
132
146
  try {
133
147
  const raw = await readFile(PREFERENCES_FILE, "utf8");
134
- const parsed = JSON.parse(raw) as Partial<PlanPreferences>;
135
- const savedPlanThinking = parsed.planThinking;
136
- const savedNormalThinking = parsed.normalThinking;
137
- if (parsed.version !== 1 || !savedPlanThinking || !savedNormalThinking || !isThinkingLevel(savedPlanThinking) || !isThinkingLevel(savedNormalThinking)) return undefined;
138
- return { version: 1, planThinking: savedPlanThinking, normalThinking: savedNormalThinking };
148
+ const parsed = JSON.parse(raw) as Record<string, any>;
149
+ if (parsed.version === 2 && isThinkingLevel(parsed.defaults?.planThinking) && isThinkingLevel(parsed.defaults?.normalThinking) && typeof parsed.perModel === "object" && parsed.perModel !== null) {
150
+ return { version: 2, defaults: parsed.defaults, perModel: parsed.perModel } as PlanPreferences;
151
+ }
152
+ if (parsed.version === 1 && isThinkingLevel(parsed.planThinking) && isThinkingLevel(parsed.normalThinking)) {
153
+ return {
154
+ version: 2,
155
+ defaults: { planThinking: parsed.planThinking as ThinkingLevel, normalThinking: parsed.normalThinking as ThinkingLevel },
156
+ perModel: {},
157
+ };
158
+ }
159
+ return undefined;
139
160
  } catch {
140
161
  return undefined;
141
162
  }
@@ -152,11 +173,60 @@ function isDestructiveBash(command: string): boolean {
152
173
  return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
153
174
  }
154
175
 
155
- function isReadOnlyBash(command: string): boolean {
176
+ function tokenizeSimpleCommand(command: string): string[] | undefined {
156
177
  const trimmed = command.trim();
157
- if (!trimmed) return true;
158
- if (/[;&|`$(){}]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return false;
159
- return /^(git\s+(status|branch|rev-parse|diff|show|log|ls-files)\b|(?:rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut)\b)/i.test(trimmed);
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 hasOptionValue(tokens: string[], index: number): boolean {
185
+ return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
186
+ }
187
+
188
+ function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
189
+ if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
190
+ let hasSpec = false;
191
+ for (let index = 2; index < tokens.length; index += 1) {
192
+ const token = tokens[index];
193
+ if (["--registry", "--tag"].includes(token)) {
194
+ if (!hasOptionValue(tokens, index)) return false;
195
+ index += 1;
196
+ continue;
197
+ }
198
+ if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
199
+ if (token.startsWith("-")) return false;
200
+ hasSpec = true;
201
+ }
202
+ return hasSpec;
203
+ }
204
+
205
+ function isAllowedGitCommand(tokens: string[]): boolean {
206
+ if (tokens[0] !== "git" || !tokens[1]) return false;
207
+ const subcommand = tokens[1];
208
+ const args = tokens.slice(2);
209
+ 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;
210
+
211
+ 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));
212
+ if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
213
+ if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
214
+ if (subcommand === "branch") {
215
+ const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
216
+ return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
217
+ }
218
+ return false;
219
+ }
220
+
221
+ export function isReadOnlyBash(command: string): boolean {
222
+ const tokens = tokenizeSimpleCommand(command);
223
+ if (!tokens) return false;
224
+ if (tokens.length === 0) return true;
225
+ const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
226
+ if (normalized.length === 0) return false;
227
+ if (isAllowedGitCommand(normalized)) return true;
228
+ if (isAllowedNpmMetadataCommand(normalized)) return true;
229
+ return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut)$/.test(normalized[0]);
160
230
  }
161
231
 
162
232
  export default function piPlanExtension(pi: ExtensionAPI): void {
@@ -199,8 +269,11 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
199
269
  } satisfies PlanState);
200
270
  }
201
271
 
272
+ let preferences: PlanPreferences | undefined;
273
+
202
274
  function persistPreferences(): void {
203
- void savePreferences({ version: 1, planThinking, normalThinking }).catch(() => undefined);
275
+ if (!preferences) return;
276
+ void savePreferences(preferences).catch(() => undefined);
204
277
  }
205
278
 
206
279
  function enablePlanTools(): void {
@@ -230,6 +303,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
230
303
  if (normalThinking === level) return;
231
304
  normalThinking = level;
232
305
  }
306
+ const key = modelKey(ctx.model);
307
+ if (key && preferences) {
308
+ preferences.perModel[key] = { planThinking, normalThinking };
309
+ }
233
310
  setStatus(ctx);
234
311
  persistState();
235
312
  persistPreferences();
@@ -452,26 +529,29 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
452
529
  });
453
530
 
454
531
  pi.on("session_start", async (_event, ctx) => {
455
- const preferences = await loadPreferences();
532
+ preferences = await loadPreferences();
533
+ if (!preferences) {
534
+ preferences = { version: 2, defaults: { planThinking, normalThinking }, perModel: {} };
535
+ }
536
+ const effective = getEffectiveThinking(preferences, ctx.model);
537
+ planThinking = effective.plan;
538
+ normalThinking = effective.normal;
539
+
456
540
  const entries = ctx.sessionManager.getEntries();
457
541
  const saved = entries
458
542
  .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === "pi-plan")
459
543
  .pop() as { data?: PlanState } | undefined;
460
- if (preferences) {
461
- planThinking = preferences.planThinking;
462
- normalThinking = preferences.normalThinking;
463
- }
464
544
  if (saved?.data) {
465
545
  planModeEnabled = saved.data.enabled ?? planModeEnabled;
466
546
  executionMode = saved.data.executing ?? executionMode;
467
- if (!preferences && isThinkingLevel(saved.data.planThinking)) planThinking = saved.data.planThinking;
468
- if (!preferences && isThinkingLevel(saved.data.normalThinking)) normalThinking = saved.data.normalThinking;
547
+ if (isThinkingLevel(saved.data.planThinking)) planThinking = saved.data.planThinking;
548
+ if (isThinkingLevel(saved.data.normalThinking)) normalThinking = saved.data.normalThinking;
469
549
  toolsBeforePlan = saved.data.toolsBeforePlan ?? toolsBeforePlan;
470
550
  lastPlanPath = saved.data.lastPlanPath ?? lastPlanPath;
471
551
  lastPlanTitle = saved.data.lastPlanTitle ?? lastPlanTitle;
472
552
  lastPlanStatus = saved.data.lastPlanStatus ?? lastPlanStatus;
473
553
  }
474
- if (!preferences) persistPreferences();
554
+ persistPreferences();
475
555
  if (pi.getFlag("plan") === true) planModeEnabled = true;
476
556
  if (planModeEnabled) {
477
557
  enablePlanTools();
@@ -483,6 +563,25 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
483
563
  clearPlanWidget(ctx);
484
564
  });
485
565
 
566
+ pi.on("model_select", async (event, ctx) => {
567
+ if (executionMode) return;
568
+ if (!preferences) return;
569
+ const effective = getEffectiveThinking(preferences, event.model);
570
+ if (planModeEnabled) {
571
+ if (planThinking !== effective.plan) {
572
+ planThinking = effective.plan;
573
+ applyThinking(planThinking);
574
+ }
575
+ } else {
576
+ if (normalThinking !== effective.normal) {
577
+ normalThinking = effective.normal;
578
+ applyThinking(normalThinking);
579
+ }
580
+ }
581
+ setStatus(ctx);
582
+ persistState();
583
+ });
584
+
486
585
  pi.on("thinking_level_select", async (event, ctx) => {
487
586
  if (applyingStoredThinking) return;
488
587
  if (!isThinkingLevel(event.level)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {