specpi 0.10.0

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 (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,723 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import { Type } from "typebox";
7
+ import {
8
+ MAX_CAPTURE_DIMENSION,
9
+ MAX_CAPTURE_PIXELS,
10
+ MAX_INLINE_IMAGE_BYTES,
11
+ MAX_PNG_BYTES,
12
+ assertDistinctPaths,
13
+ assertPngResourceBounds,
14
+ comparePngBuffers,
15
+ getAgentDir,
16
+ loadBrowserRuntime,
17
+ makeArtifactPath,
18
+ normalizeBrowserUrl,
19
+ publishBuffer,
20
+ resolveUserPath,
21
+ resolveViewport,
22
+ } from "./core.mjs";
23
+
24
+ const agentDir = getAgentDir(import.meta.url);
25
+ const runtimeDir = path.join(agentDir, "specpi", "browser-runtime");
26
+ const deterministicStyle = `
27
+ *, *::before, *::after {
28
+ animation-delay: 0s !important;
29
+ animation-duration: 0s !important;
30
+ caret-color: transparent !important;
31
+ scroll-behavior: auto !important;
32
+ transition-delay: 0s !important;
33
+ transition-duration: 0s !important;
34
+ }
35
+ `;
36
+
37
+ const OpenParams = Type.Object({
38
+ url: Type.String({ description: "HTTP(S) URL to open. Bare localhost URLs are accepted." }),
39
+ waitUntil: Type.Optional(StringEnum(["commit", "domcontentloaded", "load", "networkidle"] as const)),
40
+ });
41
+ const ViewportParams = Type.Object({
42
+ preset: Type.Optional(StringEnum(["desktop", "tablet", "mobile"] as const)),
43
+ width: Type.Optional(Type.Integer()),
44
+ height: Type.Optional(Type.Integer()),
45
+ });
46
+ const SnapshotParams = Type.Object({ maxChars: Type.Optional(Type.Integer({ minimum: 1000, maximum: 30000 })) });
47
+ const TargetParams = Type.Object({
48
+ target: Type.String({ description: "CSS selector, text locator, or snapshot ref returned by browser_snapshot." }),
49
+ });
50
+ const FillParams = Type.Object({
51
+ target: Type.String({ description: "CSS selector, text locator, or snapshot ref returned by browser_snapshot." }),
52
+ value: Type.String(),
53
+ });
54
+ const ScreenshotParams = Type.Object({
55
+ path: Type.Optional(
56
+ Type.String({ description: "Optional output path. Defaults to SpecPi's browser artifact directory." }),
57
+ ),
58
+ overwrite: Type.Optional(Type.Boolean({ description: "Must be true to replace an existing explicit output." })),
59
+ fullPage: Type.Optional(Type.Boolean()),
60
+ });
61
+ const BaselineParams = Type.Object({
62
+ path: Type.String({ description: "Explicit baseline PNG path, relative to the project or absolute." }),
63
+ overwrite: Type.Optional(Type.Boolean({ description: "Must be true to replace an existing baseline." })),
64
+ fullPage: Type.Optional(Type.Boolean()),
65
+ });
66
+ const CompareParams = Type.Object({
67
+ baselinePath: Type.String({ description: "Existing baseline PNG path, relative to the project or absolute." }),
68
+ currentPath: Type.Optional(Type.String({ description: "Optional current screenshot output path." })),
69
+ diffPath: Type.Optional(Type.String({ description: "Optional diff image output path." })),
70
+ overwrite: Type.Optional(
71
+ Type.Boolean({ description: "Must be true to replace existing explicit current/diff outputs." }),
72
+ ),
73
+ fullPage: Type.Optional(Type.Boolean()),
74
+ threshold: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
75
+ maxDiffPixelRatio: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
76
+ });
77
+
78
+ type BrowserState = {
79
+ browser?: any;
80
+ context?: any;
81
+ page?: any;
82
+ runtime?: Awaited<ReturnType<typeof loadBrowserRuntime>>;
83
+ acceptedRefs: Set<string>;
84
+ assignedRefs: Set<string>;
85
+ };
86
+
87
+ function abortedError() {
88
+ return new Error("Browser operation aborted.");
89
+ }
90
+
91
+ function imageContent(file: string, data: Buffer, note: string) {
92
+ const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
93
+ {
94
+ type: "text",
95
+ text: `${note}\nArtifact: ${file}${data.length > MAX_INLINE_IMAGE_BYTES ? `\nInline image omitted because it exceeds ${MAX_INLINE_IMAGE_BYTES} bytes.` : ""}`,
96
+ },
97
+ ];
98
+ if (data.length <= MAX_INLINE_IMAGE_BYTES) {
99
+ content.push({ type: "image", data: data.toString("base64"), mimeType: "image/png" });
100
+ }
101
+
102
+ return content;
103
+ }
104
+
105
+ export default function browserExtension(pi: ExtensionAPI) {
106
+ const state: BrowserState = { acceptedRefs: new Set(), assignedRefs: new Set() };
107
+ let operationTail: Promise<unknown> = Promise.resolve();
108
+
109
+ function serialized<T>(operation: () => Promise<T>): Promise<T> {
110
+ const result = operationTail.then(operation, operation);
111
+ operationTail = result.catch(() => {});
112
+
113
+ return result;
114
+ }
115
+
116
+ async function shutdownNow() {
117
+ const context = state.context;
118
+ const browser = state.browser;
119
+ state.page = undefined;
120
+ state.context = undefined;
121
+ state.browser = undefined;
122
+ state.acceptedRefs.clear();
123
+ state.assignedRefs.clear();
124
+ await Promise.allSettled([context?.close(), browser?.close()].filter(Boolean));
125
+ }
126
+
127
+ async function cancellable<T>(
128
+ start: () => Promise<T>,
129
+ signal?: AbortSignal,
130
+ discard?: (value: T) => Promise<unknown>,
131
+ ): Promise<T> {
132
+ if (signal?.aborted) {
133
+ throw abortedError();
134
+ }
135
+
136
+ if (!signal) {
137
+ return start();
138
+ }
139
+
140
+ let settled = false;
141
+ let operation: Promise<T> | undefined;
142
+
143
+ return new Promise<T>((resolve, reject) => {
144
+ const abort = () => {
145
+ if (settled) {
146
+ return;
147
+ }
148
+
149
+ settled = true;
150
+ if (discard && operation) {
151
+ void operation.then(discard).catch(() => {});
152
+ }
153
+
154
+ void shutdownNow().then(() => reject(abortedError()), reject);
155
+ };
156
+
157
+ signal.addEventListener("abort", abort, { once: true });
158
+ if (signal.aborted) {
159
+ signal.removeEventListener("abort", abort);
160
+ abort();
161
+
162
+ return;
163
+ }
164
+
165
+ try {
166
+ operation = start();
167
+ } catch (error) {
168
+ signal.removeEventListener("abort", abort);
169
+ settled = true;
170
+ reject(error);
171
+
172
+ return;
173
+ }
174
+
175
+ operation.then(
176
+ (value) => {
177
+ if (settled) {
178
+ return;
179
+ }
180
+
181
+ settled = true;
182
+ signal.removeEventListener("abort", abort);
183
+ resolve(value);
184
+ },
185
+ (error) => {
186
+ if (settled) {
187
+ return;
188
+ }
189
+
190
+ settled = true;
191
+ signal.removeEventListener("abort", abort);
192
+ reject(error);
193
+ },
194
+ );
195
+ });
196
+ }
197
+
198
+ async function ensurePage(signal?: AbortSignal) {
199
+ if (signal?.aborted) {
200
+ throw abortedError();
201
+ }
202
+
203
+ if (!state.runtime) {
204
+ state.runtime = await cancellable(() => loadBrowserRuntime(runtimeDir), signal);
205
+ }
206
+
207
+ if (!state.browser?.isConnected()) {
208
+ await shutdownNow();
209
+ try {
210
+ state.browser = await cancellable(
211
+ () => state.runtime!.playwright.chromium.launch({ headless: true }),
212
+ signal,
213
+ (browser: any) => browser.close(),
214
+ );
215
+ state.context = await cancellable(
216
+ () =>
217
+ state.browser.newContext({
218
+ viewport: resolveViewport({ preset: "desktop" }),
219
+ reducedMotion: "reduce",
220
+ colorScheme: "light",
221
+ serviceWorkers: "block",
222
+ }),
223
+ signal,
224
+ );
225
+ state.page = await cancellable(() => state.context.newPage(), signal);
226
+ } catch (error) {
227
+ await shutdownNow();
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ return state.page;
233
+ }
234
+
235
+ async function stabilize(page: any, signal?: AbortSignal) {
236
+ await cancellable(() => page.addStyleTag({ content: deterministicStyle }), signal);
237
+ await cancellable(() => page.evaluate(() => document.fonts?.ready), signal);
238
+ }
239
+
240
+ async function assertCaptureBounds(page: any, fullPage: boolean, signal?: AbortSignal) {
241
+ const dimensions = fullPage
242
+ ? await cancellable(
243
+ () =>
244
+ page.evaluate(() => ({
245
+ width: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
246
+ height: Math.max(
247
+ document.documentElement?.scrollHeight || 0,
248
+ document.body?.scrollHeight || 0,
249
+ ),
250
+ })),
251
+ signal,
252
+ )
253
+ : page.viewportSize();
254
+ if (
255
+ !dimensions ||
256
+ dimensions.width < 1 ||
257
+ dimensions.height < 1 ||
258
+ dimensions.width > MAX_CAPTURE_DIMENSION ||
259
+ dimensions.height > MAX_CAPTURE_DIMENSION ||
260
+ dimensions.width * dimensions.height > MAX_CAPTURE_PIXELS
261
+ ) {
262
+ throw new Error(
263
+ `Screenshot dimensions ${dimensions?.width ?? 0}x${dimensions?.height ?? 0} exceed browser image limits.`,
264
+ );
265
+ }
266
+
267
+ return dimensions;
268
+ }
269
+
270
+ async function captureMemory(signal: AbortSignal | undefined, fullPage: boolean) {
271
+ const page = await ensurePage(signal);
272
+ await stabilize(page, signal);
273
+ const dimensions = await assertCaptureBounds(page, fullPage, signal);
274
+ const screenshotOptions = fullPage
275
+ ? {
276
+ type: "png" as const,
277
+ clip: { x: 0, y: 0, width: dimensions.width, height: dimensions.height },
278
+ captureBeyondViewport: true,
279
+ }
280
+ : { type: "png" as const };
281
+ const data = Buffer.from(await cancellable(() => page.screenshot(screenshotOptions), signal));
282
+ assertPngResourceBounds(data, "Screenshot PNG");
283
+ if (signal?.aborted) {
284
+ throw abortedError();
285
+ }
286
+
287
+ return { data, page };
288
+ }
289
+
290
+ function outputPath(ctx: ExtensionContext, requested: string | undefined, kind: string) {
291
+ return requested
292
+ ? resolveUserPath(ctx.cwd, requested, `${kind} path`)
293
+ : makeArtifactPath(agentDir, process.env.PI_SESSION_ID, kind);
294
+ }
295
+
296
+ function targetLocator(page: any, target: string) {
297
+ const value = target.trim();
298
+ if (value.startsWith("@spec-")) {
299
+ if (!state.acceptedRefs.has(value)) {
300
+ throw new Error(`Unknown or stale browser snapshot ref: ${value}`);
301
+ }
302
+
303
+ return page.locator(`[data-specpi-ref="${value.slice(1)}"]`);
304
+ }
305
+
306
+ if (value.startsWith("text=")) {
307
+ return page.getByText(value.slice(5), { exact: true });
308
+ }
309
+
310
+ return page.locator(value);
311
+ }
312
+
313
+ const register = (definition: any) => {
314
+ const execute = definition.execute;
315
+ pi.registerTool({
316
+ ...definition,
317
+ executionMode: "sequential",
318
+ execute: (...args: any[]) => serialized(() => execute(...args)),
319
+ });
320
+ };
321
+
322
+ register({
323
+ name: "browser_open",
324
+ label: "Browser Open",
325
+ description:
326
+ "Open an HTTP(S) page in SpecPi's fresh isolated Chromium context. Use this for rendered local web QA.",
327
+ promptSnippet: "Open local or remote web pages in an isolated browser for rendered validation",
328
+ promptGuidelines: ["Use browser_open and browser_snapshot before claiming a web UI renders correctly."],
329
+ parameters: OpenParams,
330
+ async execute(_id: string, params: any, signal: AbortSignal | undefined) {
331
+ const page = await ensurePage(signal);
332
+ const response = await cancellable(
333
+ () => page.goto(normalizeBrowserUrl(params.url), { waitUntil: params.waitUntil ?? "domcontentloaded" }),
334
+ signal,
335
+ );
336
+ state.acceptedRefs.clear();
337
+ const title = await cancellable(() => page.title(), signal);
338
+
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text",
343
+ text: `Opened ${page.url()}\nTitle: ${title}\nStatus: ${response?.status() ?? "n/a"}`,
344
+ },
345
+ ],
346
+ details: { url: page.url(), title, status: response?.status() },
347
+ };
348
+ },
349
+ });
350
+
351
+ register({
352
+ name: "browser_set_viewport",
353
+ label: "Browser Viewport",
354
+ description: "Set the active browser viewport to desktop, tablet, mobile, or bounded explicit dimensions.",
355
+ parameters: ViewportParams,
356
+ async execute(_id: string, params: any, signal: AbortSignal | undefined) {
357
+ const page = await ensurePage(signal);
358
+ const viewport = resolveViewport(params);
359
+ await cancellable(() => page.setViewportSize(viewport), signal);
360
+ state.acceptedRefs.clear();
361
+
362
+ return {
363
+ content: [{ type: "text", text: `Viewport set to ${viewport.width}x${viewport.height}.` }],
364
+ details: viewport,
365
+ };
366
+ },
367
+ });
368
+
369
+ register({
370
+ name: "browser_snapshot",
371
+ label: "Browser Snapshot",
372
+ description:
373
+ "Inspect bounded rendered page text and interactive elements. Returns namespaced refs usable by browser_click and browser_fill.",
374
+ promptSnippet: "Inspect rendered DOM text and interactive controls",
375
+ parameters: SnapshotParams,
376
+ async execute(_id: string, params: any, signal: AbortSignal | undefined) {
377
+ const page = await ensurePage(signal);
378
+ const maxChars = params.maxChars ?? 30000;
379
+ const namespace = crypto.randomUUID();
380
+ const priorRefs = [...state.assignedRefs];
381
+ const snapshot = await cancellable(
382
+ () =>
383
+ page.evaluate(
384
+ ({ namespace, bodyLimit, priorRefs }: any) => {
385
+ const prior = new Set(priorRefs);
386
+ document.querySelectorAll("[data-specpi-ref]").forEach((element) => {
387
+ if (prior.has(element.getAttribute("data-specpi-ref"))) {
388
+ element.removeAttribute("data-specpi-ref");
389
+ }
390
+ });
391
+ const clean = (value: unknown, limit: number) => {
392
+ const source = String(value || "").replace(
393
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g,
394
+ " ",
395
+ );
396
+ let result = "";
397
+ for (let index = 0; index < source.length && result.length < limit; index++) {
398
+ const code = source.charCodeAt(index);
399
+ if (code >= 0xd800 && code <= 0xdbff) {
400
+ const next = source.charCodeAt(index + 1);
401
+ if (next >= 0xdc00 && next <= 0xdfff) {
402
+ if (result.length + 2 > limit) {
403
+ break;
404
+ }
405
+
406
+ result += source[index] + source[++index];
407
+ } else {
408
+ result += " ";
409
+ }
410
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
411
+ result += " ";
412
+ } else {
413
+ result += source[index];
414
+ }
415
+ }
416
+
417
+ return result;
418
+ };
419
+
420
+ const selectors =
421
+ "a,button,input,textarea,select,[role=button],[role=link],[contenteditable=true]";
422
+ const controls = Array.from(document.querySelectorAll(selectors))
423
+ .slice(0, 100)
424
+ .map((element, index) => {
425
+ const ref = `spec-${namespace}-${index + 1}`;
426
+ element.setAttribute("data-specpi-ref", ref);
427
+ const html = element as HTMLElement;
428
+ const input = element as HTMLInputElement;
429
+
430
+ return {
431
+ ref: `@${ref}`,
432
+ tag: element.tagName.toLowerCase(),
433
+ role: clean(element.getAttribute("role"), 40) || undefined,
434
+ name:
435
+ clean(
436
+ element.getAttribute("aria-label") ||
437
+ input.name ||
438
+ input.placeholder ||
439
+ html.innerText?.trim(),
440
+ 100,
441
+ ) || undefined,
442
+ type: clean(input.type, 40) || undefined,
443
+ disabled: input.disabled || undefined,
444
+ };
445
+ });
446
+
447
+ return {
448
+ controls,
449
+ url: clean(location.href, 300),
450
+ title: clean(document.title, 120),
451
+ text: clean((document.body?.innerText || "").replace(/\n{3,}/g, "\n\n"), bodyLimit),
452
+ };
453
+ },
454
+ { namespace, bodyLimit: Math.min(maxChars, 30000), priorRefs },
455
+ ),
456
+ signal,
457
+ );
458
+ const safePrefix = (value: string, limit: number) => {
459
+ const prefix = value.slice(0, Math.max(0, limit));
460
+ const last = prefix.charCodeAt(prefix.length - 1);
461
+
462
+ return last >= 0xd800 && last <= 0xdbff ? prefix.slice(0, -1) : prefix;
463
+ };
464
+
465
+ snapshot.url = safePrefix(snapshot.url, 300);
466
+ snapshot.title = safePrefix(snapshot.title, 120);
467
+ const fixedJson = () =>
468
+ JSON.stringify(
469
+ { controls: snapshot.controls, url: snapshot.url, title: snapshot.title, text: "" },
470
+ null,
471
+ 2,
472
+ );
473
+ const minimumControls = snapshot.controls.length ? 1 : 0;
474
+ while (snapshot.controls.length > minimumControls && fixedJson().length > maxChars - 100) {
475
+ snapshot.controls.pop();
476
+ }
477
+
478
+ while (fixedJson().length > maxChars - 100 && (snapshot.title.length || snapshot.url.length > 80)) {
479
+ if (snapshot.title.length) {
480
+ snapshot.title = safePrefix(snapshot.title, snapshot.title.length - 10);
481
+ } else {
482
+ snapshot.url = safePrefix(snapshot.url, Math.max(80, snapshot.url.length - 20));
483
+ }
484
+ }
485
+
486
+ if (fixedJson().length > maxChars - 100 && snapshot.controls.length) {
487
+ const first = snapshot.controls[0];
488
+ for (const field of ["name", "role", "type"] as const) {
489
+ while (
490
+ typeof first[field] === "string" &&
491
+ first[field].length &&
492
+ fixedJson().length > maxChars - 100
493
+ ) {
494
+ first[field] = safePrefix(first[field], first[field].length - 10) || undefined;
495
+ }
496
+ }
497
+ }
498
+
499
+ while (snapshot.controls.length && fixedJson().length > maxChars - 100) {
500
+ snapshot.controls.pop();
501
+ }
502
+
503
+ state.assignedRefs = new Set(snapshot.controls.map((control: any) => control.ref.slice(1)));
504
+ state.acceptedRefs = new Set(snapshot.controls.map((control: any) => control.ref));
505
+ const originalTextLength = snapshot.text.length;
506
+ const fixed = fixedJson();
507
+ snapshot.text = safePrefix(snapshot.text, maxChars - fixed.length - 40);
508
+ let rendered = JSON.stringify(
509
+ { controls: snapshot.controls, url: snapshot.url, title: snapshot.title, text: snapshot.text },
510
+ null,
511
+ 2,
512
+ );
513
+ while (rendered.length > maxChars && snapshot.text.length) {
514
+ snapshot.text = safePrefix(snapshot.text, snapshot.text.length - (rendered.length - maxChars));
515
+ rendered = JSON.stringify(
516
+ { controls: snapshot.controls, url: snapshot.url, title: snapshot.title, text: snapshot.text },
517
+ null,
518
+ 2,
519
+ );
520
+ }
521
+
522
+ if (rendered.length > maxChars) {
523
+ throw new Error("Unable to fit browser snapshot within maxChars.");
524
+ }
525
+
526
+ return {
527
+ content: [{ type: "text", text: rendered }],
528
+ details: {
529
+ url: snapshot.url,
530
+ title: snapshot.title,
531
+ controlCount: snapshot.controls.length,
532
+ textTruncated: snapshot.text.length < originalTextLength,
533
+ },
534
+ };
535
+ },
536
+ });
537
+
538
+ register({
539
+ name: "browser_click",
540
+ label: "Browser Click",
541
+ description: "Click an element using a CSS selector, exact text= locator, or current snapshot ref.",
542
+ parameters: TargetParams,
543
+ async execute(_id: string, params: any, signal: AbortSignal | undefined) {
544
+ const page = await ensurePage(signal);
545
+ await cancellable(() => targetLocator(page, params.target).first().click(), signal);
546
+ state.acceptedRefs.clear();
547
+
548
+ return {
549
+ content: [{ type: "text", text: `Clicked ${params.target}.\nURL: ${page.url()}` }],
550
+ details: { target: params.target, url: page.url() },
551
+ };
552
+ },
553
+ });
554
+
555
+ register({
556
+ name: "browser_fill",
557
+ label: "Browser Fill",
558
+ description: "Fill an input using a CSS selector, exact text= locator, or current snapshot ref.",
559
+ parameters: FillParams,
560
+ async execute(_id: string, params: any, signal: AbortSignal | undefined) {
561
+ const page = await ensurePage(signal);
562
+ await cancellable(() => targetLocator(page, params.target).first().fill(params.value), signal);
563
+ state.acceptedRefs.clear();
564
+
565
+ return {
566
+ content: [{ type: "text", text: `Filled ${params.target}.` }],
567
+ details: { target: params.target },
568
+ };
569
+ },
570
+ });
571
+
572
+ register({
573
+ name: "browser_screenshot",
574
+ label: "Browser Screenshot",
575
+ description: "Capture a bounded rendered PNG and return it inline when conservatively sized.",
576
+ promptSnippet: "Capture rendered desktop, tablet, or mobile screenshots for visual QA",
577
+ parameters: ScreenshotParams,
578
+ async execute(
579
+ _id: string,
580
+ params: any,
581
+ signal: AbortSignal | undefined,
582
+ _update: unknown,
583
+ ctx: ExtensionContext,
584
+ ) {
585
+ const shot = await captureMemory(signal, params.fullPage ?? false);
586
+ const file = outputPath(ctx, params.path, "screenshot");
587
+ await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
588
+ const title = await cancellable(() => shot.page.title(), signal);
589
+
590
+ return {
591
+ content: imageContent(file, shot.data, `Captured ${title || shot.page.url()}.`),
592
+ details: {
593
+ path: file,
594
+ url: shot.page.url(),
595
+ viewport: shot.page.viewportSize(),
596
+ fullPage: params.fullPage ?? false,
597
+ },
598
+ };
599
+ },
600
+ });
601
+
602
+ register({
603
+ name: "browser_save_baseline",
604
+ label: "Browser Save Baseline",
605
+ description:
606
+ "Explicitly create a visual-regression baseline PNG. Existing baselines are replaced only when overwrite=true.",
607
+ parameters: BaselineParams,
608
+ async execute(
609
+ _id: string,
610
+ params: any,
611
+ signal: AbortSignal | undefined,
612
+ _update: unknown,
613
+ ctx: ExtensionContext,
614
+ ) {
615
+ const file = resolveUserPath(ctx.cwd, params.path, "baseline path");
616
+ const shot = await captureMemory(signal, params.fullPage ?? false);
617
+ await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
618
+
619
+ return {
620
+ content: imageContent(file, shot.data, "Published visual baseline explicitly."),
621
+ details: { path: file, viewport: shot.page.viewportSize(), fullPage: params.fullPage ?? false },
622
+ };
623
+ },
624
+ });
625
+
626
+ register({
627
+ name: "browser_compare_screenshot",
628
+ label: "Browser Compare Screenshot",
629
+ description:
630
+ "Capture the current page and compare it with an explicit baseline PNG without changing the baseline.",
631
+ promptSnippet: "Compare rendered output against an explicit PNG baseline with a pixel threshold",
632
+ parameters: CompareParams,
633
+ async execute(
634
+ _id: string,
635
+ params: any,
636
+ signal: AbortSignal | undefined,
637
+ _update: unknown,
638
+ ctx: ExtensionContext,
639
+ ) {
640
+ const baselinePath = resolveUserPath(ctx.cwd, params.baselinePath, "baseline path");
641
+ const currentPath = outputPath(ctx, params.currentPath, "current");
642
+ const diffPath = outputPath(ctx, params.diffPath, "diff");
643
+ assertDistinctPaths([
644
+ ["baselinePath", baselinePath],
645
+ ["currentPath", currentPath],
646
+ ["diffPath", diffPath],
647
+ ]);
648
+ if (params.overwrite !== true) {
649
+ for (const file of [currentPath, diffPath]) {
650
+ if (
651
+ await fs.access(file).then(
652
+ () => true,
653
+ () => false,
654
+ )
655
+ ) {
656
+ throw new Error(
657
+ `Output already exists: ${file}. Pass overwrite=true only when replacement is intended.`,
658
+ );
659
+ }
660
+ }
661
+ }
662
+
663
+ const baselineStat = await fs.stat(baselinePath);
664
+ if (baselineStat.size > MAX_PNG_BYTES) {
665
+ throw new Error(`Baseline PNG exceeds the ${MAX_PNG_BYTES} byte compressed-size limit.`);
666
+ }
667
+
668
+ const baseline = await fs.readFile(baselinePath);
669
+ assertPngResourceBounds(baseline, "Baseline PNG");
670
+ const shot = await captureMemory(signal, params.fullPage ?? false);
671
+ if (!state.runtime) {
672
+ throw new Error("Browser runtime was not loaded.");
673
+ }
674
+
675
+ const comparison = comparePngBuffers(baseline, shot.data, state.runtime, {
676
+ threshold: params.threshold,
677
+ maxDiffPixelRatio: params.maxDiffPixelRatio,
678
+ });
679
+ if (signal?.aborted) {
680
+ throw abortedError();
681
+ }
682
+
683
+ await publishBuffer(currentPath, shot.data, { overwrite: params.overwrite === true, signal });
684
+ try {
685
+ await publishBuffer(diffPath, comparison.diffBuffer, { overwrite: params.overwrite === true, signal });
686
+ } catch (error) {
687
+ if (params.overwrite !== true) {
688
+ await fs.rm(currentPath, { force: true }).catch(() => {});
689
+ }
690
+
691
+ throw error;
692
+ }
693
+
694
+ const summary = `${comparison.pass ? "PASS" : "FAIL"}: ${comparison.diffPixels} differing pixels (${(comparison.diffPixelRatio * 100).toFixed(4)}%), allowed ${(comparison.maxDiffPixelRatio * 100).toFixed(4)}%.\nBaseline: ${baselinePath}\nCurrent: ${currentPath}\nDiff: ${diffPath}${comparison.dimensionsMatch ? "" : `\nDimension mismatch: ${comparison.baseline.width}x${comparison.baseline.height} vs ${comparison.current.width}x${comparison.current.height}`}`;
695
+
696
+ return {
697
+ content: imageContent(diffPath, comparison.diffBuffer, summary),
698
+ details: {
699
+ ...comparison,
700
+ diffBuffer: undefined,
701
+ baselinePath,
702
+ currentPath,
703
+ diffPath,
704
+ viewport: shot.page.viewportSize(),
705
+ },
706
+ };
707
+ },
708
+ });
709
+
710
+ register({
711
+ name: "browser_close",
712
+ label: "Browser Close",
713
+ description: "Close SpecPi's isolated browser and discard its temporary context, cookies, and storage.",
714
+ parameters: Type.Object({}),
715
+ async execute() {
716
+ await shutdownNow();
717
+
718
+ return { content: [{ type: "text", text: "Closed the isolated SpecPi browser context." }], details: {} };
719
+ },
720
+ });
721
+
722
+ pi.on("session_shutdown", () => serialized(shutdownNow));
723
+ }