@diegopetrucci/pi-extensions 0.1.76 → 0.1.79
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 +8 -8
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +275 -59
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/git.ts +0 -8
- package/extensions/annotate-git-diff/glimpseui.d.ts +5 -65
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +5 -65
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/web/index.html +3 -30
- package/extensions/code-reviewer/README.md +5 -5
- package/extensions/code-reviewer/index.ts +252 -82
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/index.ts +2 -8
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +3 -3
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +2 -46
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +48 -67
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +0 -4
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +20 -11
- package/extensions/dynamic-context-pruning/index.ts +118 -341
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +18 -11
- package/extensions/fast/index.ts +9 -5
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/index.ts +2 -16
- package/extensions/git-footer/package.json +1 -1
- package/extensions/inline-bash/index.ts +0 -16
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +246 -70
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +5 -26
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +2 -11
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +46 -65
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +127 -36
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/project-mcp-json/README.md +2 -2
- package/extensions/project-mcp-json/package.json +1 -1
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +233 -166
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -0
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +0 -19
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +4 -2
- package/extensions/triage-comments/index.ts +344 -39
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
|
|
|
6
6
|
|
|
7
7
|
### Subagents
|
|
8
8
|
|
|
9
|
-
- [`code-reviewer`](./extensions/code-reviewer): Adds a
|
|
10
|
-
- [`contrarian`](./extensions/contrarian): Adds a read-only
|
|
11
|
-
- [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`,
|
|
12
|
-
- [`oracle`](./extensions/oracle): Adds an Amp-style read-only
|
|
9
|
+
- [`code-reviewer`](./extensions/code-reviewer): Adds a `code_reviewer` subagent tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that reviews local changes for ticket fit, diff mismatches, correctness, security, simplicity, and validation gaps without implementing fixes, auto-selects a contrarian-style opposite-provider review model when available, and clamps requested thinking to the chosen model's capabilities.
|
|
10
|
+
- [`contrarian`](./extensions/contrarian): Adds a contrarian tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that spins up an isolated subprocess to stress-test plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.
|
|
11
|
+
- [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`, cached repos expiring after 7 days of non-use, and configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4).
|
|
12
|
+
- [`oracle`](./extensions/oracle): Adds an Amp-style oracle tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that auto-selects the strongest reasoning model on the current provider/subscription, supports persisted `/oracle` model/thinking defaults, requests xhigh reasoning by default and clamps to model capabilities, and shows live status while running.
|
|
13
13
|
|
|
14
14
|
### Providers
|
|
15
15
|
|
|
@@ -19,19 +19,19 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
|
|
|
19
19
|
|
|
20
20
|
- [`git-footer`](./extensions/git-footer): Adds TLH-style git dirty counts, ahead/behind, and optional PR number to pi's built-in footer status area. This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package.
|
|
21
21
|
- [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch plus cached git dirty/ahead/PR status and repo on the first line, context/model on the second, optional `DUMB ZONE`, optional `xp` marker, plus OpenAI Codex usage windows labeled from their reported durations.
|
|
22
|
-
- [`quiet-tools`](./extensions/quiet-tools):
|
|
22
|
+
- [`quiet-tools`](./extensions/quiet-tools): Always renders collapsed built-in tool rows and `mcp__*` rows as a one-line invocation plus an expand hint without changing model-visible tool results; inspect status with `/quiet-tools status`.
|
|
23
23
|
|
|
24
24
|
### / Slash commands
|
|
25
25
|
|
|
26
|
-
- [`agent-workflow-audit`](./extensions/agent-workflow-audit): Adds `/agent-workflow-audit`, which runs an isolated repo workflow audit subagent and returns only the final distilled report to the main session.
|
|
26
|
+
- [`agent-workflow-audit`](./extensions/agent-workflow-audit): Adds `/agent-workflow-audit`, which runs an isolated repo workflow audit subagent with configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) and returns only the final distilled report to the main session.
|
|
27
27
|
- [`annotate-git-diff`](./extensions/annotate-git-diff): Adds `/annotate-git-diff`, a native Glimpse UI where explicit Submit sends structured feedback to the agent and close recovery keeps an unsent draft in the editor.
|
|
28
28
|
- [`annotate-last-message`](./extensions/annotate-last-message): Adds `/annotate-last-message`, a native Glimpse UI for annotating the latest assistant reply and sending planning-oriented feedback directly to the agent.
|
|
29
29
|
- [`review`](./extensions/review): Adds `/review` and `/end-review` for interactive code reviews of local changes, commits, PRs, and selected paths.
|
|
30
|
-
- [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a
|
|
30
|
+
- [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a `triage_comments` subagent tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that can auto-detect the current branch's PR, filter resolved/outdated inline comments, classify selected review comments with evidence, and suggest handling options without implementing changes
|
|
31
31
|
|
|
32
32
|
### Context management
|
|
33
33
|
|
|
34
|
-
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at
|
|
34
|
+
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at 350k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
|
|
35
35
|
- [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
|
|
36
36
|
- [`dynamic-context-pruning`](./extensions/dynamic-context-pruning): Automatically prunes duplicate tool results, stale errored inputs, and superseded file-op outputs from long sessions behind a cache-aware net-benefit gate, with a `/prune` picker and `/context-pruning` status/control commands.
|
|
37
37
|
|
|
@@ -60,7 +60,7 @@ Options:
|
|
|
60
60
|
|
|
61
61
|
- Runs a child `createAgentSession` with an in-memory session manager.
|
|
62
62
|
- Disables inherited extensions, skills, prompt templates, themes, and context files in the child.
|
|
63
|
-
- Gives the child `read`, `grep`, `find`, `ls`, and guarded `bash` tools.
|
|
63
|
+
- Gives the child `read`, `grep`, `find`, `ls`, and guarded `bash` tools, plus any configured MCP tools (run without confirmation; may have side effects; requires host Pi >=1.0.4; older hosts degrade to no MCP, no error); the global `mcp.json` always applies; a project `.pi/mcp.json` is loaded only when the host session has already trusted the project.
|
|
64
64
|
- Returns a single final `agent-workflow-audit` custom message to the parent session.
|
|
65
65
|
- Includes a short run-boundary footer noting that intermediate command output, errors, retries, and search path stayed in the child session.
|
|
66
66
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
2
4
|
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
7
|
import type {
|
|
5
8
|
AgentSession,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
getAgentDir,
|
|
16
19
|
getMarkdownTheme,
|
|
17
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
18
22
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
19
23
|
|
|
20
24
|
const CUSTOM_TYPE = "agent-workflow-audit";
|
|
@@ -59,10 +63,8 @@ type AuditDetails = {
|
|
|
59
63
|
toolCalls: ToolCall[];
|
|
60
64
|
startedAt: number;
|
|
61
65
|
endedAt?: number;
|
|
62
|
-
error?: string;
|
|
63
66
|
initialGitStatus?: string;
|
|
64
67
|
finalGitStatus?: string;
|
|
65
|
-
reportLength?: number;
|
|
66
68
|
toolCallCount?: number;
|
|
67
69
|
};
|
|
68
70
|
|
|
@@ -204,11 +206,37 @@ function inspectFinalAssistant(messages: unknown[]): AuditAssistantOutcome {
|
|
|
204
206
|
return { ok: false, reason: "Agent Workflow Audit subagent produced no assistant message" };
|
|
205
207
|
}
|
|
206
208
|
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
function classifyRunFailure(
|
|
210
|
+
error: unknown,
|
|
211
|
+
callerAborted: boolean,
|
|
212
|
+
): { status: "aborted" | "error"; message: string; error?: string } {
|
|
213
|
+
const message = callerAborted
|
|
214
|
+
? "Aborted"
|
|
215
|
+
: error instanceof Error
|
|
216
|
+
? error.message
|
|
217
|
+
: String(error);
|
|
218
|
+
return {
|
|
219
|
+
status: callerAborted ? "aborted" : "error",
|
|
220
|
+
message,
|
|
221
|
+
error: callerAborted ? undefined : message,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createAuditAbortController(abortChild: () => void, onCallerAbort: () => void) {
|
|
226
|
+
let callerAborted = false;
|
|
227
|
+
return {
|
|
228
|
+
get callerAborted() {
|
|
229
|
+
return callerAborted;
|
|
230
|
+
},
|
|
231
|
+
abortForCleanup() {
|
|
232
|
+
abortChild();
|
|
233
|
+
},
|
|
234
|
+
abortFromCaller() {
|
|
235
|
+
callerAborted = true;
|
|
236
|
+
onCallerAbort();
|
|
237
|
+
abortChild();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
212
240
|
}
|
|
213
241
|
|
|
214
242
|
function renderCollapsedReport(report: string, lineLimit = COLLAPSED_REPORT_LINES): string {
|
|
@@ -220,14 +248,14 @@ function renderCollapsedReport(report: string, lineLimit = COLLAPSED_REPORT_LINE
|
|
|
220
248
|
].join("\n");
|
|
221
249
|
}
|
|
222
250
|
|
|
223
|
-
function summarizeGitStatus(status: string | undefined): {
|
|
251
|
+
function summarizeGitStatus(status: string | undefined): { dirtyCount: number } {
|
|
224
252
|
const lines =
|
|
225
253
|
status
|
|
226
254
|
?.split("\n")
|
|
227
255
|
.map((line) => line.trim())
|
|
228
256
|
.filter(Boolean) ?? [];
|
|
229
257
|
const dirty = lines.filter((line) => !line.startsWith("##"));
|
|
230
|
-
return {
|
|
258
|
+
return { dirtyCount: dirty.length };
|
|
231
259
|
}
|
|
232
260
|
|
|
233
261
|
function appendRunBoundary(report: string, details: AuditDetails): string {
|
|
@@ -267,10 +295,80 @@ async function gitStatusShort(
|
|
|
267
295
|
return result.stdout.trim();
|
|
268
296
|
}
|
|
269
297
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
298
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
302
|
+
* non-win32 paths.
|
|
303
|
+
*/
|
|
304
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
305
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
306
|
+
return filePath;
|
|
307
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
308
|
+
if (!match) return filePath;
|
|
309
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
310
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
315
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
316
|
+
* expandTilde.
|
|
317
|
+
*/
|
|
318
|
+
function normalizePath(
|
|
319
|
+
input: string,
|
|
320
|
+
opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
|
|
321
|
+
): string {
|
|
322
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
323
|
+
let p = input;
|
|
324
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
325
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
326
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
327
|
+
if (expandTilde) {
|
|
328
|
+
const home = os.homedir();
|
|
329
|
+
if (p === "~") p = home;
|
|
330
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
331
|
+
p = path.join(home, p.slice(2));
|
|
332
|
+
}
|
|
333
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
334
|
+
return p;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
339
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
340
|
+
*/
|
|
341
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
342
|
+
const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
|
343
|
+
const base = normalizePath(cwd);
|
|
344
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
348
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
349
|
+
return filePath.replace(/ (AM|PM)\./gi, " $1.");
|
|
350
|
+
}
|
|
351
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
352
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
353
|
+
return filePath.normalize("NFD");
|
|
354
|
+
}
|
|
355
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
356
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
357
|
+
return filePath.replace(/'/g, "’");
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
361
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
362
|
+
*/
|
|
363
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
364
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
365
|
+
return [
|
|
366
|
+
resolved,
|
|
367
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
368
|
+
nfd,
|
|
369
|
+
tryCurlyQuotePathVariant(resolved),
|
|
370
|
+
tryCurlyQuotePathVariant(nfd),
|
|
371
|
+
];
|
|
274
372
|
}
|
|
275
373
|
|
|
276
374
|
function isInside(parent: string, child: string): boolean {
|
|
@@ -288,11 +386,28 @@ async function assertToolPathInsideCwd(
|
|
|
288
386
|
): Promise<string | undefined> {
|
|
289
387
|
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
290
388
|
return `${toolName} path must be a string.`;
|
|
389
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
390
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
391
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
291
392
|
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
393
|
+
const lexicalCwd = path.resolve(cwd);
|
|
394
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
395
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
396
|
+
try {
|
|
397
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
398
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
399
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
400
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
401
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
402
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
403
|
+
for (const candidate of candidates) {
|
|
404
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
405
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
406
|
+
return `${toolName} is limited to the audited checkout: ${candidateRealPath}`;
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
return `${toolName} path could not be resolved safely.`;
|
|
410
|
+
}
|
|
296
411
|
return undefined;
|
|
297
412
|
}
|
|
298
413
|
|
|
@@ -330,6 +445,93 @@ const SAFE_DIRECT_COMMANDS = new Set([
|
|
|
330
445
|
"yarn",
|
|
331
446
|
]);
|
|
332
447
|
|
|
448
|
+
const SUBAGENT_MCP_TOOLS = [
|
|
449
|
+
"mcp__*",
|
|
450
|
+
"tool_search",
|
|
451
|
+
"list_mcp_resources",
|
|
452
|
+
"list_mcp_resource_templates",
|
|
453
|
+
"read_mcp_resource",
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
// Derive project-trust for the isolated subagent session from the host tool context.
|
|
457
|
+
// Fails closed: returns false if the host has not trusted the project, or if the subagent
|
|
458
|
+
// runs in a different directory, preventing an untrusted project's .pi/mcp.json from
|
|
459
|
+
// being loaded by the MCP extension.
|
|
460
|
+
function resolveSubagentProjectTrusted(
|
|
461
|
+
ctx: { cwd: string; isProjectTrusted(): boolean },
|
|
462
|
+
subagentCwd: string,
|
|
463
|
+
): boolean {
|
|
464
|
+
// Feature-detected for older Pi hosts that may lack isProjectTrusted.
|
|
465
|
+
const hostTrusted =
|
|
466
|
+
typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
467
|
+
? ctx.isProjectTrusted()
|
|
468
|
+
: false;
|
|
469
|
+
if (!hostTrusted) return false;
|
|
470
|
+
try {
|
|
471
|
+
return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
|
|
472
|
+
} catch {
|
|
473
|
+
return false; // fail closed if realpath resolution fails
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
|
|
478
|
+
function isMcpCompatibleVersion(v: string | undefined): boolean {
|
|
479
|
+
if (!v) return false;
|
|
480
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
481
|
+
if (!m) return false;
|
|
482
|
+
const major = Number(m[1]),
|
|
483
|
+
minor = Number(m[2]),
|
|
484
|
+
patch = Number(m[3]);
|
|
485
|
+
if (major !== 1) return major > 1;
|
|
486
|
+
if (minor !== 0) return minor > 0;
|
|
487
|
+
return patch >= 4;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Safe runtime access to MCP/tool-search factories — feature-detected for Pi >=0.99.0 hosts.
|
|
491
|
+
// Additionally gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
|
|
492
|
+
// matches that resolve nothing, so spawning MCP servers provides no benefit.
|
|
493
|
+
function createMcpSubagentFactories(): ExtensionFactory[] {
|
|
494
|
+
const ns = piCodingAgent as Record<string, unknown>;
|
|
495
|
+
if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
|
|
496
|
+
const factories: ExtensionFactory[] = [];
|
|
497
|
+
const mcp = ns["createMcpExtension"];
|
|
498
|
+
const toolSearch = ns["createToolSearchExtension"];
|
|
499
|
+
if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
|
|
500
|
+
if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
|
|
501
|
+
return factories;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
|
|
505
|
+
async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
|
|
506
|
+
if (!session) return;
|
|
507
|
+
try {
|
|
508
|
+
const runner = (
|
|
509
|
+
session as unknown as {
|
|
510
|
+
extensionRunner?: {
|
|
511
|
+
hasHandlers(n: string): boolean;
|
|
512
|
+
emit(e: { type: string; reason: string }): Promise<unknown>;
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
).extensionRunner;
|
|
516
|
+
if (runner?.hasHandlers("session_shutdown")) {
|
|
517
|
+
await runner.emit({ type: "session_shutdown", reason: "quit" });
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Shutdown errors must not prevent dispose.
|
|
521
|
+
}
|
|
522
|
+
session.dispose();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function isSubagentMcpTool(toolName: string): boolean {
|
|
526
|
+
return (
|
|
527
|
+
toolName.startsWith("mcp__") ||
|
|
528
|
+
toolName === "tool_search" ||
|
|
529
|
+
toolName === "list_mcp_resources" ||
|
|
530
|
+
toolName === "list_mcp_resource_templates" ||
|
|
531
|
+
toolName === "read_mcp_resource"
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
333
535
|
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
334
536
|
"blame",
|
|
335
537
|
"branch",
|
|
@@ -677,9 +879,7 @@ function getBlockedPackageManagerReason(commandName: string, tokens: string[]):
|
|
|
677
879
|
index += 1;
|
|
678
880
|
}
|
|
679
881
|
}
|
|
680
|
-
const runnerIndex = lowerTokens.findIndex((token) =>
|
|
681
|
-
["node", "python", "python3", "ruby", "php"].includes(token),
|
|
682
|
-
);
|
|
882
|
+
const runnerIndex = lowerTokens.findIndex((token) => ["node", "ruby", "php"].includes(token));
|
|
683
883
|
if (runnerIndex >= 0) {
|
|
684
884
|
const runnerReason = getBlockedInterpreterReason(
|
|
685
885
|
lowerTokens[runnerIndex],
|
|
@@ -944,7 +1144,7 @@ function hasInlineFlag(tokens: string[], flags: string[]): boolean {
|
|
|
944
1144
|
function getBlockedInterpreterReason(commandName: string, tokens: string[]): string | undefined {
|
|
945
1145
|
const lowerTokens = tokens.map((token) => token.toLowerCase());
|
|
946
1146
|
if (
|
|
947
|
-
["node", "
|
|
1147
|
+
["node", "bun"].includes(commandName) &&
|
|
948
1148
|
hasInlineFlag(lowerTokens, [
|
|
949
1149
|
"-e",
|
|
950
1150
|
"--eval",
|
|
@@ -961,9 +1161,6 @@ function getBlockedInterpreterReason(commandName: string, tokens: string[]): str
|
|
|
961
1161
|
) {
|
|
962
1162
|
return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
|
|
963
1163
|
}
|
|
964
|
-
if (["python", "python3"].includes(commandName) && hasInlineFlag(lowerTokens, ["-c"])) {
|
|
965
|
-
return "Agent Workflow Audit blocks inline Python execution; run documented project scripts instead.";
|
|
966
|
-
}
|
|
967
1164
|
if (["ruby", "php"].includes(commandName) && hasInlineFlag(lowerTokens, ["-e", "-r"])) {
|
|
968
1165
|
return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
|
|
969
1166
|
}
|
|
@@ -1137,10 +1334,13 @@ function createAuditRuntimeGuardExtension(options: {
|
|
|
1137
1334
|
});
|
|
1138
1335
|
|
|
1139
1336
|
pi.on("tool_call", async (event) => {
|
|
1140
|
-
if (
|
|
1337
|
+
if (
|
|
1338
|
+
!["read", "grep", "find", "ls", "bash"].includes(event.toolName) &&
|
|
1339
|
+
!isSubagentMcpTool(event.toolName)
|
|
1340
|
+
) {
|
|
1141
1341
|
return {
|
|
1142
1342
|
block: true,
|
|
1143
|
-
reason: `agent-workflow-audit exposes only read, grep, find, ls, and
|
|
1343
|
+
reason: `agent-workflow-audit exposes only read, grep, find, ls, guarded bash, and MCP tools; ${event.toolName} is not allowed.`,
|
|
1144
1344
|
};
|
|
1145
1345
|
}
|
|
1146
1346
|
|
|
@@ -1212,7 +1412,7 @@ function buildSystemPrompt(options: {
|
|
|
1212
1412
|
? "Plan-only mode is active. Do not run project commands with bash. Read instructions and manifests, infer the likely workflow, and report what would be tried plus remaining uncertainty."
|
|
1213
1413
|
: "Execution mode is active. Try documented setup, build, lint, test, run, and other obvious project commands when they are safe and relevant. A runtime guard blocks deploy/publish/VCS-mutating/destructive commands; if a command is blocked, report it as a workflow/safety finding instead of trying to bypass it.";
|
|
1214
1414
|
|
|
1215
|
-
return `You are Agent Workflow Audit, an isolated subagent
|
|
1415
|
+
return `You are Agent Workflow Audit, an isolated subagent. Your job is to stress-test how efficiently an agent can operate in the current repository and return a concise final audit report to the parent session.
|
|
1216
1416
|
|
|
1217
1417
|
Working directory: ${options.cwd}
|
|
1218
1418
|
Turn budget: ${options.maxTurns} turns total, including your final answer.
|
|
@@ -1233,7 +1433,7 @@ Workflow:
|
|
|
1233
1433
|
|
|
1234
1434
|
Constraints:
|
|
1235
1435
|
- Do not fix application code or failing tests. Do not edit, write, move, delete, commit, checkout, reset, clean, push, publish, deploy, or mutate GitHub.
|
|
1236
|
-
- Use built-in read/grep/find/ls for file inspection. Use bash only for safe documented project commands or read-only local inspection.
|
|
1436
|
+
- Use built-in read/grep/find/ls for file inspection. Use bash only for safe documented project commands or read-only local inspection. Configured MCP tools are also available and run without confirmation; use them only to gather evidence or context, not for state-changing actions unless the task explicitly requires it. MCP access requires host Pi >=1.0.4; on older hosts MCP tools are absent with no error.
|
|
1237
1437
|
- Do not paste raw full logs. Summarize command output and include short excerpts only when needed to prove a finding.
|
|
1238
1438
|
- Never present a command as successful unless tool output showed success.
|
|
1239
1439
|
- Mark inferred steps as assumptions.
|
|
@@ -1354,27 +1554,31 @@ async function runAudit(
|
|
|
1354
1554
|
let unsubscribe: (() => void) | undefined;
|
|
1355
1555
|
let runTimeout: NodeJS.Timeout | undefined;
|
|
1356
1556
|
let abortListenerAdded = false;
|
|
1357
|
-
let aborted = Boolean(ctx.signal?.aborted);
|
|
1358
1557
|
|
|
1359
1558
|
const emit = () => updateAuditUi(ctx, details, lastContent);
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1559
|
+
const abortController = createAuditAbortController(
|
|
1560
|
+
() => {
|
|
1561
|
+
void session?.abort();
|
|
1562
|
+
},
|
|
1563
|
+
() => {
|
|
1564
|
+
details.status = "aborted";
|
|
1565
|
+
details.endedAt = Date.now();
|
|
1566
|
+
lastContent = "Aborted";
|
|
1567
|
+
emit();
|
|
1568
|
+
},
|
|
1569
|
+
);
|
|
1570
|
+
const abortFromCaller = () => abortController.abortFromCaller();
|
|
1368
1571
|
|
|
1369
|
-
if (ctx.signal?.aborted)
|
|
1572
|
+
if (ctx.signal?.aborted) abortFromCaller();
|
|
1370
1573
|
if (ctx.signal && !ctx.signal.aborted) {
|
|
1371
|
-
ctx.signal.addEventListener("abort",
|
|
1574
|
+
ctx.signal.addEventListener("abort", abortFromCaller);
|
|
1372
1575
|
abortListenerAdded = true;
|
|
1373
1576
|
}
|
|
1374
1577
|
|
|
1375
1578
|
try {
|
|
1376
1579
|
emit();
|
|
1377
|
-
const
|
|
1580
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
1581
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
1378
1582
|
const resourceLoader = new DefaultResourceLoader({
|
|
1379
1583
|
cwd,
|
|
1380
1584
|
agentDir: getAgentDir(),
|
|
@@ -1386,6 +1590,7 @@ async function runAudit(
|
|
|
1386
1590
|
noContextFiles: true,
|
|
1387
1591
|
extensionFactories: [
|
|
1388
1592
|
createAuditRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS, planOnly: options.planOnly }),
|
|
1593
|
+
...createMcpSubagentFactories(),
|
|
1389
1594
|
],
|
|
1390
1595
|
systemPromptOverride: () =>
|
|
1391
1596
|
buildSystemPrompt({
|
|
@@ -1404,8 +1609,8 @@ async function runAudit(
|
|
|
1404
1609
|
await resourceLoader.reload();
|
|
1405
1610
|
|
|
1406
1611
|
const tools = options.planOnly
|
|
1407
|
-
? ["read", "grep", "find", "ls"]
|
|
1408
|
-
: ["read", "grep", "find", "ls", "bash"];
|
|
1612
|
+
? ["read", "grep", "find", "ls", ...SUBAGENT_MCP_TOOLS]
|
|
1613
|
+
: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS];
|
|
1409
1614
|
const created = await createAgentSession({
|
|
1410
1615
|
cwd,
|
|
1411
1616
|
...getModelRuntimeOption(ctx),
|
|
@@ -1417,9 +1622,17 @@ async function runAudit(
|
|
|
1417
1622
|
tools,
|
|
1418
1623
|
});
|
|
1419
1624
|
|
|
1625
|
+
// Assign session BEFORE bindExtensions so the finally block can dispose it
|
|
1626
|
+
// even if binding fails partway through (e.g. a server starts then an
|
|
1627
|
+
// extension's session_start throws).
|
|
1420
1628
|
session = created.session;
|
|
1629
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
1630
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
1421
1631
|
unsubscribe = session.subscribe((event) => {
|
|
1422
1632
|
switch (event.type) {
|
|
1633
|
+
case "message_start":
|
|
1634
|
+
if (event.message.role === "assistant") lastContent = "";
|
|
1635
|
+
break;
|
|
1423
1636
|
case "message_update":
|
|
1424
1637
|
if (event.assistantMessageEvent?.type === "text_delta") {
|
|
1425
1638
|
lastContent += event.assistantMessageEvent.delta ?? "";
|
|
@@ -1454,7 +1667,7 @@ async function runAudit(
|
|
|
1454
1667
|
}
|
|
1455
1668
|
});
|
|
1456
1669
|
|
|
1457
|
-
if (!
|
|
1670
|
+
if (!abortController.callerAborted) {
|
|
1458
1671
|
const promptPromise = session.prompt(
|
|
1459
1672
|
buildUserPrompt({
|
|
1460
1673
|
cwd,
|
|
@@ -1466,12 +1679,11 @@ async function runAudit(
|
|
|
1466
1679
|
);
|
|
1467
1680
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1468
1681
|
runTimeout = setTimeout(() => {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
new Error(
|
|
1472
|
-
`/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
|
|
1473
|
-
),
|
|
1682
|
+
const timeoutError = new Error(
|
|
1683
|
+
`/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
|
|
1474
1684
|
);
|
|
1685
|
+
reject(timeoutError);
|
|
1686
|
+
abortController.abortForCleanup();
|
|
1475
1687
|
}, MAX_RUN_MS);
|
|
1476
1688
|
});
|
|
1477
1689
|
await Promise.race([promptPromise, timeoutPromise]);
|
|
@@ -1486,40 +1698,45 @@ async function runAudit(
|
|
|
1486
1698
|
if (outcome.ok) {
|
|
1487
1699
|
lastContent = outcome.answer;
|
|
1488
1700
|
details.status = "done";
|
|
1489
|
-
} else if (
|
|
1701
|
+
} else if (abortController.callerAborted) {
|
|
1490
1702
|
lastContent = "Aborted";
|
|
1491
1703
|
details.status = "aborted";
|
|
1492
1704
|
} else {
|
|
1493
1705
|
throw new Error(outcome.reason);
|
|
1494
1706
|
}
|
|
1495
1707
|
details.endedAt = Date.now();
|
|
1496
|
-
details.reportLength = lastContent.length;
|
|
1497
1708
|
emit();
|
|
1498
1709
|
return { report: lastContent, details };
|
|
1499
1710
|
} catch (error) {
|
|
1500
|
-
const
|
|
1501
|
-
const message =
|
|
1502
|
-
details.status =
|
|
1503
|
-
details.error = wasAbort ? undefined : message;
|
|
1711
|
+
const failure = classifyRunFailure(error, abortController.callerAborted);
|
|
1712
|
+
const { message } = failure;
|
|
1713
|
+
details.status = failure.status;
|
|
1504
1714
|
details.endedAt = Date.now();
|
|
1505
1715
|
lastContent = message;
|
|
1506
|
-
details.reportLength = lastContent.length;
|
|
1507
1716
|
emit();
|
|
1508
1717
|
return { report: `## Agent Workflow Audit failed\n\n${message}`, details };
|
|
1509
1718
|
} finally {
|
|
1510
1719
|
if (runTimeout) clearTimeout(runTimeout);
|
|
1511
|
-
if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort",
|
|
1720
|
+
if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort", abortFromCaller);
|
|
1512
1721
|
unsubscribe?.();
|
|
1513
|
-
session
|
|
1722
|
+
await disposeSubagentSession(session);
|
|
1514
1723
|
}
|
|
1515
1724
|
}
|
|
1516
1725
|
|
|
1517
1726
|
export const __test__ = {
|
|
1727
|
+
SUBAGENT_MCP_TOOLS,
|
|
1728
|
+
isMcpCompatibleVersion,
|
|
1729
|
+
resolveSubagentProjectTrusted,
|
|
1730
|
+
createMcpSubagentFactories,
|
|
1731
|
+
disposeSubagentSession,
|
|
1732
|
+
classifyRunFailure,
|
|
1733
|
+
createAuditAbortController,
|
|
1734
|
+
createAuditRuntimeGuardExtension,
|
|
1518
1735
|
appendRunBoundary,
|
|
1519
1736
|
assertToolPathInsideCwd,
|
|
1737
|
+
resolveToCwd,
|
|
1520
1738
|
buildSystemPrompt,
|
|
1521
1739
|
buildUserPrompt,
|
|
1522
|
-
createAuditRuntimeGuardExtension,
|
|
1523
1740
|
inspectFinalAssistant,
|
|
1524
1741
|
formatToolCall,
|
|
1525
1742
|
getBlockedBashReason,
|
|
@@ -1633,7 +1850,6 @@ export default function agentWorkflowAuditExtension(pi: ExtensionAPI) {
|
|
|
1633
1850
|
if (!details) return;
|
|
1634
1851
|
details.finalGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
|
|
1635
1852
|
report = appendRunBoundary(report, details);
|
|
1636
|
-
details.reportLength = report.length;
|
|
1637
1853
|
const parentDetails: AuditDetails = {
|
|
1638
1854
|
...details,
|
|
1639
1855
|
toolCallCount: details.toolCalls.length,
|
|
@@ -381,20 +381,14 @@ const binaryExtensions = new Set([
|
|
|
381
381
|
".7z",
|
|
382
382
|
".a",
|
|
383
383
|
".avi",
|
|
384
|
-
".avif",
|
|
385
384
|
".bin",
|
|
386
|
-
".bmp",
|
|
387
385
|
".class",
|
|
388
386
|
".dll",
|
|
389
387
|
".dylib",
|
|
390
388
|
".eot",
|
|
391
389
|
".exe",
|
|
392
|
-
".gif",
|
|
393
390
|
".gz",
|
|
394
|
-
".ico",
|
|
395
391
|
".jar",
|
|
396
|
-
".jpeg",
|
|
397
|
-
".jpg",
|
|
398
392
|
".lockb",
|
|
399
393
|
".map",
|
|
400
394
|
".mov",
|
|
@@ -403,7 +397,6 @@ const binaryExtensions = new Set([
|
|
|
403
397
|
".o",
|
|
404
398
|
".otf",
|
|
405
399
|
".pdf",
|
|
406
|
-
".png",
|
|
407
400
|
".pyc",
|
|
408
401
|
".so",
|
|
409
402
|
".svgz",
|
|
@@ -411,7 +404,6 @@ const binaryExtensions = new Set([
|
|
|
411
404
|
".ttf",
|
|
412
405
|
".wasm",
|
|
413
406
|
".webm",
|
|
414
|
-
".webp",
|
|
415
407
|
".woff",
|
|
416
408
|
".woff2",
|
|
417
409
|
".zip",
|