@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.
- package/index.ts +216 -130
- package/lib/bash-gating.ts +154 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
409
|
+
updateFooter(ctx);
|
|
346
410
|
clearPlanWidget(ctx);
|
|
347
411
|
persistState();
|
|
348
|
-
ctx.ui.notify(`Plan mode enabled.
|
|
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
|
-
|
|
420
|
+
updateFooter(ctx);
|
|
357
421
|
clearPlanWidget(ctx);
|
|
358
422
|
persistState();
|
|
359
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|