@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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-dynamic-context-pruning",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "description": "A pi extension that dynamically prunes stale context to keep sessions lean.",
6
6
  "keywords": [
@@ -143,13 +143,13 @@ Optional global config:
143
143
  ~/<pi-config-dir>/agent/extensions/fast.json
144
144
  ```
145
145
 
146
- Optional project config:
146
+ Optional project config, from the nearest ancestor of the working directory (the working directory itself counts):
147
147
 
148
148
  ```text
149
149
  <project>/<pi-config-dir>/fast.json
150
150
  ```
151
151
 
152
- Here `<pi-config-dir>` is Pi's runtime config directory name (`CONFIG_DIR_NAME`; `.pi` by default). Project config overrides global config after Pi reports that the project is trusted.
152
+ Here `<pi-config-dir>` is Pi's runtime config directory name (`CONFIG_DIR_NAME`; `.pi` by default). The loader uses that nearest ancestor `fast.json`, which overrides global config after Pi reports that the project is trusted.
153
153
 
154
154
  ```json
155
155
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-fast",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A pi extension that enables Fast mode for supported direct OpenAI API, OpenAI Codex, and Anthropic Claude models based on the currently selected model.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,19 +1,3 @@
1
- /**
2
- * Inline Bash Extension - expands inline bash commands in user prompts.
3
- *
4
- * Start pi with this extension:
5
- * pi -e ./extensions/inline-bash/index.ts
6
- *
7
- * Then type prompts with inline bash:
8
- * What's in !{pwd}?
9
- * The current branch is !{git branch --show-current} and status: !{git status --short}
10
- * My node version is !{node --version}
11
- *
12
- * The !{command} patterns are executed and replaced with their trimmed output
13
- * before the prompt is sent to the agent. Very large expansions are truncated.
14
- *
15
- * Note: Regular !command syntax (whole-line bash) is preserved and works as before.
16
- */
17
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
2
 
19
3
  export default function (pi: ExtensionAPI) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-inline-bash",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "A pi extension that expands inline bash commands in user prompts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,7 +35,7 @@ Then reload pi:
35
35
  ## Behavior
36
36
 
37
37
  - Tool name: `librarian`
38
- - Uses a restricted subagent with `bash` and `read`
38
+ - Uses a restricted subagent with `bash`, `read`, and any configured MCP tools (MCP tools run without confirmation; may have side effects; use for evidence gathering only; requires host Pi >=1.0.4; older hosts degrade to no MCP, no error); the global `mcp.json` always applies; a project `.pi/mcp.json` is never loaded (the librarian subagent runs in a separate temp workspace)
39
39
  - Uses `gh` for GitHub search/API access
40
40
  - Uses cached local checkouts only when enabled
41
41
  - Toggle cache behavior for future calls with `/librarian-cache on | off | toggle | status`
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Dirent, Stats } from "node:fs";
3
+ import { realpathSync } from "node:fs";
3
4
  import type { FileHandle } from "node:fs/promises";
4
5
  import * as fs from "node:fs/promises";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
 
8
10
  import type {
9
11
  ExtensionAPI,
@@ -19,6 +21,7 @@ import {
19
21
  getAgentDir,
20
22
  getMarkdownTheme,
21
23
  } from "@earendil-works/pi-coding-agent";
24
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
22
25
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
23
26
  import { Type } from "typebox";
24
27
 
@@ -90,6 +93,82 @@ function getModelRuntimeOption(ctx: {
90
93
  const DEFAULT_CACHE_MODE: CacheMode = "disabled";
91
94
  const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
92
95
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
96
+ const SUBAGENT_MCP_TOOLS = [
97
+ "mcp__*",
98
+ "tool_search",
99
+ "list_mcp_resources",
100
+ "list_mcp_resource_templates",
101
+ "read_mcp_resource",
102
+ ];
103
+
104
+ // Derive project-trust for the isolated subagent session from the host tool context.
105
+ // Fails closed: returns false if the host has not trusted the project, or if the subagent
106
+ // runs in a different directory (e.g. a temp workspace), preventing an untrusted project's
107
+ // .pi/mcp.json from being loaded by the MCP extension.
108
+ function resolveSubagentProjectTrusted(
109
+ ctx: { cwd: string; isProjectTrusted(): boolean },
110
+ subagentCwd: string,
111
+ ): boolean {
112
+ // Feature-detected for older Pi hosts that may lack isProjectTrusted.
113
+ const hostTrusted =
114
+ typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
115
+ ? ctx.isProjectTrusted()
116
+ : false;
117
+ if (!hostTrusted) return false;
118
+ try {
119
+ return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
120
+ } catch {
121
+ return false; // fail closed if realpath resolution fails
122
+ }
123
+ }
124
+
125
+ /** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage → false). */
126
+ function isMcpCompatibleVersion(v: string | undefined): boolean {
127
+ if (!v) return false;
128
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
129
+ if (!m) return false;
130
+ const major = Number(m[1]),
131
+ minor = Number(m[2]),
132
+ patch = Number(m[3]);
133
+ if (major !== 1) return major > 1;
134
+ if (minor !== 0) return minor > 0;
135
+ return patch >= 4;
136
+ }
137
+
138
+ // Safe runtime access to MCP/tool-search factories — feature-detected for Pi >=0.99.0 hosts.
139
+ // Additionally gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
140
+ // matches that resolve nothing, so spawning MCP servers provides no benefit.
141
+ function createMcpSubagentFactories(): ExtensionFactory[] {
142
+ const ns = piCodingAgent as Record<string, unknown>;
143
+ if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
144
+ const factories: ExtensionFactory[] = [];
145
+ const mcp = ns["createMcpExtension"];
146
+ const toolSearch = ns["createToolSearchExtension"];
147
+ if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
148
+ if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
149
+ return factories;
150
+ }
151
+
152
+ // Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
153
+ async function disposeSubagentSession(
154
+ session: { extensionRunner?: unknown; dispose(): void } | undefined,
155
+ ): Promise<void> {
156
+ if (!session) return;
157
+ try {
158
+ const runner = session.extensionRunner as
159
+ | {
160
+ hasHandlers(n: string): boolean;
161
+ emit(e: { type: string; reason: string }): Promise<unknown>;
162
+ }
163
+ | undefined;
164
+ if (runner?.hasHandlers("session_shutdown")) {
165
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
166
+ }
167
+ } catch {
168
+ // Shutdown errors must not prevent dispose.
169
+ }
170
+ session.dispose();
171
+ }
93
172
 
94
173
  const PREFERRED_FAST_MODEL_PATTERNS = [
95
174
  /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
@@ -903,6 +982,82 @@ async function buildLibrarianCandidates(
903
982
  return candidates;
904
983
  }
905
984
 
985
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
986
+
987
+ /**
988
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
989
+ * non-win32 paths.
990
+ */
991
+ function normalizeWindowsShellPathLocal(filePath: string): string {
992
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
993
+ return filePath;
994
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
995
+ if (!match) return filePath;
996
+ const suffix = match[2]?.replaceAll("/", "\\");
997
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
998
+ }
999
+
1000
+ /**
1001
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
1002
+ * drive-path normalisation always runs; only tilde expansion is gated by
1003
+ * expandTilde.
1004
+ */
1005
+ function normalizePath(
1006
+ input: string,
1007
+ opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
1008
+ ): string {
1009
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
1010
+ let p = input;
1011
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
1012
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
1013
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
1014
+ if (expandTilde) {
1015
+ const home = os.homedir();
1016
+ if (p === "~") p = home;
1017
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
1018
+ p = path.join(home, p.slice(2));
1019
+ }
1020
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
1021
+ return p;
1022
+ }
1023
+
1024
+ /**
1025
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
1026
+ * __test__ for parity tests; not imported by other extensions.
1027
+ */
1028
+ function resolveToCwd(rawPath: string, cwd: string): string {
1029
+ const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
1030
+ const base = normalizePath(cwd);
1031
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
1032
+ }
1033
+
1034
+ /** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
1035
+ function tryMacOSScreenshotPathVariant(filePath: string): string {
1036
+ return filePath.replace(/ (AM|PM)\./gi, " $1.");
1037
+ }
1038
+ /** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
1039
+ function tryNFDPathVariant(filePath: string): string {
1040
+ return filePath.normalize("NFD");
1041
+ }
1042
+ /** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
1043
+ function tryCurlyQuotePathVariant(filePath: string): string {
1044
+ return filePath.replace(/'/g, "’");
1045
+ }
1046
+ /**
1047
+ * Returns all read path candidates including Pi's resolveReadPathAsync fallback
1048
+ * variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
1049
+ */
1050
+ function getReadPathCandidates(resolved: string): string[] {
1051
+ const nfd = tryNFDPathVariant(resolved);
1052
+ return [
1053
+ resolved,
1054
+ tryMacOSScreenshotPathVariant(resolved),
1055
+ nfd,
1056
+ tryCurlyQuotePathVariant(resolved),
1057
+ tryCurlyQuotePathVariant(nfd),
1058
+ ];
1059
+ }
1060
+
906
1061
  function resolveToolPath(cwd: string, rawPath: string): string {
907
1062
  const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
908
1063
  return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
@@ -992,16 +1147,53 @@ function createLibrarianRuntimeGuardExtension(options: {
992
1147
  if (event.toolName === "read") {
993
1148
  const input = event.input as { path?: unknown };
994
1149
  if (typeof input.path !== "string") return undefined;
1150
+ // Canonicalize allowed roots. A path is allowed if it is inside EITHER
1151
+ // the original root (options.workspace / options.cacheRoot) OR its
1152
+ // realpath'd form. This handles both: a realpath'd file under a
1153
+ // symlinked alias workspace (lexical match on original root) and an
1154
+ // existing file canonicalized beyond the alias (match on real root).
1155
+ const realWorkspace = await fs
1156
+ .realpath(options.workspace)
1157
+ .catch(() => path.resolve(options.workspace));
1158
+ const realCacheRoot = await fs
1159
+ .realpath(options.cacheRoot)
1160
+ .catch(() => path.resolve(options.cacheRoot));
1161
+ function isAllowedPath(p: string): boolean {
1162
+ return (
1163
+ isInside(options.workspace, p) ||
1164
+ isInside(realWorkspace, p) ||
1165
+ (options.cacheEnabled && (isInside(options.cacheRoot, p) || isInside(realCacheRoot, p)))
1166
+ );
1167
+ }
995
1168
  const resolved = resolveToolPath(options.workspace, input.path);
996
1169
  const realPath = await fs.realpath(resolved).catch(() => resolved);
997
- const allowed =
998
- isInside(options.workspace, realPath) ||
999
- (options.cacheEnabled && isInside(options.cacheRoot, realPath));
1000
- if (!allowed)
1170
+ if (!isAllowedPath(realPath))
1001
1171
  return {
1002
1172
  block: true,
1003
1173
  reason: `Librarian read is limited to its workspace/cache: ${realPath}`,
1004
1174
  };
1175
+ // Pi-effective path check (monotonic: block if either resolution is
1176
+ // outside). For the read tool, also check Pi's resolveReadPathAsync
1177
+ // fallback variants so a missing file whose variant resolves outside is
1178
+ // not silently allowed.
1179
+ try {
1180
+ const piResolved = resolveToCwd(input.path, options.workspace);
1181
+ const candidates = getReadPathCandidates(piResolved);
1182
+ // Deliberate policy: all candidates are checked regardless of existence (not
1183
+ // Pi's existence-ordered selection) so the guard cannot be raced by creating
1184
+ // a variant between the hook and the read. Trade-off: a read is blocked when
1185
+ // an escaping variant symlink sits next to a safe primary file.
1186
+ for (const candidate of candidates) {
1187
+ const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
1188
+ if (!isAllowedPath(candidateRealPath))
1189
+ return {
1190
+ block: true,
1191
+ reason: `Librarian read is limited to its workspace/cache: ${candidateRealPath}`,
1192
+ };
1193
+ }
1194
+ } catch {
1195
+ return { block: true, reason: "Librarian read path could not be resolved safely." };
1196
+ }
1005
1197
  }
1006
1198
 
1007
1199
  if (event.toolName === "bash") {
@@ -1045,7 +1237,7 @@ function buildSystemPrompt(options: {
1045
1237
  ? `\nLocal checkout cache is ENABLED for this call.\n- Cache root: ${options.cacheRoot}\n- Use checkout path pattern: ${options.cacheRoot}/github.com/<owner>/<repo>\n- Reuse an existing checkout when it has a .git directory. Fetch/prune before relying on it: git -C "$DIR" fetch --all --prune --tags --quiet\n- If missing, clone with gh repo clone "$REPO" "$DIR" (or git clone https://github.com/$REPO.git "$DIR").\n- If a ref/branch/SHA is requested, fetch it and check it out locally before citing files from that ref.\n- After using a checkout, update its cache marker: touch "$DIR/${CACHE_MARKER_FILE}"\n- Prefer local rg/read inside cached checkouts once a repo is cloned, and cite absolute cached paths with line ranges.\n- Clone only repositories that are relevant to the query; do not bulk-clone broad owner/org scopes unless necessary.`
1046
1238
  : `\nLocal checkout cache is DISABLED for this call.\n- Do not clone repositories.\n- Use gh search/API/tree/contents calls and cache only necessary proof files under ${options.workspace}/repos/<owner>/<repo>/<path>.`;
1047
1239
 
1048
- return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse only the available bash/read tools. Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nPerformance guidance:\n- Prefer fast, parallel exploration. When probes are independent, issue multiple separate tool calls in the same assistant turn instead of waiting for each result one-by-one.\n- Aim for 4-8 independent bash/read calls in an exploration turn when useful: parallel gh searches, tree probes, contents fetches, and targeted file reads.\n- Keep dependent work sequential, and avoid concurrent writes to the same file, checkout, or cache directory.\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
1240
+ return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse the available bash and read tools for local inspection, and any configured MCP tools for additional context (MCP tools run without confirmation inside this subagent; use them only to gather evidence, not for state-changing actions unless the task explicitly asks; MCP access requires host Pi >=1.0.4, older hosts degrade to no MCP). Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nPerformance guidance:\n- Prefer fast, parallel exploration. When probes are independent, issue multiple separate tool calls in the same assistant turn instead of waiting for each result one-by-one.\n- Aim for 4-8 independent bash/read calls in an exploration turn when useful: parallel gh searches, tree probes, contents fetches, and targeted file reads.\n- Keep dependent work sequential, and avoid concurrent writes to the same file, checkout, or cache directory.\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
1049
1241
  - A runtime guard blocks destructive shell commands, credential/environment inspection, and reads outside the workspace/cache.\n- Never paste whole files. Use short snippets only when they clarify the evidence.\n- If evidence is partial or access fails (404/403), state the limitation clearly.\n- Do not present anything as fact unless it appeared in tool output or in a file you read.\n\nRecommended search flow:\n1. If symbols/text are known, start with gh search code and the provided repo/owner filters.\n2. If a repo is known but paths are unclear, resolve the default branch and inspect the git tree or contents API.\n3. Fetch or clone only the files/repos required to prove the answer.\n4. Use rg/read/nl -ba locally to produce stable path and line evidence.\n\nUseful gh patterns:\n- gh repo view "$REPO" --json defaultBranchRef --jq '.defaultBranchRef.name'\n- gh search code '<terms>' --json path,repository,sha,url,textMatches --limit ${options.maxSearchResults}\n- gh api "repos/$REPO/git/trees/$REF?recursive=1" > tree.json\n- gh api "repos/$REPO/contents/$FILE?ref=$REF" --jq .content | tr -d '\\n' | base64 --decode > "repos/$REPO/$FILE"\n- rg -n '<pattern>' '<local path>'\n- nl -ba '<local file>' | sed -n '10,30p'\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences.\n## Locations\n- \`path\` or \`path:lineStart-lineEnd\` — what is here and why it matters; include GitHub URL when useful. If nothing relevant is found, write \`- (none)\`.\n## Evidence\n- \`path\` or \`path:lineStart-lineEnd\` — what this proves. Include concise snippets only if useful.\n## Searched\nOnly include when incomplete/not found or when the search path matters. List queries, filters, and probes used.\n## Next steps\nOptional: 1-3 narrow follow-up checks for remaining ambiguity.`;
1050
1242
  }
1051
1243
 
@@ -1277,6 +1469,13 @@ function renderAnswer(details: LibrarianDetails): string {
1277
1469
  }
1278
1470
 
1279
1471
  export const __test__ = {
1472
+ SUBAGENT_MCP_TOOLS,
1473
+ isMcpCompatibleVersion,
1474
+ resolveSubagentProjectTrusted,
1475
+ createMcpSubagentFactories,
1476
+ disposeSubagentSession,
1477
+ createLibrarianRuntimeGuardExtension,
1478
+ resolveToCwd,
1280
1479
  buildLibrarianCandidates,
1281
1480
  findAvailableModel,
1282
1481
  isModelAvailabilityError,
@@ -1652,7 +1851,10 @@ export default function librarianExtension(pi: ExtensionAPI) {
1652
1851
  // Keep the research session from inheriting user/project tool, shell,
1653
1852
  // retry, or provider-request settings. The host model runtime is still
1654
1853
  // passed explicitly below so the selected provider/auth policy is kept.
1655
- const isolatedSettingsManager = SettingsManager.inMemory({});
1854
+ // workspace is a temp clone dir — always a different dir from ctx.cwd,
1855
+ // so projectTrusted is always false here (project .pi/mcp.json is never loaded).
1856
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, workspace);
1857
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
1656
1858
  const resourceLoader = new DefaultResourceLoader({
1657
1859
  cwd: workspace,
1658
1860
  agentDir: getAgentDir(),
@@ -1669,6 +1871,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1669
1871
  cacheRoot,
1670
1872
  cacheEnabled: cacheDecision.enabled,
1671
1873
  }),
1874
+ ...createMcpSubagentFactories(),
1672
1875
  ],
1673
1876
  systemPromptOverride: () => systemPrompt,
1674
1877
  skillsOverride: () => ({ skills: [], diagnostics: [] }),
@@ -1693,43 +1896,48 @@ export default function librarianExtension(pi: ExtensionAPI) {
1693
1896
  sessionManager: SessionManager.inMemory(workspace),
1694
1897
  model: candidate.model,
1695
1898
  thinkingLevel: candidate.details.thinkingLevel,
1696
- tools: ["read", "bash"],
1899
+ tools: ["read", "bash", ...SUBAGENT_MCP_TOOLS],
1697
1900
  });
1698
1901
 
1902
+ // Assign session BEFORE bindExtensions so the finally block can dispose it
1903
+ // even if binding fails partway through (e.g. a server starts then an
1904
+ // extension's session_start throws).
1699
1905
  session = created.session as typeof session;
1700
- if (abortController.callerAborted) abortController.abortForCleanup();
1701
- unsubscribe = (created.session as any).subscribe((event: any) => {
1702
- addSessionEventUsage(usage, event);
1703
- switch (event.type) {
1704
- case "turn_end":
1705
- details.turns += 1;
1706
- emit();
1707
- break;
1708
- case "tool_execution_start":
1709
- details.toolCalls.push({
1710
- id: event.toolCallId,
1711
- name: event.toolName,
1712
- args: event.args,
1713
- startedAt: Date.now(),
1714
- });
1715
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1716
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1717
- }
1718
- emit();
1719
- break;
1720
- case "tool_execution_end": {
1721
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1722
- if (call) {
1723
- call.endedAt = Date.now();
1724
- call.isError = event.isError;
1906
+ try {
1907
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
1908
+ await (created.session as any).bindExtensions?.({ mode: "json" });
1909
+ if (abortController.callerAborted) abortController.abortForCleanup();
1910
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1911
+ addSessionEventUsage(usage, event);
1912
+ switch (event.type) {
1913
+ case "turn_end":
1914
+ details.turns += 1;
1915
+ emit();
1916
+ break;
1917
+ case "tool_execution_start":
1918
+ details.toolCalls.push({
1919
+ id: event.toolCallId,
1920
+ name: event.toolName,
1921
+ args: event.args,
1922
+ startedAt: Date.now(),
1923
+ });
1924
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1925
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1926
+ }
1927
+ emit();
1928
+ break;
1929
+ case "tool_execution_end": {
1930
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1931
+ if (call) {
1932
+ call.endedAt = Date.now();
1933
+ call.isError = event.isError;
1934
+ }
1935
+ emit();
1936
+ break;
1725
1937
  }
1726
- emit();
1727
- break;
1728
1938
  }
1729
- }
1730
- });
1939
+ });
1731
1940
 
1732
- try {
1733
1941
  if (!abortController.callerAborted) {
1734
1942
  const promptPromise = created.session.prompt(
1735
1943
  buildUserPrompt(query, repos, owners, maxSearchResults, details.cache),
@@ -1758,7 +1966,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1758
1966
  }
1759
1967
  unsubscribe?.();
1760
1968
  unsubscribe = undefined;
1761
- session?.dispose();
1969
+ await disposeSubagentSession(session);
1762
1970
  session = undefined;
1763
1971
  }
1764
1972
  };
@@ -1841,7 +2049,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1841
2049
  if (runTimeout) clearTimeout(runTimeout);
1842
2050
  if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1843
2051
  unsubscribe?.();
1844
- session?.dispose();
2052
+ await disposeSubagentSession(session);
1845
2053
  }
1846
2054
  },
1847
2055
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -10,7 +10,7 @@ It replaces pi's built-in footer with a cleaner two-line layout that focuses on
10
10
  - TLH-style git dirty counts, ahead/behind counts, and optional PR number
11
11
  - current repo name
12
12
  - current context percentage
13
- - red `DUMB ZONE` indicator when context usage is above 200k tokens
13
+ - red `DUMB ZONE` indicator when context usage is above 300k tokens
14
14
  - current model and thinking level
15
15
  - OpenAI Codex usage windows, labeled from the durations reported by OpenAI
16
16
  - `xp` marker when Pi experimental features are enabled
@@ -39,7 +39,7 @@ main · +1 ~2 ?3 ↑1 • PR #42
39
39
 
40
40
  The markers are `!` conflicts, `+` staged, `~` unstaged, `?` untracked, `↑` ahead, and `↓` behind. PR numbers are fetched best-effort with `gh pr view`. Git polling is skipped until Pi reports that the project is trusted, and `git status` is run with fsmonitor disabled.
41
41
 
42
- When context usage is above 200k tokens, the bottom-left line includes a red warning:
42
+ When context usage is above 300k tokens, the bottom-left line includes a red warning:
43
43
 
44
44
  ```text
45
45
  44.1% · DUMB ZONE
@@ -115,7 +115,7 @@ Example:
115
115
  "showPercent": true,
116
116
  "dumbZone": {
117
117
  "enabled": true,
118
- "thresholdTokens": 200000,
118
+ "thresholdTokens": 300000,
119
119
  "label": "DUMB ZONE",
120
120
  "color": "error"
121
121
  }
@@ -231,7 +231,7 @@ Disable git dirty/ahead/PR status:
231
231
 
232
232
  - **Top left:** current git branch plus dirty/ahead/PR status when available
233
233
  - **Top right:** current repo directory name
234
- - **Bottom left:** current context usage percentage, plus red `DUMB ZONE` above 200k context tokens
234
+ - **Bottom left:** current context usage percentage, plus red `DUMB ZONE` above 300k context tokens
235
235
  - **Bottom left on `openai-codex`:** current context usage percentage plus the Codex usage windows reported for the account
236
236
  - **Bottom left on `openai` ChatGPT OAuth:** current context usage percentage plus `usage unsupported`; no legacy WHAM request is attempted
237
237
  - **Bottom left with `PI_EXPERIMENTAL=1`:** current context usage percentage plus `xp`
@@ -246,7 +246,7 @@ This extension also lives inside the broader [`pi-extensions`](../../README.md)
246
246
  - Replaces pi's built-in footer entirely.
247
247
  - Uses pi footer data for git branch updates, plus background cached git/gh checks for dirty counts and PR number after project trust is granted.
248
248
  - Shows only context percentage, not context window size.
249
- - Shows `DUMB ZONE` only while context usage is above 200k tokens.
249
+ - Shows `DUMB ZONE` only while context usage is above 300k tokens.
250
250
  - Shows the model id rather than a provider-specific display label.
251
251
  - Shows `xp` when `PI_EXPERIMENTAL=1`.
252
252
  - For `openai-codex`, reads pi's stored OAuth login and fetches usage from ChatGPT's backend usage endpoint. Window labels are inferred from each window's reported duration; missing or unrecognized durations use neutral `usage` labels rather than guessing from window position.
@@ -24,7 +24,7 @@ const DEFAULT_CONFIG: MinimalFooterConfig = {
24
24
  showPercent: true,
25
25
  dumbZone: {
26
26
  enabled: true,
27
- thresholdTokens: 200_000,
27
+ thresholdTokens: 300_000,
28
28
  label: "DUMB ZONE",
29
29
  color: "error",
30
30
  },
@@ -121,7 +121,6 @@ type UsageSessionState = {
121
121
  snapshot?: UsageSnapshot;
122
122
  lastFetchedAt?: number;
123
123
  loading: boolean;
124
- error?: string;
125
124
  availability?: UsageAvailability;
126
125
  usageIdentity?: UsageIdentity;
127
126
  requestVersion: number;
@@ -834,7 +833,6 @@ function clearUsageState(state: UsageSessionState): void {
834
833
  state.snapshot = undefined;
835
834
  state.lastFetchedAt = undefined;
836
835
  state.loading = false;
837
- state.error = undefined;
838
836
  state.availability = undefined;
839
837
  }
840
838
 
@@ -909,7 +907,6 @@ function invalidateUsageState(state: UsageSessionState, usageIdentity?: UsageIde
909
907
  state.snapshot = undefined;
910
908
  state.lastFetchedAt = undefined;
911
909
  state.loading = false;
912
- state.error = undefined;
913
910
  state.availability = undefined;
914
911
  state.usageIdentity = usageIdentity;
915
912
  // The previous promise may still be resolving. Clearing the reference lets
@@ -961,7 +958,7 @@ async function refreshUsageIfNeeded(
961
958
 
962
959
  const mode = getUsageMode(ctx, config, resolvedUsageIdentity);
963
960
  if (!mode) {
964
- if (state.snapshot || state.availability || state.loading || state.error) {
961
+ if (state.snapshot || state.availability || state.loading) {
965
962
  clearUsageState(state);
966
963
  state.requestRender?.();
967
964
  }
@@ -1006,19 +1003,16 @@ async function refreshUsageIfNeeded(
1006
1003
  if (snapshot) {
1007
1004
  state.snapshot = snapshot;
1008
1005
  state.lastFetchedAt = snapshot.fetchedAt;
1009
- state.error = undefined;
1010
1006
  } else {
1011
1007
  state.snapshot = undefined;
1012
1008
  state.lastFetchedAt = Date.now();
1013
- state.error = undefined;
1014
1009
  state.availability = "unavailable";
1015
1010
  }
1016
- } catch (error) {
1011
+ } catch {
1017
1012
  if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1018
1013
  state.snapshot = undefined;
1019
1014
  state.lastFetchedAt = Date.now();
1020
1015
  state.availability = "unavailable";
1021
- state.error = error instanceof Error ? error.message : String(error);
1022
1016
  } finally {
1023
1017
  if (
1024
1018
  state.disposed ||
@@ -1085,7 +1079,6 @@ export default function (pi: ExtensionAPI) {
1085
1079
  state.snapshot = undefined;
1086
1080
  state.lastFetchedAt = undefined;
1087
1081
  state.loading = false;
1088
- state.error = undefined;
1089
1082
  state.availability = undefined;
1090
1083
  state.usageIdentity = undefined;
1091
1084
  state.inflight = undefined;
@@ -3,7 +3,7 @@
3
3
  "showPercent": true,
4
4
  "dumbZone": {
5
5
  "enabled": true,
6
- "thresholdTokens": 200000,
6
+ "thresholdTokens": 300000,
7
7
  "label": "DUMB ZONE",
8
8
  "color": "error"
9
9
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-minimal-footer",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "A minimal custom footer for pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -4,11 +4,11 @@
4
4
 
5
5
  An Amp-style oracle for [pi](https://github.com/earendil-works/pi).
6
6
 
7
- It adds an `oracle` tool that spins up a separate read-only pi subprocess and sends it to the strongest reasoning model available on the **same provider/subscription** the user is currently using.
7
+ It adds an `oracle` tool that spins up a separate pi subprocess with read-only built-in tools and any configured MCP tools, and sends it to the strongest reasoning model available on the **same provider/subscription** the user is currently using.
8
8
 
9
9
  ## What it does
10
10
 
11
- - creates an isolated read-only subprocess
11
+ - creates an isolated subprocess with read-only built-in tools (read, grep, find, ls) and any configured MCP tools
12
12
  - auto-picks the strongest reasoning model on the current provider
13
13
  - uses provider-specific hardcoded rankings first, then a heuristic fallback
14
14
  - requests `xhigh` by default for reasoning models, then clamps to the model-supported thinking level
@@ -110,7 +110,8 @@ When the current Pi session has a non-empty model scope, Oracle limits automatic
110
110
 
111
111
  ## Notes
112
112
 
113
- - The oracle is intentionally **read-only by default**.
113
+ - The oracle subprocess uses read-only built-in tools (read, grep, find, ls) by default, plus any configured MCP tools. It can optionally include bash for non-mutating inspection.
114
+ - Configured MCP tools run without confirmation inside the subprocess and may have side effects — use them only for gathering evidence. MCP access requires host Pi >=1.0.4; older hosts degrade to no MCP, no error.
114
115
  - It is best for review, analysis, planning, debugging, and second opinions.
115
116
  - It is slower than using the main model directly, so it should be used selectively.
116
117
  - While it runs in interactive mode, it adds a footer status line and a widget below the editor.