@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
|
@@ -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",
|
|
@@ -120,8 +272,6 @@ type ToolCall = {
|
|
|
120
272
|
id: string;
|
|
121
273
|
name: string;
|
|
122
274
|
args: unknown;
|
|
123
|
-
startedAt: number;
|
|
124
|
-
endedAt?: number;
|
|
125
275
|
isError?: boolean;
|
|
126
276
|
};
|
|
127
277
|
|
|
@@ -372,7 +522,7 @@ function buildSystemPrompt(options: {
|
|
|
372
522
|
maxTurns: number;
|
|
373
523
|
maxRunSeconds: number;
|
|
374
524
|
}): 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.\"`;
|
|
525
|
+
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
526
|
}
|
|
377
527
|
|
|
378
528
|
function buildUserPrompt(input: NormalizedInput, cwd: string): string {
|
|
@@ -508,10 +658,80 @@ function isInside(parent: string, child: string): boolean {
|
|
|
508
658
|
);
|
|
509
659
|
}
|
|
510
660
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
661
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
665
|
+
* non-win32 paths.
|
|
666
|
+
*/
|
|
667
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
668
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
669
|
+
return filePath;
|
|
670
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
671
|
+
if (!match) return filePath;
|
|
672
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
673
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
678
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
679
|
+
* expandTilde.
|
|
680
|
+
*/
|
|
681
|
+
function normalizePath(
|
|
682
|
+
input: string,
|
|
683
|
+
opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
|
|
684
|
+
): string {
|
|
685
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
686
|
+
let p = input;
|
|
687
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
688
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
689
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
690
|
+
if (expandTilde) {
|
|
691
|
+
const home = os.homedir();
|
|
692
|
+
if (p === "~") p = home;
|
|
693
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
694
|
+
p = path.join(home, p.slice(2));
|
|
695
|
+
}
|
|
696
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
697
|
+
return p;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
702
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
703
|
+
*/
|
|
704
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
705
|
+
const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
|
706
|
+
const base = normalizePath(cwd);
|
|
707
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
711
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
712
|
+
return filePath.replace(/ (AM|PM)\./gi, " $1.");
|
|
713
|
+
}
|
|
714
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
715
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
716
|
+
return filePath.normalize("NFD");
|
|
717
|
+
}
|
|
718
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
719
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
720
|
+
return filePath.replace(/'/g, "’");
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
724
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
725
|
+
*/
|
|
726
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
727
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
728
|
+
return [
|
|
729
|
+
resolved,
|
|
730
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
731
|
+
nfd,
|
|
732
|
+
tryCurlyQuotePathVariant(resolved),
|
|
733
|
+
tryCurlyQuotePathVariant(nfd),
|
|
734
|
+
];
|
|
515
735
|
}
|
|
516
736
|
|
|
517
737
|
async function assertToolPathInsideCwd(
|
|
@@ -521,10 +741,28 @@ async function assertToolPathInsideCwd(
|
|
|
521
741
|
): Promise<string | undefined> {
|
|
522
742
|
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
523
743
|
return `${toolName} path must be a string.`;
|
|
744
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
745
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
746
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
524
747
|
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
748
|
+
const lexicalCwd = path.resolve(cwd);
|
|
749
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
750
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
751
|
+
try {
|
|
752
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
753
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
754
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
755
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
756
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
757
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
758
|
+
for (const candidate of candidates) {
|
|
759
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
760
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
761
|
+
return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
return `${toolName} path could not be resolved safely.`;
|
|
765
|
+
}
|
|
528
766
|
return undefined;
|
|
529
767
|
}
|
|
530
768
|
|
|
@@ -683,6 +921,10 @@ function getExecutableName(token: string): string {
|
|
|
683
921
|
return path.basename(token).toLowerCase();
|
|
684
922
|
}
|
|
685
923
|
|
|
924
|
+
function shellQuoteToken(token: string): string {
|
|
925
|
+
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
926
|
+
}
|
|
927
|
+
|
|
686
928
|
function getShellSyntaxReason(command: string): string | undefined {
|
|
687
929
|
return tokenizeShellCommand(command).reason;
|
|
688
930
|
}
|
|
@@ -805,8 +1047,24 @@ function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): bool
|
|
|
805
1047
|
return remoteAction === "show" || remoteAction === "get-url";
|
|
806
1048
|
}
|
|
807
1049
|
|
|
808
|
-
function
|
|
809
|
-
|
|
1050
|
+
function isBlockedGitLocalFileOption(
|
|
1051
|
+
token: string,
|
|
1052
|
+
flag: string,
|
|
1053
|
+
blockedPrefixes: readonly string[],
|
|
1054
|
+
): boolean {
|
|
1055
|
+
if (!token.startsWith("--")) return false;
|
|
1056
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
1057
|
+
if (option === flag) return true;
|
|
1058
|
+
return blockedPrefixes.some((prefix) => option.startsWith(prefix));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function getUnsafeGitArgumentReason(
|
|
1062
|
+
tokens: string[],
|
|
1063
|
+
parsed: { subcommand?: string; index: number },
|
|
1064
|
+
): string | undefined {
|
|
1065
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1066
|
+
const token = tokens[index];
|
|
1067
|
+
const lowerToken = token.toLowerCase();
|
|
810
1068
|
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
811
1069
|
return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
812
1070
|
}
|
|
@@ -825,21 +1083,62 @@ function getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
|
|
|
825
1083
|
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
826
1084
|
return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
|
|
827
1085
|
}
|
|
1086
|
+
if (token === "--filters" || token.startsWith("--filters=")) {
|
|
1087
|
+
return "Triage bash blocks git --filters because it can execute clean/smudge filters from local config.";
|
|
1088
|
+
}
|
|
1089
|
+
if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
|
|
1090
|
+
return "Triage bash blocks git signature verification flags because they can execute configured GPG helpers.";
|
|
1091
|
+
}
|
|
1092
|
+
if (token === "--help" || token.startsWith("--help=")) {
|
|
1093
|
+
return "Triage bash blocks git help output because it can execute configured help, man, or browser helpers.";
|
|
1094
|
+
}
|
|
1095
|
+
if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
|
|
1096
|
+
return "Triage bash blocks git signature format atoms because they can execute configured GPG helpers.";
|
|
1097
|
+
}
|
|
828
1098
|
if (token === "--output" || token.startsWith("--output=")) {
|
|
829
1099
|
return "Triage bash blocks git output-writing flags.";
|
|
830
1100
|
}
|
|
1101
|
+
for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
|
|
1102
|
+
if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
|
|
1103
|
+
}
|
|
1104
|
+
if (index > parsed.index) {
|
|
1105
|
+
for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
|
|
1106
|
+
[]) {
|
|
1107
|
+
if (option.matches(token)) return option.reason;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
831
1110
|
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
832
1111
|
if (pathReason) return pathReason;
|
|
833
1112
|
}
|
|
834
1113
|
return undefined;
|
|
835
1114
|
}
|
|
836
1115
|
|
|
1116
|
+
function buildSafeGitCommand(tokens: string[]): string {
|
|
1117
|
+
const parsed = getGitSubcommand(tokens);
|
|
1118
|
+
const prefix = [
|
|
1119
|
+
"git",
|
|
1120
|
+
"--no-pager",
|
|
1121
|
+
"--no-optional-locks",
|
|
1122
|
+
...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
|
|
1123
|
+
];
|
|
1124
|
+
const subcommand = parsed.subcommand;
|
|
1125
|
+
if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
|
|
1126
|
+
const args = tokens.slice(parsed.index + 1);
|
|
1127
|
+
const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
|
|
1128
|
+
(flag) => !args.includes(flag),
|
|
1129
|
+
);
|
|
1130
|
+
return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
837
1133
|
function getBlockedGitReason(command: string): string | undefined {
|
|
838
1134
|
const tokens = tokenizeSegment(command);
|
|
839
1135
|
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
840
1136
|
const parsed = getGitSubcommand(tokens);
|
|
841
1137
|
if (parsed.reason) return parsed.reason;
|
|
842
|
-
|
|
1138
|
+
if (parsed.subcommand === "help") {
|
|
1139
|
+
return "Triage bash blocks git help because it can execute configured help, man, or browser helpers.";
|
|
1140
|
+
}
|
|
1141
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
|
|
843
1142
|
if (argumentReason) return argumentReason;
|
|
844
1143
|
if (!parsed.subcommand) return undefined;
|
|
845
1144
|
if (parsed.subcommand === "branch") {
|
|
@@ -998,10 +1297,10 @@ function createTriageRuntimeGuardExtension(options: {
|
|
|
998
1297
|
});
|
|
999
1298
|
|
|
1000
1299
|
pi.on("tool_call", async (event) => {
|
|
1001
|
-
if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
|
|
1300
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
|
|
1002
1301
|
return {
|
|
1003
1302
|
block: true,
|
|
1004
|
-
reason: `triage_comments exposes read-only tools
|
|
1303
|
+
reason: `triage_comments exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
|
|
1005
1304
|
};
|
|
1006
1305
|
}
|
|
1007
1306
|
|
|
@@ -1050,6 +1349,9 @@ function createTriageRuntimeGuardExtension(options: {
|
|
|
1050
1349
|
const command = typeof input.command === "string" ? input.command : "";
|
|
1051
1350
|
const reason = getBlockedBashReason(command);
|
|
1052
1351
|
if (reason) return { block: true, reason };
|
|
1352
|
+
const tokens = tokenizeSegment(command.trim());
|
|
1353
|
+
if (getExecutableName(tokens[0] ?? "") === "git")
|
|
1354
|
+
input.command = buildSafeGitCommand(tokens);
|
|
1053
1355
|
}
|
|
1054
1356
|
|
|
1055
1357
|
return undefined;
|
|
@@ -1287,10 +1589,8 @@ function parseTriageCommandArgs(args: string): ParsedTriageCommandArgs {
|
|
|
1287
1589
|
|
|
1288
1590
|
if (normalized === "--help" || normalized === "-h" || normalized === "help")
|
|
1289
1591
|
return { help: true };
|
|
1290
|
-
if (normalized === "paste"
|
|
1291
|
-
|
|
1292
|
-
if (normalized === "pr" || normalized === "pull" || normalized === "pull-request")
|
|
1293
|
-
return { mode: "pr", target: rest || undefined };
|
|
1592
|
+
if (normalized === "paste") return { mode: "paste", pastePrefill: rest || undefined };
|
|
1593
|
+
if (normalized === "pr") return { mode: "pr", target: rest || undefined };
|
|
1294
1594
|
if (looksLikePrTarget(trimmed)) return { mode: "pr", target: trimmed };
|
|
1295
1595
|
|
|
1296
1596
|
return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
|
|
@@ -2001,14 +2301,6 @@ function applyInlineCommentFilter(
|
|
|
2001
2301
|
kept.push(comment);
|
|
2002
2302
|
}
|
|
2003
2303
|
|
|
2004
|
-
const commentsWithFilterMetadata = kept.map((comment) => ({
|
|
2005
|
-
...comment,
|
|
2006
|
-
metadata: compactRecord({
|
|
2007
|
-
...comment.metadata,
|
|
2008
|
-
preFilterDisplayNumber: comment.displayNumber,
|
|
2009
|
-
}),
|
|
2010
|
-
}));
|
|
2011
|
-
|
|
2012
2304
|
const summary: AppliedInlineCommentFilter = {
|
|
2013
2305
|
filter,
|
|
2014
2306
|
originalCount: comments.length,
|
|
@@ -2019,7 +2311,7 @@ function applyInlineCommentFilter(
|
|
|
2019
2311
|
keptInlineWithoutThreadMetadataCount,
|
|
2020
2312
|
};
|
|
2021
2313
|
|
|
2022
|
-
return { comments: assignDisplayNumbers(
|
|
2314
|
+
return { comments: assignDisplayNumbers(kept), summary };
|
|
2023
2315
|
}
|
|
2024
2316
|
|
|
2025
2317
|
function parseSelectionList(input: string, max: number): SelectionParseResult {
|
|
@@ -2327,23 +2619,29 @@ async function runPrMode(
|
|
|
2327
2619
|
}
|
|
2328
2620
|
|
|
2329
2621
|
export const __test__ = {
|
|
2622
|
+
SUBAGENT_MCP_TOOLS,
|
|
2623
|
+
isMcpCompatibleVersion,
|
|
2624
|
+
resolveSubagentProjectTrusted,
|
|
2625
|
+
createMcpSubagentFactories,
|
|
2626
|
+
disposeSubagentSession,
|
|
2627
|
+
createTriageRuntimeGuardExtension,
|
|
2628
|
+
applyInlineCommentFilter,
|
|
2330
2629
|
assertToolPathInsideCwd,
|
|
2630
|
+
resolveToCwd,
|
|
2631
|
+
buildSafeGitCommand,
|
|
2331
2632
|
buildCommandPayload,
|
|
2332
2633
|
buildSystemPrompt,
|
|
2333
2634
|
buildUserPrompt,
|
|
2334
|
-
createTriageRuntimeGuardExtension,
|
|
2335
2635
|
ensureImplementationNote,
|
|
2336
2636
|
formatFetchedCommentsForSelection,
|
|
2337
2637
|
formatInlineFilterContext,
|
|
2338
2638
|
formatToolCall,
|
|
2339
2639
|
getBlockedBashReason,
|
|
2340
|
-
normalizeComment,
|
|
2341
2640
|
normalizeInput,
|
|
2342
2641
|
parseSelectionList,
|
|
2343
2642
|
parseTriageCommandArgs,
|
|
2344
2643
|
prepareArguments,
|
|
2345
2644
|
aggregateAssistantUsage,
|
|
2346
|
-
addAssistantMessageUsage,
|
|
2347
2645
|
addSessionEventUsage,
|
|
2348
2646
|
inspectFinalAssistant,
|
|
2349
2647
|
classifyRunFailure,
|
|
@@ -2411,9 +2709,9 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2411
2709
|
name: "triage_comments",
|
|
2412
2710
|
label: "Triage comments",
|
|
2413
2711
|
description:
|
|
2414
|
-
"
|
|
2712
|
+
"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
2713
|
promptSnippet:
|
|
2416
|
-
"Triage selected PR review comments in
|
|
2714
|
+
"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
2715
|
promptGuidelines: [
|
|
2418
2716
|
"Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
|
|
2419
2717
|
"Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
|
|
@@ -2476,7 +2774,8 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2476
2774
|
});
|
|
2477
2775
|
|
|
2478
2776
|
// Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
|
|
2479
|
-
const
|
|
2777
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
2778
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
2480
2779
|
const resourceLoader = new DefaultResourceLoader({
|
|
2481
2780
|
cwd,
|
|
2482
2781
|
agentDir: getAgentDir(),
|
|
@@ -2486,7 +2785,10 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2486
2785
|
noPromptTemplates: true,
|
|
2487
2786
|
noThemes: true,
|
|
2488
2787
|
noContextFiles: true,
|
|
2489
|
-
extensionFactories: [
|
|
2788
|
+
extensionFactories: [
|
|
2789
|
+
createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
|
|
2790
|
+
...createMcpSubagentFactories(),
|
|
2791
|
+
],
|
|
2490
2792
|
systemPromptOverride: () => systemPrompt,
|
|
2491
2793
|
appendSystemPromptOverride: () => [],
|
|
2492
2794
|
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
@@ -2505,10 +2807,15 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2505
2807
|
sessionManager: SessionManager.inMemory(cwd),
|
|
2506
2808
|
model: ctx.model,
|
|
2507
2809
|
thinkingLevel: pi.getThinkingLevel(),
|
|
2508
|
-
tools: ["read", "grep", "find", "ls", "bash"],
|
|
2810
|
+
tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
|
|
2509
2811
|
});
|
|
2510
2812
|
|
|
2813
|
+
// Assign session BEFORE bindExtensions so the finally block can dispose it
|
|
2814
|
+
// even if binding fails partway through (e.g. a server starts then an
|
|
2815
|
+
// extension's session_start throws).
|
|
2511
2816
|
session = created.session;
|
|
2817
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
2818
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
2512
2819
|
unsubscribe = session.subscribe((event) => {
|
|
2513
2820
|
addSessionEventUsage(usage, event);
|
|
2514
2821
|
switch (event.type) {
|
|
@@ -2521,7 +2828,6 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2521
2828
|
id: event.toolCallId,
|
|
2522
2829
|
name: event.toolName,
|
|
2523
2830
|
args: event.args,
|
|
2524
|
-
startedAt: Date.now(),
|
|
2525
2831
|
});
|
|
2526
2832
|
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
2527
2833
|
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
@@ -2531,7 +2837,6 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2531
2837
|
case "tool_execution_end": {
|
|
2532
2838
|
const call = details.toolCalls.find((item) => item.id === event.toolCallId);
|
|
2533
2839
|
if (call) {
|
|
2534
|
-
call.endedAt = Date.now();
|
|
2535
2840
|
call.isError = event.isError;
|
|
2536
2841
|
}
|
|
2537
2842
|
emit();
|
|
@@ -2598,7 +2903,7 @@ export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
|
2598
2903
|
if (runTimeout) clearTimeout(runTimeout);
|
|
2599
2904
|
if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
|
|
2600
2905
|
unsubscribe?.();
|
|
2601
|
-
session
|
|
2906
|
+
await disposeSubagentSession(session);
|
|
2602
2907
|
}
|
|
2603
2908
|
},
|
|
2604
2909
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.79",
|
|
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",
|