@diegopetrucci/pi-extensions 0.1.81 → 0.1.82

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 (46) hide show
  1. package/README.md +6 -1
  2. package/extensions/agent-workflow-audit/index.ts +47 -25
  3. package/extensions/agent-workflow-audit/package.json +1 -1
  4. package/extensions/brrr/index.ts +21 -15
  5. package/extensions/brrr/package.json +1 -1
  6. package/extensions/code-reviewer/index.ts +52 -19
  7. package/extensions/code-reviewer/package.json +1 -1
  8. package/extensions/contrarian/index.ts +17 -17
  9. package/extensions/contrarian/package.json +1 -1
  10. package/extensions/developer/.pi-fleet-tested-version +1 -0
  11. package/extensions/developer/README.md +70 -0
  12. package/extensions/developer/index.ts +906 -0
  13. package/extensions/developer/package.json +40 -0
  14. package/extensions/diff-summarizer/.pi-fleet-tested-version +1 -0
  15. package/extensions/diff-summarizer/README.md +75 -0
  16. package/extensions/diff-summarizer/index.ts +1585 -0
  17. package/extensions/diff-summarizer/package.json +40 -0
  18. package/extensions/librarian/index.ts +10 -0
  19. package/extensions/librarian/package.json +1 -1
  20. package/extensions/minimal-footer/index.ts +5 -1
  21. package/extensions/minimal-footer/package.json +1 -1
  22. package/extensions/notify/index.ts +7 -2
  23. package/extensions/notify/package.json +1 -1
  24. package/extensions/oracle/index.ts +17 -17
  25. package/extensions/oracle/package.json +1 -1
  26. package/extensions/permission-gate/README.md +6 -11
  27. package/extensions/permission-gate/index.ts +11 -54
  28. package/extensions/permission-gate/package.json +1 -3
  29. package/extensions/project-mcp-json/README.md +4 -3
  30. package/extensions/project-mcp-json/package.json +1 -1
  31. package/extensions/repo-scout/.pi-fleet-tested-version +1 -0
  32. package/extensions/repo-scout/README.md +74 -0
  33. package/extensions/repo-scout/index.ts +1575 -0
  34. package/extensions/repo-scout/package.json +40 -0
  35. package/extensions/test-runner/.pi-fleet-tested-version +1 -0
  36. package/extensions/test-runner/README.md +70 -0
  37. package/extensions/test-runner/index.ts +798 -0
  38. package/extensions/test-runner/package.json +40 -0
  39. package/extensions/triage-comments/index.ts +66 -16
  40. package/extensions/triage-comments/package.json +1 -1
  41. package/extensions/web-scout/.pi-fleet-tested-version +1 -0
  42. package/extensions/web-scout/README.md +67 -0
  43. package/extensions/web-scout/index.ts +1204 -0
  44. package/extensions/web-scout/package.json +41 -0
  45. package/package.json +7 -2
  46. package/extensions/permission-gate/powershell-safety.ts +0 -1053
@@ -0,0 +1,798 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { realpathSync } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import type { AgentSession, ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ DefaultResourceLoader,
10
+ SessionManager,
11
+ SettingsManager,
12
+ createAgentSession,
13
+ getAgentDir,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+
18
+ import type { Usage } from "@earendil-works/pi-ai";
19
+
20
+ const MAX_TOOL_CALLS_TO_KEEP = 80;
21
+ // Validation can be long (e.g. npm ci plus full test suites), so budgets are generous.
22
+ const MAX_TURNS = 60;
23
+ const MAX_RUN_MS = 60 * 60 * 1000;
24
+ const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
25
+ const DEFAULT_THINKING_LEVEL = "low";
26
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
27
+ const BUILTIN_TOOL_NAMES = ["bash"] as const;
28
+
29
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
30
+ type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
31
+
32
+ const SUBAGENT_MCP_TOOLS = [
33
+ "mcp__*",
34
+ "tool_search",
35
+ "list_mcp_resources",
36
+ "list_mcp_resource_templates",
37
+ "read_mcp_resource",
38
+ ];
39
+
40
+ // Derive project-trust for the isolated subagent session from the host tool context.
41
+ // Fails closed: returns false if the host has not trusted the project, or if the subagent
42
+ // runs in a different directory (e.g. a temp workspace), preventing an untrusted project's
43
+ // .pi/mcp.json from being loaded by the MCP extension.
44
+ function resolveSubagentProjectTrusted(
45
+ ctx: { cwd: string; isProjectTrusted(): boolean },
46
+ subagentCwd: string,
47
+ ): boolean {
48
+ // Feature-detected for older Pi hosts that may lack isProjectTrusted.
49
+ const hostTrusted =
50
+ typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
51
+ ? ctx.isProjectTrusted()
52
+ : false;
53
+ if (!hostTrusted) return false;
54
+ try {
55
+ return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
56
+ } catch {
57
+ return false; // fail closed if realpath resolution fails
58
+ }
59
+ }
60
+
61
+ function isSubagentMcpTool(toolName: string): boolean {
62
+ return (
63
+ toolName.startsWith("mcp__") ||
64
+ toolName === "tool_search" ||
65
+ toolName === "list_mcp_resources" ||
66
+ toolName === "list_mcp_resource_templates" ||
67
+ toolName === "read_mcp_resource"
68
+ );
69
+ }
70
+
71
+ /** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
72
+ function isMcpCompatibleVersion(v: string | undefined): boolean {
73
+ if (!v) return false;
74
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
75
+ if (!m) return false;
76
+ const major = Number(m[1]),
77
+ minor = Number(m[2]),
78
+ patch = Number(m[3]);
79
+ if (major !== 1) return major > 1;
80
+ if (minor !== 0) return minor > 0;
81
+ return patch >= 4;
82
+ }
83
+
84
+ // Gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
85
+ // matches that resolve nothing, so spawning MCP servers provides no benefit.
86
+ function createMcpSubagentFactories(): ExtensionFactory[] {
87
+ const ns = piCodingAgent as Record<string, unknown>;
88
+ if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
89
+ const factories: ExtensionFactory[] = [];
90
+ const mcp = ns["createMcpExtension"];
91
+ const toolSearch = ns["createToolSearchExtension"];
92
+ if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
93
+ if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
94
+ return factories;
95
+ }
96
+
97
+ // Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
98
+ async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
99
+ if (!session) return;
100
+ try {
101
+ const runner = (
102
+ session as unknown as {
103
+ extensionRunner?: {
104
+ hasHandlers(n: string): boolean;
105
+ emit(e: { type: string; reason: string }): Promise<unknown>;
106
+ };
107
+ }
108
+ ).extensionRunner;
109
+ if (runner?.hasHandlers("session_shutdown")) {
110
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
111
+ }
112
+ } catch {
113
+ // Shutdown errors must not prevent dispose.
114
+ }
115
+ session.dispose();
116
+ }
117
+
118
+ const TestRunnerParams = Type.Object({
119
+ steps: Type.Array(Type.String(), {
120
+ minItems: 1,
121
+ description: "Exact shell commands to run, in order. Execution stops at the first failure.",
122
+ }),
123
+ context: Type.Optional(
124
+ Type.String({
125
+ description: "Optional caller context such as the validation scope or known flaky steps.",
126
+ }),
127
+ ),
128
+ cwd: Type.Optional(
129
+ Type.String({
130
+ description:
131
+ "Optional working directory for the steps; defaults to the current session directory.",
132
+ }),
133
+ ),
134
+ });
135
+
136
+ type ToolCall = {
137
+ name: string;
138
+ args: unknown;
139
+ };
140
+
141
+ type RunStatus = "running" | "done" | "error" | "aborted";
142
+
143
+ type RunDetails = {
144
+ status: RunStatus;
145
+ cwd: string;
146
+ steps: string[];
147
+ turns: number;
148
+ toolCalls: ToolCall[];
149
+ startedAt: number;
150
+ endedAt?: number;
151
+ error?: string;
152
+ modelRef?: string;
153
+ modelName?: string;
154
+ requestedThinkingLevel?: ThinkingLevel;
155
+ effectiveThinkingLevel?: ThinkingLevel;
156
+ thinkingLevelClamped?: boolean;
157
+ thinkingLevelNote?: string;
158
+ };
159
+
160
+ type PiModel = {
161
+ provider: string;
162
+ id: string;
163
+ name?: string;
164
+ reasoning?: boolean;
165
+ contextWindow?: number;
166
+ maxTokens?: number;
167
+ thinkingLevelMap?: ThinkingLevelMap;
168
+ };
169
+
170
+ type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
171
+ type CreateAgentSessionModel = CreateAgentSessionOptions["model"];
172
+
173
+ function getModelRuntimeOption(ctx: {
174
+ modelRegistry?: unknown;
175
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
176
+ const modelRuntime = (
177
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
178
+ )?.runtime;
179
+ return modelRuntime ? { modelRuntime } : {};
180
+ }
181
+
182
+ function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
183
+ return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel)
184
+ ? (value as ThinkingLevel)
185
+ : undefined;
186
+ }
187
+
188
+ // Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
189
+ function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
190
+ if (!model.reasoning) return level === "off";
191
+
192
+ const map = model.thinkingLevelMap;
193
+ if (level === "xhigh" || level === "max") {
194
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
195
+ }
196
+ return map?.[level] !== null;
197
+ }
198
+
199
+ function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
200
+ if (isThinkingLevelSupported(model, requested)) return requested;
201
+
202
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
203
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
204
+ const level = THINKING_LEVELS[index];
205
+ if (isThinkingLevelSupported(model, level)) return level;
206
+ }
207
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
208
+ const level = THINKING_LEVELS[index];
209
+ if (isThinkingLevelSupported(model, level)) return level;
210
+ }
211
+
212
+ return "off";
213
+ }
214
+
215
+ function resolveThinkingLevel(
216
+ model: PiModel | undefined,
217
+ requestedThinkingLevel: ThinkingLevel | undefined,
218
+ ): {
219
+ requested: ThinkingLevel;
220
+ effective: ThinkingLevel;
221
+ clamped: boolean;
222
+ note: string;
223
+ } {
224
+ const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
225
+ const effective = model ? clampThinkingLevel(model, requested) : requested;
226
+ const clamped = effective !== requested;
227
+ if (clamped) {
228
+ return {
229
+ requested,
230
+ effective,
231
+ clamped,
232
+ note: `requested ${requested}; clamped to ${effective}`,
233
+ };
234
+ }
235
+ if (requestedThinkingLevel) {
236
+ return { requested, effective, clamped, note: `requested ${requested}` };
237
+ }
238
+ return {
239
+ requested,
240
+ effective,
241
+ clamped,
242
+ note: model?.reasoning
243
+ ? `defaulted to ${effective}`
244
+ : `defaulted to off for non-reasoning model`,
245
+ };
246
+ }
247
+
248
+ function applyModelAttemptDetails(
249
+ details: RunDetails,
250
+ model: PiModel,
251
+ thinking: ReturnType<typeof resolveThinkingLevel>,
252
+ ): void {
253
+ details.modelRef = `${model.provider}/${model.id}`;
254
+ details.modelName = model.name;
255
+ details.requestedThinkingLevel = thinking.requested;
256
+ details.effectiveThinkingLevel = thinking.effective;
257
+ details.thinkingLevelClamped = thinking.clamped || undefined;
258
+ details.thinkingLevelNote = thinking.note;
259
+ }
260
+
261
+ function createEmptyUsage(): Usage {
262
+ return {
263
+ input: 0,
264
+ output: 0,
265
+ cacheRead: 0,
266
+ cacheWrite: 0,
267
+ totalTokens: 0,
268
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
269
+ };
270
+ }
271
+
272
+ function addUsage(total: Usage, usage: Usage): void {
273
+ total.input += usage.input || 0;
274
+ total.output += usage.output || 0;
275
+ total.cacheRead += usage.cacheRead || 0;
276
+ total.cacheWrite += usage.cacheWrite || 0;
277
+ if (usage.cacheWrite1h !== undefined)
278
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
279
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
280
+ total.totalTokens += usage.totalTokens || 0;
281
+ total.cost.input += usage.cost?.input || 0;
282
+ total.cost.output += usage.cost?.output || 0;
283
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
284
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
285
+ total.cost.total += usage.cost?.total || 0;
286
+ }
287
+
288
+ type AssistantLikeMessage = {
289
+ role?: string;
290
+ content?: unknown;
291
+ stopReason?: unknown;
292
+ errorMessage?: unknown;
293
+ usage?: unknown;
294
+ };
295
+
296
+ function addAssistantMessageUsage(total: Usage, message: unknown): void {
297
+ const assistant = message as AssistantLikeMessage;
298
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
299
+ return;
300
+ addUsage(total, assistant.usage as Usage);
301
+ }
302
+
303
+ function addSessionEventUsage(total: Usage, event: unknown): void {
304
+ const sessionEvent = event as {
305
+ type?: string;
306
+ message?: unknown;
307
+ result?: { usage?: Usage };
308
+ };
309
+ if (sessionEvent?.type === "message_end")
310
+ return void addAssistantMessageUsage(total, sessionEvent.message);
311
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
312
+ addUsage(total, sessionEvent.result.usage);
313
+ }
314
+
315
+ function inspectFinalAssistant(
316
+ messages: unknown[],
317
+ ):
318
+ | { ok: true; answer: string; stopReason?: string }
319
+ | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
320
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
321
+ const message = messages[i] as AssistantLikeMessage;
322
+ if (message?.role !== "assistant") continue;
323
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
324
+ const errorMessage =
325
+ typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
326
+ const answer = Array.isArray(message.content)
327
+ ? message.content
328
+ .map((part) =>
329
+ part &&
330
+ typeof part === "object" &&
331
+ (part as { type?: string }).type === "text" &&
332
+ typeof (part as { text?: unknown }).text === "string"
333
+ ? (part as { text: string }).text
334
+ : "",
335
+ )
336
+ .join("")
337
+ .trim()
338
+ : "";
339
+ if (stopReason === "error")
340
+ return {
341
+ ok: false,
342
+ reason: `test-runner subagent error: ${errorMessage || "provider/model error"}`,
343
+ stopReason,
344
+ errorMessage,
345
+ };
346
+ if (stopReason === "aborted")
347
+ return {
348
+ ok: false,
349
+ reason: "test-runner subagent aborted before producing a usable answer",
350
+ stopReason,
351
+ };
352
+ if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
353
+ return {
354
+ ok: false,
355
+ reason: stopReason
356
+ ? `test-runner subagent produced no final assistant text (stopReason: ${stopReason})`
357
+ : "test-runner subagent produced no final assistant text",
358
+ stopReason: stopReason || undefined,
359
+ errorMessage: errorMessage || undefined,
360
+ };
361
+ }
362
+ return {
363
+ ok: false,
364
+ reason: "test-runner subagent produced no assistant message",
365
+ };
366
+ }
367
+
368
+ function classifyRunFailure(
369
+ error: unknown,
370
+ aborted: boolean,
371
+ ): { status: "aborted" | "error"; message: string; error?: string } {
372
+ const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
373
+ return {
374
+ status: aborted ? "aborted" : "error",
375
+ message,
376
+ error: aborted ? undefined : message,
377
+ };
378
+ }
379
+
380
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
381
+
382
+ /**
383
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
384
+ * non-win32 paths.
385
+ */
386
+ function normalizeWindowsShellPathLocal(filePath: string): string {
387
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
388
+ return filePath;
389
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
390
+ if (!match) return filePath;
391
+ const suffix = match[2]?.replaceAll("/", "\\");
392
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
393
+ }
394
+
395
+ /**
396
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
397
+ * drive-path normalisation always runs; only tilde expansion is gated by
398
+ * expandTilde.
399
+ */
400
+ function normalizePath(
401
+ input: string,
402
+ opts: {
403
+ normalizeUnicodeSpaces?: boolean;
404
+ stripAtPrefix?: boolean;
405
+ expandTilde?: boolean;
406
+ } = {},
407
+ ): string {
408
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
409
+ let p = input;
410
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
411
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
412
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
413
+ if (expandTilde) {
414
+ const home = os.homedir();
415
+ if (p === "~") p = home;
416
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
417
+ p = path.join(home, p.slice(2));
418
+ }
419
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
420
+ return p;
421
+ }
422
+
423
+ /**
424
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
425
+ * __test__ for parity tests; not imported by other extensions.
426
+ */
427
+ function resolveToCwd(rawPath: string, cwd: string): string {
428
+ const normalized = normalizePath(rawPath, {
429
+ normalizeUnicodeSpaces: true,
430
+ stripAtPrefix: true,
431
+ });
432
+ const base = normalizePath(cwd);
433
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
434
+ }
435
+
436
+ function createTestRunnerRuntimeGuardExtension(options: {
437
+ cwd: string;
438
+ maxTurns: number;
439
+ }): ExtensionFactory {
440
+ return (pi) => {
441
+ let currentTurn = 0;
442
+
443
+ pi.on("turn_start", async (event) => {
444
+ currentTurn = event.turnIndex;
445
+ });
446
+
447
+ pi.on("tool_call", async (event) => {
448
+ const allowed =
449
+ (BUILTIN_TOOL_NAMES as readonly string[]).includes(event.toolName) ||
450
+ isSubagentMcpTool(event.toolName);
451
+ if (!allowed) {
452
+ return {
453
+ block: true,
454
+ reason: `test-runner exposes bash and MCP tools only; ${event.toolName} is not allowed.`,
455
+ };
456
+ }
457
+
458
+ if (currentTurn >= options.maxTurns - 1) {
459
+ return {
460
+ block: true,
461
+ reason: `Tool use is disabled on final test-runner turn ${options.maxTurns}/${options.maxTurns}. Report the PASS/FAIL results so far and any skipped steps now.`,
462
+ };
463
+ }
464
+
465
+ if (event.toolName === "bash") {
466
+ const input = event.input as { timeout?: unknown };
467
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
468
+ }
469
+
470
+ return undefined;
471
+ });
472
+
473
+ pi.on("tool_result", async (event) => ({
474
+ content: [
475
+ ...(event.content ?? []),
476
+ {
477
+ type: "text",
478
+ text: `\n\n[test-runner turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
479
+ },
480
+ ],
481
+ }));
482
+ };
483
+ }
484
+
485
+ function buildSystemPrompt(options: {
486
+ cwd: string;
487
+ maxTurns: number;
488
+ maxRunSeconds: number;
489
+ }): string {
490
+ return `You are the test-runner, an isolated execution-only agent running inside the test-runner package. Execute the exact validation steps assigned by the caller and return a concise pass/fail report.
491
+
492
+ Working directory: ${options.cwd}
493
+ Turn budget: ${options.maxTurns} turns total, including your final answer.
494
+ Wall-clock budget: ${options.maxRunSeconds} seconds.
495
+
496
+ Available tools: bash and any configured MCP tools. bash is unrestricted and not sandboxed, so run only the assigned steps. MCP tools run without confirmation; use them only if a step explicitly calls for one. MCP access requires host Pi >=1.0.4; on older hosts MCP tools are absent with no error.
497
+
498
+ You are execution-only for repository operations. Never edit files, install dependencies, fix failures, update snapshots, or stage, commit, or push changes. Do not use a mutating shell command or start a long-lived watcher/server except where an assigned step is itself that command. Do not run anything that is not an assigned step, other than trivial read-only commands needed to report results. If a blocker prevents validation, report it in your final result and stop. There is no way to ask questions mid-run.
499
+
500
+ ## Run protocol
501
+
502
+ 1. The numbered steps in the user message are the source of truth. If they are missing or empty, stop and report the blocker without running anything.
503
+ 2. Run each step exactly as given, in order, as one complete shell command. Do not infer, invent, reorder, replace, split, or broaden steps from prose.
504
+ 3. Stop after the first failed step (non-zero exit, timeout, or error). Do not run later steps.
505
+ 4. Record each exact step with its result and PASS or FAIL. Do not change the repository to make validation pass.
506
+
507
+ ## Output
508
+
509
+ Report the validation scope, each exact step with its result and PASS or FAIL, the overall result (PASS only if every step passed), skipped steps and reasons, and no claims beyond actions actually performed.`;
510
+ }
511
+
512
+ function buildUserPrompt(input: { steps: string[]; context?: string }, cwd: string): string {
513
+ const steps = input.steps.map((step, index) => `${index + 1}. ${step}`).join("\n");
514
+ return `Run these validation steps exactly, in order, in ${cwd}. Stop at the first failure.\n\nSteps:\n${steps}\n\nOptional caller context:\n${input.context?.trim() ? input.context : "(not provided)"}\n\nRespond using the required output format.`;
515
+ }
516
+
517
+ function formatToolCall(call: ToolCall): string {
518
+ const args =
519
+ call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
520
+ if (call.name === "bash") {
521
+ const command = typeof args.command === "string" ? args.command : "";
522
+ return `bash ${command.slice(0, 120)}`.trim();
523
+ }
524
+ return call.name;
525
+ }
526
+
527
+ function formatDuration(ms: number): string {
528
+ if (ms < 1000) return `${ms}ms`;
529
+ const seconds = Math.round(ms / 100) / 10;
530
+ if (seconds < 60) return `${seconds}s`;
531
+ const minutes = Math.floor(seconds / 60);
532
+ const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
533
+ return `${minutes}m ${remainingSeconds}s`;
534
+ }
535
+
536
+ function appendRunDetails(report: string, details: RunDetails): string {
537
+ const trimmed = report.trim();
538
+ const toolSummary =
539
+ details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
540
+ const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
541
+ const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
542
+ const thinkingSummary = details.effectiveThinkingLevel
543
+ ? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
544
+ : "thinking unknown";
545
+ const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
546
+ if (!trimmed)
547
+ return `## Validation scope\n- unknown\n\n## Steps\n- none reported\n\n## Overall result\n- FAIL: the isolated test-runner session returned no assistant text.${suffix}`;
548
+ return `${trimmed}${suffix}`;
549
+ }
550
+
551
+ export const __test__ = {
552
+ DEFAULT_BASH_TIMEOUT_SECONDS,
553
+ DEFAULT_THINKING_LEVEL,
554
+ SUBAGENT_MCP_TOOLS,
555
+ BUILTIN_TOOL_NAMES,
556
+ isMcpCompatibleVersion,
557
+ resolveSubagentProjectTrusted,
558
+ createMcpSubagentFactories,
559
+ disposeSubagentSession,
560
+ createTestRunnerRuntimeGuardExtension,
561
+ buildSystemPrompt,
562
+ buildUserPrompt,
563
+ normalizeThinkingLevel,
564
+ resolveThinkingLevel,
565
+ createEmptyUsage,
566
+ addAssistantMessageUsage,
567
+ addSessionEventUsage,
568
+ inspectFinalAssistant,
569
+ classifyRunFailure,
570
+ appendRunDetails,
571
+ };
572
+
573
+ async function resolveRunCwd(baseCwd: string, requested: unknown): Promise<string> {
574
+ const base = path.resolve(baseCwd);
575
+ if (requested === undefined) return base;
576
+ if (typeof requested !== "string" || !requested.trim())
577
+ throw new Error("Invalid parameters: expected cwd to be a non-empty string when provided.");
578
+ const resolved = resolveToCwd(requested, base);
579
+ const stat = await fs.stat(resolved).catch(() => undefined);
580
+ if (!stat?.isDirectory())
581
+ throw new Error(`Invalid parameters: cwd is not an existing directory: ${resolved}`);
582
+ return resolved;
583
+ }
584
+
585
+ export default function testRunnerExtension(pi: ExtensionAPI) {
586
+ pi.on("tool_result", async (event) => {
587
+ if (event.toolName !== "test_runner") return undefined;
588
+ const details = event.details as RunDetails | undefined;
589
+ if (details?.status !== "error") return undefined;
590
+ return { isError: true };
591
+ });
592
+
593
+ pi.registerTool({
594
+ name: "test_runner",
595
+ label: "Test Runner",
596
+ description:
597
+ "Isolated test runner that executes exact shell validation steps in order using unrestricted bash and configured MCP tools, stops at the first failure, and reports PASS/FAIL per step without changing the repository.",
598
+ promptSnippet:
599
+ "Delegate exact validation commands to an isolated test-runner child agent that runs them in order, stops at the first failure, and reports PASS/FAIL per step with run details.",
600
+ promptGuidelines: [
601
+ "Use test_runner to run exact validation shell commands (for example npm ci, then npm test) in an isolated child agent; pass each command as one entry in steps, in order.",
602
+ "test_runner stops at the first failing step and never edits, fixes, installs, or commits on its own, but bash is unrestricted and not sandboxed: only pass commands you would run yourself.",
603
+ ],
604
+ parameters: TestRunnerParams,
605
+
606
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
607
+ const steps = Array.isArray(params.steps)
608
+ ? params.steps.filter((step): step is string => typeof step === "string")
609
+ : [];
610
+ if (
611
+ steps.length === 0 ||
612
+ steps.length !== params.steps?.length ||
613
+ steps.some((s) => !s.trim())
614
+ )
615
+ throw new Error(
616
+ "Invalid parameters: expected steps to be a non-empty array of non-empty strings.",
617
+ );
618
+ const cwd = await resolveRunCwd(ctx.cwd, params.cwd);
619
+
620
+ const input = {
621
+ steps,
622
+ context: typeof params.context === "string" ? params.context : undefined,
623
+ };
624
+ const details: RunDetails = {
625
+ status: "running",
626
+ cwd,
627
+ steps,
628
+ turns: 0,
629
+ toolCalls: [],
630
+ startedAt: Date.now(),
631
+ };
632
+
633
+ let lastContent = "(running validation steps...)";
634
+ let session: AgentSession | undefined;
635
+ let unsubscribe: (() => void) | undefined;
636
+ let runTimeout: NodeJS.Timeout | undefined;
637
+ let abortListenerAdded = false;
638
+ let callerAborted = Boolean(signal?.aborted);
639
+ const usage = createEmptyUsage();
640
+
641
+ const emit = () => {
642
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
643
+ };
644
+
645
+ const abortForCleanup = () => {
646
+ void session?.abort();
647
+ };
648
+
649
+ const abortFromCaller = () => {
650
+ callerAborted = true;
651
+ details.status = "aborted";
652
+ details.endedAt = Date.now();
653
+ lastContent = "Aborted";
654
+ emit();
655
+ abortForCleanup();
656
+ };
657
+
658
+ if (signal?.aborted) abortFromCaller();
659
+ if (signal && !signal.aborted) {
660
+ signal.addEventListener("abort", abortFromCaller);
661
+ abortListenerAdded = true;
662
+ }
663
+
664
+ try {
665
+ emit();
666
+
667
+ const model = ctx.model as PiModel | undefined;
668
+ if (!model)
669
+ throw new Error(
670
+ "No active session model is available. Select a model before using test_runner.",
671
+ );
672
+
673
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
674
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
675
+ const resourceLoader = new DefaultResourceLoader({
676
+ cwd,
677
+ agentDir: getAgentDir(),
678
+ settingsManager: isolatedSettingsManager,
679
+ noExtensions: true,
680
+ noSkills: true,
681
+ noPromptTemplates: true,
682
+ noThemes: true,
683
+ noContextFiles: true,
684
+ extensionFactories: [
685
+ createTestRunnerRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
686
+ ...createMcpSubagentFactories(),
687
+ ],
688
+ systemPromptOverride: () =>
689
+ buildSystemPrompt({
690
+ cwd,
691
+ maxTurns: MAX_TURNS,
692
+ maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
693
+ }),
694
+ appendSystemPromptOverride: () => [],
695
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
696
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
697
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
698
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
699
+ });
700
+
701
+ await resourceLoader.reload();
702
+
703
+ const thinking = resolveThinkingLevel(model, DEFAULT_THINKING_LEVEL);
704
+ applyModelAttemptDetails(details, model, thinking);
705
+
706
+ const created = await createAgentSession({
707
+ cwd,
708
+ ...getModelRuntimeOption(ctx),
709
+ resourceLoader,
710
+ settingsManager: isolatedSettingsManager,
711
+ sessionManager: SessionManager.inMemory(cwd),
712
+ model: model as CreateAgentSessionModel,
713
+ thinkingLevel: thinking.effective,
714
+ tools: [...BUILTIN_TOOL_NAMES, ...SUBAGENT_MCP_TOOLS],
715
+ });
716
+ session = created.session;
717
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
718
+ await (created.session as any).bindExtensions?.({ mode: "json" });
719
+ unsubscribe = session.subscribe((event) => {
720
+ addSessionEventUsage(usage, event);
721
+ switch (event.type) {
722
+ case "turn_end":
723
+ details.turns += 1;
724
+ emit();
725
+ break;
726
+ case "tool_execution_start":
727
+ details.toolCalls.push({
728
+ name: event.toolName,
729
+ args: event.args,
730
+ });
731
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
732
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
733
+ }
734
+ emit();
735
+ break;
736
+ case "message_update":
737
+ if (event.assistantMessageEvent?.type === "text_delta") {
738
+ lastContent += event.assistantMessageEvent.delta ?? "";
739
+ emit();
740
+ }
741
+ break;
742
+ }
743
+ });
744
+
745
+ if (!callerAborted) {
746
+ const promptPromise = session.prompt(buildUserPrompt(input, cwd), {
747
+ expandPromptTemplates: false,
748
+ });
749
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
750
+ runTimeout = setTimeout(() => {
751
+ abortForCleanup();
752
+ reject(
753
+ new Error(`test-runner timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`),
754
+ );
755
+ }, MAX_RUN_MS);
756
+ });
757
+ await Promise.race([promptPromise, timeoutPromise]);
758
+ }
759
+
760
+ const outcome = inspectFinalAssistant(session.state.messages);
761
+ if (outcome.ok) {
762
+ lastContent = outcome.answer;
763
+ details.status = "done";
764
+ } else if (callerAborted) {
765
+ lastContent = "Aborted";
766
+ details.status = "aborted";
767
+ } else {
768
+ throw new Error(outcome.reason);
769
+ }
770
+ details.endedAt = Date.now();
771
+ const report = appendRunDetails(lastContent, details);
772
+ lastContent = report;
773
+ emit();
774
+ return { content: [{ type: "text", text: report }], details, usage };
775
+ } catch (error) {
776
+ const failure = classifyRunFailure(error, callerAborted);
777
+ details.status = failure.status;
778
+ details.error = failure.error;
779
+ details.endedAt = Date.now();
780
+ lastContent = appendRunDetails(
781
+ `## Validation scope\n- Test-runner run failed before completing validation.\n\n## Steps\n- none reported\n\n## Overall result\n- FAIL: ${failure.message}`,
782
+ details,
783
+ );
784
+ emit();
785
+ return {
786
+ content: [{ type: "text", text: lastContent }],
787
+ details,
788
+ usage,
789
+ };
790
+ } finally {
791
+ if (runTimeout) clearTimeout(runTimeout);
792
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
793
+ unsubscribe?.();
794
+ await disposeSubagentSession(session);
795
+ }
796
+ },
797
+ });
798
+ }