@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.
- package/README.md +17 -0
- package/index.ts +120 -21
- 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:
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
176
|
+
function tokenizeSimpleCommand(command: string): string[] | undefined {
|
|
156
177
|
const trimmed = command.trim();
|
|
157
|
-
if (!trimmed) return
|
|
158
|
-
if (/[;&|`$(){}]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
468
|
-
if (
|
|
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
|
-
|
|
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;
|