@matyah00/openpi 0.1.2
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 +117 -0
- package/agents/agent-chain.yaml +113 -0
- package/agents/backend.md +13 -0
- package/agents/basher.md +27 -0
- package/agents/builder.md +14 -0
- package/agents/code-searcher.md +27 -0
- package/agents/context-pruner.md +29 -0
- package/agents/directory-lister.md +25 -0
- package/agents/documenter.md +13 -0
- package/agents/editor.md +27 -0
- package/agents/file-picker.md +27 -0
- package/agents/frontend.md +14 -0
- package/agents/glob-matcher.md +25 -0
- package/agents/librarian.md +27 -0
- package/agents/loop-controller.md +41 -0
- package/agents/pi-pi/agent-expert.md +97 -0
- package/agents/pi-pi/cli-expert.md +41 -0
- package/agents/pi-pi/config-expert.md +63 -0
- package/agents/pi-pi/ext-expert.md +43 -0
- package/agents/pi-pi/keybinding-expert.md +134 -0
- package/agents/pi-pi/pi-orchestrator.md +57 -0
- package/agents/pi-pi/prompt-expert.md +70 -0
- package/agents/pi-pi/skill-expert.md +42 -0
- package/agents/pi-pi/theme-expert.md +40 -0
- package/agents/pi-pi/tui-expert.md +85 -0
- package/agents/plan-reviewer.md +22 -0
- package/agents/planner.md +14 -0
- package/agents/problem-architect.md +55 -0
- package/agents/red-team.md +13 -0
- package/agents/reviewer.md +14 -0
- package/agents/rule-verifier.md +35 -0
- package/agents/scout.md +14 -0
- package/agents/security-auditor.md +35 -0
- package/agents/ship-guard.md +34 -0
- package/agents/spec-reviewer.md +41 -0
- package/agents/teams.yaml +73 -0
- package/agents/tester.md +27 -0
- package/agents/thinker.md +26 -0
- package/agents/worker.md +27 -0
- package/damage-control-rules.yaml +277 -0
- package/extensions/agent-chain.ts +293 -0
- package/extensions/agent-team.ts +312 -0
- package/extensions/audit-tools.ts +260 -0
- package/extensions/commands.ts +169 -0
- package/extensions/damage-control-continue.ts +243 -0
- package/extensions/lib/packagePaths.ts +13 -0
- package/extensions/minimal.ts +34 -0
- package/extensions/openpi.ts +255 -0
- package/extensions/pure-focus.ts +24 -0
- package/extensions/purpose-gate.ts +84 -0
- package/extensions/search-tools.ts +277 -0
- package/extensions/state-tools.ts +276 -0
- package/extensions/system-select.ts +120 -0
- package/extensions/theme-cycler.ts +181 -0
- package/extensions/themeMap.ts +145 -0
- package/extensions/tool-counter-widget.ts +68 -0
- package/extensions/tool-counter.ts +102 -0
- package/extensions/workflow.ts +642 -0
- package/package.json +60 -0
- package/prompts/blueprint.md +66 -0
- package/prompts/clarify.md +26 -0
- package/prompts/compress.md +23 -0
- package/prompts/debate.md +23 -0
- package/prompts/deep.md +36 -0
- package/prompts/deps.md +24 -0
- package/prompts/explore.md +22 -0
- package/prompts/ghost-test.md +22 -0
- package/prompts/goal.md +26 -0
- package/prompts/parallel.md +42 -0
- package/prompts/plan-team.md +31 -0
- package/prompts/prime.md +17 -0
- package/prompts/review.md +23 -0
- package/prompts/sentinel.md +29 -0
- package/prompts/ship.md +30 -0
- package/prompts/snapshot.md +26 -0
- package/prompts/spec.md +58 -0
- package/prompts/test.md +13 -0
- package/prompts/validate.md +19 -0
- package/skills/bowser/SKILL.md +114 -0
- package/skills/env-scanner/SKILL.md +25 -0
- package/skills/security-guard/SKILL.md +24 -0
- package/skills/session-continuity/SKILL.md +20 -0
- package/skills/spec-driven/SKILL.md +25 -0
- package/skills/test-first/SKILL.md +23 -0
- package/skills/ultrathink/SKILL.md +27 -0
- package/themes/catppuccin-mocha.json +86 -0
- package/themes/cyberpunk.json +81 -0
- package/themes/dracula.json +81 -0
- package/themes/everforest.json +82 -0
- package/themes/gruvbox.json +80 -0
- package/themes/midnight-ocean.json +76 -0
- package/themes/nord.json +84 -0
- package/themes/ocean-breeze.json +83 -0
- package/themes/rose-pine.json +82 -0
- package/themes/synthwave.json +82 -0
- package/themes/tokyo-night.json +83 -0
- package/tsconfig.json +15 -0
- package/types/pi-shims.d.ts +102 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SKIP = new Set([
|
|
9
|
+
".git",
|
|
10
|
+
"node_modules",
|
|
11
|
+
".next",
|
|
12
|
+
".nuxt",
|
|
13
|
+
".turbo",
|
|
14
|
+
"dist",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"target",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const SECRET_PATTERNS = [
|
|
24
|
+
["aws-access-key", /AKIA[A-Z0-9]{16}/g],
|
|
25
|
+
["github-token", /ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}/g],
|
|
26
|
+
["gitlab-token", /glpat-[A-Za-z0-9_-]{20,}/g],
|
|
27
|
+
["slack-token", /xox[baprs]-[A-Za-z0-9-]{20,}/g],
|
|
28
|
+
["npm-token", /npm_[A-Za-z0-9]{30,}/g],
|
|
29
|
+
["google-api-key", /AIza[0-9A-Za-z_-]{35}/g],
|
|
30
|
+
["stripe-live-key", /sk_live_[0-9A-Za-z]{20,}|pk_live_[0-9A-Za-z]{20,}/g],
|
|
31
|
+
["sendgrid-key", /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g],
|
|
32
|
+
["private-key", /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g],
|
|
33
|
+
["jwt-like-token", /eyJ[A-Za-z0-9_-]{40,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
const GHOST_PATTERNS = [
|
|
37
|
+
["always-equal", /def\s+__eq__[\s\S]{0,120}return\s+True|__eq__\s*=\s*lambda[^:\n]*:\s*True/g],
|
|
38
|
+
["exit-bypass", /sys\.exit\s*\(\s*0\s*\)|os\._exit\s*\(\s*0\s*\)|process\.exit\s*\(\s*0\s*\)/g],
|
|
39
|
+
["pytest-report-patch", /TestReport\.from_item_and_call|pytest_runtest_makereport|monkeypatch.*TestReport/g],
|
|
40
|
+
["assertion-monkeypatch", /expect\s*=\s*jest\.fn|assert\s*=\s*lambda|monkeypatch.*assert/g],
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
function walkFiles(root: string, maxFiles: number): string[] {
|
|
44
|
+
const files: string[] = [];
|
|
45
|
+
const walk = (dir: string) => {
|
|
46
|
+
if (files.length >= maxFiles) return;
|
|
47
|
+
let entries: fs.Dirent[];
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (files.length >= maxFiles) return;
|
|
55
|
+
if (DEFAULT_SKIP.has(entry.name)) continue;
|
|
56
|
+
const fullPath = path.join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
58
|
+
else if (entry.isFile()) files.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
walk(root);
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function relative(root: string, filePath: string): string {
|
|
66
|
+
return path.relative(root, filePath).replace(/\\/g, "/") || ".";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readSmallText(filePath: string): string | null {
|
|
70
|
+
try {
|
|
71
|
+
const stat = fs.statSync(filePath);
|
|
72
|
+
if (stat.size > 1_000_000) return null;
|
|
73
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function lineOf(content: string, index: number): number {
|
|
80
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function redact(value: string): string {
|
|
84
|
+
if (value.length <= 12) return "[redacted]";
|
|
85
|
+
return `${value.slice(0, 8)}...${value.slice(-3)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function git(args: string[], cwd: string): string {
|
|
89
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
90
|
+
if (result.status !== 0) return "";
|
|
91
|
+
return result.stdout.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectFrameworks(root: string): string[] {
|
|
95
|
+
const found = new Set<string>();
|
|
96
|
+
const packageJson = path.join(root, "package.json");
|
|
97
|
+
if (fs.existsSync(packageJson)) {
|
|
98
|
+
const raw = readSmallText(packageJson);
|
|
99
|
+
if (raw) {
|
|
100
|
+
const pkg = JSON.parse(raw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
101
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
102
|
+
if (deps.next) found.add("Next.js");
|
|
103
|
+
if (deps.react) found.add("React");
|
|
104
|
+
if (deps.vite) found.add("Vite");
|
|
105
|
+
if (deps["@sveltejs/kit"]) found.add("SvelteKit");
|
|
106
|
+
if (deps.vue) found.add("Vue");
|
|
107
|
+
if (deps.express) found.add("Express");
|
|
108
|
+
if (deps.vitest) found.add("Vitest");
|
|
109
|
+
if (deps.jest) found.add("Jest");
|
|
110
|
+
if (deps.typescript) found.add("TypeScript");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "requirements.txt"))) found.add("Python");
|
|
114
|
+
if (fs.existsSync(path.join(root, "Cargo.toml"))) found.add("Rust");
|
|
115
|
+
if (fs.existsSync(path.join(root, "go.mod"))) found.add("Go");
|
|
116
|
+
if (fs.existsSync(path.join(root, "Dockerfile"))) found.add("Docker");
|
|
117
|
+
return Array.from(found);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dependencyInventory(root: string): string {
|
|
121
|
+
const sections: string[] = [];
|
|
122
|
+
const packageJson = path.join(root, "package.json");
|
|
123
|
+
if (fs.existsSync(packageJson)) {
|
|
124
|
+
const raw = readSmallText(packageJson);
|
|
125
|
+
if (raw) {
|
|
126
|
+
const pkg = JSON.parse(raw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
127
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
128
|
+
const loose = Object.entries(deps).filter(([, version]) => /^[~^*]/.test(version));
|
|
129
|
+
sections.push(`Node packages: ${Object.keys(deps).length}`);
|
|
130
|
+
sections.push(`Node lockfile: ${fs.existsSync(path.join(root, "package-lock.json")) || fs.existsSync(path.join(root, "pnpm-lock.yaml")) || fs.existsSync(path.join(root, "yarn.lock")) || fs.existsSync(path.join(root, "bun.lock")) ? "present" : "missing"}`);
|
|
131
|
+
if (loose.length) sections.push(`Loose pins: ${loose.slice(0, 20).map(([name, version]) => `${name}@${version}`).join(", ")}${loose.length > 20 ? `, +${loose.length - 20} more` : ""}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const file of ["requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile"]) {
|
|
135
|
+
if (fs.existsSync(path.join(root, file))) sections.push(`Manifest: ${file}`);
|
|
136
|
+
}
|
|
137
|
+
return sections.join("\n") || "No common dependency manifests found.";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
141
|
+
pi.registerTool({
|
|
142
|
+
name: "env_scan",
|
|
143
|
+
label: "Env Scan",
|
|
144
|
+
description: "Summarize project environment: git status, manifests, package managers, frameworks, and top-level structure.",
|
|
145
|
+
promptSnippet: "Use env_scan before setup, dependency work, unfamiliar repos, or build diagnostics.",
|
|
146
|
+
parameters: Type.Object({
|
|
147
|
+
maxTopEntries: Type.Optional(Type.Number({ description: "Maximum top-level entries to show. Default 80." })),
|
|
148
|
+
}),
|
|
149
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
150
|
+
const maxTopEntries = Math.max(10, Math.min(Number(params.maxTopEntries ?? 80), 200));
|
|
151
|
+
const entries = fs.readdirSync(ctx.cwd, { withFileTypes: true })
|
|
152
|
+
.filter((entry) => !DEFAULT_SKIP.has(entry.name))
|
|
153
|
+
.slice(0, maxTopEntries)
|
|
154
|
+
.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
|
|
155
|
+
const branch = git(["branch", "--show-current"], ctx.cwd) || "(no git branch)";
|
|
156
|
+
const status = git(["status", "--short"], ctx.cwd) || "(clean or not a git repo)";
|
|
157
|
+
const frameworks = detectFrameworks(ctx.cwd);
|
|
158
|
+
const text = [
|
|
159
|
+
`cwd: ${ctx.cwd}`,
|
|
160
|
+
`branch: ${branch}`,
|
|
161
|
+
`frameworks: ${frameworks.length ? frameworks.join(", ") : "none detected"}`,
|
|
162
|
+
"",
|
|
163
|
+
"git status:",
|
|
164
|
+
status,
|
|
165
|
+
"",
|
|
166
|
+
"dependencies:",
|
|
167
|
+
dependencyInventory(ctx.cwd),
|
|
168
|
+
"",
|
|
169
|
+
"top-level:",
|
|
170
|
+
...entries,
|
|
171
|
+
].join("\n");
|
|
172
|
+
return { content: [{ type: "text", text }] };
|
|
173
|
+
},
|
|
174
|
+
renderCall(_args, theme) {
|
|
175
|
+
return new Text(theme.fg("toolTitle", theme.bold("env_scan")), 0, 0);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
pi.registerTool({
|
|
180
|
+
name: "secret_scan",
|
|
181
|
+
label: "Secret Scan",
|
|
182
|
+
description: "Read-only high-signal secret scan with redacted findings.",
|
|
183
|
+
promptSnippet: "Use secret_scan before shipping, importing external files, or touching credentials.",
|
|
184
|
+
parameters: Type.Object({
|
|
185
|
+
maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 5000." })),
|
|
186
|
+
}),
|
|
187
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
188
|
+
const maxFiles = Math.max(100, Math.min(Number(params.maxFiles ?? 5000), 20000));
|
|
189
|
+
const files = walkFiles(ctx.cwd, maxFiles);
|
|
190
|
+
const findings: string[] = [];
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
const content = readSmallText(file);
|
|
193
|
+
if (!content) continue;
|
|
194
|
+
for (const [rule, pattern] of SECRET_PATTERNS) {
|
|
195
|
+
pattern.lastIndex = 0;
|
|
196
|
+
let match: RegExpExecArray | null;
|
|
197
|
+
while ((match = pattern.exec(content))) {
|
|
198
|
+
findings.push(`${relative(ctx.cwd, file)}:${lineOf(content, match.index)} ${rule} ${redact(match[0])}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const envGitignored = !fs.existsSync(path.join(ctx.cwd, ".env")) || (readSmallText(path.join(ctx.cwd, ".gitignore")) || "").includes(".env");
|
|
203
|
+
const verdict = findings.length ? "BLOCKED" : "CLEAN";
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: "text", text: [`secret_scan verdict: ${verdict}`, `.env gitignored: ${envGitignored ? "yes" : "no"}`, "", ...(findings.length ? findings : ["No high-signal secrets found."])].join("\n") }],
|
|
206
|
+
details: { findings },
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
renderCall(_args, theme) {
|
|
210
|
+
return new Text(theme.fg("toolTitle", theme.bold("secret_scan")), 0, 0);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.registerTool({
|
|
215
|
+
name: "ghost_test_scan",
|
|
216
|
+
label: "Ghost Test Scan",
|
|
217
|
+
description: "Static scan for test-suite reward hacking patterns such as always-true equality, exit bypasses, and framework patching.",
|
|
218
|
+
promptSnippet: "Use ghost_test_scan when validating test integrity before trusting green tests.",
|
|
219
|
+
parameters: Type.Object({
|
|
220
|
+
maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 3000." })),
|
|
221
|
+
}),
|
|
222
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
223
|
+
const maxFiles = Math.max(100, Math.min(Number(params.maxFiles ?? 3000), 10000));
|
|
224
|
+
const files = walkFiles(ctx.cwd, maxFiles).filter((file) => /test|spec|conftest|setup/i.test(file));
|
|
225
|
+
const findings: string[] = [];
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const content = readSmallText(file);
|
|
228
|
+
if (!content) continue;
|
|
229
|
+
for (const [rule, pattern] of GHOST_PATTERNS) {
|
|
230
|
+
pattern.lastIndex = 0;
|
|
231
|
+
let match: RegExpExecArray | null;
|
|
232
|
+
while ((match = pattern.exec(content))) {
|
|
233
|
+
findings.push(`${relative(ctx.cwd, file)}:${lineOf(content, match.index)} ${rule}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text", text: [`ghost_test_scan verdict: ${findings.length ? "SUSPICIOUS" : "CLEAN"}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No static reward-hack patterns found."])].join("\n") }],
|
|
239
|
+
details: { findings },
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
renderCall(_args, theme) {
|
|
243
|
+
return new Text(theme.fg("toolTitle", theme.bold("ghost_test_scan")), 0, 0);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
pi.registerTool({
|
|
248
|
+
name: "dependency_inventory",
|
|
249
|
+
label: "Dependency Inventory",
|
|
250
|
+
description: "Read-only dependency manifest inventory with lockfile and loose-pin checks.",
|
|
251
|
+
promptSnippet: "Use dependency_inventory before dependency updates, migration planning, or ship gates.",
|
|
252
|
+
parameters: Type.Object({}),
|
|
253
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
254
|
+
return { content: [{ type: "text", text: dependencyInventory(ctx.cwd) }] };
|
|
255
|
+
},
|
|
256
|
+
renderCall(_args, theme) {
|
|
257
|
+
return new Text(theme.fg("toolTitle", theme.bold("dependency_inventory")), 0, 0);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { bundledPromptsDir } from "./lib/packagePaths.ts";
|
|
5
|
+
|
|
6
|
+
type CommandDef = {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
category: string;
|
|
10
|
+
aliases: string[];
|
|
11
|
+
body: string;
|
|
12
|
+
source: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseFrontmatter(raw: string): { fields: Record<string, string | string[]>; body: string } {
|
|
16
|
+
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
17
|
+
if (!match) return { fields: {}, body: raw };
|
|
18
|
+
|
|
19
|
+
const fields: Record<string, string | string[]> = {};
|
|
20
|
+
let currentListKey = "";
|
|
21
|
+
for (const line of match[1].split("\n")) {
|
|
22
|
+
const listMatch = line.match(/^\s+-\s+(.+)$/);
|
|
23
|
+
if (listMatch && currentListKey) {
|
|
24
|
+
const existing = fields[currentListKey];
|
|
25
|
+
fields[currentListKey] = Array.isArray(existing) ? [...existing, listMatch[1].trim()] : [listMatch[1].trim()];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const idx = line.indexOf(":");
|
|
30
|
+
if (idx > 0) {
|
|
31
|
+
const key = line.slice(0, idx).trim();
|
|
32
|
+
const value = line.slice(idx + 1).trim();
|
|
33
|
+
currentListKey = key;
|
|
34
|
+
fields[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { fields, body: match[2].trim() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stringField(value: string | string[] | undefined): string {
|
|
42
|
+
return typeof value === "string" ? value : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function arrayField(value: string | string[] | undefined): string[] {
|
|
46
|
+
if (Array.isArray(value)) return value;
|
|
47
|
+
if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expandArgs(template: string, args: string): string {
|
|
52
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
53
|
+
let result = template.replace(/\$ARGUMENTS|\$@/g, args);
|
|
54
|
+
for (let i = 0; i < parts.length; i++) {
|
|
55
|
+
result = result.replaceAll(`$${i + 1}`, parts[i]);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scanPromptDir(dir: string, source: string): CommandDef[] {
|
|
61
|
+
if (!existsSync(dir)) return [];
|
|
62
|
+
const commands: CommandDef[] = [];
|
|
63
|
+
|
|
64
|
+
for (const file of readdirSync(dir)) {
|
|
65
|
+
if (!file.endsWith(".md")) continue;
|
|
66
|
+
const raw = readFileSync(join(dir, file), "utf-8");
|
|
67
|
+
const { fields, body } = parseFrontmatter(raw);
|
|
68
|
+
const firstLine = body.split("\n").find((line) => line.trim())?.trim() || "";
|
|
69
|
+
commands.push({
|
|
70
|
+
name: stringField(fields.name) || basename(file, ".md"),
|
|
71
|
+
description: stringField(fields.description) || firstLine.slice(0, 120),
|
|
72
|
+
category: stringField(fields.category) || "general",
|
|
73
|
+
aliases: arrayField(fields.aliases),
|
|
74
|
+
body,
|
|
75
|
+
source,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return commands;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default function (pi: ExtensionAPI) {
|
|
83
|
+
const cwd = process.cwd();
|
|
84
|
+
const commands = [
|
|
85
|
+
...scanPromptDir(bundledPromptsDir, "openpi"),
|
|
86
|
+
...scanPromptDir(join(cwd, ".pi", "prompts"), ".pi"),
|
|
87
|
+
];
|
|
88
|
+
const byName = new Map<string, CommandDef>();
|
|
89
|
+
const aliases = new Map<string, string>();
|
|
90
|
+
const conflicts: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const command of commands) {
|
|
93
|
+
const key = command.name.toLowerCase();
|
|
94
|
+
if (byName.has(key)) {
|
|
95
|
+
conflicts.push(`${command.name}: ${byName.get(key)!.source} wins over ${command.source}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
byName.set(key, command);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const command of byName.values()) {
|
|
102
|
+
for (const alias of command.aliases) {
|
|
103
|
+
const key = alias.toLowerCase();
|
|
104
|
+
if (byName.has(key) || aliases.has(key)) {
|
|
105
|
+
conflicts.push(`${alias}: alias collision for ${command.name}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
aliases.set(key, command.name.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function register(name: string, canonicalKey: string) {
|
|
113
|
+
const command = byName.get(canonicalKey);
|
|
114
|
+
if (!command) return;
|
|
115
|
+
pi.registerCommand(name, {
|
|
116
|
+
description: `[${command.source}/${command.category}] ${command.description}`.slice(0, 120),
|
|
117
|
+
handler: async (args) => {
|
|
118
|
+
pi.sendUserMessage(expandArgs(command.body, args || ""));
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const [key, command] of byName) {
|
|
124
|
+
register(command.name, key);
|
|
125
|
+
}
|
|
126
|
+
for (const [alias, canonicalKey] of aliases) {
|
|
127
|
+
register(alias, canonicalKey);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pi.registerCommand("commands", {
|
|
131
|
+
description: "List openpi command templates",
|
|
132
|
+
handler: async () => {
|
|
133
|
+
const grouped = new Map<string, CommandDef[]>();
|
|
134
|
+
for (const command of byName.values()) {
|
|
135
|
+
const list = grouped.get(command.category) || [];
|
|
136
|
+
list.push(command);
|
|
137
|
+
grouped.set(command.category, list);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lines = ["# Commands", ""];
|
|
141
|
+
for (const [category, list] of Array.from(grouped.entries()).sort()) {
|
|
142
|
+
lines.push(`## ${category}`, "");
|
|
143
|
+
for (const command of list.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
144
|
+
const aliasSuffix = command.aliases.length ? ` (${command.aliases.map((a) => `/${a}`).join(", ")})` : "";
|
|
145
|
+
lines.push(`- /${command.name}${aliasSuffix} - ${command.description}`);
|
|
146
|
+
}
|
|
147
|
+
lines.push("");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pi.sendMessage({ customType: "openpi-commands", content: lines.join("\n"), display: true });
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
pi.registerCommand("command:status", {
|
|
155
|
+
description: "Show openpi command loader status",
|
|
156
|
+
handler: async () => {
|
|
157
|
+
const lines = [
|
|
158
|
+
"# Command Status",
|
|
159
|
+
"",
|
|
160
|
+
`Commands: ${byName.size}`,
|
|
161
|
+
`Aliases: ${aliases.size}`,
|
|
162
|
+
`Conflicts: ${conflicts.length}`,
|
|
163
|
+
"",
|
|
164
|
+
...conflicts.map((conflict) => `- ${conflict}`),
|
|
165
|
+
];
|
|
166
|
+
pi.sendMessage({ customType: "openpi-command-status", content: lines.join("\n"), display: true });
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damage-Control (continue) — same rules, but the agent keeps working
|
|
3
|
+
*
|
|
4
|
+
* Difference from damage-control.ts:
|
|
5
|
+
* - The blocked tool result is replaced with actionable feedback that
|
|
6
|
+
* distinguishes destructive vs non-destructive intent and tells the
|
|
7
|
+
* agent how to adapt.
|
|
8
|
+
* - We do NOT call ctx.abort(), so the agent's turn continues and can
|
|
9
|
+
* try an alternate path (e.g. assume a .env key exists instead of
|
|
10
|
+
* reading it to verify).
|
|
11
|
+
*
|
|
12
|
+
* Usage: pi -e extensions/damage-control-continue.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { parse as yamlParse } from "yaml";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import { applyExtensionDefaults } from "./themeMap.ts";
|
|
22
|
+
import { bundledDamageRulesPath } from "./lib/packagePaths.ts";
|
|
23
|
+
|
|
24
|
+
interface Rule {
|
|
25
|
+
pattern: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
ask?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Rules {
|
|
31
|
+
bashToolPatterns: Rule[];
|
|
32
|
+
zeroAccessPaths: string[];
|
|
33
|
+
readOnlyPaths: string[];
|
|
34
|
+
noDeletePaths: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function continueFeedback(toolName: string, violationReason: string, invocation: string): string {
|
|
38
|
+
return [
|
|
39
|
+
`🛡️ Damage-Control: ${toolName} blocked — ${violationReason}`,
|
|
40
|
+
``,
|
|
41
|
+
`Attempted: ${invocation}`,
|
|
42
|
+
``,
|
|
43
|
+
`Don't call ${toolName} directly like this. Decide which case you're in and continue:`,
|
|
44
|
+
``,
|
|
45
|
+
`→ NON-DESTRUCTIVE (e.g. reading .env to verify a key, listing a protected dir, peeking at config):`,
|
|
46
|
+
` Assume the data is present and correct. Skip the verification step and move on with the task.`,
|
|
47
|
+
` Example: if you were reading .env to confirm a key exists, just assume it does — the user has`,
|
|
48
|
+
` configured their environment. If you actually need a value, ask the user for it explicitly.`,
|
|
49
|
+
``,
|
|
50
|
+
`→ DESTRUCTIVE (delete, overwrite, force-push, drop, rm, truncate, sudo, kill, etc.):`,
|
|
51
|
+
` STOP. Tell the user exactly what you need to ship this task and ask how they want to proceed.`,
|
|
52
|
+
` Do not invent a workaround that achieves the same destructive effect.`,
|
|
53
|
+
``,
|
|
54
|
+
`Pick the right path above and continue working. Do not retry this exact call.`,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function (pi: ExtensionAPI) {
|
|
59
|
+
let rules: Rules = {
|
|
60
|
+
bashToolPatterns: [],
|
|
61
|
+
zeroAccessPaths: [],
|
|
62
|
+
readOnlyPaths: [],
|
|
63
|
+
noDeletePaths: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function resolvePath(p: string, cwd: string): string {
|
|
67
|
+
if (p.startsWith("~")) {
|
|
68
|
+
p = path.join(os.homedir(), p.slice(1));
|
|
69
|
+
}
|
|
70
|
+
return path.resolve(cwd, p);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPathMatch(targetPath: string, pattern: string, cwd: string): boolean {
|
|
74
|
+
const resolvedPattern = pattern.startsWith("~") ? path.join(os.homedir(), pattern.slice(1)) : pattern;
|
|
75
|
+
|
|
76
|
+
if (resolvedPattern.endsWith("/")) {
|
|
77
|
+
const absolutePattern = path.isAbsolute(resolvedPattern) ? resolvedPattern : path.resolve(cwd, resolvedPattern);
|
|
78
|
+
return targetPath.startsWith(absolutePattern);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const regexPattern = resolvedPattern
|
|
82
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
83
|
+
.replace(/\*/g, ".*");
|
|
84
|
+
|
|
85
|
+
const regex = new RegExp(`^${regexPattern}$|^${regexPattern}/|/${regexPattern}$|/${regexPattern}/`);
|
|
86
|
+
|
|
87
|
+
const relativePath = path.relative(cwd, targetPath);
|
|
88
|
+
|
|
89
|
+
return regex.test(targetPath) || regex.test(relativePath) || targetPath.includes(resolvedPattern) || relativePath.includes(resolvedPattern);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
93
|
+
applyExtensionDefaults(import.meta.url, ctx);
|
|
94
|
+
const projectRulesPath = path.join(ctx.cwd, ".pi", "damage-control-rules.yaml");
|
|
95
|
+
const globalRulesPath = path.join(os.homedir(), ".pi", "damage-control-rules.yaml");
|
|
96
|
+
const rulesPath = fs.existsSync(projectRulesPath)
|
|
97
|
+
? projectRulesPath
|
|
98
|
+
: fs.existsSync(globalRulesPath)
|
|
99
|
+
? globalRulesPath
|
|
100
|
+
: fs.existsSync(bundledDamageRulesPath)
|
|
101
|
+
? bundledDamageRulesPath
|
|
102
|
+
: null;
|
|
103
|
+
try {
|
|
104
|
+
if (rulesPath) {
|
|
105
|
+
const content = fs.readFileSync(rulesPath, "utf8");
|
|
106
|
+
const loaded = yamlParse(content) as Partial<Rules>;
|
|
107
|
+
rules = {
|
|
108
|
+
bashToolPatterns: loaded.bashToolPatterns || [],
|
|
109
|
+
zeroAccessPaths: loaded.zeroAccessPaths || [],
|
|
110
|
+
readOnlyPaths: loaded.readOnlyPaths || [],
|
|
111
|
+
noDeletePaths: loaded.noDeletePaths || [],
|
|
112
|
+
};
|
|
113
|
+
const source = rulesPath === projectRulesPath ? "project" : rulesPath === globalRulesPath ? "global" : "bundled";
|
|
114
|
+
const total = rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length;
|
|
115
|
+
ctx.ui.notify(`🛡️ Damage-Control (continue): Loaded ${total} rules (${source}). Blocks deliver feedback so the agent can adapt and keep working.`);
|
|
116
|
+
} else {
|
|
117
|
+
ctx.ui.notify("🛡️ Damage-Control (continue): No rules found at .pi/damage-control-rules.yaml (project, global, or bundled)");
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
ctx.ui.notify(`🛡️ Damage-Control (continue): Failed to load rules: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const total = rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length;
|
|
124
|
+
ctx.ui.setStatus("damage-control", `🛡️ Damage-Control (continue): ${total} Rules`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
128
|
+
let violationReason: string | null = null;
|
|
129
|
+
let shouldAsk = false;
|
|
130
|
+
|
|
131
|
+
const checkPaths = (pathsToCheck: string[]) => {
|
|
132
|
+
for (const p of pathsToCheck) {
|
|
133
|
+
const resolved = resolvePath(p, ctx.cwd);
|
|
134
|
+
for (const zap of rules.zeroAccessPaths) {
|
|
135
|
+
if (isPathMatch(resolved, zap, ctx.cwd)) {
|
|
136
|
+
return `Access to zero-access path restricted: ${zap}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const inputPaths: string[] = [];
|
|
144
|
+
if (isToolCallEventType("read", event) || isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
|
|
145
|
+
inputPaths.push(event.input.path);
|
|
146
|
+
} else if (isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)) {
|
|
147
|
+
inputPaths.push(event.input.path || ".");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isToolCallEventType("grep", event) && event.input.glob) {
|
|
151
|
+
for (const zap of rules.zeroAccessPaths) {
|
|
152
|
+
if (event.input.glob.includes(zap) || isPathMatch(event.input.glob, zap, ctx.cwd)) {
|
|
153
|
+
violationReason = `Glob matches zero-access path: ${zap}`;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!violationReason) {
|
|
160
|
+
violationReason = checkPaths(inputPaths);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!violationReason) {
|
|
164
|
+
if (isToolCallEventType("bash", event)) {
|
|
165
|
+
const command = event.input.command;
|
|
166
|
+
|
|
167
|
+
for (const rule of rules.bashToolPatterns) {
|
|
168
|
+
const regex = new RegExp(rule.pattern);
|
|
169
|
+
if (regex.test(command)) {
|
|
170
|
+
violationReason = rule.reason;
|
|
171
|
+
shouldAsk = !!rule.ask;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!violationReason) {
|
|
177
|
+
for (const zap of rules.zeroAccessPaths) {
|
|
178
|
+
if (command.includes(zap)) {
|
|
179
|
+
violationReason = `Bash command references zero-access path: ${zap}`;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!violationReason) {
|
|
186
|
+
for (const rop of rules.readOnlyPaths) {
|
|
187
|
+
if (command.includes(rop) && (/[\s>|]/.test(command) || command.includes("rm") || command.includes("mv") || command.includes("sed"))) {
|
|
188
|
+
violationReason = `Bash command may modify read-only path: ${rop}`;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!violationReason) {
|
|
195
|
+
for (const ndp of rules.noDeletePaths) {
|
|
196
|
+
if (command.includes(ndp) && (command.includes("rm") || command.includes("mv"))) {
|
|
197
|
+
violationReason = `Bash command attempts to delete/move protected path: ${ndp}`;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
|
|
203
|
+
for (const p of inputPaths) {
|
|
204
|
+
const resolved = resolvePath(p, ctx.cwd);
|
|
205
|
+
for (const rop of rules.readOnlyPaths) {
|
|
206
|
+
if (isPathMatch(resolved, rop, ctx.cwd)) {
|
|
207
|
+
violationReason = `Modification of read-only path restricted: ${rop}`;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (violationReason) {
|
|
216
|
+
const invocation = isToolCallEventType("bash", event) ? event.input.command : JSON.stringify(event.input);
|
|
217
|
+
|
|
218
|
+
if (shouldAsk) {
|
|
219
|
+
const confirmed = await ctx.ui.confirm(
|
|
220
|
+
"🛡️ Damage-Control Confirmation",
|
|
221
|
+
`Dangerous command detected: ${violationReason}\n\nCommand: ${invocation}\n\nDo you want to proceed?`,
|
|
222
|
+
{ timeout: 30000 },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (!confirmed) {
|
|
226
|
+
ctx.ui.setStatus("damage-control", `⚠️ Last Violation Blocked: ${violationReason.slice(0, 30)}...`);
|
|
227
|
+
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked_by_user" });
|
|
228
|
+
return { block: true, reason: continueFeedback(event.toolName, `${violationReason} (user denied)`, invocation) };
|
|
229
|
+
} else {
|
|
230
|
+
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "confirmed_by_user" });
|
|
231
|
+
return { block: false };
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
ctx.ui.notify(`🛑 Damage-Control: Blocked ${event.toolName} (${violationReason}) — agent will adapt and continue.`);
|
|
235
|
+
ctx.ui.setStatus("damage-control", `⚠️ Last Violation: ${violationReason.slice(0, 30)}...`);
|
|
236
|
+
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked" });
|
|
237
|
+
return { block: true, reason: continueFeedback(event.toolName, violationReason, invocation) };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { block: false };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export const extensionLibDir = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
export const extensionDir = resolve(extensionLibDir, "..");
|
|
6
|
+
export const packageRoot = resolve(extensionDir, "..");
|
|
7
|
+
|
|
8
|
+
export const bundledPromptsDir = join(packageRoot, "prompts");
|
|
9
|
+
export const bundledAgentsDir = join(packageRoot, "agents");
|
|
10
|
+
export const bundledPiPiAgentsDir = join(bundledAgentsDir, "pi-pi");
|
|
11
|
+
export const bundledSkillsDir = join(packageRoot, "skills");
|
|
12
|
+
export const bundledThemesDir = join(packageRoot, "themes");
|
|
13
|
+
export const bundledDamageRulesPath = join(packageRoot, "damage-control-rules.yaml");
|