@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
package/README.md CHANGED
@@ -6,10 +6,10 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
6
6
 
7
7
  ### Subagents
8
8
 
9
- - [`code-reviewer`](./extensions/code-reviewer): Adds a read-only `code_reviewer` subagent tool that reviews local changes for ticket fit, diff mismatches, correctness, security, simplicity, and validation gaps without implementing fixes, auto-selects a contrarian-style opposite-provider review model when available, and clamps requested thinking to the chosen model's capabilities.
10
- - [`contrarian`](./extensions/contrarian): Adds a read-only contrarian tool that spins up an isolated subprocess to stress-test plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.
11
- - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`, and cached repos expiring after 7 days of non-use.
12
- - [`oracle`](./extensions/oracle): Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, supports persisted `/oracle` model/thinking defaults, requests xhigh reasoning by default and clamps to model capabilities, and shows live status while running.
9
+ - [`code-reviewer`](./extensions/code-reviewer): Adds a `code_reviewer` subagent tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that reviews local changes for ticket fit, diff mismatches, correctness, security, simplicity, and validation gaps without implementing fixes, auto-selects a contrarian-style opposite-provider review model when available, and clamps requested thinking to the chosen model's capabilities.
10
+ - [`contrarian`](./extensions/contrarian): Adds a contrarian tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that spins up an isolated subprocess to stress-test plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.
11
+ - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`, cached repos expiring after 7 days of non-use, and configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4).
12
+ - [`oracle`](./extensions/oracle): Adds an Amp-style oracle tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that auto-selects the strongest reasoning model on the current provider/subscription, supports persisted `/oracle` model/thinking defaults, requests xhigh reasoning by default and clamps to model capabilities, and shows live status while running.
13
13
 
14
14
  ### Providers
15
15
 
@@ -19,19 +19,19 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
19
19
 
20
20
  - [`git-footer`](./extensions/git-footer): Adds TLH-style git dirty counts, ahead/behind, and optional PR number to pi's built-in footer status area. This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package.
21
21
  - [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch plus cached git dirty/ahead/PR status and repo on the first line, context/model on the second, optional `DUMB ZONE`, optional `xp` marker, plus OpenAI Codex usage windows labeled from their reported durations.
22
- - [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as a one-line invocation plus an expand hint without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
22
+ - [`quiet-tools`](./extensions/quiet-tools): Always renders collapsed built-in tool rows and `mcp__*` rows as a one-line invocation plus an expand hint without changing model-visible tool results; inspect status with `/quiet-tools status`.
23
23
 
24
24
  ### / Slash commands
25
25
 
26
- - [`agent-workflow-audit`](./extensions/agent-workflow-audit): Adds `/agent-workflow-audit`, which runs an isolated repo workflow audit subagent and returns only the final distilled report to the main session.
26
+ - [`agent-workflow-audit`](./extensions/agent-workflow-audit): Adds `/agent-workflow-audit`, which runs an isolated repo workflow audit subagent with configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) and returns only the final distilled report to the main session.
27
27
  - [`annotate-git-diff`](./extensions/annotate-git-diff): Adds `/annotate-git-diff`, a native Glimpse UI where explicit Submit sends structured feedback to the agent and close recovery keeps an unsent draft in the editor.
28
28
  - [`annotate-last-message`](./extensions/annotate-last-message): Adds `/annotate-last-message`, a native Glimpse UI for annotating the latest assistant reply and sending planning-oriented feedback directly to the agent.
29
29
  - [`review`](./extensions/review): Adds `/review` and `/end-review` for interactive code reviews of local changes, commits, PRs, and selected paths.
30
- - [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a read-only `triage_comments` subagent tool that can auto-detect the current branch's PR, filter resolved/outdated inline comments, classify selected review comments with evidence, and suggest handling options without implementing changes
30
+ - [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a `triage_comments` subagent tool with read-only built-in tools plus configured MCP tools (run without confirmation, may have side effects, Pi >=1.0.4) that can auto-detect the current branch's PR, filter resolved/outdated inline comments, classify selected review comments with evidence, and suggest handling options without implementing changes
31
31
 
32
32
  ### Context management
33
33
 
34
- - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
34
+ - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 350k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
35
35
  - [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
36
36
  - [`dynamic-context-pruning`](./extensions/dynamic-context-pruning): Automatically prunes duplicate tool results, stale errored inputs, and superseded file-op outputs from long sessions behind a cache-aware net-benefit gate, with a `/prune` picker and `/context-pruning` status/control commands.
37
37
 
@@ -60,7 +60,7 @@ Options:
60
60
 
61
61
  - Runs a child `createAgentSession` with an in-memory session manager.
62
62
  - Disables inherited extensions, skills, prompt templates, themes, and context files in the child.
63
- - Gives the child `read`, `grep`, `find`, `ls`, and guarded `bash` tools.
63
+ - Gives the child `read`, `grep`, `find`, `ls`, and guarded `bash` tools, plus any configured MCP tools (run without confirmation; may have side effects; 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 loaded only when the host session has already trusted the project.
64
64
  - Returns a single final `agent-workflow-audit` custom message to the parent session.
65
65
  - Includes a short run-boundary footer noting that intermediate command output, errors, retries, and search path stayed in the child session.
66
66
 
@@ -1,5 +1,8 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import { realpathSync } from "node:fs";
3
+ import * as os from "node:os";
2
4
  import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import type {
5
8
  AgentSession,
@@ -15,6 +18,7 @@ import {
15
18
  getAgentDir,
16
19
  getMarkdownTheme,
17
20
  } from "@earendil-works/pi-coding-agent";
21
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
18
22
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
19
23
 
20
24
  const CUSTOM_TYPE = "agent-workflow-audit";
@@ -59,10 +63,8 @@ type AuditDetails = {
59
63
  toolCalls: ToolCall[];
60
64
  startedAt: number;
61
65
  endedAt?: number;
62
- error?: string;
63
66
  initialGitStatus?: string;
64
67
  finalGitStatus?: string;
65
- reportLength?: number;
66
68
  toolCallCount?: number;
67
69
  };
68
70
 
@@ -204,11 +206,37 @@ function inspectFinalAssistant(messages: unknown[]): AuditAssistantOutcome {
204
206
  return { ok: false, reason: "Agent Workflow Audit subagent produced no assistant message" };
205
207
  }
206
208
 
207
- function isAbortLikeError(error: unknown): boolean {
208
- if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError")
209
- return true;
210
- const message = error instanceof Error ? error.message : String(error);
211
- return /aborted|cancelled|canceled/i.test(message);
209
+ function classifyRunFailure(
210
+ error: unknown,
211
+ callerAborted: boolean,
212
+ ): { status: "aborted" | "error"; message: string; error?: string } {
213
+ const message = callerAborted
214
+ ? "Aborted"
215
+ : error instanceof Error
216
+ ? error.message
217
+ : String(error);
218
+ return {
219
+ status: callerAborted ? "aborted" : "error",
220
+ message,
221
+ error: callerAborted ? undefined : message,
222
+ };
223
+ }
224
+
225
+ function createAuditAbortController(abortChild: () => void, onCallerAbort: () => void) {
226
+ let callerAborted = false;
227
+ return {
228
+ get callerAborted() {
229
+ return callerAborted;
230
+ },
231
+ abortForCleanup() {
232
+ abortChild();
233
+ },
234
+ abortFromCaller() {
235
+ callerAborted = true;
236
+ onCallerAbort();
237
+ abortChild();
238
+ },
239
+ };
212
240
  }
213
241
 
214
242
  function renderCollapsedReport(report: string, lineLimit = COLLAPSED_REPORT_LINES): string {
@@ -220,14 +248,14 @@ function renderCollapsedReport(report: string, lineLimit = COLLAPSED_REPORT_LINE
220
248
  ].join("\n");
221
249
  }
222
250
 
223
- function summarizeGitStatus(status: string | undefined): { lineCount: number; dirtyCount: number } {
251
+ function summarizeGitStatus(status: string | undefined): { dirtyCount: number } {
224
252
  const lines =
225
253
  status
226
254
  ?.split("\n")
227
255
  .map((line) => line.trim())
228
256
  .filter(Boolean) ?? [];
229
257
  const dirty = lines.filter((line) => !line.startsWith("##"));
230
- return { lineCount: lines.length, dirtyCount: dirty.length };
258
+ return { dirtyCount: dirty.length };
231
259
  }
232
260
 
233
261
  function appendRunBoundary(report: string, details: AuditDetails): string {
@@ -267,10 +295,80 @@ async function gitStatusShort(
267
295
  return result.stdout.trim();
268
296
  }
269
297
 
270
- function resolveToolPath(cwd: string, rawPath: unknown): string {
271
- const input = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
272
- const normalized = input.startsWith("@") ? input.slice(1) : input;
273
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
298
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
299
+
300
+ /**
301
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
302
+ * non-win32 paths.
303
+ */
304
+ function normalizeWindowsShellPathLocal(filePath: string): string {
305
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
306
+ return filePath;
307
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
308
+ if (!match) return filePath;
309
+ const suffix = match[2]?.replaceAll("/", "\\");
310
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
311
+ }
312
+
313
+ /**
314
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
315
+ * drive-path normalisation always runs; only tilde expansion is gated by
316
+ * expandTilde.
317
+ */
318
+ function normalizePath(
319
+ input: string,
320
+ opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
321
+ ): string {
322
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
323
+ let p = input;
324
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
325
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
326
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
327
+ if (expandTilde) {
328
+ const home = os.homedir();
329
+ if (p === "~") p = home;
330
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
331
+ p = path.join(home, p.slice(2));
332
+ }
333
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
334
+ return p;
335
+ }
336
+
337
+ /**
338
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
339
+ * __test__ for parity tests; not imported by other extensions.
340
+ */
341
+ function resolveToCwd(rawPath: string, cwd: string): string {
342
+ const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
343
+ const base = normalizePath(cwd);
344
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
345
+ }
346
+
347
+ /** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
348
+ function tryMacOSScreenshotPathVariant(filePath: string): string {
349
+ return filePath.replace(/ (AM|PM)\./gi, " $1.");
350
+ }
351
+ /** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
352
+ function tryNFDPathVariant(filePath: string): string {
353
+ return filePath.normalize("NFD");
354
+ }
355
+ /** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
356
+ function tryCurlyQuotePathVariant(filePath: string): string {
357
+ return filePath.replace(/'/g, "’");
358
+ }
359
+ /**
360
+ * Returns all read path candidates including Pi's resolveReadPathAsync fallback
361
+ * variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
362
+ */
363
+ function getReadPathCandidates(resolved: string): string[] {
364
+ const nfd = tryNFDPathVariant(resolved);
365
+ return [
366
+ resolved,
367
+ tryMacOSScreenshotPathVariant(resolved),
368
+ nfd,
369
+ tryCurlyQuotePathVariant(resolved),
370
+ tryCurlyQuotePathVariant(nfd),
371
+ ];
274
372
  }
275
373
 
276
374
  function isInside(parent: string, child: string): boolean {
@@ -288,11 +386,28 @@ async function assertToolPathInsideCwd(
288
386
  ): Promise<string | undefined> {
289
387
  if (rawPath !== undefined && typeof rawPath !== "string")
290
388
  return `${toolName} path must be a string.`;
389
+ // A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
390
+ // the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
391
+ // where realpath fails and the lexical fallback starts with the alias prefix.
291
392
  const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
292
- const resolved = resolveToolPath(cwd, rawPath);
293
- const realPath = await fs.realpath(resolved).catch(() => resolved);
294
- if (!isInside(root, realPath))
295
- return `${toolName} is limited to the audited checkout: ${realPath}`;
393
+ const lexicalCwd = path.resolve(cwd);
394
+ // For the read tool, also check Pi's resolveReadPathAsync fallback variants so
395
+ // a missing file whose variant resolves outside is not silently allowed.
396
+ try {
397
+ const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
398
+ const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
399
+ // Deliberate policy: all candidates are checked regardless of existence (not
400
+ // Pi's existence-ordered selection) so the guard cannot be raced by creating
401
+ // a variant between the hook and the read. Trade-off: a read is blocked when
402
+ // an escaping variant symlink sits next to a safe primary file.
403
+ for (const candidate of candidates) {
404
+ const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
405
+ if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
406
+ return `${toolName} is limited to the audited checkout: ${candidateRealPath}`;
407
+ }
408
+ } catch {
409
+ return `${toolName} path could not be resolved safely.`;
410
+ }
296
411
  return undefined;
297
412
  }
298
413
 
@@ -330,6 +445,93 @@ const SAFE_DIRECT_COMMANDS = new Set([
330
445
  "yarn",
331
446
  ]);
332
447
 
448
+ const SUBAGENT_MCP_TOOLS = [
449
+ "mcp__*",
450
+ "tool_search",
451
+ "list_mcp_resources",
452
+ "list_mcp_resource_templates",
453
+ "read_mcp_resource",
454
+ ];
455
+
456
+ // Derive project-trust for the isolated subagent session from the host tool context.
457
+ // Fails closed: returns false if the host has not trusted the project, or if the subagent
458
+ // runs in a different directory, preventing an untrusted project's .pi/mcp.json from
459
+ // being loaded by the MCP extension.
460
+ function resolveSubagentProjectTrusted(
461
+ ctx: { cwd: string; isProjectTrusted(): boolean },
462
+ subagentCwd: string,
463
+ ): boolean {
464
+ // Feature-detected for older Pi hosts that may lack isProjectTrusted.
465
+ const hostTrusted =
466
+ typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
467
+ ? ctx.isProjectTrusted()
468
+ : false;
469
+ if (!hostTrusted) return false;
470
+ try {
471
+ return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
472
+ } catch {
473
+ return false; // fail closed if realpath resolution fails
474
+ }
475
+ }
476
+
477
+ /** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
478
+ function isMcpCompatibleVersion(v: string | undefined): boolean {
479
+ if (!v) return false;
480
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
481
+ if (!m) return false;
482
+ const major = Number(m[1]),
483
+ minor = Number(m[2]),
484
+ patch = Number(m[3]);
485
+ if (major !== 1) return major > 1;
486
+ if (minor !== 0) return minor > 0;
487
+ return patch >= 4;
488
+ }
489
+
490
+ // Safe runtime access to MCP/tool-search factories — feature-detected for Pi >=0.99.0 hosts.
491
+ // Additionally gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
492
+ // matches that resolve nothing, so spawning MCP servers provides no benefit.
493
+ function createMcpSubagentFactories(): ExtensionFactory[] {
494
+ const ns = piCodingAgent as Record<string, unknown>;
495
+ if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
496
+ const factories: ExtensionFactory[] = [];
497
+ const mcp = ns["createMcpExtension"];
498
+ const toolSearch = ns["createToolSearchExtension"];
499
+ if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
500
+ if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
501
+ return factories;
502
+ }
503
+
504
+ // Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
505
+ async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
506
+ if (!session) return;
507
+ try {
508
+ const runner = (
509
+ session as unknown as {
510
+ extensionRunner?: {
511
+ hasHandlers(n: string): boolean;
512
+ emit(e: { type: string; reason: string }): Promise<unknown>;
513
+ };
514
+ }
515
+ ).extensionRunner;
516
+ if (runner?.hasHandlers("session_shutdown")) {
517
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
518
+ }
519
+ } catch {
520
+ // Shutdown errors must not prevent dispose.
521
+ }
522
+ session.dispose();
523
+ }
524
+
525
+ function isSubagentMcpTool(toolName: string): boolean {
526
+ return (
527
+ toolName.startsWith("mcp__") ||
528
+ toolName === "tool_search" ||
529
+ toolName === "list_mcp_resources" ||
530
+ toolName === "list_mcp_resource_templates" ||
531
+ toolName === "read_mcp_resource"
532
+ );
533
+ }
534
+
333
535
  const READ_ONLY_GIT_SUBCOMMANDS = new Set([
334
536
  "blame",
335
537
  "branch",
@@ -677,9 +879,7 @@ function getBlockedPackageManagerReason(commandName: string, tokens: string[]):
677
879
  index += 1;
678
880
  }
679
881
  }
680
- const runnerIndex = lowerTokens.findIndex((token) =>
681
- ["node", "python", "python3", "ruby", "php"].includes(token),
682
- );
882
+ const runnerIndex = lowerTokens.findIndex((token) => ["node", "ruby", "php"].includes(token));
683
883
  if (runnerIndex >= 0) {
684
884
  const runnerReason = getBlockedInterpreterReason(
685
885
  lowerTokens[runnerIndex],
@@ -944,7 +1144,7 @@ function hasInlineFlag(tokens: string[], flags: string[]): boolean {
944
1144
  function getBlockedInterpreterReason(commandName: string, tokens: string[]): string | undefined {
945
1145
  const lowerTokens = tokens.map((token) => token.toLowerCase());
946
1146
  if (
947
- ["node", "deno", "bun"].includes(commandName) &&
1147
+ ["node", "bun"].includes(commandName) &&
948
1148
  hasInlineFlag(lowerTokens, [
949
1149
  "-e",
950
1150
  "--eval",
@@ -961,9 +1161,6 @@ function getBlockedInterpreterReason(commandName: string, tokens: string[]): str
961
1161
  ) {
962
1162
  return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
963
1163
  }
964
- if (["python", "python3"].includes(commandName) && hasInlineFlag(lowerTokens, ["-c"])) {
965
- return "Agent Workflow Audit blocks inline Python execution; run documented project scripts instead.";
966
- }
967
1164
  if (["ruby", "php"].includes(commandName) && hasInlineFlag(lowerTokens, ["-e", "-r"])) {
968
1165
  return `Agent Workflow Audit blocks inline/preload ${commandName} execution; run documented project scripts instead.`;
969
1166
  }
@@ -1137,10 +1334,13 @@ function createAuditRuntimeGuardExtension(options: {
1137
1334
  });
1138
1335
 
1139
1336
  pi.on("tool_call", async (event) => {
1140
- if (!["read", "grep", "find", "ls", "bash"].includes(event.toolName)) {
1337
+ if (
1338
+ !["read", "grep", "find", "ls", "bash"].includes(event.toolName) &&
1339
+ !isSubagentMcpTool(event.toolName)
1340
+ ) {
1141
1341
  return {
1142
1342
  block: true,
1143
- reason: `agent-workflow-audit exposes only read, grep, find, ls, and guarded bash; ${event.toolName} is not allowed.`,
1343
+ reason: `agent-workflow-audit exposes only read, grep, find, ls, guarded bash, and MCP tools; ${event.toolName} is not allowed.`,
1144
1344
  };
1145
1345
  }
1146
1346
 
@@ -1212,7 +1412,7 @@ function buildSystemPrompt(options: {
1212
1412
  ? "Plan-only mode is active. Do not run project commands with bash. Read instructions and manifests, infer the likely workflow, and report what would be tried plus remaining uncertainty."
1213
1413
  : "Execution mode is active. Try documented setup, build, lint, test, run, and other obvious project commands when they are safe and relevant. A runtime guard blocks deploy/publish/VCS-mutating/destructive commands; if a command is blocked, report it as a workflow/safety finding instead of trying to bypass it.";
1214
1414
 
1215
- return `You are Agent Workflow Audit, an isolated subagent running inside The Last Harness. Your job is to stress-test how efficiently an agent can operate in the current repository and return a concise final audit report to the parent session.
1415
+ return `You are Agent Workflow Audit, an isolated subagent. Your job is to stress-test how efficiently an agent can operate in the current repository and return a concise final audit report to the parent session.
1216
1416
 
1217
1417
  Working directory: ${options.cwd}
1218
1418
  Turn budget: ${options.maxTurns} turns total, including your final answer.
@@ -1233,7 +1433,7 @@ Workflow:
1233
1433
 
1234
1434
  Constraints:
1235
1435
  - Do not fix application code or failing tests. Do not edit, write, move, delete, commit, checkout, reset, clean, push, publish, deploy, or mutate GitHub.
1236
- - Use built-in read/grep/find/ls for file inspection. Use bash only for safe documented project commands or read-only local inspection.
1436
+ - Use built-in read/grep/find/ls for file inspection. Use bash only for safe documented project commands or read-only local inspection. 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.
1237
1437
  - Do not paste raw full logs. Summarize command output and include short excerpts only when needed to prove a finding.
1238
1438
  - Never present a command as successful unless tool output showed success.
1239
1439
  - Mark inferred steps as assumptions.
@@ -1354,27 +1554,31 @@ async function runAudit(
1354
1554
  let unsubscribe: (() => void) | undefined;
1355
1555
  let runTimeout: NodeJS.Timeout | undefined;
1356
1556
  let abortListenerAdded = false;
1357
- let aborted = Boolean(ctx.signal?.aborted);
1358
1557
 
1359
1558
  const emit = () => updateAuditUi(ctx, details, lastContent);
1360
- const abort = () => {
1361
- aborted = true;
1362
- details.status = "aborted";
1363
- details.endedAt = Date.now();
1364
- lastContent = "Aborted";
1365
- emit();
1366
- void session?.abort();
1367
- };
1559
+ const abortController = createAuditAbortController(
1560
+ () => {
1561
+ void session?.abort();
1562
+ },
1563
+ () => {
1564
+ details.status = "aborted";
1565
+ details.endedAt = Date.now();
1566
+ lastContent = "Aborted";
1567
+ emit();
1568
+ },
1569
+ );
1570
+ const abortFromCaller = () => abortController.abortFromCaller();
1368
1571
 
1369
- if (ctx.signal?.aborted) abort();
1572
+ if (ctx.signal?.aborted) abortFromCaller();
1370
1573
  if (ctx.signal && !ctx.signal.aborted) {
1371
- ctx.signal.addEventListener("abort", abort);
1574
+ ctx.signal.addEventListener("abort", abortFromCaller);
1372
1575
  abortListenerAdded = true;
1373
1576
  }
1374
1577
 
1375
1578
  try {
1376
1579
  emit();
1377
- const isolatedSettingsManager = SettingsManager.inMemory({});
1580
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
1581
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
1378
1582
  const resourceLoader = new DefaultResourceLoader({
1379
1583
  cwd,
1380
1584
  agentDir: getAgentDir(),
@@ -1386,6 +1590,7 @@ async function runAudit(
1386
1590
  noContextFiles: true,
1387
1591
  extensionFactories: [
1388
1592
  createAuditRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS, planOnly: options.planOnly }),
1593
+ ...createMcpSubagentFactories(),
1389
1594
  ],
1390
1595
  systemPromptOverride: () =>
1391
1596
  buildSystemPrompt({
@@ -1404,8 +1609,8 @@ async function runAudit(
1404
1609
  await resourceLoader.reload();
1405
1610
 
1406
1611
  const tools = options.planOnly
1407
- ? ["read", "grep", "find", "ls"]
1408
- : ["read", "grep", "find", "ls", "bash"];
1612
+ ? ["read", "grep", "find", "ls", ...SUBAGENT_MCP_TOOLS]
1613
+ : ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS];
1409
1614
  const created = await createAgentSession({
1410
1615
  cwd,
1411
1616
  ...getModelRuntimeOption(ctx),
@@ -1417,9 +1622,17 @@ async function runAudit(
1417
1622
  tools,
1418
1623
  });
1419
1624
 
1625
+ // Assign session BEFORE bindExtensions so the finally block can dispose it
1626
+ // even if binding fails partway through (e.g. a server starts then an
1627
+ // extension's session_start throws).
1420
1628
  session = created.session;
1629
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
1630
+ await (created.session as any).bindExtensions?.({ mode: "json" });
1421
1631
  unsubscribe = session.subscribe((event) => {
1422
1632
  switch (event.type) {
1633
+ case "message_start":
1634
+ if (event.message.role === "assistant") lastContent = "";
1635
+ break;
1423
1636
  case "message_update":
1424
1637
  if (event.assistantMessageEvent?.type === "text_delta") {
1425
1638
  lastContent += event.assistantMessageEvent.delta ?? "";
@@ -1454,7 +1667,7 @@ async function runAudit(
1454
1667
  }
1455
1668
  });
1456
1669
 
1457
- if (!aborted) {
1670
+ if (!abortController.callerAborted) {
1458
1671
  const promptPromise = session.prompt(
1459
1672
  buildUserPrompt({
1460
1673
  cwd,
@@ -1466,12 +1679,11 @@ async function runAudit(
1466
1679
  );
1467
1680
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
1468
1681
  runTimeout = setTimeout(() => {
1469
- abort();
1470
- reject(
1471
- new Error(
1472
- `/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1473
- ),
1682
+ const timeoutError = new Error(
1683
+ `/agent-workflow-audit timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1474
1684
  );
1685
+ reject(timeoutError);
1686
+ abortController.abortForCleanup();
1475
1687
  }, MAX_RUN_MS);
1476
1688
  });
1477
1689
  await Promise.race([promptPromise, timeoutPromise]);
@@ -1486,40 +1698,45 @@ async function runAudit(
1486
1698
  if (outcome.ok) {
1487
1699
  lastContent = outcome.answer;
1488
1700
  details.status = "done";
1489
- } else if (aborted) {
1701
+ } else if (abortController.callerAborted) {
1490
1702
  lastContent = "Aborted";
1491
1703
  details.status = "aborted";
1492
1704
  } else {
1493
1705
  throw new Error(outcome.reason);
1494
1706
  }
1495
1707
  details.endedAt = Date.now();
1496
- details.reportLength = lastContent.length;
1497
1708
  emit();
1498
1709
  return { report: lastContent, details };
1499
1710
  } catch (error) {
1500
- const wasAbort = aborted || isAbortLikeError(error);
1501
- const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1502
- details.status = wasAbort ? "aborted" : "error";
1503
- details.error = wasAbort ? undefined : message;
1711
+ const failure = classifyRunFailure(error, abortController.callerAborted);
1712
+ const { message } = failure;
1713
+ details.status = failure.status;
1504
1714
  details.endedAt = Date.now();
1505
1715
  lastContent = message;
1506
- details.reportLength = lastContent.length;
1507
1716
  emit();
1508
1717
  return { report: `## Agent Workflow Audit failed\n\n${message}`, details };
1509
1718
  } finally {
1510
1719
  if (runTimeout) clearTimeout(runTimeout);
1511
- if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort", abort);
1720
+ if (ctx.signal && abortListenerAdded) ctx.signal.removeEventListener("abort", abortFromCaller);
1512
1721
  unsubscribe?.();
1513
- session?.dispose();
1722
+ await disposeSubagentSession(session);
1514
1723
  }
1515
1724
  }
1516
1725
 
1517
1726
  export const __test__ = {
1727
+ SUBAGENT_MCP_TOOLS,
1728
+ isMcpCompatibleVersion,
1729
+ resolveSubagentProjectTrusted,
1730
+ createMcpSubagentFactories,
1731
+ disposeSubagentSession,
1732
+ classifyRunFailure,
1733
+ createAuditAbortController,
1734
+ createAuditRuntimeGuardExtension,
1518
1735
  appendRunBoundary,
1519
1736
  assertToolPathInsideCwd,
1737
+ resolveToCwd,
1520
1738
  buildSystemPrompt,
1521
1739
  buildUserPrompt,
1522
- createAuditRuntimeGuardExtension,
1523
1740
  inspectFinalAssistant,
1524
1741
  formatToolCall,
1525
1742
  getBlockedBashReason,
@@ -1633,7 +1850,6 @@ export default function agentWorkflowAuditExtension(pi: ExtensionAPI) {
1633
1850
  if (!details) return;
1634
1851
  details.finalGitStatus = await gitStatusShort(pi, ctx.cwd, ctx.signal).catch(() => undefined);
1635
1852
  report = appendRunBoundary(report, details);
1636
- details.reportLength = report.length;
1637
1853
  const parentDetails: AuditDetails = {
1638
1854
  ...details,
1639
1855
  toolCallCount: details.toolCalls.length,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-agent-workflow-audit",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "A pi extension that runs an isolated repo workflow audit and returns only the final report to the main session.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -381,20 +381,14 @@ const binaryExtensions = new Set([
381
381
  ".7z",
382
382
  ".a",
383
383
  ".avi",
384
- ".avif",
385
384
  ".bin",
386
- ".bmp",
387
385
  ".class",
388
386
  ".dll",
389
387
  ".dylib",
390
388
  ".eot",
391
389
  ".exe",
392
- ".gif",
393
390
  ".gz",
394
- ".ico",
395
391
  ".jar",
396
- ".jpeg",
397
- ".jpg",
398
392
  ".lockb",
399
393
  ".map",
400
394
  ".mov",
@@ -403,7 +397,6 @@ const binaryExtensions = new Set([
403
397
  ".o",
404
398
  ".otf",
405
399
  ".pdf",
406
- ".png",
407
400
  ".pyc",
408
401
  ".so",
409
402
  ".svgz",
@@ -411,7 +404,6 @@ const binaryExtensions = new Set([
411
404
  ".ttf",
412
405
  ".wasm",
413
406
  ".webm",
414
- ".webp",
415
407
  ".woff",
416
408
  ".woff2",
417
409
  ".zip",