@diegopetrucci/pi-extensions 0.1.76 → 0.1.78
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 +277 -44
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/git.ts +0 -8
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/web/index.html +3 -3
- package/extensions/code-reviewer/README.md +5 -5
- package/extensions/code-reviewer/index.ts +258 -54
- 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/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +47 -40
- 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 +5 -5
- package/extensions/dynamic-context-pruning/index.ts +114 -232
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +2 -2
- package/extensions/fast/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 +247 -39
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +3 -10
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +45 -38
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +151 -28
- 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/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 +349 -29
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +4 -4
|
@@ -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,
|
|
@@ -17,6 +20,7 @@ import {
|
|
|
17
20
|
getAgentDir,
|
|
18
21
|
getMarkdownTheme,
|
|
19
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
20
24
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
21
25
|
import { Type } from "typebox";
|
|
22
26
|
|
|
@@ -58,6 +62,91 @@ const TRIAGE_COMMAND_USAGE =
|
|
|
58
62
|
const IMPLEMENTATION_NOTE =
|
|
59
63
|
"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
|
|
60
64
|
|
|
65
|
+
const SUBAGENT_MCP_TOOLS = [
|
|
66
|
+
"mcp__*",
|
|
67
|
+
"tool_search",
|
|
68
|
+
"list_mcp_resources",
|
|
69
|
+
"list_mcp_resource_templates",
|
|
70
|
+
"read_mcp_resource",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Derive project-trust for the isolated subagent session from the host tool context.
|
|
74
|
+
// Fails closed: returns false if the host has not trusted the project, or if the subagent
|
|
75
|
+
// runs in a different directory, preventing an untrusted project's .pi/mcp.json from
|
|
76
|
+
// being loaded by the MCP extension.
|
|
77
|
+
function resolveSubagentProjectTrusted(
|
|
78
|
+
ctx: { cwd: string; isProjectTrusted(): boolean },
|
|
79
|
+
subagentCwd: string,
|
|
80
|
+
): boolean {
|
|
81
|
+
// Feature-detected for older Pi hosts that may lack isProjectTrusted.
|
|
82
|
+
const hostTrusted =
|
|
83
|
+
typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
84
|
+
? ctx.isProjectTrusted()
|
|
85
|
+
: false;
|
|
86
|
+
if (!hostTrusted) return false;
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
|
|
89
|
+
} catch {
|
|
90
|
+
return false; // fail closed if realpath resolution fails
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isSubagentMcpTool(toolName: string): boolean {
|
|
95
|
+
return (
|
|
96
|
+
toolName.startsWith("mcp__") ||
|
|
97
|
+
toolName === "tool_search" ||
|
|
98
|
+
toolName === "list_mcp_resources" ||
|
|
99
|
+
toolName === "list_mcp_resource_templates" ||
|
|
100
|
+
toolName === "read_mcp_resource"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
|
|
105
|
+
function isMcpCompatibleVersion(v: string | undefined): boolean {
|
|
106
|
+
if (!v) return false;
|
|
107
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
108
|
+
if (!m) return false;
|
|
109
|
+
const major = Number(m[1]),
|
|
110
|
+
minor = Number(m[2]),
|
|
111
|
+
patch = Number(m[3]);
|
|
112
|
+
if (major !== 1) return major > 1;
|
|
113
|
+
if (minor !== 0) return minor > 0;
|
|
114
|
+
return patch >= 4;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
|
|
118
|
+
// matches that resolve nothing, so spawning MCP servers provides no benefit.
|
|
119
|
+
function createMcpSubagentFactories(): ExtensionFactory[] {
|
|
120
|
+
const ns = piCodingAgent as Record<string, unknown>;
|
|
121
|
+
if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
|
|
122
|
+
const factories: ExtensionFactory[] = [];
|
|
123
|
+
const mcp = ns["createMcpExtension"];
|
|
124
|
+
const toolSearch = ns["createToolSearchExtension"];
|
|
125
|
+
if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
|
|
126
|
+
if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
|
|
127
|
+
return factories;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
|
|
131
|
+
async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
|
|
132
|
+
if (!session) return;
|
|
133
|
+
try {
|
|
134
|
+
const runner = (
|
|
135
|
+
session as unknown as {
|
|
136
|
+
extensionRunner?: {
|
|
137
|
+
hasHandlers(n: string): boolean;
|
|
138
|
+
emit(e: { type: string; reason: string }): Promise<unknown>;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
).extensionRunner;
|
|
142
|
+
if (runner?.hasHandlers("session_shutdown")) {
|
|
143
|
+
await runner.emit({ type: "session_shutdown", reason: "quit" });
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Shutdown errors must not prevent dispose.
|
|
147
|
+
}
|
|
148
|
+
session.dispose();
|
|
149
|
+
}
|
|
61
150
|
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
|
|
62
151
|
const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
|
|
63
152
|
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
@@ -93,6 +182,69 @@ const SAFE_GIT_BRANCH_FLAGS = new Set([
|
|
|
93
182
|
"--no-merged",
|
|
94
183
|
]);
|
|
95
184
|
const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
185
|
+
const SAFE_GIT_CONFIG_OVERRIDES = [
|
|
186
|
+
"core.pager=cat",
|
|
187
|
+
"core.fsmonitor=false",
|
|
188
|
+
"diff.external=",
|
|
189
|
+
"log.showSignature=false",
|
|
190
|
+
];
|
|
191
|
+
const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
|
|
192
|
+
blame: ["--no-textconv"],
|
|
193
|
+
diff: ["--no-ext-diff", "--no-textconv"],
|
|
194
|
+
log: ["--no-ext-diff", "--no-textconv"],
|
|
195
|
+
show: ["--no-ext-diff", "--no-textconv"],
|
|
196
|
+
whatchanged: ["--no-ext-diff", "--no-textconv"],
|
|
197
|
+
};
|
|
198
|
+
const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
|
|
199
|
+
{
|
|
200
|
+
flag: "--pathspec-from-file",
|
|
201
|
+
blockedPrefixes: ["--pathspec"],
|
|
202
|
+
reason:
|
|
203
|
+
"Triage bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
flag: "--ignore-revs-file",
|
|
207
|
+
blockedPrefixes: ["--ignore-rev"],
|
|
208
|
+
reason:
|
|
209
|
+
"Triage bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
|
|
210
|
+
},
|
|
211
|
+
] as const;
|
|
212
|
+
const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<
|
|
213
|
+
Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>
|
|
214
|
+
> = {
|
|
215
|
+
blame: [
|
|
216
|
+
{
|
|
217
|
+
reason:
|
|
218
|
+
"Triage bash blocks git blame --contents because it can read local files outside built-in path guards.",
|
|
219
|
+
matches: (token) => {
|
|
220
|
+
if (!token.startsWith("--")) return false;
|
|
221
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
222
|
+
return option === "--contents" || option.startsWith("--con");
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
reason:
|
|
227
|
+
"Triage bash blocks git blame -S because it can read local revs files outside built-in path guards.",
|
|
228
|
+
matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
"ls-files": [
|
|
232
|
+
{
|
|
233
|
+
reason:
|
|
234
|
+
"Triage bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
235
|
+
matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
reason:
|
|
239
|
+
"Triage bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
240
|
+
matches: (token) => {
|
|
241
|
+
if (!token.startsWith("--")) return false;
|
|
242
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
243
|
+
return option === "--exclude-from" || option.startsWith("--exclude-f");
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
96
248
|
const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
|
|
97
249
|
"--repo",
|
|
98
250
|
"-R",
|
|
@@ -372,7 +524,7 @@ function buildSystemPrompt(options: {
|
|
|
372
524
|
maxTurns: number;
|
|
373
525
|
maxRunSeconds: number;
|
|
374
526
|
}): string {
|
|
375
|
-
return `You are Triage Comments, an isolated read-only code-review investigation agent running inside the triage-comments package. Your job is to evaluate selected PR review comments against the local checkout and local git context.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, local filesystem utility commands, mutating git, and mutating gh/GitHub API calls.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Keep all inspection local to the checkout unless a read-only gh call is necessary for supplied PR metadata.\n- Treat the supplied comments as claims to verify, not as facts. Cite file paths, line ranges, git diff/status/log output, or supplied diff hunks as evidence.\n- If evidence is missing, stale, contradictory, or the local checkout does not match the PR context, say so and classify accordingly.\n- Distinguish objective correctness issues from style/preferences.\n- For valid or partially valid comments, propose one or more handling options, but do not choose implementation without parent/user confirmation.\n\nVerdicts must be one of: valid, invalid, partially valid, subjective, needs clarification.\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences summarizing the triage.\n\n## Per-comment triage\nFor each selected comment, use:\n### Comment <index or id> — <verdict>\n- **What the reviewer asked:** concise paraphrase.\n- **Evidence:** path/line or command-output citations. If evidence is insufficient, state exactly what is missing.\n- **Reasoning:** why the evidence supports the verdict.\n- **Suggested response:** a short review-thread reply the parent/user can post or adapt.\n- **Handling options:** one or more options for valid/partially valid comments; for invalid/subjective/unclear comments, give the appropriate response/clarification path.\n\n## Read-only checks performed\nList the files/commands/probes used, or \`- (none)\`.\n\n## Before implementation\nState explicitly: \"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.\"`;
|
|
527
|
+
return `You are Triage Comments, an isolated read-only code-review investigation agent running inside the triage-comments package. Your job is to evaluate selected PR review comments against the local checkout and local git context.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, local filesystem utility commands, mutating git, and mutating gh/GitHub API calls. 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.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Keep all inspection local to the checkout unless a read-only gh call is necessary for supplied PR metadata.\n- Treat the supplied comments as claims to verify, not as facts. Cite file paths, line ranges, git diff/status/log output, or supplied diff hunks as evidence.\n- If evidence is missing, stale, contradictory, or the local checkout does not match the PR context, say so and classify accordingly.\n- Distinguish objective correctness issues from style/preferences.\n- For valid or partially valid comments, propose one or more handling options, but do not choose implementation without parent/user confirmation.\n\nVerdicts must be one of: valid, invalid, partially valid, subjective, needs clarification.\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences summarizing the triage.\n\n## Per-comment triage\nFor each selected comment, use:\n### Comment <index or id> — <verdict>\n- **What the reviewer asked:** concise paraphrase.\n- **Evidence:** path/line or command-output citations. If evidence is insufficient, state exactly what is missing.\n- **Reasoning:** why the evidence supports the verdict.\n- **Suggested response:** a short review-thread reply the parent/user can post or adapt.\n- **Handling options:** one or more options for valid/partially valid comments; for invalid/subjective/unclear comments, give the appropriate response/clarification path.\n\n## Read-only checks performed\nList the files/commands/probes used, or \`- (none)\`.\n\n## Before implementation\nState explicitly: \"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.\"`;
|
|
376
528
|
}
|
|
377
529
|
|
|
378
530
|
function buildUserPrompt(input: NormalizedInput, cwd: string): string {
|
|
@@ -508,6 +660,82 @@ function isInside(parent: string, child: string): boolean {
|
|
|
508
660
|
);
|
|
509
661
|
}
|
|
510
662
|
|
|
663
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
667
|
+
* non-win32 paths.
|
|
668
|
+
*/
|
|
669
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
670
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
671
|
+
return filePath;
|
|
672
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
673
|
+
if (!match) return filePath;
|
|
674
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
675
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
680
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
681
|
+
* expandTilde.
|
|
682
|
+
*/
|
|
683
|
+
function normalizePath(
|
|
684
|
+
input: string,
|
|
685
|
+
opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
|
|
686
|
+
): string {
|
|
687
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
688
|
+
let p = input;
|
|
689
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
690
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
691
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
692
|
+
if (expandTilde) {
|
|
693
|
+
const home = os.homedir();
|
|
694
|
+
if (p === "~") p = home;
|
|
695
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
696
|
+
p = path.join(home, p.slice(2));
|
|
697
|
+
}
|
|
698
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
699
|
+
return p;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
704
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
705
|
+
*/
|
|
706
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
707
|
+
const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
|
708
|
+
const base = normalizePath(cwd);
|
|
709
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
713
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
714
|
+
return filePath.replace(/ (AM|PM)\./gi, " $1.");
|
|
715
|
+
}
|
|
716
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
717
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
718
|
+
return filePath.normalize("NFD");
|
|
719
|
+
}
|
|
720
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
721
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
722
|
+
return filePath.replace(/'/g, "’");
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
726
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
727
|
+
*/
|
|
728
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
729
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
730
|
+
return [
|
|
731
|
+
resolved,
|
|
732
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
733
|
+
nfd,
|
|
734
|
+
tryCurlyQuotePathVariant(resolved),
|
|
735
|
+
tryCurlyQuotePathVariant(nfd),
|
|
736
|
+
];
|
|
737
|
+
}
|
|
738
|
+
|
|
511
739
|
function resolveToolPath(cwd: string, rawPath: string | undefined): string {
|
|
512
740
|
const input = rawPath?.trim() || ".";
|
|
513
741
|
const normalized = input.startsWith("@") ? input.slice(1) : input;
|
|
@@ -521,10 +749,33 @@ async function assertToolPathInsideCwd(
|
|
|
521
749
|
): Promise<string | undefined> {
|
|
522
750
|
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
523
751
|
return `${toolName} path must be a string.`;
|
|
752
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
753
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
754
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
524
755
|
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
756
|
+
const lexicalCwd = path.resolve(cwd);
|
|
525
757
|
const resolved = resolveToolPath(cwd, rawPath);
|
|
526
758
|
const realPath = await fs.realpath(resolved).catch(() => resolved);
|
|
527
|
-
if (!isInside(root, realPath)
|
|
759
|
+
if (!isInside(root, realPath) && !isInside(lexicalCwd, realPath))
|
|
760
|
+
return `${toolName} is limited to the local checkout: ${realPath}`;
|
|
761
|
+
// Pi-effective path check (monotonic: block if either resolution is outside).
|
|
762
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
763
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
764
|
+
try {
|
|
765
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
766
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
767
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
768
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
769
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
770
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
771
|
+
for (const candidate of candidates) {
|
|
772
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
773
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
774
|
+
return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
|
|
775
|
+
}
|
|
776
|
+
} catch {
|
|
777
|
+
return `${toolName} path could not be resolved safely.`;
|
|
778
|
+
}
|
|
528
779
|
return undefined;
|
|
529
780
|
}
|
|
530
781
|
|
|
@@ -683,6 +934,10 @@ function getExecutableName(token: string): string {
|
|
|
683
934
|
return path.basename(token).toLowerCase();
|
|
684
935
|
}
|
|
685
936
|
|
|
937
|
+
function shellQuoteToken(token: string): string {
|
|
938
|
+
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
939
|
+
}
|
|
940
|
+
|
|
686
941
|
function getShellSyntaxReason(command: string): string | undefined {
|
|
687
942
|
return tokenizeShellCommand(command).reason;
|
|
688
943
|
}
|
|
@@ -805,8 +1060,24 @@ function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): bool
|
|
|
805
1060
|
return remoteAction === "show" || remoteAction === "get-url";
|
|
806
1061
|
}
|
|
807
1062
|
|
|
808
|
-
function
|
|
809
|
-
|
|
1063
|
+
function isBlockedGitLocalFileOption(
|
|
1064
|
+
token: string,
|
|
1065
|
+
flag: string,
|
|
1066
|
+
blockedPrefixes: readonly string[],
|
|
1067
|
+
): boolean {
|
|
1068
|
+
if (!token.startsWith("--")) return false;
|
|
1069
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
1070
|
+
if (option === flag) return true;
|
|
1071
|
+
return blockedPrefixes.some((prefix) => option.startsWith(prefix));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function getUnsafeGitArgumentReason(
|
|
1075
|
+
tokens: string[],
|
|
1076
|
+
parsed: { subcommand?: string; index: number },
|
|
1077
|
+
): string | undefined {
|
|
1078
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1079
|
+
const token = tokens[index];
|
|
1080
|
+
const lowerToken = token.toLowerCase();
|
|
810
1081
|
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
811
1082
|
return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
812
1083
|
}
|
|
@@ -825,21 +1096,62 @@ function getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
|
|
|
825
1096
|
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
826
1097
|
return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
|
|
827
1098
|
}
|
|
1099
|
+
if (token === "--filters" || token.startsWith("--filters=")) {
|
|
1100
|
+
return "Triage bash blocks git --filters because it can execute clean/smudge filters from local config.";
|
|
1101
|
+
}
|
|
1102
|
+
if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
|
|
1103
|
+
return "Triage bash blocks git signature verification flags because they can execute configured GPG helpers.";
|
|
1104
|
+
}
|
|
1105
|
+
if (token === "--help" || token.startsWith("--help=")) {
|
|
1106
|
+
return "Triage bash blocks git help output because it can execute configured help, man, or browser helpers.";
|
|
1107
|
+
}
|
|
1108
|
+
if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
|
|
1109
|
+
return "Triage bash blocks git signature format atoms because they can execute configured GPG helpers.";
|
|
1110
|
+
}
|
|
828
1111
|
if (token === "--output" || token.startsWith("--output=")) {
|
|
829
1112
|
return "Triage bash blocks git output-writing flags.";
|
|
830
1113
|
}
|
|
1114
|
+
for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
|
|
1115
|
+
if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
|
|
1116
|
+
}
|
|
1117
|
+
if (index > parsed.index) {
|
|
1118
|
+
for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
|
|
1119
|
+
[]) {
|
|
1120
|
+
if (option.matches(token)) return option.reason;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
831
1123
|
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
832
1124
|
if (pathReason) return pathReason;
|
|
833
1125
|
}
|
|
834
1126
|
return undefined;
|
|
835
1127
|
}
|
|
836
1128
|
|
|
1129
|
+
function buildSafeGitCommand(tokens: string[]): string {
|
|
1130
|
+
const parsed = getGitSubcommand(tokens);
|
|
1131
|
+
const prefix = [
|
|
1132
|
+
"git",
|
|
1133
|
+
"--no-pager",
|
|
1134
|
+
"--no-optional-locks",
|
|
1135
|
+
...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
|
|
1136
|
+
];
|
|
1137
|
+
const subcommand = parsed.subcommand;
|
|
1138
|
+
if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
|
|
1139
|
+
const args = tokens.slice(parsed.index + 1);
|
|
1140
|
+
const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
|
|
1141
|
+
(flag) => !args.includes(flag),
|
|
1142
|
+
);
|
|
1143
|
+
return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
|
|
1144
|
+
}
|
|
1145
|
+
|
|
837
1146
|
function getBlockedGitReason(command: string): string | undefined {
|
|
838
1147
|
const tokens = tokenizeSegment(command);
|
|
839
1148
|
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
840
1149
|
const parsed = getGitSubcommand(tokens);
|
|
841
1150
|
if (parsed.reason) return parsed.reason;
|
|
842
|
-
|
|
1151
|
+
if (parsed.subcommand === "help") {
|
|
1152
|
+
return "Triage bash blocks git help because it can execute configured help, man, or browser helpers.";
|
|
1153
|
+
}
|
|
1154
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
|
|
843
1155
|
if (argumentReason) return argumentReason;
|
|
844
1156
|
if (!parsed.subcommand) return undefined;
|
|
845
1157
|
if (parsed.subcommand === "branch") {
|
|
@@ -998,10 +1310,10 @@ function createTriageRuntimeGuardExtension(options: {
|
|
|
998
1310
|
});
|
|
999
1311
|
|
|
1000
1312
|
pi.on("tool_call", async (event) => {
|
|
1001
|
-
if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
|
|
1313
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
|
|
1002
1314
|
return {
|
|
1003
1315
|
block: true,
|
|
1004
|
-
reason: `triage_comments exposes read-only tools
|
|
1316
|
+
reason: `triage_comments exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
|
|
1005
1317
|
};
|
|
1006
1318
|
}
|
|
1007
1319
|
|
|
@@ -1050,6 +1362,9 @@ function createTriageRuntimeGuardExtension(options: {
|
|
|
1050
1362
|
const command = typeof input.command === "string" ? input.command : "";
|
|
1051
1363
|
const reason = getBlockedBashReason(command);
|
|
1052
1364
|
if (reason) return { block: true, reason };
|
|
1365
|
+
const tokens = tokenizeSegment(command.trim());
|
|
1366
|
+
if (getExecutableName(tokens[0] ?? "") === "git")
|
|
1367
|
+
input.command = buildSafeGitCommand(tokens);
|
|
1053
1368
|
}
|
|
1054
1369
|
|
|
1055
1370
|
return undefined;
|
|
@@ -1287,10 +1602,8 @@ function parseTriageCommandArgs(args: string): ParsedTriageCommandArgs {
|
|
|
1287
1602
|
|
|
1288
1603
|
if (normalized === "--help" || normalized === "-h" || normalized === "help")
|
|
1289
1604
|
return { help: true };
|
|
1290
|
-
if (normalized === "paste"
|
|
1291
|
-
|
|
1292
|
-
if (normalized === "pr" || normalized === "pull" || normalized === "pull-request")
|
|
1293
|
-
return { mode: "pr", target: rest || undefined };
|
|
1605
|
+
if (normalized === "paste") return { mode: "paste", pastePrefill: rest || undefined };
|
|
1606
|
+
if (normalized === "pr") return { mode: "pr", target: rest || undefined };
|
|
1294
1607
|
if (looksLikePrTarget(trimmed)) return { mode: "pr", target: trimmed };
|
|
1295
1608
|
|
|
1296
1609
|
return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
|
|
@@ -2001,14 +2314,6 @@ function applyInlineCommentFilter(
|
|
|
2001
2314
|
kept.push(comment);
|
|
2002
2315
|
}
|
|
2003
2316
|
|
|
2004
|
-
const commentsWithFilterMetadata = kept.map((comment) => ({
|
|
2005
|
-
...comment,
|
|
2006
|
-
metadata: compactRecord({
|
|
2007
|
-
...comment.metadata,
|
|
2008
|
-
preFilterDisplayNumber: comment.displayNumber,
|
|
2009
|
-
}),
|
|
2010
|
-
}));
|
|
2011
|
-
|
|
2012
2317
|
const summary: AppliedInlineCommentFilter = {
|
|
2013
2318
|
filter,
|
|
2014
2319
|
originalCount: comments.length,
|
|
@@ -2019,7 +2324,7 @@ function applyInlineCommentFilter(
|
|
|
2019
2324
|
keptInlineWithoutThreadMetadataCount,
|
|
2020
2325
|
};
|
|
2021
2326
|
|
|
2022
|
-
return { comments: assignDisplayNumbers(
|
|
2327
|
+
return { comments: assignDisplayNumbers(kept), summary };
|
|
2023
2328
|
}
|
|
2024
2329
|
|
|
2025
2330
|
function parseSelectionList(input: string, max: number): SelectionParseResult {
|
|
@@ -2327,23 +2632,29 @@ async function runPrMode(
|
|
|
2327
2632
|
}
|
|
2328
2633
|
|
|
2329
2634
|
export const __test__ = {
|
|
2635
|
+
SUBAGENT_MCP_TOOLS,
|
|
2636
|
+
isMcpCompatibleVersion,
|
|
2637
|
+
resolveSubagentProjectTrusted,
|
|
2638
|
+
createMcpSubagentFactories,
|
|
2639
|
+
disposeSubagentSession,
|
|
2640
|
+
createTriageRuntimeGuardExtension,
|
|
2641
|
+
applyInlineCommentFilter,
|
|
2330
2642
|
assertToolPathInsideCwd,
|
|
2643
|
+
resolveToCwd,
|
|
2644
|
+
buildSafeGitCommand,
|
|
2331
2645
|
buildCommandPayload,
|
|
2332
2646
|
buildSystemPrompt,
|
|
2333
2647
|
buildUserPrompt,
|
|
2334
|
-
createTriageRuntimeGuardExtension,
|
|
2335
2648
|
ensureImplementationNote,
|
|
2336
2649
|
formatFetchedCommentsForSelection,
|
|
2337
2650
|
formatInlineFilterContext,
|
|
2338
2651
|
formatToolCall,
|
|
2339
2652
|
getBlockedBashReason,
|
|
2340
|
-
normalizeComment,
|
|
2341
2653
|
normalizeInput,
|
|
2342
2654
|
parseSelectionList,
|
|
2343
2655
|
parseTriageCommandArgs,
|
|
2344
2656
|
prepareArguments,
|
|
2345
2657
|
aggregateAssistantUsage,
|
|
2346
|
-
addAssistantMessageUsage,
|
|
2347
2658
|
addSessionEventUsage,
|
|
2348
2659
|
inspectFinalAssistant,
|
|
2349
2660
|
classifyRunFailure,
|
|
@@ -2411,9 +2722,9 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2411
2722
|
name: "triage_comments",
|
|
2412
2723
|
label: "Triage comments",
|
|
2413
2724
|
description:
|
|
2414
|
-
"
|
|
2725
|
+
"Isolated subagent with read-only built-in tools and configured MCP tools that triages selected PR review comments against the local checkout and git context. It classifies each comment with evidence, suggests review responses, and proposes handling options without implementing changes.",
|
|
2415
2726
|
promptSnippet:
|
|
2416
|
-
"Triage selected PR review comments in
|
|
2727
|
+
"Triage selected PR review comments in an isolated subagent using read-only built-in tools plus any configured MCP tools; returns verdicts, evidence, response text, and handling options without editing files.",
|
|
2417
2728
|
promptGuidelines: [
|
|
2418
2729
|
"Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
|
|
2419
2730
|
"Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
|
|
@@ -2476,7 +2787,8 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2476
2787
|
});
|
|
2477
2788
|
|
|
2478
2789
|
// Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
|
|
2479
|
-
const
|
|
2790
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
2791
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
2480
2792
|
const resourceLoader = new DefaultResourceLoader({
|
|
2481
2793
|
cwd,
|
|
2482
2794
|
agentDir: getAgentDir(),
|
|
@@ -2486,7 +2798,10 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2486
2798
|
noPromptTemplates: true,
|
|
2487
2799
|
noThemes: true,
|
|
2488
2800
|
noContextFiles: true,
|
|
2489
|
-
extensionFactories: [
|
|
2801
|
+
extensionFactories: [
|
|
2802
|
+
createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
|
|
2803
|
+
...createMcpSubagentFactories(),
|
|
2804
|
+
],
|
|
2490
2805
|
systemPromptOverride: () => systemPrompt,
|
|
2491
2806
|
appendSystemPromptOverride: () => [],
|
|
2492
2807
|
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
@@ -2505,10 +2820,15 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2505
2820
|
sessionManager: SessionManager.inMemory(cwd),
|
|
2506
2821
|
model: ctx.model,
|
|
2507
2822
|
thinkingLevel: pi.getThinkingLevel(),
|
|
2508
|
-
tools: ["read", "grep", "find", "ls", "bash"],
|
|
2823
|
+
tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
|
|
2509
2824
|
});
|
|
2510
2825
|
|
|
2826
|
+
// Assign session BEFORE bindExtensions so the finally block can dispose it
|
|
2827
|
+
// even if binding fails partway through (e.g. a server starts then an
|
|
2828
|
+
// extension's session_start throws).
|
|
2511
2829
|
session = created.session;
|
|
2830
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
2831
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
2512
2832
|
unsubscribe = session.subscribe((event) => {
|
|
2513
2833
|
addSessionEventUsage(usage, event);
|
|
2514
2834
|
switch (event.type) {
|
|
@@ -2598,7 +2918,7 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2598
2918
|
if (runTimeout) clearTimeout(runTimeout);
|
|
2599
2919
|
if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
|
|
2600
2920
|
unsubscribe?.();
|
|
2601
|
-
session
|
|
2921
|
+
await disposeSubagentSession(session);
|
|
2602
2922
|
}
|
|
2603
2923
|
},
|
|
2604
2924
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "A collection of pi extensions and skills for annotation UIs, context management, workflow audits, contrarian review, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, model/provider helpers, and Xiaohei-style article illustrations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -74,9 +74,9 @@
|
|
|
74
74
|
]
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@earendil-works/pi-ai": "^1.0.
|
|
78
|
-
"@earendil-works/pi-coding-agent": "^1.0.
|
|
79
|
-
"@earendil-works/pi-tui": "^1.0.
|
|
77
|
+
"@earendil-works/pi-ai": "^1.0.4",
|
|
78
|
+
"@earendil-works/pi-coding-agent": "^1.0.4",
|
|
79
|
+
"@earendil-works/pi-tui": "^1.0.4",
|
|
80
80
|
"@types/node": "^25.9.1",
|
|
81
81
|
"oxfmt": "0.67.0",
|
|
82
82
|
"typebox": "^1.3.6",
|