@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.
Files changed (53) hide show
  1. package/README.md +8 -8
  2. package/extensions/agent-workflow-audit/README.md +1 -1
  3. package/extensions/agent-workflow-audit/index.ts +277 -44
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/git.ts +0 -8
  6. package/extensions/annotate-git-diff/package.json +1 -1
  7. package/extensions/annotate-last-message/package.json +1 -1
  8. package/extensions/annotate-last-message/web/index.html +3 -3
  9. package/extensions/code-reviewer/README.md +5 -5
  10. package/extensions/code-reviewer/index.ts +258 -54
  11. package/extensions/code-reviewer/package.json +1 -1
  12. package/extensions/confirm-destructive/index.ts +2 -8
  13. package/extensions/confirm-destructive/package.json +1 -1
  14. package/extensions/context-cap/README.md +9 -9
  15. package/extensions/context-cap/index.ts +3 -3
  16. package/extensions/context-cap/package.json +2 -2
  17. package/extensions/contrarian/README.md +4 -3
  18. package/extensions/contrarian/index.ts +47 -40
  19. package/extensions/contrarian/package.json +1 -1
  20. package/extensions/dirty-repo-guard/index.ts +0 -4
  21. package/extensions/dirty-repo-guard/package.json +1 -1
  22. package/extensions/dynamic-context-pruning/README.md +5 -5
  23. package/extensions/dynamic-context-pruning/index.ts +114 -232
  24. package/extensions/dynamic-context-pruning/package.json +1 -1
  25. package/extensions/fast/README.md +2 -2
  26. package/extensions/fast/package.json +1 -1
  27. package/extensions/inline-bash/index.ts +0 -16
  28. package/extensions/inline-bash/package.json +1 -1
  29. package/extensions/librarian/README.md +1 -1
  30. package/extensions/librarian/index.ts +247 -39
  31. package/extensions/librarian/package.json +1 -1
  32. package/extensions/minimal-footer/README.md +5 -5
  33. package/extensions/minimal-footer/index.ts +3 -10
  34. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  35. package/extensions/minimal-footer/package.json +1 -1
  36. package/extensions/oracle/README.md +4 -3
  37. package/extensions/oracle/index.ts +45 -38
  38. package/extensions/oracle/package.json +1 -1
  39. package/extensions/permission-gate/README.md +3 -0
  40. package/extensions/permission-gate/index.ts +151 -28
  41. package/extensions/permission-gate/package.json +1 -1
  42. package/extensions/project-mcp-json/README.md +2 -2
  43. package/extensions/project-mcp-json/package.json +1 -1
  44. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  45. package/extensions/quiet-tools/README.md +30 -19
  46. package/extensions/quiet-tools/index.ts +233 -166
  47. package/extensions/quiet-tools/package.json +2 -2
  48. package/extensions/todo/index.ts +0 -19
  49. package/extensions/todo/package.json +1 -1
  50. package/extensions/triage-comments/README.md +4 -2
  51. package/extensions/triage-comments/index.ts +349 -29
  52. package/extensions/triage-comments/package.json +1 -1
  53. 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)) return `${toolName} is limited to the local checkout: ${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 getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
809
- for (const token of tokens.slice(1)) {
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
- const argumentReason = getUnsafeGitArgumentReason(tokens);
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 only; ${event.toolName} is not allowed.`,
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" || normalized === "manual")
1291
- return { mode: "paste", pastePrefill: rest || undefined };
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(commentsWithFilterMetadata), summary };
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
- "Read-only subagent 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.",
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 a read-only isolated subagent; returns verdicts, evidence, response text, and handling options without editing files.",
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 isolatedSettingsManager = SettingsManager.inMemory({});
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: [createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
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?.dispose();
2921
+ await disposeSubagentSession(session);
2602
2922
  }
2603
2923
  },
2604
2924
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-triage-comments",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A pi extension that adds /triage-comments and a read-only triage_comments subagent tool for review-comment triage.",
5
5
  "keywords": [
6
6
  "pi-package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.76",
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.0",
78
- "@earendil-works/pi-coding-agent": "^1.0.0",
79
- "@earendil-works/pi-tui": "^1.0.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",