@dreki-gg/pi-plan-mode 0.4.0 → 0.5.1
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`8e9aa09`](https://github.com/dreki-gg/pi-extensions/commit/8e9aa0963fe81286e9c5972f6a9d666645807f1a) Thanks [@jalbarrang](https://github.com/jalbarrang)! - fix(plan-mode): allow safe bash commands that were incorrectly blocked in plan mode
|
|
8
|
+
|
|
9
|
+
Three fixes to `isSafeCommand`:
|
|
10
|
+
|
|
11
|
+
- Allow `mkdir -p .plans/` since the planner needs to create plan directories
|
|
12
|
+
- Fix redirect pattern to not false-positive on stderr redirects like `2>/dev/null`
|
|
13
|
+
- Split piped commands and validate each segment independently, so `curl ... | grep ... | head` works correctly
|
|
14
|
+
|
|
15
|
+
## 0.5.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- [`32797ff`](https://github.com/dreki-gg/pi-extensions/commit/32797ff18d968e22c6c44e95c46e3393d8928cef) Thanks [@jalbarrang](https://github.com/jalbarrang)! - feat(plan-mode): add Windows compatibility — replace Unix shell commands with cross-platform Bun/Node APIs
|
|
20
|
+
|
|
21
|
+
Plan-mode no longer shells out to `cat`, `bash`, or `mkdir` via `pi.exec()`. File I/O now uses `Bun.file()` / `Bun.write()` and `node:fs/promises` `mkdir`, making the extension fully cross-platform. Destructive and safe command pattern lists now include Windows equivalents (`del`, `rd`, `copy`, `move`, `powershell`, `dir`, `where`, `tasklist`, etc.).
|
|
22
|
+
|
|
23
|
+
Also fixes Windows compatibility in three other packages:
|
|
24
|
+
|
|
25
|
+
- **browser-tools**: `spawn` now uses `shell: true` on Windows so `.cmd` wrappers resolve correctly; `shellEscape` uses double-quote style on Windows; install guidance is platform-aware (Homebrew shown only on macOS).
|
|
26
|
+
- **subagent**: `spawn` uses `shell: true` on Windows when the command is bare `pi`, allowing `pi.cmd` resolution.
|
|
27
|
+
- **lsp**: `globalConfigPath()` now uses `os.homedir()` on Windows instead of the unreliable `process.env.HOME`.
|
|
28
|
+
|
|
3
29
|
## 0.4.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
|
@@ -22,6 +22,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
|
22
22
|
import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai';
|
|
23
23
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
24
24
|
import { Key } from '@earendil-works/pi-tui';
|
|
25
|
+
import { mkdir } from 'node:fs/promises';
|
|
25
26
|
import {
|
|
26
27
|
extractTodoItems,
|
|
27
28
|
isSafeCommand,
|
|
@@ -111,7 +112,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
111
112
|
status: 'in-progress' | 'done',
|
|
112
113
|
title?: string,
|
|
113
114
|
): Promise<void> {
|
|
114
|
-
const manifest = await readPlansJson(
|
|
115
|
+
const manifest = await readPlansJson();
|
|
115
116
|
const existing = manifest[planName];
|
|
116
117
|
const now = new Date().toISOString();
|
|
117
118
|
|
|
@@ -122,10 +123,9 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
122
123
|
completed: status === 'done' ? now : null,
|
|
123
124
|
};
|
|
124
125
|
|
|
125
|
-
await
|
|
126
|
+
await mkdir('.plans', { recursive: true });
|
|
126
127
|
const content = serializePlansJson(manifest);
|
|
127
|
-
|
|
128
|
-
await pi.exec('bash', ['-c', `cat > .plans/plans.json << 'PLANS_EOF'\n${content}PLANS_EOF`]);
|
|
128
|
+
await Bun.write('.plans/plans.json', content);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// ── UI updates ────────────────────────────────────────────────────────────
|
|
@@ -419,10 +419,8 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
|
|
|
419
419
|
let title = 'Untitled plan';
|
|
420
420
|
if (path.endsWith('PLAN.md')) {
|
|
421
421
|
try {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
title = extractPlanTitle(result.stdout);
|
|
425
|
-
}
|
|
422
|
+
const content = await Bun.file(path).text();
|
|
423
|
+
title = extractPlanTitle(content);
|
|
426
424
|
} catch {
|
|
427
425
|
// Fall through
|
|
428
426
|
}
|
|
@@ -432,11 +430,9 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
|
|
|
432
430
|
} else if (match && planDir && path.endsWith('PLAN.md')) {
|
|
433
431
|
// planDir already set but PLAN.md just written — update title
|
|
434
432
|
try {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
await updatePlansManifest(match[1], 'in-progress', title);
|
|
439
|
-
}
|
|
433
|
+
const content = await Bun.file(path).text();
|
|
434
|
+
const title = extractPlanTitle(content);
|
|
435
|
+
await updatePlansManifest(match[1], 'in-progress', title);
|
|
440
436
|
} catch {
|
|
441
437
|
// Fall through
|
|
442
438
|
}
|
|
@@ -500,10 +496,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
|
|
|
500
496
|
// Read the plan to extract todos
|
|
501
497
|
let planContent = '';
|
|
502
498
|
try {
|
|
503
|
-
|
|
504
|
-
if (result.code === 0) {
|
|
505
|
-
planContent = result.stdout;
|
|
506
|
-
}
|
|
499
|
+
planContent = await Bun.file(planMdPath).text();
|
|
507
500
|
} catch {
|
|
508
501
|
// Fall through — will use empty plan content
|
|
509
502
|
}
|
|
@@ -516,10 +509,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
|
|
|
516
509
|
// Read the start prompt for clean handoff
|
|
517
510
|
let startPrompt = '';
|
|
518
511
|
try {
|
|
519
|
-
|
|
520
|
-
if (result.code === 0) {
|
|
521
|
-
startPrompt = result.stdout.trim();
|
|
522
|
-
}
|
|
512
|
+
startPrompt = (await Bun.file(startPromptPath).text()).trim();
|
|
523
513
|
} catch {
|
|
524
514
|
// Fall through
|
|
525
515
|
}
|
|
@@ -25,12 +25,15 @@ export type PlansManifest = Record<string, PlanEntry>;
|
|
|
25
25
|
|
|
26
26
|
const PLANS_JSON = '.plans/plans.json';
|
|
27
27
|
|
|
28
|
-
/** Read plans.json
|
|
29
|
-
export async function readPlansJson(
|
|
28
|
+
/** Read plans.json, returning current manifest (empty object if missing). */
|
|
29
|
+
export async function readPlansJson(): Promise<PlansManifest> {
|
|
30
30
|
try {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
31
|
+
const file = Bun.file(PLANS_JSON);
|
|
32
|
+
if (await file.exists()) {
|
|
33
|
+
const text = await file.text();
|
|
34
|
+
if (text.trim()) {
|
|
35
|
+
return JSON.parse(text) as PlansManifest;
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
38
|
} catch {
|
|
36
39
|
// File doesn't exist or isn't valid JSON
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isSafeCommand } from './utils.js';
|
|
3
|
+
|
|
4
|
+
describe('isSafeCommand', () => {
|
|
5
|
+
// ── Commands that SHOULD be allowed ──────────────────────────────────────
|
|
6
|
+
describe('allowed commands', () => {
|
|
7
|
+
test('mkdir -p .plans/<name>', () => {
|
|
8
|
+
expect(isSafeCommand('mkdir -p .plans/monorepo-src-migration')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('mkdir .plans/<name>', () => {
|
|
12
|
+
expect(isSafeCommand('mkdir .plans/my-plan')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('curl with 2>/dev/null pipe chain', () => {
|
|
16
|
+
expect(
|
|
17
|
+
isSafeCommand(
|
|
18
|
+
`curl -sL "https://effect.website/docs/platform/introduction/" 2>/dev/null | grep -oP '(?<=href=")[^"]*' | grep -iE "(http|server|rpc)" | head -20`,
|
|
19
|
+
),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('curl with 2>/dev/null simple', () => {
|
|
24
|
+
expect(
|
|
25
|
+
isSafeCommand(
|
|
26
|
+
`curl -s "https://raw.githubusercontent.com/Effect-TS/effect/main/packages/platform/README.md" 2>/dev/null | head -100`,
|
|
27
|
+
),
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('simple ls', () => {
|
|
32
|
+
expect(isSafeCommand('ls -la')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('git status', () => {
|
|
36
|
+
expect(isSafeCommand('git status')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('git log', () => {
|
|
40
|
+
expect(isSafeCommand('git log --oneline -10')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('find piped to grep', () => {
|
|
44
|
+
expect(isSafeCommand('find . -name "*.ts" | grep -v node_modules')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('cat piped to head', () => {
|
|
48
|
+
expect(isSafeCommand('cat README.md | head -50')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rg (ripgrep)', () => {
|
|
52
|
+
expect(isSafeCommand('rg "pattern" src/')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('grep with context', () => {
|
|
56
|
+
expect(isSafeCommand('grep -rn "export" src/ | head -20')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('npm list', () => {
|
|
60
|
+
expect(isSafeCommand('npm list --depth=0')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('curl piped to jq', () => {
|
|
64
|
+
expect(isSafeCommand('curl -s https://api.example.com | jq .name')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Commands that SHOULD be blocked ──────────────────────────────────────
|
|
69
|
+
describe('blocked commands', () => {
|
|
70
|
+
test('rm -rf', () => {
|
|
71
|
+
expect(isSafeCommand('rm -rf node_modules')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('mkdir outside .plans/', () => {
|
|
75
|
+
expect(isSafeCommand('mkdir -p src/new-dir')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('stdout redirect to file', () => {
|
|
79
|
+
expect(isSafeCommand('echo "hello" > file.txt')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('append redirect', () => {
|
|
83
|
+
expect(isSafeCommand('echo "hello" >> file.txt')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('git commit', () => {
|
|
87
|
+
expect(isSafeCommand('git commit -m "test"')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('npm install', () => {
|
|
91
|
+
expect(isSafeCommand('npm install lodash')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('mv file', () => {
|
|
95
|
+
expect(isSafeCommand('mv old.ts new.ts')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('sudo anything', () => {
|
|
99
|
+
expect(isSafeCommand('sudo ls')).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('cp file', () => {
|
|
103
|
+
expect(isSafeCommand('cp a.ts b.ts')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('touch file', () => {
|
|
107
|
+
expect(isSafeCommand('touch newfile.ts')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -24,7 +24,8 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
24
24
|
/\btruncate\b/i,
|
|
25
25
|
/\bdd\b/i,
|
|
26
26
|
/\bshred\b/i,
|
|
27
|
-
/
|
|
27
|
+
// stdout redirect — but NOT stderr redirects like 2>/dev/null or 2>&1
|
|
28
|
+
/(?<!\d)>(?!>|&)/,
|
|
28
29
|
/>>/,
|
|
29
30
|
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
30
31
|
/\byarn\s+(add|remove|install|publish)/i,
|
|
@@ -43,6 +44,17 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
43
44
|
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
44
45
|
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
45
46
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
47
|
+
// Windows equivalents
|
|
48
|
+
/\bdel\b/i,
|
|
49
|
+
/\brd\b/i,
|
|
50
|
+
/\bcopy\b/i,
|
|
51
|
+
/\bmove\b/i,
|
|
52
|
+
/\bren\b/i,
|
|
53
|
+
/\brename\b/i,
|
|
54
|
+
/\bicacls\b/i,
|
|
55
|
+
/\battrib\b/i,
|
|
56
|
+
/\bpowershell\b/i,
|
|
57
|
+
/\bpwsh\b/i,
|
|
46
58
|
];
|
|
47
59
|
|
|
48
60
|
// ── Safe read-only bash patterns (allowed in plan mode) ─────────────────────
|
|
@@ -98,12 +110,78 @@ const SAFE_PATTERNS = [
|
|
|
98
110
|
/^\s*fd\b/,
|
|
99
111
|
/^\s*bat\b/,
|
|
100
112
|
/^\s*eza\b/,
|
|
113
|
+
// Windows equivalents
|
|
114
|
+
/^\s*dir\b/,
|
|
115
|
+
/^\s*where\b/,
|
|
116
|
+
/^\s*set\b/,
|
|
117
|
+
/^\s*systeminfo\b/,
|
|
118
|
+
/^\s*tasklist\b/,
|
|
101
119
|
];
|
|
102
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Check if a command is safe for plan mode.
|
|
123
|
+
*
|
|
124
|
+
* For simple commands, checks that the command starts with a safe pattern and
|
|
125
|
+
* doesn't contain destructive patterns. For piped commands, each segment is
|
|
126
|
+
* checked individually. Special exceptions allow `mkdir -p` for `.plans/` paths.
|
|
127
|
+
*/
|
|
103
128
|
export function isSafeCommand(command: string): boolean {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
// Special case: allow mkdir for .plans/ directory (planner needs this)
|
|
130
|
+
if (/^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Split piped commands and check each segment independently
|
|
135
|
+
const segments = splitPipeSegments(command);
|
|
136
|
+
return segments.every((seg) => {
|
|
137
|
+
const trimmed = seg.trim();
|
|
138
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(trimmed));
|
|
139
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(trimmed));
|
|
140
|
+
return !isDestructive && isSafe;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Split a command on unquoted pipe (`|`) characters.
|
|
146
|
+
* Respects single/double quotes and escaped characters.
|
|
147
|
+
*/
|
|
148
|
+
function splitPipeSegments(command: string): string[] {
|
|
149
|
+
const segments: string[] = [];
|
|
150
|
+
let current = '';
|
|
151
|
+
let inSingle = false;
|
|
152
|
+
let inDouble = false;
|
|
153
|
+
let escaped = false;
|
|
154
|
+
|
|
155
|
+
for (const ch of command) {
|
|
156
|
+
if (escaped) {
|
|
157
|
+
current += ch;
|
|
158
|
+
escaped = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (ch === '\\') {
|
|
162
|
+
current += ch;
|
|
163
|
+
escaped = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === "'" && !inDouble) {
|
|
167
|
+
inSingle = !inSingle;
|
|
168
|
+
current += ch;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === '"' && !inSingle) {
|
|
172
|
+
inDouble = !inDouble;
|
|
173
|
+
current += ch;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ch === '|' && !inSingle && !inDouble) {
|
|
177
|
+
segments.push(current);
|
|
178
|
+
current = '';
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
current += ch;
|
|
182
|
+
}
|
|
183
|
+
if (current) segments.push(current);
|
|
184
|
+
return segments;
|
|
107
185
|
}
|
|
108
186
|
|
|
109
187
|
// ── Plan extraction ─────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "24",
|
|
39
|
+
"bun-types": "latest",
|
|
39
40
|
"oxfmt": "^0.43.0",
|
|
40
41
|
"oxlint": "^1.58.0",
|
|
41
42
|
"typescript": "^6.0.0"
|