@matyah00/openpi 0.1.4 → 0.2.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/README.md +29 -11
- package/agents/agent-chain.yaml +50 -0
- package/agents/api-designer.md +58 -0
- package/agents/docs-writer.md +38 -0
- package/agents/migration-expert.md +62 -0
- package/agents/perf-auditor.md +64 -0
- package/agents/teams.yaml +23 -0
- package/damage-control-rules.yaml +153 -0
- package/extensions/agent-chain.ts +144 -57
- package/extensions/agent-team.ts +43 -28
- package/extensions/audit-tools.ts +125 -6
- package/extensions/damage-control-continue.ts +2 -2
- package/extensions/lib/auditLogger.ts +29 -0
- package/extensions/minimal.ts +3 -3
- package/extensions/openpi.ts +170 -22
- package/extensions/pure-focus.ts +1 -1
- package/extensions/purpose-gate.ts +2 -2
- package/extensions/search-tools.ts +21 -3
- package/extensions/theme-cycler.ts +2 -2
- package/extensions/themeMap.ts +1 -1
- package/extensions/tool-counter-widget.ts +2 -2
- package/extensions/tool-counter.ts +3 -3
- package/extensions/workflow.ts +77 -5
- package/package.json +14 -3
- package/prompts/docs.md +37 -0
- package/prompts/migrate.md +44 -0
- package/prompts/perf.md +52 -0
- package/prompts/refactor.md +53 -0
- package/scripts/validate-package.mjs +127 -0
- package/skills/perf-auditor/SKILL.md +49 -0
- package/skills/refactor-guide/SKILL.md +39 -0
- package/tsconfig.json +2 -1
- package/types/pi-shims.d.ts +0 -48
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
2
|
-
import { Type } from "
|
|
3
|
-
import { Text } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
6
|
+
import { tmpdir } from "os";
|
|
6
7
|
import { join, resolve } from "path";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
7
9
|
import { bundledAgentsDir } from "./lib/packagePaths.ts";
|
|
10
|
+
import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
|
|
11
|
+
import { writeAuditLog } from "./lib/auditLogger.ts";
|
|
8
12
|
|
|
9
|
-
type ChainStep = { agent: string; prompt: string };
|
|
10
|
-
type ChainDef = { name: string; description: string; steps: ChainStep[] };
|
|
13
|
+
type ChainStep = { agent: string; prompt: string; timeout?: number };
|
|
14
|
+
type ChainDef = { name: string; description: string; steps: ChainStep[]; continueOnError?: boolean };
|
|
11
15
|
type AgentDef = { name: string; description: string; tools: string; systemPrompt: string };
|
|
12
16
|
type StepState = { agent: string; status: "pending" | "running" | "done" | "error"; elapsed: number; lastWork: string };
|
|
13
17
|
|
|
@@ -16,57 +20,53 @@ function displayName(name: string): string {
|
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
function parseChainYaml(raw: string): ChainDef[] {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (promptMatch && currentStep) {
|
|
45
|
-
currentStep.prompt = promptMatch[1].trim().replace(/^["']|["']$/g, "").replace(/\\n/g, "\n");
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (current && currentStep) current.steps.push(currentStep);
|
|
49
|
-
return chains;
|
|
23
|
+
const parsed = parseYaml(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
25
|
+
|
|
26
|
+
return Object.entries(parsed as Record<string, unknown>).flatMap(([name, value]) => {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
28
|
+
const chain = value as { description?: unknown; steps?: unknown; continueOnError?: unknown };
|
|
29
|
+
if (!Array.isArray(chain.steps)) return [];
|
|
30
|
+
const steps = chain.steps.flatMap((step): ChainStep[] => {
|
|
31
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
|
|
32
|
+
const item = step as { agent?: unknown; prompt?: unknown; timeout?: unknown };
|
|
33
|
+
if (typeof item.agent !== "string" || typeof item.prompt !== "string") return [];
|
|
34
|
+
return [{
|
|
35
|
+
agent: item.agent,
|
|
36
|
+
prompt: item.prompt,
|
|
37
|
+
timeout: typeof item.timeout === "number" ? item.timeout : undefined,
|
|
38
|
+
}];
|
|
39
|
+
});
|
|
40
|
+
if (!steps.length) return [];
|
|
41
|
+
return [{
|
|
42
|
+
name,
|
|
43
|
+
description: typeof chain.description === "string" ? chain.description : "",
|
|
44
|
+
steps,
|
|
45
|
+
continueOnError: typeof chain.continueOnError === "boolean" ? chain.continueOnError : undefined,
|
|
46
|
+
}];
|
|
47
|
+
});
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
function parseAgentFile(filePath: string): AgentDef | null {
|
|
53
51
|
const raw = readFileSync(filePath, "utf-8");
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const line of match[1].split("\n")) {
|
|
58
|
-
const idx = line.indexOf(":");
|
|
59
|
-
if (idx > 0) frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
60
|
-
}
|
|
61
|
-
if (!frontmatter.name) return null;
|
|
52
|
+
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
|
|
53
|
+
const name = stringField(frontmatter.name);
|
|
54
|
+
if (!name) return null;
|
|
62
55
|
return {
|
|
63
|
-
name
|
|
64
|
-
description: frontmatter.description
|
|
65
|
-
tools: frontmatter.tools || "read,grep,find,ls",
|
|
66
|
-
systemPrompt:
|
|
56
|
+
name,
|
|
57
|
+
description: stringField(frontmatter.description),
|
|
58
|
+
tools: arrayField(frontmatter.tools).join(",") || "read,grep,find,ls",
|
|
59
|
+
systemPrompt: body,
|
|
67
60
|
};
|
|
68
61
|
}
|
|
69
62
|
|
|
63
|
+
function writeSystemPromptFile(agentName: string, systemPrompt: string): { dir: string; filePath: string } {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "openpi-chain-"));
|
|
65
|
+
const filePath = join(dir, `system-${agentName.replace(/[^\w.-]+/g, "_")}.md`);
|
|
66
|
+
writeFileSync(filePath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
67
|
+
return { dir, filePath };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
70
|
function scanAgentDirs(cwd: string): Map<string, AgentDef> {
|
|
71
71
|
const dirs = [join(cwd, ".pi", "agents"), join(cwd, "agents"), bundledAgentsDir];
|
|
72
72
|
const agents = new Map<string, AgentDef>();
|
|
@@ -121,17 +121,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
121
121
|
}));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
function runAgent(
|
|
124
|
+
function runAgent(
|
|
125
|
+
agentDef: AgentDef,
|
|
126
|
+
task: string,
|
|
127
|
+
stepIndex: number,
|
|
128
|
+
timeoutSecs: number | undefined,
|
|
129
|
+
ctx: any,
|
|
130
|
+
): Promise<{ output: string; exitCode: number; elapsed: number }> {
|
|
125
131
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
126
132
|
const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-");
|
|
127
133
|
const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`);
|
|
134
|
+
const systemPrompt = writeSystemPromptFile(agentKey, agentDef.systemPrompt);
|
|
128
135
|
const args = [
|
|
129
136
|
"--mode", "json",
|
|
130
137
|
"-p",
|
|
131
138
|
"--no-extensions",
|
|
132
139
|
"--tools", agentDef.tools,
|
|
133
140
|
"--thinking", "off",
|
|
134
|
-
"--append-system-prompt",
|
|
141
|
+
"--append-system-prompt", systemPrompt.filePath,
|
|
135
142
|
"--session", agentSessionFile,
|
|
136
143
|
];
|
|
137
144
|
if (model) args.splice(4, 0, "--model", model);
|
|
@@ -142,8 +149,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
142
149
|
const start = Date.now();
|
|
143
150
|
const chunks: string[] = [];
|
|
144
151
|
|
|
152
|
+
const timeoutMs = timeoutSecs ? timeoutSecs * 1000 : 120000;
|
|
153
|
+
|
|
145
154
|
return new Promise((resolveDone) => {
|
|
155
|
+
const cleanupPrompt = () => rmSync(systemPrompt.dir, { recursive: true, force: true });
|
|
146
156
|
const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
|
|
157
|
+
|
|
158
|
+
let killed = false;
|
|
159
|
+
const killTimer = setTimeout(() => {
|
|
160
|
+
killed = true;
|
|
161
|
+
proc.kill("SIGTERM");
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
if (proc.exitCode === null) proc.kill("SIGKILL");
|
|
164
|
+
}, 2000);
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
|
|
147
167
|
const timer = setInterval(() => {
|
|
148
168
|
state.elapsed = Date.now() - start;
|
|
149
169
|
updateWidget();
|
|
@@ -170,13 +190,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
190
|
proc.stderr!.setEncoding("utf-8");
|
|
171
191
|
proc.stderr!.on("data", () => {});
|
|
172
192
|
proc.on("close", (code) => {
|
|
193
|
+
clearTimeout(killTimer);
|
|
173
194
|
clearInterval(timer);
|
|
195
|
+
cleanupPrompt();
|
|
174
196
|
state.elapsed = Date.now() - start;
|
|
175
197
|
if (code === 0) agentSessions.set(agentKey, agentSessionFile);
|
|
176
|
-
|
|
198
|
+
const exitCode = killed ? 124 : (code ?? 1);
|
|
199
|
+
const finalOutput = killed ? `${chunks.join("")}\n\n[Agent timeout after ${timeoutSecs}s]` : chunks.join("");
|
|
200
|
+
resolveDone({ output: finalOutput, exitCode, elapsed: state.elapsed });
|
|
177
201
|
});
|
|
178
202
|
proc.on("error", (error) => {
|
|
203
|
+
clearTimeout(killTimer);
|
|
179
204
|
clearInterval(timer);
|
|
205
|
+
cleanupPrompt();
|
|
180
206
|
resolveDone({ output: `Error spawning agent: ${error.message}`, exitCode: 1, elapsed: Date.now() - start });
|
|
181
207
|
});
|
|
182
208
|
});
|
|
@@ -190,28 +216,89 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
216
|
|
|
191
217
|
let input = task;
|
|
192
218
|
const original = task;
|
|
219
|
+
const outputs: string[] = [];
|
|
220
|
+
|
|
193
221
|
for (let i = 0; i < activeChain.steps.length; i++) {
|
|
194
222
|
const step = activeChain.steps[i];
|
|
195
223
|
stepStates[i].status = "running";
|
|
196
224
|
updateWidget();
|
|
197
225
|
|
|
198
|
-
|
|
226
|
+
let prompt = step.prompt.replace(/\$INPUT/g, input).replace(/\$ORIGINAL/g, original);
|
|
227
|
+
// Replace $STEP_N references
|
|
228
|
+
prompt = prompt.replace(/\$STEP_(\d+)/g, (match, numStr) => {
|
|
229
|
+
const num = parseInt(numStr, 10);
|
|
230
|
+
if (num > 0 && num <= outputs.length) {
|
|
231
|
+
return outputs[num - 1];
|
|
232
|
+
}
|
|
233
|
+
return match;
|
|
234
|
+
});
|
|
235
|
+
|
|
199
236
|
const agentDef = allAgents.get(step.agent.toLowerCase());
|
|
200
237
|
if (!agentDef) {
|
|
201
238
|
stepStates[i].status = "error";
|
|
202
|
-
|
|
239
|
+
const errResult = { output: `Agent "${step.agent}" not found`, success: false, elapsed: Date.now() - start };
|
|
240
|
+
writeAuditLog(ctx.cwd, {
|
|
241
|
+
type: "chain_run",
|
|
242
|
+
name: activeChain.name,
|
|
243
|
+
task,
|
|
244
|
+
input: task,
|
|
245
|
+
output: errResult.output,
|
|
246
|
+
exitCode: 1,
|
|
247
|
+
elapsedMs: errResult.elapsed,
|
|
248
|
+
metadata: { stepsCount: activeChain.steps.length, success: false, error: "agent_not_found" }
|
|
249
|
+
});
|
|
250
|
+
return errResult;
|
|
203
251
|
}
|
|
204
252
|
|
|
205
|
-
const result = await runAgent(agentDef, prompt, i, ctx);
|
|
253
|
+
const result = await runAgent(agentDef, prompt, i, step.timeout, ctx);
|
|
254
|
+
writeAuditLog(ctx.cwd, {
|
|
255
|
+
type: "chain_step",
|
|
256
|
+
name: `${activeChain.name}:${step.agent}`,
|
|
257
|
+
task: prompt,
|
|
258
|
+
input: input,
|
|
259
|
+
output: result.output,
|
|
260
|
+
exitCode: result.exitCode,
|
|
261
|
+
elapsedMs: result.elapsed,
|
|
262
|
+
metadata: { stepIndex: i, chainName: activeChain.name, agent: step.agent }
|
|
263
|
+
});
|
|
264
|
+
|
|
206
265
|
if (result.exitCode !== 0) {
|
|
207
266
|
stepStates[i].status = "error";
|
|
208
|
-
|
|
267
|
+
if (activeChain.continueOnError) {
|
|
268
|
+
outputs.push("");
|
|
269
|
+
updateWidget();
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const errResult = { output: result.output, success: false, elapsed: Date.now() - start };
|
|
273
|
+
writeAuditLog(ctx.cwd, {
|
|
274
|
+
type: "chain_run",
|
|
275
|
+
name: activeChain.name,
|
|
276
|
+
task,
|
|
277
|
+
input: task,
|
|
278
|
+
output: errResult.output,
|
|
279
|
+
exitCode: 1,
|
|
280
|
+
elapsedMs: errResult.elapsed,
|
|
281
|
+
metadata: { stepsCount: activeChain.steps.length, success: false, error: "step_failed", failedStep: step.agent }
|
|
282
|
+
});
|
|
283
|
+
return errResult;
|
|
209
284
|
}
|
|
210
285
|
stepStates[i].status = "done";
|
|
211
286
|
input = result.output;
|
|
287
|
+
outputs.push(result.output);
|
|
212
288
|
updateWidget();
|
|
213
289
|
}
|
|
214
|
-
|
|
290
|
+
const finalResult = { output: input, success: true, elapsed: Date.now() - start };
|
|
291
|
+
writeAuditLog(ctx.cwd, {
|
|
292
|
+
type: "chain_run",
|
|
293
|
+
name: activeChain.name,
|
|
294
|
+
task,
|
|
295
|
+
input: task,
|
|
296
|
+
output: finalResult.output,
|
|
297
|
+
exitCode: 0,
|
|
298
|
+
elapsedMs: finalResult.elapsed,
|
|
299
|
+
metadata: { stepsCount: activeChain.steps.length, success: true }
|
|
300
|
+
});
|
|
301
|
+
return finalResult;
|
|
215
302
|
}
|
|
216
303
|
|
|
217
304
|
pi.registerTool({
|
package/extensions/agent-team.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
2
|
-
import { Type } from "
|
|
3
|
-
import { Text } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
6
|
+
import { tmpdir } from "os";
|
|
6
7
|
import { join, resolve } from "path";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
7
9
|
import { bundledAgentsDir } from "./lib/packagePaths.ts";
|
|
10
|
+
import { arrayField, parseMarkdownFrontmatter, stringField } from "./lib/markdown.ts";
|
|
11
|
+
import { writeAuditLog } from "./lib/auditLogger.ts";
|
|
8
12
|
|
|
9
13
|
type AgentDef = {
|
|
10
14
|
name: string;
|
|
@@ -28,42 +32,39 @@ function displayName(name: string): string {
|
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function parseTeamsYaml(raw: string): Record<string, string[]> {
|
|
35
|
+
const parsed = parseYaml(raw);
|
|
36
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
37
|
+
|
|
31
38
|
const teams: Record<string, string[]> = {};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (teamMatch) {
|
|
36
|
-
current = teamMatch[1].trim();
|
|
37
|
-
teams[current] = [];
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
|
41
|
-
if (itemMatch && current) teams[current].push(itemMatch[1].trim());
|
|
39
|
+
for (const [name, members] of Object.entries(parsed as Record<string, unknown>)) {
|
|
40
|
+
if (!Array.isArray(members)) continue;
|
|
41
|
+
teams[name] = members.filter((member): member is string => typeof member === "string");
|
|
42
42
|
}
|
|
43
43
|
return teams;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function parseAgentFile(filePath: string): AgentDef | null {
|
|
47
47
|
const raw = readFileSync(filePath, "utf-8");
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const frontmatter: Record<string, string> = {};
|
|
52
|
-
for (const line of match[1].split("\n")) {
|
|
53
|
-
const idx = line.indexOf(":");
|
|
54
|
-
if (idx > 0) frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
55
|
-
}
|
|
56
|
-
if (!frontmatter.name) return null;
|
|
48
|
+
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
|
|
49
|
+
const name = stringField(frontmatter.name);
|
|
50
|
+
if (!name) return null;
|
|
57
51
|
|
|
58
52
|
return {
|
|
59
|
-
name
|
|
60
|
-
description: frontmatter.description
|
|
61
|
-
tools: frontmatter.tools || "read,grep,find,ls",
|
|
62
|
-
systemPrompt:
|
|
53
|
+
name,
|
|
54
|
+
description: stringField(frontmatter.description),
|
|
55
|
+
tools: arrayField(frontmatter.tools).join(",") || "read,grep,find,ls",
|
|
56
|
+
systemPrompt: body,
|
|
63
57
|
file: filePath,
|
|
64
58
|
};
|
|
65
59
|
}
|
|
66
60
|
|
|
61
|
+
function writeSystemPromptFile(agentKey: string, systemPrompt: string): { dir: string; filePath: string } {
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "openpi-team-"));
|
|
63
|
+
const filePath = join(dir, `system-${agentKey.replace(/[^\w.-]+/g, "_")}.md`);
|
|
64
|
+
writeFileSync(filePath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
65
|
+
return { dir, filePath };
|
|
66
|
+
}
|
|
67
|
+
|
|
67
68
|
function scanAgentDirs(cwd: string): AgentDef[] {
|
|
68
69
|
const dirs = [join(cwd, ".pi", "agents"), join(cwd, "agents"), bundledAgentsDir];
|
|
69
70
|
const agents: AgentDef[] = [];
|
|
@@ -161,13 +162,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
162
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
162
163
|
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
|
|
163
164
|
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
|
|
165
|
+
const systemPrompt = writeSystemPromptFile(agentKey, state.def.systemPrompt);
|
|
164
166
|
const args = [
|
|
165
167
|
"--mode", "json",
|
|
166
168
|
"-p",
|
|
167
169
|
"--no-extensions",
|
|
168
170
|
"--tools", state.def.tools,
|
|
169
171
|
"--thinking", "off",
|
|
170
|
-
"--append-system-prompt",
|
|
172
|
+
"--append-system-prompt", systemPrompt.filePath,
|
|
171
173
|
"--session", agentSessionFile,
|
|
172
174
|
];
|
|
173
175
|
if (model) args.splice(4, 0, "--model", model);
|
|
@@ -178,6 +180,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
180
|
const chunks: string[] = [];
|
|
179
181
|
|
|
180
182
|
return new Promise((resolveDone) => {
|
|
183
|
+
const cleanupPrompt = () => rmSync(systemPrompt.dir, { recursive: true, force: true });
|
|
181
184
|
const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
|
|
182
185
|
const timer = setInterval(() => {
|
|
183
186
|
state.elapsed = Date.now() - start;
|
|
@@ -206,6 +209,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
209
|
proc.stderr!.on("data", () => {});
|
|
207
210
|
proc.on("close", (code) => {
|
|
208
211
|
clearInterval(timer);
|
|
212
|
+
cleanupPrompt();
|
|
209
213
|
state.elapsed = Date.now() - start;
|
|
210
214
|
state.status = code === 0 ? "done" : "error";
|
|
211
215
|
if (code === 0) state.sessionFile = agentSessionFile;
|
|
@@ -214,6 +218,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
218
|
});
|
|
215
219
|
proc.on("error", (error) => {
|
|
216
220
|
clearInterval(timer);
|
|
221
|
+
cleanupPrompt();
|
|
217
222
|
state.status = "error";
|
|
218
223
|
state.lastWork = error.message;
|
|
219
224
|
updateWidget();
|
|
@@ -234,6 +239,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
239
|
const { agent, task } = params as { agent: string; task: string };
|
|
235
240
|
onUpdate?.({ content: [{ type: "text", text: `Dispatching to ${agent}...` }], details: { agent, task, status: "running" } });
|
|
236
241
|
const result = await dispatchAgent(agent, task, ctx);
|
|
242
|
+
writeAuditLog(ctx.cwd, {
|
|
243
|
+
type: "team_agent",
|
|
244
|
+
name: agent,
|
|
245
|
+
task: task,
|
|
246
|
+
input: task,
|
|
247
|
+
output: result.output,
|
|
248
|
+
exitCode: result.exitCode,
|
|
249
|
+
elapsedMs: result.elapsed,
|
|
250
|
+
metadata: { teamName: activeTeamName }
|
|
251
|
+
});
|
|
237
252
|
const text = result.output.length > 8000 ? `${result.output.slice(0, 8000)}\n\n... [truncated]` : result.output;
|
|
238
253
|
return {
|
|
239
254
|
content: [{ type: "text", text: `[${agent}] ${result.exitCode === 0 ? "done" : "error"} in ${Math.round(result.elapsed / 1000)}s\n\n${text}` }],
|
|
@@ -31,6 +31,19 @@ const SECRET_PATTERNS = [
|
|
|
31
31
|
["sendgrid-key", /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g],
|
|
32
32
|
["private-key", /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g],
|
|
33
33
|
["jwt-like-token", /eyJ[A-Za-z0-9_-]{40,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
|
|
34
|
+
// New patterns
|
|
35
|
+
["supabase-key", /sbp_[a-f0-9]{40}/g],
|
|
36
|
+
["twilio-sid", /AC[a-f0-9]{32}/g],
|
|
37
|
+
["twilio-auth", /SK[a-f0-9]{32}/g],
|
|
38
|
+
["mailgun-key", /key-[a-zA-Z0-9]{32}/g],
|
|
39
|
+
["slack-webhook", /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[a-zA-Z0-9]+/g],
|
|
40
|
+
["digitalocean-token", /dop_v1_[a-f0-9]{64}/g],
|
|
41
|
+
["databricks-token", /dapi[a-f0-9]{32}/g],
|
|
42
|
+
["openai-key", /sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}/g],
|
|
43
|
+
["anthropic-key", /sk-ant-[a-zA-Z0-9_-]{90,}/g],
|
|
44
|
+
["azure-connection-string", /DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[^;]+/g],
|
|
45
|
+
["vercel-token", /vercel_[A-Za-z0-9_-]{24,}/g],
|
|
46
|
+
["hashicorp-vault-token", /hvs\.[A-Za-z0-9_-]{24,}/g],
|
|
34
47
|
] as const;
|
|
35
48
|
|
|
36
49
|
const GHOST_PATTERNS = [
|
|
@@ -38,6 +51,30 @@ const GHOST_PATTERNS = [
|
|
|
38
51
|
["exit-bypass", /sys\.exit\s*\(\s*0\s*\)|os\._exit\s*\(\s*0\s*\)|process\.exit\s*\(\s*0\s*\)/g],
|
|
39
52
|
["pytest-report-patch", /TestReport\.from_item_and_call|pytest_runtest_makereport|monkeypatch.*TestReport/g],
|
|
40
53
|
["assertion-monkeypatch", /expect\s*=\s*jest\.fn|assert\s*=\s*lambda|monkeypatch.*assert/g],
|
|
54
|
+
// New patterns
|
|
55
|
+
["empty-test-body", /it\s*\(\s*['"][^'"]*['"]\s*,\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)|def\s+test_\w+\s*\([^)]*\)\s*:\s*\n\s*pass\b/g],
|
|
56
|
+
["catch-all-swallow", /catch\s*\([^)]*\)\s*\{\s*\}|except\s*:\s*\n\s*pass\b/g],
|
|
57
|
+
["self-comparison", /expect\s*\(\s*(\w+)\s*\)\s*\.toBe\s*\(\s*\1\s*\)|assert\s+(\w+)\s*==\s*\2\b/g],
|
|
58
|
+
["disabled-tests", /\bxit\s*\(|\bxdescribe\s*\(|\bxtest\s*\(|@pytest\.mark\.skip(?!\(reason)|\.skip\s*\(\s*\)/g],
|
|
59
|
+
["console-only-validation", /(?:it|test)\s*\([^)]*\)\s*(?:=>|{)[\s\S]{0,200}console\.\w+[\s\S]{0,50}(?:\}|\))\s*(?:;|\n)(?![\s\S]{0,50}(?:expect|assert|should))/g],
|
|
60
|
+
["unreachable-assertion", /\breturn\b[\s\S]{0,20}\n\s*(?:expect|assert)\b/g],
|
|
61
|
+
["mock-everything", /jest\.mock\s*\(\s*['"]\.\.?\//g],
|
|
62
|
+
["timeout-zero", /setTimeout\s*\(\s*(?:done|resolve|callback)\s*,\s*0\s*\)/g],
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
const SAST_PATTERNS = [
|
|
66
|
+
["eval-usage", /\beval\s*\(/g],
|
|
67
|
+
["function-constructor", /new\s+Function\s*\(/g],
|
|
68
|
+
["child-process-exec", /child_process.*\.exec\s*\(|exec\s*\(\s*`/g],
|
|
69
|
+
["sql-concat", /['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,80}\$\{|['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,80}\+\s*\w/g],
|
|
70
|
+
["innerhtml-assign", /\.innerHTML\s*=\s*(?!['"`]<)/g],
|
|
71
|
+
["dangerous-react", /dangerouslySetInnerHTML/g],
|
|
72
|
+
["path-traversal", /\.\.\/|\.\.\\|req\.\w+\.\w+.*(?:readFile|writeFile|createReadStream|path\.join|path\.resolve)/g],
|
|
73
|
+
["prototype-pollution", /\[['"`]__proto__['"`]\]|\[['"`]constructor['"`]\]|\[['"`]prototype['"`]\]/g],
|
|
74
|
+
["ssrf-pattern", /fetch\s*\(\s*(?:req\.|request\.|params\.|query\.)|axios\.\w+\s*\(\s*(?:req\.|request\.|params\.|query\.)/g],
|
|
75
|
+
["hardcoded-secret-assign", /(?:password|secret|apiKey|api_key|token|auth)\s*[:=]\s*['"][^'"]{8,}['"]/g],
|
|
76
|
+
["unvalidated-redirect", /res\.redirect\s*\(\s*req\.|response\.redirect\s*\(\s*request\./g],
|
|
77
|
+
["cors-wildcard", /(?:Access-Control-Allow-Origin|origin)\s*[:=]\s*['"]\*['"]/g],
|
|
41
78
|
] as const;
|
|
42
79
|
|
|
43
80
|
function walkFiles(root: string, maxFiles: number): string[] {
|
|
@@ -103,17 +140,64 @@ function detectFrameworks(root: string): string[] {
|
|
|
103
140
|
if (deps.react) found.add("React");
|
|
104
141
|
if (deps.vite) found.add("Vite");
|
|
105
142
|
if (deps["@sveltejs/kit"]) found.add("SvelteKit");
|
|
143
|
+
if (deps.svelte) found.add("Svelte");
|
|
106
144
|
if (deps.vue) found.add("Vue");
|
|
107
145
|
if (deps.express) found.add("Express");
|
|
108
146
|
if (deps.vitest) found.add("Vitest");
|
|
109
147
|
if (deps.jest) found.add("Jest");
|
|
110
148
|
if (deps.typescript) found.add("TypeScript");
|
|
149
|
+
// New framework detections
|
|
150
|
+
if (deps.astro) found.add("Astro");
|
|
151
|
+
if (deps["@remix-run/node"] || deps["@remix-run/react"]) found.add("Remix");
|
|
152
|
+
if (deps.nuxt) found.add("Nuxt");
|
|
153
|
+
if (deps["@angular/core"]) found.add("Angular");
|
|
154
|
+
if (deps["@nestjs/core"]) found.add("NestJS");
|
|
155
|
+
if (deps.fastify) found.add("Fastify");
|
|
156
|
+
if (deps.hono) found.add("Hono");
|
|
157
|
+
if (deps.elysia) found.add("Elysia");
|
|
158
|
+
if (deps.prisma || deps["@prisma/client"]) found.add("Prisma");
|
|
159
|
+
if (deps["drizzle-orm"]) found.add("Drizzle");
|
|
160
|
+
if (deps["@trpc/server"]) found.add("tRPC");
|
|
161
|
+
if (deps.graphql || deps["@apollo/server"]) found.add("GraphQL");
|
|
162
|
+
if (deps.tailwindcss) found.add("Tailwind CSS");
|
|
163
|
+
if (deps.playwright || deps["@playwright/test"]) found.add("Playwright");
|
|
164
|
+
if (deps.cypress) found.add("Cypress");
|
|
165
|
+
if (deps.storybook || deps["@storybook/react"]) found.add("Storybook");
|
|
166
|
+
if (deps.turborepo || deps.turbo) found.add("Turborepo");
|
|
167
|
+
if (deps.electron) found.add("Electron");
|
|
168
|
+
if (deps["react-native"]) found.add("React Native");
|
|
169
|
+
if (deps.expo) found.add("Expo");
|
|
111
170
|
}
|
|
112
171
|
}
|
|
113
|
-
if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "requirements.txt")))
|
|
172
|
+
if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "requirements.txt"))) {
|
|
173
|
+
found.add("Python");
|
|
174
|
+
const pyReqs = readSmallText(path.join(root, "requirements.txt"));
|
|
175
|
+
const pyProject = readSmallText(path.join(root, "pyproject.toml"));
|
|
176
|
+
const pyContent = (pyReqs || "") + (pyProject || "");
|
|
177
|
+
if (pyContent.includes("fastapi") || pyContent.includes("FastAPI")) found.add("FastAPI");
|
|
178
|
+
if (pyContent.includes("django") || pyContent.includes("Django")) found.add("Django");
|
|
179
|
+
if (pyContent.includes("flask") || pyContent.includes("Flask")) found.add("Flask");
|
|
180
|
+
if (pyContent.includes("pytest")) found.add("pytest");
|
|
181
|
+
}
|
|
114
182
|
if (fs.existsSync(path.join(root, "Cargo.toml"))) found.add("Rust");
|
|
115
183
|
if (fs.existsSync(path.join(root, "go.mod"))) found.add("Go");
|
|
116
184
|
if (fs.existsSync(path.join(root, "Dockerfile"))) found.add("Docker");
|
|
185
|
+
if (fs.existsSync(path.join(root, "Gemfile"))) {
|
|
186
|
+
found.add("Ruby");
|
|
187
|
+
const gemfile = readSmallText(path.join(root, "Gemfile"));
|
|
188
|
+
if (gemfile?.includes("rails")) found.add("Rails");
|
|
189
|
+
}
|
|
190
|
+
if (fs.existsSync(path.join(root, "mix.exs"))) {
|
|
191
|
+
found.add("Elixir");
|
|
192
|
+
const mix = readSmallText(path.join(root, "mix.exs"));
|
|
193
|
+
if (mix?.includes("phoenix")) found.add("Phoenix");
|
|
194
|
+
}
|
|
195
|
+
if (fs.existsSync(path.join(root, "pom.xml")) || fs.existsSync(path.join(root, "build.gradle"))) {
|
|
196
|
+
found.add("Java");
|
|
197
|
+
const pom = readSmallText(path.join(root, "pom.xml")) || "";
|
|
198
|
+
const gradle = readSmallText(path.join(root, "build.gradle")) || "";
|
|
199
|
+
if (pom.includes("spring-boot") || gradle.includes("spring-boot")) found.add("Spring Boot");
|
|
200
|
+
}
|
|
117
201
|
return Array.from(found);
|
|
118
202
|
}
|
|
119
203
|
|
|
@@ -131,7 +215,7 @@ function dependencyInventory(root: string): string {
|
|
|
131
215
|
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
216
|
}
|
|
133
217
|
}
|
|
134
|
-
for (const file of ["requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile"]) {
|
|
218
|
+
for (const file of ["requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile", "mix.exs", "pom.xml", "build.gradle"]) {
|
|
135
219
|
if (fs.existsSync(path.join(root, file))) sections.push(`Manifest: ${file}`);
|
|
136
220
|
}
|
|
137
221
|
return sections.join("\n") || "No common dependency manifests found.";
|
|
@@ -179,7 +263,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
|
179
263
|
pi.registerTool({
|
|
180
264
|
name: "secret_scan",
|
|
181
265
|
label: "Secret Scan",
|
|
182
|
-
description: "Read-only high-signal secret scan with redacted findings.",
|
|
266
|
+
description: "Read-only high-signal secret scan with redacted findings. Detects 22 secret patterns including cloud provider keys, API tokens, and private keys.",
|
|
183
267
|
promptSnippet: "Use secret_scan before shipping, importing external files, or touching credentials.",
|
|
184
268
|
parameters: Type.Object({
|
|
185
269
|
maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 5000." })),
|
|
@@ -202,7 +286,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
|
202
286
|
const envGitignored = !fs.existsSync(path.join(ctx.cwd, ".env")) || (readSmallText(path.join(ctx.cwd, ".gitignore")) || "").includes(".env");
|
|
203
287
|
const verdict = findings.length ? "BLOCKED" : "CLEAN";
|
|
204
288
|
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") }],
|
|
289
|
+
content: [{ type: "text", text: [`secret_scan verdict: ${verdict}`, `patterns: ${SECRET_PATTERNS.length}`, `files scanned: ${files.length}`, `.env gitignored: ${envGitignored ? "yes" : "no"}`, "", ...(findings.length ? findings : ["No high-signal secrets found."])].join("\n") }],
|
|
206
290
|
details: { findings },
|
|
207
291
|
};
|
|
208
292
|
},
|
|
@@ -214,7 +298,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
|
214
298
|
pi.registerTool({
|
|
215
299
|
name: "ghost_test_scan",
|
|
216
300
|
label: "Ghost Test Scan",
|
|
217
|
-
description: "Static scan for test-suite reward hacking patterns
|
|
301
|
+
description: "Static scan for test-suite reward hacking patterns: always-true equality, exit bypasses, empty test bodies, disabled tests, self-comparisons, and framework patching.",
|
|
218
302
|
promptSnippet: "Use ghost_test_scan when validating test integrity before trusting green tests.",
|
|
219
303
|
parameters: Type.Object({
|
|
220
304
|
maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 3000." })),
|
|
@@ -235,7 +319,7 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
|
235
319
|
}
|
|
236
320
|
}
|
|
237
321
|
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") }],
|
|
322
|
+
content: [{ type: "text", text: [`ghost_test_scan verdict: ${findings.length ? "SUSPICIOUS" : "CLEAN"}`, `patterns: ${GHOST_PATTERNS.length}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No static reward-hack patterns found."])].join("\n") }],
|
|
239
323
|
details: { findings },
|
|
240
324
|
};
|
|
241
325
|
},
|
|
@@ -257,4 +341,39 @@ export default function auditToolsExtension(pi: ExtensionAPI) {
|
|
|
257
341
|
return new Text(theme.fg("toolTitle", theme.bold("dependency_inventory")), 0, 0);
|
|
258
342
|
},
|
|
259
343
|
});
|
|
344
|
+
|
|
345
|
+
pi.registerTool({
|
|
346
|
+
name: "sast_scan",
|
|
347
|
+
label: "SAST Scan",
|
|
348
|
+
description: "Static application security testing: detects eval(), SQL injection, prototype pollution, SSRF, XSS, unvalidated redirects, CORS wildcards, and hardcoded secrets in code.",
|
|
349
|
+
promptSnippet: "Use sast_scan to detect dangerous code patterns before shipping or reviewing external contributions.",
|
|
350
|
+
parameters: Type.Object({
|
|
351
|
+
maxFiles: Type.Optional(Type.Number({ description: "Maximum files to scan. Default 5000." })),
|
|
352
|
+
}),
|
|
353
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
354
|
+
const maxFiles = Math.max(100, Math.min(Number(params.maxFiles ?? 5000), 20000));
|
|
355
|
+
const codeExts = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|php)$/i;
|
|
356
|
+
const files = walkFiles(ctx.cwd, maxFiles).filter((file) => codeExts.test(file));
|
|
357
|
+
const findings: string[] = [];
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
const content = readSmallText(file);
|
|
360
|
+
if (!content) continue;
|
|
361
|
+
for (const [rule, pattern] of SAST_PATTERNS) {
|
|
362
|
+
pattern.lastIndex = 0;
|
|
363
|
+
let match: RegExpExecArray | null;
|
|
364
|
+
while ((match = pattern.exec(content))) {
|
|
365
|
+
findings.push(`${relative(ctx.cwd, file)}:${lineOf(content, match.index)} ${rule}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const severity = findings.some((f) => /eval-usage|sql-concat|child-process-exec|prototype-pollution/.test(f)) ? "HIGH" : findings.length ? "MEDIUM" : "CLEAN";
|
|
370
|
+
return {
|
|
371
|
+
content: [{ type: "text", text: [`sast_scan verdict: ${severity}`, `patterns: ${SAST_PATTERNS.length}`, `files scanned: ${files.length}`, "", ...(findings.length ? findings : ["No dangerous code patterns found."])].join("\n") }],
|
|
372
|
+
details: { findings, severity },
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
renderCall(_args, theme) {
|
|
376
|
+
return new Text(theme.fg("toolTitle", theme.bold("sast_scan")), 0, 0);
|
|
377
|
+
},
|
|
378
|
+
});
|
|
260
379
|
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Usage: pi -e extensions/damage-control-continue.ts
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { ExtensionAPI } from "@
|
|
16
|
-
import { isToolCallEventType } from "@
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { parse as yamlParse } from "yaml";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|