@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,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/,
@@ -687,11 +766,6 @@ async function writeLibrarianPreferences(preferences: LibrarianPreferences): Pro
687
766
  await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
688
767
  }
689
768
 
690
- async function writeCachePreference(preference: CacheMode): Promise<void> {
691
- const preferences = await readLibrarianPreferences();
692
- await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
693
- }
694
-
695
769
  function resolveCacheDecision(preference: CacheMode): { enabled: boolean; reason: string } {
696
770
  if (preference === "enabled") {
697
771
  return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
@@ -903,9 +977,80 @@ async function buildLibrarianCandidates(
903
977
  return candidates;
904
978
  }
905
979
 
906
- function resolveToolPath(cwd: string, rawPath: string): string {
907
- const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
908
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
980
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
981
+
982
+ /**
983
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
984
+ * non-win32 paths.
985
+ */
986
+ function normalizeWindowsShellPathLocal(filePath: string): string {
987
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
988
+ return filePath;
989
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
990
+ if (!match) return filePath;
991
+ const suffix = match[2]?.replaceAll("/", "\\");
992
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
993
+ }
994
+
995
+ /**
996
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
997
+ * drive-path normalisation always runs; only tilde expansion is gated by
998
+ * expandTilde.
999
+ */
1000
+ function normalizePath(
1001
+ input: string,
1002
+ opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
1003
+ ): string {
1004
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
1005
+ let p = input;
1006
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
1007
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
1008
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
1009
+ if (expandTilde) {
1010
+ const home = os.homedir();
1011
+ if (p === "~") p = home;
1012
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
1013
+ p = path.join(home, p.slice(2));
1014
+ }
1015
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
1016
+ return p;
1017
+ }
1018
+
1019
+ /**
1020
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
1021
+ * __test__ for parity tests; not imported by other extensions.
1022
+ */
1023
+ function resolveToCwd(rawPath: string, cwd: string): string {
1024
+ const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
1025
+ const base = normalizePath(cwd);
1026
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
1027
+ }
1028
+
1029
+ /** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
1030
+ function tryMacOSScreenshotPathVariant(filePath: string): string {
1031
+ return filePath.replace(/ (AM|PM)\./gi, " $1.");
1032
+ }
1033
+ /** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
1034
+ function tryNFDPathVariant(filePath: string): string {
1035
+ return filePath.normalize("NFD");
1036
+ }
1037
+ /** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
1038
+ function tryCurlyQuotePathVariant(filePath: string): string {
1039
+ return filePath.replace(/'/g, "’");
1040
+ }
1041
+ /**
1042
+ * Returns all read path candidates including Pi's resolveReadPathAsync fallback
1043
+ * variants.
1044
+ */
1045
+ function getReadPathCandidates(resolved: string): string[] {
1046
+ const nfd = tryNFDPathVariant(resolved);
1047
+ return [
1048
+ resolved,
1049
+ tryMacOSScreenshotPathVariant(resolved),
1050
+ nfd,
1051
+ tryCurlyQuotePathVariant(resolved),
1052
+ tryCurlyQuotePathVariant(nfd),
1053
+ ];
909
1054
  }
910
1055
 
911
1056
  function getBlockedBashReason(
@@ -992,16 +1137,45 @@ function createLibrarianRuntimeGuardExtension(options: {
992
1137
  if (event.toolName === "read") {
993
1138
  const input = event.input as { path?: unknown };
994
1139
  if (typeof input.path !== "string") return undefined;
995
- const resolved = resolveToolPath(options.workspace, input.path);
996
- 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)
1001
- return {
1002
- block: true,
1003
- reason: `Librarian read is limited to its workspace/cache: ${realPath}`,
1004
- };
1140
+ // Canonicalize allowed roots. A path is allowed if it is inside EITHER
1141
+ // the original root (options.workspace / options.cacheRoot) OR its
1142
+ // realpath'd form. This handles both: a realpath'd file under a
1143
+ // symlinked alias workspace (lexical match on original root) and an
1144
+ // existing file canonicalized beyond the alias (match on real root).
1145
+ const realWorkspace = await fs
1146
+ .realpath(options.workspace)
1147
+ .catch(() => path.resolve(options.workspace));
1148
+ const realCacheRoot = await fs
1149
+ .realpath(options.cacheRoot)
1150
+ .catch(() => path.resolve(options.cacheRoot));
1151
+ function isAllowedPath(p: string): boolean {
1152
+ return (
1153
+ isInside(options.workspace, p) ||
1154
+ isInside(realWorkspace, p) ||
1155
+ (options.cacheEnabled && (isInside(options.cacheRoot, p) || isInside(realCacheRoot, p)))
1156
+ );
1157
+ }
1158
+ // For the read tool, also check Pi's resolveReadPathAsync fallback
1159
+ // variants so a missing file whose variant resolves outside is not
1160
+ // silently allowed.
1161
+ try {
1162
+ const piResolved = resolveToCwd(input.path, options.workspace);
1163
+ const candidates = getReadPathCandidates(piResolved);
1164
+ // Deliberate policy: all candidates are checked regardless of existence (not
1165
+ // Pi's existence-ordered selection) so the guard cannot be raced by creating
1166
+ // a variant between the hook and the read. Trade-off: a read is blocked when
1167
+ // an escaping variant symlink sits next to a safe primary file.
1168
+ for (const candidate of candidates) {
1169
+ const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
1170
+ if (!isAllowedPath(candidateRealPath))
1171
+ return {
1172
+ block: true,
1173
+ reason: `Librarian read is limited to its workspace/cache: ${candidateRealPath}`,
1174
+ };
1175
+ }
1176
+ } catch {
1177
+ return { block: true, reason: "Librarian read path could not be resolved safely." };
1178
+ }
1005
1179
  }
1006
1180
 
1007
1181
  if (event.toolName === "bash") {
@@ -1045,7 +1219,7 @@ function buildSystemPrompt(options: {
1045
1219
  ? `\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
1220
  : `\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
1221
 
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.
1222
+ 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
1223
  - 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
1224
  }
1051
1225
 
@@ -1277,13 +1451,19 @@ function renderAnswer(details: LibrarianDetails): string {
1277
1451
  }
1278
1452
 
1279
1453
  export const __test__ = {
1454
+ SUBAGENT_MCP_TOOLS,
1455
+ isMcpCompatibleVersion,
1456
+ resolveSubagentProjectTrusted,
1457
+ createMcpSubagentFactories,
1458
+ disposeSubagentSession,
1459
+ createLibrarianRuntimeGuardExtension,
1460
+ resolveToCwd,
1280
1461
  buildLibrarianCandidates,
1281
1462
  findAvailableModel,
1282
1463
  isModelAvailabilityError,
1283
1464
  parseModelPreference,
1284
1465
  resolveThinkingLevel,
1285
1466
  aggregateAssistantUsage,
1286
- addAssistantMessageUsage,
1287
1467
  addSessionEventUsage,
1288
1468
  inspectFinalAssistant,
1289
1469
  classifyRunFailure,
@@ -1291,14 +1471,12 @@ export const __test__ = {
1291
1471
  createLibrarianAbortController,
1292
1472
  cleanupExpiredCache,
1293
1473
  cleanupExpiredRepos,
1294
- cleanupLockOwnershipError,
1295
1474
  tryAcquireCleanupLock,
1296
1475
  heartbeatCleanupLock,
1297
1476
  releaseCleanupLock,
1298
1477
  CLEANUP_LOCK_FILE,
1299
1478
  CLEANUP_LOCK_STALE_MS,
1300
1479
  CLEANUP_LOCK_MAX_LEASE_MS,
1301
- isCleanupLockLeaseExpired,
1302
1480
  };
1303
1481
 
1304
1482
  export default function librarianExtension(pi: ExtensionAPI) {
@@ -1469,17 +1647,6 @@ export default function librarianExtension(pi: ExtensionAPI) {
1469
1647
  const cacheRoot = getUserCacheReposRoot();
1470
1648
  const configPath = getCacheConfigPath();
1471
1649
 
1472
- const setPreference = async (mode: CacheMode): Promise<string | undefined> => {
1473
- cachePreference = mode;
1474
- try {
1475
- await writeCachePreference(mode);
1476
- return undefined;
1477
- } catch (error) {
1478
- const message = error instanceof Error ? error.message : String(error);
1479
- return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1480
- }
1481
- };
1482
-
1483
1650
  const formatSetMessage = (mode: CacheMode, warning?: string): string => {
1484
1651
  const main =
1485
1652
  mode === "enabled"
@@ -1489,20 +1656,20 @@ export default function librarianExtension(pi: ExtensionAPI) {
1489
1656
  };
1490
1657
 
1491
1658
  if (action === "on" || action === "enable") {
1492
- const warning = await setPreference("enabled");
1659
+ const warning = await savePreferences({ ...currentPreferences(), cacheMode: "enabled" });
1493
1660
  notifyCommand(ctx, formatSetMessage("enabled", warning), warning ? "warning" : "info");
1494
1661
  return;
1495
1662
  }
1496
1663
 
1497
1664
  if (action === "off" || action === "disable") {
1498
- const warning = await setPreference("disabled");
1665
+ const warning = await savePreferences({ ...currentPreferences(), cacheMode: "disabled" });
1499
1666
  notifyCommand(ctx, formatSetMessage("disabled", warning), warning ? "warning" : "info");
1500
1667
  return;
1501
1668
  }
1502
1669
 
1503
1670
  if (action === "toggle") {
1504
1671
  const next = cachePreference === "enabled" ? "disabled" : "enabled";
1505
- const warning = await setPreference(next);
1672
+ const warning = await savePreferences({ ...currentPreferences(), cacheMode: next });
1506
1673
  notifyCommand(ctx, formatSetMessage(next, warning), warning ? "warning" : "info");
1507
1674
  return;
1508
1675
  }
@@ -1652,7 +1819,10 @@ export default function librarianExtension(pi: ExtensionAPI) {
1652
1819
  // Keep the research session from inheriting user/project tool, shell,
1653
1820
  // retry, or provider-request settings. The host model runtime is still
1654
1821
  // passed explicitly below so the selected provider/auth policy is kept.
1655
- const isolatedSettingsManager = SettingsManager.inMemory({});
1822
+ // workspace is a temp clone dir — always a different dir from ctx.cwd,
1823
+ // so projectTrusted is always false here (project .pi/mcp.json is never loaded).
1824
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, workspace);
1825
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
1656
1826
  const resourceLoader = new DefaultResourceLoader({
1657
1827
  cwd: workspace,
1658
1828
  agentDir: getAgentDir(),
@@ -1669,6 +1839,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1669
1839
  cacheRoot,
1670
1840
  cacheEnabled: cacheDecision.enabled,
1671
1841
  }),
1842
+ ...createMcpSubagentFactories(),
1672
1843
  ],
1673
1844
  systemPromptOverride: () => systemPrompt,
1674
1845
  skillsOverride: () => ({ skills: [], diagnostics: [] }),
@@ -1693,43 +1864,48 @@ export default function librarianExtension(pi: ExtensionAPI) {
1693
1864
  sessionManager: SessionManager.inMemory(workspace),
1694
1865
  model: candidate.model,
1695
1866
  thinkingLevel: candidate.details.thinkingLevel,
1696
- tools: ["read", "bash"],
1867
+ tools: ["read", "bash", ...SUBAGENT_MCP_TOOLS],
1697
1868
  });
1698
1869
 
1870
+ // Assign session BEFORE bindExtensions so the finally block can dispose it
1871
+ // even if binding fails partway through (e.g. a server starts then an
1872
+ // extension's session_start throws).
1699
1873
  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;
1874
+ try {
1875
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
1876
+ await (created.session as any).bindExtensions?.({ mode: "json" });
1877
+ if (abortController.callerAborted) abortController.abortForCleanup();
1878
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1879
+ addSessionEventUsage(usage, event);
1880
+ switch (event.type) {
1881
+ case "turn_end":
1882
+ details.turns += 1;
1883
+ emit();
1884
+ break;
1885
+ case "tool_execution_start":
1886
+ details.toolCalls.push({
1887
+ id: event.toolCallId,
1888
+ name: event.toolName,
1889
+ args: event.args,
1890
+ startedAt: Date.now(),
1891
+ });
1892
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1893
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1894
+ }
1895
+ emit();
1896
+ break;
1897
+ case "tool_execution_end": {
1898
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1899
+ if (call) {
1900
+ call.endedAt = Date.now();
1901
+ call.isError = event.isError;
1902
+ }
1903
+ emit();
1904
+ break;
1725
1905
  }
1726
- emit();
1727
- break;
1728
1906
  }
1729
- }
1730
- });
1907
+ });
1731
1908
 
1732
- try {
1733
1909
  if (!abortController.callerAborted) {
1734
1910
  const promptPromise = created.session.prompt(
1735
1911
  buildUserPrompt(query, repos, owners, maxSearchResults, details.cache),
@@ -1758,7 +1934,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1758
1934
  }
1759
1935
  unsubscribe?.();
1760
1936
  unsubscribe = undefined;
1761
- session?.dispose();
1937
+ await disposeSubagentSession(session);
1762
1938
  session = undefined;
1763
1939
  }
1764
1940
  };
@@ -1841,7 +2017,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1841
2017
  if (runTimeout) clearTimeout(runTimeout);
1842
2018
  if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1843
2019
  unsubscribe?.();
1844
- session?.dispose();
2020
+ await disposeSubagentSession(session);
1845
2021
  }
1846
2022
  },
1847
2023
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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 350k 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 350k 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": 350000,
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 350k 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 350k 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: 350_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;
@@ -149,10 +148,6 @@ type GitStatusSnapshot = {
149
148
 
150
149
  type PullRequestSnapshot = {
151
150
  number?: number | string;
152
- state?: string;
153
- isDraft?: boolean;
154
- url?: string;
155
- title?: string;
156
151
  };
157
152
 
158
153
  type CommandResult = {
@@ -201,7 +196,7 @@ const GIT_STATUS_ARGS = [
201
196
  "--porcelain=v2",
202
197
  "--branch",
203
198
  ] as const;
204
- const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
199
+ const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number"] as const;
205
200
 
206
201
  function readConfigFile(path: string): RecursivePartial<MinimalFooterConfig> {
207
202
  if (!existsSync(path)) return {};
@@ -469,10 +464,6 @@ function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
469
464
  if (typeof record.number === "number" || typeof record.number === "string") {
470
465
  snapshot.number = record.number;
471
466
  }
472
- if (typeof record.state === "string") snapshot.state = record.state;
473
- if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
474
- if (typeof record.url === "string") snapshot.url = record.url;
475
- if (typeof record.title === "string") snapshot.title = record.title;
476
467
  return snapshot;
477
468
  }
478
469
 
@@ -495,13 +486,7 @@ function pullRequestSnapshotsEqual(
495
486
  left: PullRequestSnapshot | undefined,
496
487
  right: PullRequestSnapshot | undefined,
497
488
  ): boolean {
498
- return (
499
- left?.number === right?.number &&
500
- left?.state === right?.state &&
501
- left?.isDraft === right?.isDraft &&
502
- left?.url === right?.url &&
503
- left?.title === right?.title
504
- );
489
+ return left?.number === right?.number;
505
490
  }
506
491
 
507
492
  function defaultRunner(
@@ -834,7 +819,6 @@ function clearUsageState(state: UsageSessionState): void {
834
819
  state.snapshot = undefined;
835
820
  state.lastFetchedAt = undefined;
836
821
  state.loading = false;
837
- state.error = undefined;
838
822
  state.availability = undefined;
839
823
  }
840
824
 
@@ -909,7 +893,6 @@ function invalidateUsageState(state: UsageSessionState, usageIdentity?: UsageIde
909
893
  state.snapshot = undefined;
910
894
  state.lastFetchedAt = undefined;
911
895
  state.loading = false;
912
- state.error = undefined;
913
896
  state.availability = undefined;
914
897
  state.usageIdentity = usageIdentity;
915
898
  // The previous promise may still be resolving. Clearing the reference lets
@@ -961,7 +944,7 @@ async function refreshUsageIfNeeded(
961
944
 
962
945
  const mode = getUsageMode(ctx, config, resolvedUsageIdentity);
963
946
  if (!mode) {
964
- if (state.snapshot || state.availability || state.loading || state.error) {
947
+ if (state.snapshot || state.availability || state.loading) {
965
948
  clearUsageState(state);
966
949
  state.requestRender?.();
967
950
  }
@@ -1006,19 +989,16 @@ async function refreshUsageIfNeeded(
1006
989
  if (snapshot) {
1007
990
  state.snapshot = snapshot;
1008
991
  state.lastFetchedAt = snapshot.fetchedAt;
1009
- state.error = undefined;
1010
992
  } else {
1011
993
  state.snapshot = undefined;
1012
994
  state.lastFetchedAt = Date.now();
1013
- state.error = undefined;
1014
995
  state.availability = "unavailable";
1015
996
  }
1016
- } catch (error) {
997
+ } catch {
1017
998
  if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1018
999
  state.snapshot = undefined;
1019
1000
  state.lastFetchedAt = Date.now();
1020
1001
  state.availability = "unavailable";
1021
- state.error = error instanceof Error ? error.message : String(error);
1022
1002
  } finally {
1023
1003
  if (
1024
1004
  state.disposed ||
@@ -1085,7 +1065,6 @@ export default function (pi: ExtensionAPI) {
1085
1065
  state.snapshot = undefined;
1086
1066
  state.lastFetchedAt = undefined;
1087
1067
  state.loading = false;
1088
- state.error = undefined;
1089
1068
  state.availability = undefined;
1090
1069
  state.usageIdentity = undefined;
1091
1070
  state.inflight = undefined;
@@ -3,7 +3,7 @@
3
3
  "showPercent": true,
4
4
  "dumbZone": {
5
5
  "enabled": true,
6
- "thresholdTokens": 200000,
6
+ "thresholdTokens": 350000,
7
7
  "label": "DUMB ZONE",
8
8
  "color": "error"
9
9
  }