@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 { AgentSession, ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
5
8
  import {
@@ -9,6 +12,7 @@ import {
9
12
  createAgentSession,
10
13
  getAgentDir,
11
14
  } from "@earendil-works/pi-coding-agent";
15
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
12
16
  import { Type } from "typebox";
13
17
 
14
18
  import type { Usage } from "@earendil-works/pi-ai";
@@ -72,6 +76,33 @@ const CODE_REVIEWER_MODEL_PREFERENCES = [
72
76
  "grok-4.5",
73
77
  "gemini-3.5-flash",
74
78
  ];
79
+ const AZURE_MODEL_PREFERENCES = [
80
+ "gpt-6-astra ",
81
+ "gpt-6-astra",
82
+ "gpt-6.1-sol ",
83
+ "gpt-6-sol ",
84
+ "gpt-6-sol",
85
+ "gpt-5.6-sol",
86
+ "gpt-5.6-terra",
87
+ "gpt-6-luna ",
88
+ "gpt-6-luna",
89
+ "gpt-5.6-luna",
90
+ "gpt-5.5-pro",
91
+ "gpt-5.5",
92
+ "gpt-5.4-pro",
93
+ "gpt-5.4",
94
+ "gpt-5.3-codex",
95
+ "gpt-5-pro",
96
+ "gpt-5-chat-latest",
97
+ "gpt-5.2-pro",
98
+ "gpt-5.2",
99
+ "gpt-5.1",
100
+ "o3-pro",
101
+ "o1-pro",
102
+ "gpt-5.4-mini",
103
+ "gpt-5-mini",
104
+ ];
105
+
75
106
  const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
76
107
  "amazon-bedrock": [
77
108
  "global.anthropic.claude-opus-5-5 ",
@@ -102,39 +133,12 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
102
133
  "claude-sonnet-4-6",
103
134
  "claude-sonnet-4-5",
104
135
  ],
105
- "azure-openai-responses": [
106
- "gpt-6-astra ",
107
- "gpt-6-astra",
108
- "gpt-6.1-sol ",
109
- "gpt-6-sol ",
110
- "gpt-6-sol",
111
- "gpt-5.6-sol",
112
- "gpt-5.6-terra",
113
- "gpt-6-luna ",
114
- "gpt-6-luna",
115
- "gpt-5.6-luna",
116
- "gpt-5.5-pro",
117
- "gpt-5.5",
118
- "gpt-5.4-pro",
119
- "gpt-5.4",
120
- "gpt-5.3-codex",
121
- "gpt-5-pro",
122
- "gpt-5-chat-latest",
123
- "gpt-5.2-pro",
124
- "gpt-5.2",
125
- "gpt-5.1",
126
- "o3-pro",
127
- "o1-pro",
128
- "gpt-5.4-mini",
129
- "gpt-5-mini",
130
- ],
136
+ azure: AZURE_MODEL_PREFERENCES,
137
+ // Legacy alias for Pi 1.0.0–1.0.2 hosts (renamed to `azure` in Pi 1.0.3).
138
+ "azure-openai-responses": AZURE_MODEL_PREFERENCES,
131
139
  "cloudflare-ai-gateway": [
132
140
  "claude-fable-5",
133
141
  "claude-opus-5",
134
- "claude-opus-4.8",
135
- "claude-opus-4.7",
136
- "claude-opus-4.6",
137
- "claude-opus-4.5",
138
142
  "gpt-6-astra ",
139
143
  "gpt-6-astra",
140
144
  "gpt-5.5",
@@ -286,6 +290,92 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
286
290
  ],
287
291
  };
288
292
 
293
+ const SUBAGENT_MCP_TOOLS = [
294
+ "mcp__*",
295
+ "tool_search",
296
+ "list_mcp_resources",
297
+ "list_mcp_resource_templates",
298
+ "read_mcp_resource",
299
+ ];
300
+
301
+ // Derive project-trust for the isolated subagent session from the host tool context.
302
+ // Fails closed: returns false if the host has not trusted the project, or if the subagent
303
+ // runs in a different directory (e.g. a temp workspace), preventing an untrusted project's
304
+ // .pi/mcp.json from being loaded by the MCP extension.
305
+ function resolveSubagentProjectTrusted(
306
+ ctx: { cwd: string; isProjectTrusted(): boolean },
307
+ subagentCwd: string,
308
+ ): boolean {
309
+ // Feature-detected for older Pi hosts that may lack isProjectTrusted.
310
+ const hostTrusted =
311
+ typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
312
+ ? ctx.isProjectTrusted()
313
+ : false;
314
+ if (!hostTrusted) return false;
315
+ try {
316
+ return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
317
+ } catch {
318
+ return false; // fail closed if realpath resolution fails
319
+ }
320
+ }
321
+
322
+ function isSubagentMcpTool(toolName: string): boolean {
323
+ return (
324
+ toolName.startsWith("mcp__") ||
325
+ toolName === "tool_search" ||
326
+ toolName === "list_mcp_resources" ||
327
+ toolName === "list_mcp_resource_templates" ||
328
+ toolName === "read_mcp_resource"
329
+ );
330
+ }
331
+
332
+ /** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
333
+ function isMcpCompatibleVersion(v: string | undefined): boolean {
334
+ if (!v) return false;
335
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
336
+ if (!m) return false;
337
+ const major = Number(m[1]),
338
+ minor = Number(m[2]),
339
+ patch = Number(m[3]);
340
+ if (major !== 1) return major > 1;
341
+ if (minor !== 0) return minor > 0;
342
+ return patch >= 4;
343
+ }
344
+
345
+ // Gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
346
+ // matches that resolve nothing, so spawning MCP servers provides no benefit.
347
+ function createMcpSubagentFactories(): ExtensionFactory[] {
348
+ const ns = piCodingAgent as Record<string, unknown>;
349
+ if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
350
+ const factories: ExtensionFactory[] = [];
351
+ const mcp = ns["createMcpExtension"];
352
+ const toolSearch = ns["createToolSearchExtension"];
353
+ if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
354
+ if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
355
+ return factories;
356
+ }
357
+
358
+ // Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
359
+ async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
360
+ if (!session) return;
361
+ try {
362
+ const runner = (
363
+ session as unknown as {
364
+ extensionRunner?: {
365
+ hasHandlers(n: string): boolean;
366
+ emit(e: { type: string; reason: string }): Promise<unknown>;
367
+ };
368
+ }
369
+ ).extensionRunner;
370
+ if (runner?.hasHandlers("session_shutdown")) {
371
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
372
+ }
373
+ } catch {
374
+ // Shutdown errors must not prevent dispose.
375
+ }
376
+ session.dispose();
377
+ }
378
+
289
379
  const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
290
380
  const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
291
381
  const READ_ONLY_GIT_SUBCOMMANDS = new Set([
@@ -321,7 +411,12 @@ const SAFE_GIT_BRANCH_FLAGS = new Set([
321
411
  "--no-merged",
322
412
  ]);
323
413
  const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
324
- const SAFE_GIT_CONFIG_OVERRIDES = ["core.pager=cat", "core.fsmonitor=false", "diff.external="];
414
+ const SAFE_GIT_CONFIG_OVERRIDES = [
415
+ "core.pager=cat",
416
+ "core.fsmonitor=false",
417
+ "diff.external=",
418
+ "log.showSignature=false",
419
+ ];
325
420
  const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
326
421
  blame: ["--no-textconv"],
327
422
  diff: ["--no-ext-diff", "--no-textconv"],
@@ -330,12 +425,6 @@ const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
330
425
  whatchanged: ["--no-ext-diff", "--no-textconv"],
331
426
  };
332
427
  const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
333
- {
334
- flag: "--contents",
335
- blockedPrefixes: ["--con"],
336
- reason:
337
- "Code Reviewer bash blocks git --contents because it can read local files outside built-in path guards.",
338
- },
339
428
  {
340
429
  flag: "--pathspec-from-file",
341
430
  blockedPrefixes: ["--pathspec"],
@@ -353,6 +442,15 @@ const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<
353
442
  Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>
354
443
  > = {
355
444
  blame: [
445
+ {
446
+ reason:
447
+ "Code Reviewer bash blocks git blame --contents because it can read local files outside built-in path guards.",
448
+ matches: (token) => {
449
+ if (!token.startsWith("--")) return false;
450
+ const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
451
+ return option === "--contents" || option.startsWith("--con");
452
+ },
453
+ },
356
454
  {
357
455
  reason:
358
456
  "Code Reviewer bash blocks git blame -S because it can read local revs files outside built-in path guards.",
@@ -405,12 +503,8 @@ const CodeReviewerParams = Type.Object({
405
503
  });
406
504
 
407
505
  type ToolCall = {
408
- id: string;
409
506
  name: string;
410
507
  args: unknown;
411
- startedAt: number;
412
- endedAt?: number;
413
- isError?: boolean;
414
508
  };
415
509
 
416
510
  type ReviewStatus = "running" | "done" | "error" | "aborted";
@@ -859,10 +953,80 @@ function isInside(parent: string, child: string): boolean {
859
953
  );
860
954
  }
861
955
 
862
- function resolveToolPath(cwd: string, rawPath: string | undefined): string {
863
- const input = rawPath?.trim() || ".";
864
- const normalized = input.startsWith("@") ? input.slice(1) : input;
865
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
956
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
957
+
958
+ /**
959
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
960
+ * non-win32 paths.
961
+ */
962
+ function normalizeWindowsShellPathLocal(filePath: string): string {
963
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
964
+ return filePath;
965
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
966
+ if (!match) return filePath;
967
+ const suffix = match[2]?.replaceAll("/", "\\");
968
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
969
+ }
970
+
971
+ /**
972
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
973
+ * drive-path normalisation always runs; only tilde expansion is gated by
974
+ * expandTilde.
975
+ */
976
+ function normalizePath(
977
+ input: string,
978
+ opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
979
+ ): string {
980
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
981
+ let p = input;
982
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
983
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
984
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
985
+ if (expandTilde) {
986
+ const home = os.homedir();
987
+ if (p === "~") p = home;
988
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
989
+ p = path.join(home, p.slice(2));
990
+ }
991
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
992
+ return p;
993
+ }
994
+
995
+ /**
996
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
997
+ * __test__ for parity tests; not imported by other extensions.
998
+ */
999
+ function resolveToCwd(rawPath: string, cwd: string): string {
1000
+ const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
1001
+ const base = normalizePath(cwd);
1002
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
1003
+ }
1004
+
1005
+ /** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
1006
+ function tryMacOSScreenshotPathVariant(filePath: string): string {
1007
+ return filePath.replace(/ (AM|PM)\./gi, "\u202F$1.");
1008
+ }
1009
+ /** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
1010
+ function tryNFDPathVariant(filePath: string): string {
1011
+ return filePath.normalize("NFD");
1012
+ }
1013
+ /** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
1014
+ function tryCurlyQuotePathVariant(filePath: string): string {
1015
+ return filePath.replace(/'/g, "\u2019");
1016
+ }
1017
+ /**
1018
+ * Returns all read path candidates including Pi's resolveReadPathAsync fallback
1019
+ * variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
1020
+ */
1021
+ function getReadPathCandidates(resolved: string): string[] {
1022
+ const nfd = tryNFDPathVariant(resolved);
1023
+ return [
1024
+ resolved,
1025
+ tryMacOSScreenshotPathVariant(resolved),
1026
+ nfd,
1027
+ tryCurlyQuotePathVariant(resolved),
1028
+ tryCurlyQuotePathVariant(nfd),
1029
+ ];
866
1030
  }
867
1031
 
868
1032
  async function assertToolPathInsideCwd(
@@ -872,18 +1036,28 @@ async function assertToolPathInsideCwd(
872
1036
  ): Promise<string | undefined> {
873
1037
  if (rawPath !== undefined && typeof rawPath !== "string")
874
1038
  return `${toolName} path must be a string.`;
875
- if (typeof rawPath === "string") {
876
- const normalizedInput = rawPath.trim().startsWith("@")
877
- ? rawPath.trim().slice(1)
878
- : rawPath.trim();
879
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalizedInput)) {
880
- return `${toolName} path must not traverse outside the local checkout.`;
1039
+ // A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
1040
+ // the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
1041
+ // where realpath fails and the lexical fallback starts with the alias prefix.
1042
+ const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
1043
+ const lexicalCwd = path.resolve(cwd);
1044
+ // For the read tool, also check Pi's resolveReadPathAsync fallback variants so
1045
+ // a missing file whose variant resolves outside is not silently allowed.
1046
+ try {
1047
+ const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
1048
+ const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
1049
+ // Deliberate policy: all candidates are checked regardless of existence (not
1050
+ // Pi's existence-ordered selection) so the guard cannot be raced by creating
1051
+ // a variant between the hook and the read. Trade-off: a read is blocked when
1052
+ // an escaping variant symlink sits next to a safe primary file.
1053
+ for (const candidate of candidates) {
1054
+ const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
1055
+ if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
1056
+ return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
881
1057
  }
1058
+ } catch {
1059
+ return `${toolName} path could not be resolved safely.`;
882
1060
  }
883
- const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
884
- const resolved = resolveToolPath(cwd, rawPath);
885
- const realPath = await fs.realpath(resolved).catch(() => resolved);
886
- if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
887
1061
  return undefined;
888
1062
  }
889
1063
 
@@ -1428,10 +1602,10 @@ function createCodeReviewerRuntimeGuardExtension(options: {
1428
1602
  });
1429
1603
 
1430
1604
  pi.on("tool_call", async (event) => {
1431
- if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
1605
+ if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
1432
1606
  return {
1433
1607
  block: true,
1434
- reason: `code_reviewer exposes read-only tools only; ${event.toolName} is not allowed.`,
1608
+ reason: `code_reviewer exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
1435
1609
  };
1436
1610
  }
1437
1611
 
@@ -1505,7 +1679,7 @@ function buildSystemPrompt(options: {
1505
1679
  maxTurns: number;
1506
1680
  maxRunSeconds: number;
1507
1681
  }): string {
1508
- return `You are Code Reviewer, an isolated read-only review agent running inside the code-reviewer package. Review the proposed change against the stated task and the local checkout, then return a concise evidence-based review report.\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, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation.\n\nReview priorities, in order:\n1. Ticket fit: does the change actually satisfy the requested task and stay in scope?\n2. Diff accuracy: do the files and edits match what the task claims changed?\n3. Correctness: could the change fail, regress behavior, mishandle errors, or break edge cases?\n4. Security and safety: look for unsafe command use, path handling issues, secret exposure, injection risks, destructive behavior, and policy violations.\n5. Simplicity and maintainability: call out unnecessary complexity, unclear behavior, or avoidable duplication.\n6. Tests and validation: check whether meaningful verification exists and whether gaps matter for this task.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, install, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Treat the supplied task and diff/context as claims to verify, not as facts. Cite file paths, line ranges, git diff/status output, or supplied diff excerpts as evidence.\n- Prefer the built-in file tools over bash when local file inspection is enough.\n- If evidence is missing, stale, or contradictory, say so plainly.\n- Keep the final review concise and actionable. Report the most important findings first; do not pad with non-findings.\n\nOutput format, exact order:\n## Verdict\nOne short paragraph with the overall review outcome.\n\n## Findings\nUse bullets ordered by severity. For each finding include: severity ('blocker', 'major', or 'minor'), a short title, evidence, and why it matters. If there are no findings, write '- none'.\n\n## Validation\nList the read-only checks you performed, including key files inspected, notable git/gh commands, and any uncertainty that limited the review, or '- none'.\n\n## Scope check\nState whether the change appears to match the requested task and note any missing or extra scope.`;
1682
+ return `You are Code Reviewer, an isolated read-only review agent running inside the code-reviewer package. Review the proposed change against the stated task and the local checkout, then return a concise evidence-based review report.\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, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation. 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\nReview priorities, in order:\n1. Ticket fit: does the change actually satisfy the requested task and stay in scope?\n2. Diff accuracy: do the files and edits match what the task claims changed?\n3. Correctness: could the change fail, regress behavior, mishandle errors, or break edge cases?\n4. Security and safety: look for unsafe command use, path handling issues, secret exposure, injection risks, destructive behavior, and policy violations.\n5. Simplicity and maintainability: call out unnecessary complexity, unclear behavior, or avoidable duplication.\n6. Tests and validation: check whether meaningful verification exists and whether gaps matter for this task.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, install, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Treat the supplied task and diff/context as claims to verify, not as facts. Cite file paths, line ranges, git diff/status output, or supplied diff excerpts as evidence.\n- Prefer the built-in file tools over bash when local file inspection is enough.\n- If evidence is missing, stale, or contradictory, say so plainly.\n- Keep the final review concise and actionable. Report the most important findings first; do not pad with non-findings.\n\nOutput format, exact order:\n## Verdict\nOne short paragraph with the overall review outcome.\n\n## Findings\nUse bullets ordered by severity. For each finding include: severity ('blocker', 'major', or 'minor'), a short title, evidence, and why it matters. If there are no findings, write '- none'.\n\n## Validation\nList the read-only checks you performed, including key files inspected, notable git/gh commands, and any uncertainty that limited the review, or '- none'.\n\n## Scope check\nState whether the change appears to match the requested task and note any missing or extra scope.`;
1509
1683
  }
1510
1684
 
1511
1685
  function buildUserPrompt(
@@ -1564,17 +1738,20 @@ function appendRunDetails(report: string, details: ReviewDetails): string {
1564
1738
  : "thinking unknown";
1565
1739
  const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
1566
1740
  if (!trimmed)
1567
- return `## Verdict\nNo review output was produced.\n\n## Findings\n- major — Missing review output. Evidence: the isolated code_reviewer session returned no assistant text. Why it matters: the review could not be completed.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- ${suffix.trim()}`;
1741
+ return `## Verdict\nNo review output was produced.\n\n## Findings\n- major — Missing review output. Evidence: the isolated code_reviewer session returned no assistant text. Why it matters: the review could not be completed.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.${suffix}`;
1568
1742
  return `${trimmed}${suffix}`;
1569
1743
  }
1570
1744
 
1571
1745
  export const __test__ = {
1572
- assertToolPathInsideCwd,
1573
- buildSystemPrompt,
1574
- buildUserPrompt,
1746
+ SUBAGENT_MCP_TOOLS,
1747
+ isMcpCompatibleVersion,
1748
+ resolveSubagentProjectTrusted,
1749
+ createMcpSubagentFactories,
1750
+ disposeSubagentSession,
1575
1751
  createCodeReviewerRuntimeGuardExtension,
1752
+ assertToolPathInsideCwd,
1753
+ resolveToCwd,
1576
1754
  buildSafeGitCommand,
1577
- formatToolCall,
1578
1755
  getBlockedBashReason,
1579
1756
  isModelAvailabilityError,
1580
1757
  normalizeThinkingLevel,
@@ -1599,11 +1776,11 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1599
1776
  name: "code_reviewer",
1600
1777
  label: "Code reviewer",
1601
1778
  description:
1602
- "Read-only isolated reviewer that checks a proposed change for ticket fit, diff accuracy, correctness, security, simplicity, and validation gaps without implementing anything.",
1779
+ "Isolated reviewer with read-only built-in tools and configured MCP tools that checks a proposed change for ticket fit, diff accuracy, correctness, security, simplicity, and validation gaps without implementing anything.",
1603
1780
  promptSnippet:
1604
- "Run an isolated read-only code review against the local checkout and optional diff; returns concise findings with evidence and run details.",
1781
+ "Run an isolated code review against the local checkout and optional diff using read-only built-in tools plus any configured MCP tools; returns concise findings with evidence and run details.",
1605
1782
  promptGuidelines: [
1606
- "Use code_reviewer when you want an independent read-only review of a proposed change or task implementation.",
1783
+ "Use code_reviewer when you want an independent review of a proposed change or task implementation; it uses read-only built-in tools and any configured MCP tools.",
1607
1784
  "Provide the requested task and any relevant diff/context. Do not use code_reviewer to implement or edit files.",
1608
1785
  ],
1609
1786
  parameters: CodeReviewerParams,
@@ -1661,7 +1838,8 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1661
1838
  try {
1662
1839
  emit();
1663
1840
 
1664
- const isolatedSettingsManager = SettingsManager.inMemory({});
1841
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
1842
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
1665
1843
  const resourceLoader = new DefaultResourceLoader({
1666
1844
  cwd,
1667
1845
  agentDir: getAgentDir(),
@@ -1673,6 +1851,7 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1673
1851
  noContextFiles: true,
1674
1852
  extensionFactories: [
1675
1853
  createCodeReviewerRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
1854
+ ...createMcpSubagentFactories(),
1676
1855
  ],
1677
1856
  systemPromptOverride: () =>
1678
1857
  buildSystemPrompt({
@@ -1712,10 +1891,12 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1712
1891
  sessionManager: SessionManager.inMemory(cwd),
1713
1892
  model: candidate as CreateAgentSessionModel,
1714
1893
  thinkingLevel: thinking.effective,
1715
- tools: ["read", "grep", "find", "ls", "bash"],
1894
+ tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
1716
1895
  });
1717
1896
  attemptSession = created.session;
1718
1897
  session = attemptSession;
1898
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
1899
+ await (created.session as any).bindExtensions?.({ mode: "json" });
1719
1900
  attemptUnsubscribe = attemptSession.subscribe((event) => {
1720
1901
  addSessionEventUsage(usage, event);
1721
1902
  switch (event.type) {
@@ -1725,25 +1906,14 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1725
1906
  break;
1726
1907
  case "tool_execution_start":
1727
1908
  details.toolCalls.push({
1728
- id: event.toolCallId,
1729
1909
  name: event.toolName,
1730
1910
  args: event.args,
1731
- startedAt: Date.now(),
1732
1911
  });
1733
1912
  if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1734
1913
  details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1735
1914
  }
1736
1915
  emit();
1737
1916
  break;
1738
- case "tool_execution_end": {
1739
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1740
- if (call) {
1741
- call.endedAt = Date.now();
1742
- call.isError = event.isError;
1743
- }
1744
- emit();
1745
- break;
1746
- }
1747
1917
  case "message_update":
1748
1918
  if (event.assistantMessageEvent?.type === "text_delta") {
1749
1919
  lastContent += event.assistantMessageEvent.delta ?? "";
@@ -1797,7 +1967,7 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1797
1967
  if (attemptSession && !succeeded) {
1798
1968
  attemptUnsubscribe?.();
1799
1969
  if (unsubscribe === attemptUnsubscribe) unsubscribe = undefined;
1800
- attemptSession.dispose();
1970
+ await disposeSubagentSession(attemptSession);
1801
1971
  if (session === attemptSession) session = undefined;
1802
1972
  }
1803
1973
  }
@@ -1828,7 +1998,7 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1828
1998
  details.error = failure.error;
1829
1999
  details.endedAt = Date.now();
1830
2000
  lastContent = appendRunDetails(
1831
- `## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- failure: ${message}`,
2001
+ `## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.`,
1832
2002
  details,
1833
2003
  );
1834
2004
  emit();
@@ -1837,7 +2007,7 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1837
2007
  if (runTimeout) clearTimeout(runTimeout);
1838
2008
  if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1839
2009
  unsubscribe?.();
1840
- session?.dispose();
2010
+ await disposeSubagentSession(session);
1841
2011
  }
1842
2012
  },
1843
2013
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-code-reviewer",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "A standalone pi extension that adds a read-only code_reviewer subagent tool for isolated code reviews.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -12,20 +12,14 @@ import type {
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  function hasUnsavedUserWork(entries: SessionEntry[]): boolean {
15
- let sawAssistant = false;
16
-
17
15
  for (let index = entries.length - 1; index >= 0; index -= 1) {
18
16
  const entry = entries[index];
19
17
  if (entry.type !== "message") continue;
20
- if (entry.message.role === "assistant") {
21
- sawAssistant = true;
22
- break;
23
- }
18
+ if (entry.message.role === "assistant") return false;
24
19
  if (entry.message.role === "user") return true;
25
20
  }
26
21
 
27
- if (sawAssistant) return false;
28
- return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
22
+ return false;
29
23
  }
30
24
 
31
25
  export default function (pi: ExtensionAPI) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-confirm-destructive",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "A pi extension that confirms destructive session actions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,6 +1,6 @@
1
1
  # context-cap
2
2
 
3
- A pi extension that treats large-context models as having an effective 200k-token context window, so pi's built-in auto-compaction starts earlier, and avoids the dumb zone.
3
+ A pi extension that treats large-context models as having an effective 350k-token context window, so pi's built-in auto-compaction starts earlier, and avoids the dumb zone.
4
4
 
5
5
  By default, pi auto-compacts when:
6
6
 
@@ -11,10 +11,10 @@ contextTokens > model.contextWindow - reserveTokens
11
11
  This extension changes the active model's in-memory `contextWindow` to:
12
12
 
13
13
  ```text
14
- min(originalContextWindow, 200000)
14
+ min(originalContextWindow, 350000)
15
15
  ```
16
16
 
17
- With pi's default `reserveTokens` of 16,384, models larger than 200k will proactively compact around 183,616 tokens.
17
+ With pi's default `reserveTokens` of 16,384, models larger than 350k will proactively compact around 333,616 tokens.
18
18
 
19
19
  ## Commands
20
20
 
@@ -65,21 +65,21 @@ Then reload pi:
65
65
 
66
66
  ### Settings guard and alternative
67
67
 
68
- - On Pi hosts exposing `getSettings()`, the extension resolves `reserveTokens` using Pi's precedence: `compaction.modelOverrides["provider/model-id"].reserveTokens`, then `compaction.reserveTokens`, then the default `16,384`. It skips the cap when the resolved value is at least `200,000` (which would make the threshold zero or negative), or when any explicit entry exists for that model. Hosts without `getSettings()` use the older cap behavior without this guard.
69
- - Pi's provider request path also derives an output-token budget from the effective window (`contextWindow - contextTokens - 4096`, with any provider-specific clamp applied afterward). The cap can therefore reduce response or thinking-token budgets near the limit, in addition to causing earlier compaction; Pi's overflow handling may also treat a request above 200k as overflow and retry after compaction. Use `/context-cap off` when you need the full model window temporarily.
70
- - As a session-scoped alternative, configure Pi's own `compaction.modelOverrides` instead of this extension. The exact key is `provider/model-id`; to match this extension's default 200k threshold for a model with original window `W`, let `R` be the configured `reserveTokens` value (default `16,384`) and use:
68
+ - On Pi hosts exposing `getSettings()`, the extension skips the cap whenever `compaction.modelOverrides` contains the active model's key (`provider/model-id`), whatever fields that entry sets; it does not read `reserveTokens` from the override. Otherwise it uses `compaction.reserveTokens`, then the default `16,384`, and skips the cap when that value is at least `350,000` (which would make the threshold zero or negative). Hosts without `getSettings()` use the older cap behavior without this guard.
69
+ - Pi's provider request path also derives an output-token budget from the effective window (`contextWindow - contextTokens - 4096`, with any provider-specific clamp applied afterward). The cap can therefore reduce response or thinking-token budgets near the limit, in addition to causing earlier compaction; Pi's overflow handling may also treat a request above 350k as overflow and retry after compaction. Use `/context-cap off` when you need the full model window temporarily.
70
+ - As a session-scoped alternative, configure Pi's own `compaction.modelOverrides` instead of this extension. The exact key is `provider/model-id`; to match this extension's default 350k threshold for a model with original window `W`, let `R` be the configured `reserveTokens` value (default `16,384`) and use:
71
71
 
72
72
  ```text
73
- reserveTokens = W - 200000 + R
73
+ reserveTokens = W - 350000 + R
74
74
  ```
75
75
 
76
- For example, with the default `R`, a 1,000,000-token model uses `816384`, yielding the same `183616` threshold as `200000 - 16384`:
76
+ For example, with the default `R`, a 1,000,000-token model uses `666384`, yielding the same `333616` threshold as `350000 - 16384`:
77
77
 
78
78
  ```json
79
79
  {
80
80
  "compaction": {
81
81
  "modelOverrides": {
82
- "provider/model-id": { "reserveTokens": 816384 }
82
+ "provider/model-id": { "reserveTokens": 666384 }
83
83
  }
84
84
  }
85
85
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Model, Api } from "@earendil-works/pi-ai";
3
3
 
4
- const DEFAULT_MAX_CONTEXT_WINDOW = 200_000;
4
+ const DEFAULT_MAX_CONTEXT_WINDOW = 350_000;
5
5
  const DEFAULT_RESERVE_TOKENS = 16_384;
6
6
  const PROCESS_STATE_KEY = Symbol.for("pi-extensions.context-cap.model-holds.v1");
7
7
  type AnyModel = Model<Api> | Model<any>;
@@ -224,7 +224,7 @@ function unavailableReason(result: ApplyResult | undefined): string | undefined
224
224
  return "physical model has no compatible context-window limit";
225
225
  }
226
226
  if (result?.reason === "reserve") {
227
- return `compaction reserveTokens (${formatTokens(result.reserveTokens ?? DEFAULT_MAX_CONTEXT_WINDOW)}) is at least 200k; the auto-compaction threshold would be zero or negative`;
227
+ return `compaction reserveTokens (${formatTokens(result.reserveTokens ?? DEFAULT_MAX_CONTEXT_WINDOW)}) is at least ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)}; the auto-compaction threshold would be zero or negative`;
228
228
  }
229
229
  if (result?.reason === "modelOverride") {
230
230
  return `compaction.modelOverrides[\"${result.key}\"] is configured; leaving the model window unchanged`;
@@ -317,7 +317,7 @@ export default function contextCapExtension(pi: ExtensionAPI) {
317
317
  }
318
318
 
319
319
  pi.registerCommand("context-cap", {
320
- description: "Toggle the 200k effective context-window cap for auto-compaction",
320
+ description: `Toggle the ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)} effective context-window cap for auto-compaction`,
321
321
  getArgumentCompletions: (prefix) => {
322
322
  const commands = ["on", "off", "toggle", "status"];
323
323
  const matches = commands.filter((command) => command.startsWith(prefix.trim()));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-cap",
3
- "version": "0.1.13",
4
- "description": "A pi extension that caps effective model context windows at 200k tokens for earlier auto-compaction.",
3
+ "version": "0.1.16",
4
+ "description": "A pi extension that caps effective model context windows at 350k tokens for earlier auto-compaction.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",