@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.
Files changed (63) 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 +275 -59
  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/glimpseui.d.ts +5 -65
  7. package/extensions/annotate-git-diff/package.json +1 -1
  8. package/extensions/annotate-last-message/glimpseui.d.ts +5 -65
  9. package/extensions/annotate-last-message/package.json +1 -1
  10. package/extensions/annotate-last-message/web/index.html +3 -30
  11. package/extensions/code-reviewer/README.md +5 -5
  12. package/extensions/code-reviewer/index.ts +252 -82
  13. package/extensions/code-reviewer/package.json +1 -1
  14. package/extensions/confirm-destructive/index.ts +2 -8
  15. package/extensions/confirm-destructive/package.json +1 -1
  16. package/extensions/context-cap/README.md +9 -9
  17. package/extensions/context-cap/index.ts +3 -3
  18. package/extensions/context-cap/package.json +2 -2
  19. package/extensions/context-inspector/index.ts +2 -46
  20. package/extensions/context-inspector/package.json +1 -1
  21. package/extensions/contrarian/README.md +4 -3
  22. package/extensions/contrarian/index.ts +48 -67
  23. package/extensions/contrarian/package.json +1 -1
  24. package/extensions/dirty-repo-guard/index.ts +0 -4
  25. package/extensions/dirty-repo-guard/package.json +1 -1
  26. package/extensions/dynamic-context-pruning/README.md +20 -11
  27. package/extensions/dynamic-context-pruning/index.ts +118 -341
  28. package/extensions/dynamic-context-pruning/package.json +1 -1
  29. package/extensions/fast/README.md +18 -11
  30. package/extensions/fast/index.ts +9 -5
  31. package/extensions/fast/package.json +1 -1
  32. package/extensions/git-footer/index.ts +2 -16
  33. package/extensions/git-footer/package.json +1 -1
  34. package/extensions/inline-bash/index.ts +0 -16
  35. package/extensions/inline-bash/package.json +1 -1
  36. package/extensions/librarian/README.md +1 -1
  37. package/extensions/librarian/index.ts +246 -70
  38. package/extensions/librarian/package.json +1 -1
  39. package/extensions/minimal-footer/README.md +5 -5
  40. package/extensions/minimal-footer/index.ts +5 -26
  41. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  42. package/extensions/minimal-footer/openai-usage.ts +2 -11
  43. package/extensions/minimal-footer/package.json +1 -1
  44. package/extensions/oracle/README.md +4 -3
  45. package/extensions/oracle/index.ts +46 -65
  46. package/extensions/oracle/package.json +1 -1
  47. package/extensions/permission-gate/README.md +3 -0
  48. package/extensions/permission-gate/index.ts +127 -36
  49. package/extensions/permission-gate/package.json +1 -1
  50. package/extensions/project-mcp-json/README.md +2 -2
  51. package/extensions/project-mcp-json/package.json +1 -1
  52. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  53. package/extensions/quiet-tools/README.md +30 -19
  54. package/extensions/quiet-tools/index.ts +233 -166
  55. package/extensions/quiet-tools/package.json +2 -2
  56. package/extensions/review/README.md +2 -0
  57. package/extensions/review/package.json +1 -1
  58. package/extensions/todo/index.ts +0 -19
  59. package/extensions/todo/package.json +1 -1
  60. package/extensions/triage-comments/README.md +4 -2
  61. package/extensions/triage-comments/index.ts +344 -39
  62. package/extensions/triage-comments/package.json +1 -1
  63. 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
- function resolveToolPath(cwd: string, rawPath: string | undefined): string {
512
- const input = rawPath?.trim() || ".";
513
- const normalized = input.startsWith("@") ? input.slice(1) : input;
514
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
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 resolved = resolveToolPath(cwd, rawPath);
526
- const realPath = await fs.realpath(resolved).catch(() => resolved);
527
- if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
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 getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
809
- for (const token of tokens.slice(1)) {
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
- const argumentReason = getUnsafeGitArgumentReason(tokens);
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 only; ${event.toolName} is not allowed.`,
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" || 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 };
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(commentsWithFilterMetadata), summary };
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
- "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.",
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 a read-only isolated subagent; returns verdicts, evidence, response text, and handling options without editing files.",
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 isolatedSettingsManager = SettingsManager.inMemory({});
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: [createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
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?.dispose();
2906
+ await disposeSubagentSession(session);
2602
2907
  }
2603
2908
  },
2604
2909
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-triage-comments",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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.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.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",