gsd-pi 2.7.1 → 2.8.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 (53) hide show
  1. package/README.md +12 -5
  2. package/dist/loader.js +0 -0
  3. package/dist/modes/interactive/theme/dark.json +85 -0
  4. package/dist/modes/interactive/theme/light.json +84 -0
  5. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  6. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  7. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  8. package/dist/modes/interactive/theme/theme.js +949 -0
  9. package/dist/modes/interactive/theme/theme.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  11. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  13. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  14. package/node_modules/cliui/CHANGELOG.md +121 -0
  15. package/node_modules/color-convert/CHANGELOG.md +54 -0
  16. package/node_modules/esprima/ChangeLog +235 -0
  17. package/node_modules/mz/HISTORY.md +66 -0
  18. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  19. package/node_modules/source-map/CHANGELOG.md +301 -0
  20. package/node_modules/thenify/History.md +11 -0
  21. package/node_modules/thenify-all/History.md +11 -0
  22. package/node_modules/y18n/CHANGELOG.md +100 -0
  23. package/node_modules/yargs/CHANGELOG.md +88 -0
  24. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  25. package/package.json +5 -2
  26. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  30. package/src/resources/extensions/browser-tools/capture.ts +165 -0
  31. package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
  32. package/src/resources/extensions/browser-tools/index.ts +47 -4985
  33. package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
  34. package/src/resources/extensions/browser-tools/package.json +5 -1
  35. package/src/resources/extensions/browser-tools/refs.ts +264 -0
  36. package/src/resources/extensions/browser-tools/settle.ts +197 -0
  37. package/src/resources/extensions/browser-tools/state.ts +408 -0
  38. package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
  39. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
  40. package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
  41. package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
  42. package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
  43. package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
  44. package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
  45. package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
  46. package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
  47. package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
  48. package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
  49. package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
  50. package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
  51. package/src/resources/extensions/browser-tools/utils.ts +660 -0
  52. package/src/resources/extensions/gsd/git-service.ts +3 -0
  53. package/src/resources/extensions/shared/interview-ui.ts +1 -1
@@ -0,0 +1,400 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ formatTimelineEntries,
7
+ buildFailureHypothesis,
8
+ summarizeBrowserSession,
9
+ } from "../core.js";
10
+ import type { ToolDeps } from "../state.js";
11
+ import {
12
+ ARTIFACT_ROOT,
13
+ HAR_FILENAME,
14
+ getPageRegistry,
15
+ getActiveFrame,
16
+ getConsoleLogs,
17
+ getNetworkLogs,
18
+ getDialogLogs,
19
+ getActionTimeline,
20
+ getActiveTraceSession,
21
+ setActiveTraceSession,
22
+ getHarState,
23
+ setHarState,
24
+ getSessionStartedAt,
25
+ getSessionArtifactDir,
26
+ } from "../state.js";
27
+ import {
28
+ getActiveFrameMetadata,
29
+ ensureDir,
30
+ } from "../utils.js";
31
+
32
+ export function registerSessionTools(pi: ExtensionAPI, deps: ToolDeps): void {
33
+ // -------------------------------------------------------------------------
34
+ // browser_close
35
+ // -------------------------------------------------------------------------
36
+ pi.registerTool({
37
+ name: "browser_close",
38
+ label: "Browser Close",
39
+ description: "Close the browser and clean up all resources.",
40
+ parameters: Type.Object({}),
41
+
42
+ async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
43
+ try {
44
+ await deps.closeBrowser();
45
+ return {
46
+ content: [{ type: "text", text: "Browser closed." }],
47
+ details: {},
48
+ };
49
+ } catch (err: any) {
50
+ return {
51
+ content: [{ type: "text", text: `Close failed: ${err.message}` }],
52
+ details: { error: err.message },
53
+ isError: true,
54
+ };
55
+ }
56
+ },
57
+ });
58
+
59
+ // -------------------------------------------------------------------------
60
+ // browser_trace_start
61
+ // -------------------------------------------------------------------------
62
+ pi.registerTool({
63
+ name: "browser_trace_start",
64
+ label: "Browser Trace Start",
65
+ description: "Start a Playwright trace for the current browser session and persist trace metadata under the session artifact directory.",
66
+ parameters: Type.Object({
67
+ name: Type.Optional(Type.String({ description: "Optional short trace session name for artifact filenames." })),
68
+ title: Type.Optional(Type.String({ description: "Optional trace title recorded in metadata." })),
69
+ }),
70
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
71
+ try {
72
+ const { context: browserContext } = await deps.ensureBrowser();
73
+ const activeTrace = getActiveTraceSession();
74
+ if (activeTrace) {
75
+ return {
76
+ content: [{ type: "text", text: `Trace already active: ${activeTrace.name}` }],
77
+ details: { error: "trace_already_active", activeTraceSession: activeTrace, ...deps.getSessionArtifactMetadata() },
78
+ isError: true,
79
+ };
80
+ }
81
+ const startedAt = Date.now();
82
+ const name = (params.name?.trim() || `trace-${deps.formatArtifactTimestamp(startedAt)}`).replace(/[^a-zA-Z0-9._-]+/g, "-");
83
+ await browserContext.tracing.start({ screenshots: true, snapshots: true, sources: true, title: params.title ?? name });
84
+ setActiveTraceSession({ startedAt, name, title: params.title ?? name });
85
+ return {
86
+ content: [{ type: "text", text: `Trace started: ${name}\nSession dir: ${getSessionArtifactDir()}` }],
87
+ details: { activeTraceSession: getActiveTraceSession(), ...deps.getSessionArtifactMetadata() },
88
+ };
89
+ } catch (err: any) {
90
+ return {
91
+ content: [{ type: "text", text: `Trace start failed: ${err.message}` }],
92
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
93
+ isError: true,
94
+ };
95
+ }
96
+ },
97
+ });
98
+
99
+ // -------------------------------------------------------------------------
100
+ // browser_trace_stop
101
+ // -------------------------------------------------------------------------
102
+ pi.registerTool({
103
+ name: "browser_trace_stop",
104
+ label: "Browser Trace Stop",
105
+ description: "Stop the active Playwright trace and write the trace zip to disk under the session artifact directory.",
106
+ parameters: Type.Object({
107
+ name: Type.Optional(Type.String({ description: "Optional artifact basename override for the trace zip." })),
108
+ }),
109
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
110
+ try {
111
+ const { context: browserContext } = await deps.ensureBrowser();
112
+ const activeTrace = getActiveTraceSession();
113
+ if (!activeTrace) {
114
+ return {
115
+ content: [{ type: "text", text: "No active trace session to stop." }],
116
+ details: { error: "trace_not_active", ...deps.getSessionArtifactMetadata() },
117
+ isError: true,
118
+ };
119
+ }
120
+ const traceSession = activeTrace;
121
+ const traceName = (params.name?.trim() || traceSession.name).replace(/[^a-zA-Z0-9._-]+/g, "-");
122
+ const tracePath = deps.buildSessionArtifactPath(`${traceName}.trace.zip`);
123
+ await browserContext.tracing.stop({ path: tracePath });
124
+ const fileStat = await stat(tracePath);
125
+ setActiveTraceSession(null);
126
+ return {
127
+ content: [{ type: "text", text: `Trace stopped: ${tracePath}` }],
128
+ details: {
129
+ path: tracePath,
130
+ bytes: fileStat.size,
131
+ elapsedMs: Date.now() - traceSession.startedAt,
132
+ traceName,
133
+ ...deps.getSessionArtifactMetadata(),
134
+ },
135
+ };
136
+ } catch (err: any) {
137
+ return {
138
+ content: [{ type: "text", text: `Trace stop failed: ${err.message}` }],
139
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
140
+ isError: true,
141
+ };
142
+ }
143
+ },
144
+ });
145
+
146
+ // -------------------------------------------------------------------------
147
+ // browser_export_har
148
+ // -------------------------------------------------------------------------
149
+ pi.registerTool({
150
+ name: "browser_export_har",
151
+ label: "Browser Export HAR",
152
+ description: "Export the truthfully recorded session HAR from disk to a stable artifact path and return compact metadata.",
153
+ parameters: Type.Object({
154
+ filename: Type.Optional(Type.String({ description: "Optional destination filename within the session artifact directory." })),
155
+ }),
156
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
157
+ try {
158
+ await deps.ensureBrowser();
159
+ const harState = getHarState();
160
+ if (!harState.enabled || !harState.configuredAtContextCreation || !harState.path) {
161
+ return {
162
+ content: [{ type: "text", text: "HAR export unavailable: HAR recording was not enabled at browser context creation." }],
163
+ details: { error: "har_not_enabled", ...deps.getSessionArtifactMetadata() },
164
+ isError: true,
165
+ };
166
+ }
167
+ const sourcePath = harState.path;
168
+ const destinationName = (params.filename?.trim() || `export-${HAR_FILENAME}`).replace(/[^a-zA-Z0-9._-]+/g, "-");
169
+ const destinationPath = deps.buildSessionArtifactPath(destinationName);
170
+ const exportResult = sourcePath === destinationPath
171
+ ? { path: sourcePath, bytes: (await stat(sourcePath)).size }
172
+ : await deps.copyArtifactFile(sourcePath, destinationPath);
173
+ setHarState({
174
+ ...harState,
175
+ exportCount: harState.exportCount + 1,
176
+ lastExportedPath: exportResult.path,
177
+ lastExportedAt: Date.now(),
178
+ });
179
+ return {
180
+ content: [{ type: "text", text: `HAR exported: ${exportResult.path}` }],
181
+ details: { path: exportResult.path, bytes: exportResult.bytes, ...deps.getSessionArtifactMetadata() },
182
+ };
183
+ } catch (err: any) {
184
+ return {
185
+ content: [{ type: "text", text: `HAR export failed: ${err.message}` }],
186
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
187
+ isError: true,
188
+ };
189
+ }
190
+ },
191
+ });
192
+
193
+ // -------------------------------------------------------------------------
194
+ // browser_timeline
195
+ // -------------------------------------------------------------------------
196
+ pi.registerTool({
197
+ name: "browser_timeline",
198
+ label: "Browser Timeline",
199
+ description: "Return a compact structured summary of the tracked browser action timeline and optional on-disk export path.",
200
+ parameters: Type.Object({
201
+ writeToDisk: Type.Optional(Type.Boolean({ description: "Write the timeline JSON to disk under the session artifact directory." })),
202
+ filename: Type.Optional(Type.String({ description: "Optional JSON filename when writeToDisk is true." })),
203
+ }),
204
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
205
+ try {
206
+ await deps.ensureBrowser();
207
+ const actionTimeline = getActionTimeline();
208
+ const timeline = formatTimelineEntries(actionTimeline.entries, {
209
+ limit: actionTimeline.limit,
210
+ totalActions: actionTimeline.nextId - 1,
211
+ });
212
+ let artifact: { path: string; bytes: number } | null = null;
213
+ if (params.writeToDisk) {
214
+ const filename = (params.filename?.trim() || "timeline.json").replace(/[^a-zA-Z0-9._-]+/g, "-");
215
+ artifact = await deps.writeArtifactFile(deps.buildSessionArtifactPath(filename), JSON.stringify(timeline, null, 2));
216
+ }
217
+ return {
218
+ content: [{ type: "text", text: artifact ? `${timeline.summary}\nArtifact: ${artifact.path}` : timeline.summary }],
219
+ details: { ...timeline, artifact, ...deps.getSessionArtifactMetadata() },
220
+ };
221
+ } catch (err: any) {
222
+ return {
223
+ content: [{ type: "text", text: `Timeline failed: ${err.message}` }],
224
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
225
+ isError: true,
226
+ };
227
+ }
228
+ },
229
+ });
230
+
231
+ // -------------------------------------------------------------------------
232
+ // browser_session_summary
233
+ // -------------------------------------------------------------------------
234
+ pi.registerTool({
235
+ name: "browser_session_summary",
236
+ label: "Browser Session Summary",
237
+ description: "Return a compact structured summary of the current browser session, including pages, actions, waits/assertions, bounded-history caveats, and trace/HAR state.",
238
+ parameters: Type.Object({}),
239
+ async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
240
+ try {
241
+ await deps.ensureBrowser();
242
+ const pages = await deps.getLivePagesSnapshot();
243
+ const actionTimeline = getActionTimeline();
244
+ const pageRegistry = getPageRegistry();
245
+ const consoleLogs = getConsoleLogs();
246
+ const networkLogs = getNetworkLogs();
247
+ const dialogLogs = getDialogLogs();
248
+ const baseSummary = summarizeBrowserSession({
249
+ timeline: actionTimeline,
250
+ totalActions: actionTimeline.nextId - 1,
251
+ pages,
252
+ activePageId: pageRegistry.activePageId,
253
+ activeFrame: getActiveFrameMetadata(),
254
+ consoleEntries: consoleLogs,
255
+ networkEntries: networkLogs,
256
+ dialogEntries: dialogLogs,
257
+ consoleLimit: 1000,
258
+ networkLimit: 1000,
259
+ dialogLimit: 1000,
260
+ sessionStartedAt: getSessionStartedAt(),
261
+ now: Date.now(),
262
+ });
263
+ const failureHypothesis = buildFailureHypothesis({
264
+ timeline: actionTimeline,
265
+ consoleEntries: consoleLogs,
266
+ networkEntries: networkLogs,
267
+ dialogEntries: dialogLogs,
268
+ });
269
+ const activeTrace = getActiveTraceSession();
270
+ const traceState = activeTrace
271
+ ? { status: "active", ...activeTrace }
272
+ : { status: "inactive", lastTracePath: getSessionArtifactDir() ? deps.buildSessionArtifactPath("*.trace.zip") : null };
273
+ const harState = getHarState();
274
+ const harSummary = {
275
+ enabled: harState.enabled,
276
+ configuredAtContextCreation: harState.configuredAtContextCreation,
277
+ path: harState.path,
278
+ exportCount: harState.exportCount,
279
+ lastExportedPath: harState.lastExportedPath,
280
+ lastExportedAt: harState.lastExportedAt,
281
+ };
282
+ return {
283
+ content: [{ type: "text", text: `${baseSummary.summary}\nFailure hypothesis: ${failureHypothesis}` }],
284
+ details: {
285
+ ...baseSummary,
286
+ failureHypothesis,
287
+ trace: traceState,
288
+ har: harSummary,
289
+ ...deps.getSessionArtifactMetadata(),
290
+ },
291
+ };
292
+ } catch (err: any) {
293
+ return {
294
+ content: [{ type: "text", text: `Session summary failed: ${err.message}` }],
295
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
296
+ isError: true,
297
+ };
298
+ }
299
+ },
300
+ });
301
+
302
+ // -------------------------------------------------------------------------
303
+ // browser_debug_bundle
304
+ // -------------------------------------------------------------------------
305
+ pi.registerTool({
306
+ name: "browser_debug_bundle",
307
+ label: "Browser Debug Bundle",
308
+ description: "Write a timestamped debug bundle to disk with screenshot, logs, timeline, pages, session summary, and accessibility output, then return compact paths and counts.",
309
+ parameters: Type.Object({
310
+ selector: Type.Optional(Type.String({ description: "Optional CSS selector to scope the accessibility snapshot before fallback behavior applies." })),
311
+ name: Type.Optional(Type.String({ description: "Optional short bundle name suffix for the output directory." })),
312
+ }),
313
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
314
+ try {
315
+ const { page: p } = await deps.ensureBrowser();
316
+ const startedAt = Date.now();
317
+ const sessionDir = await deps.ensureSessionArtifactDir();
318
+ const bundleDir = path.join(ARTIFACT_ROOT, `${deps.formatArtifactTimestamp(startedAt)}-${deps.sanitizeArtifactName(params.name ?? "debug-bundle", "debug-bundle")}`);
319
+ await ensureDir(bundleDir);
320
+ const pages = await deps.getLivePagesSnapshot();
321
+ const actionTimeline = getActionTimeline();
322
+ const pageRegistry = getPageRegistry();
323
+ const consoleLogs = getConsoleLogs();
324
+ const networkLogs = getNetworkLogs();
325
+ const dialogLogs = getDialogLogs();
326
+ const timeline = formatTimelineEntries(actionTimeline.entries, {
327
+ limit: actionTimeline.limit,
328
+ totalActions: actionTimeline.nextId - 1,
329
+ });
330
+ const sessionSummary = summarizeBrowserSession({
331
+ timeline: actionTimeline,
332
+ totalActions: actionTimeline.nextId - 1,
333
+ pages,
334
+ activePageId: pageRegistry.activePageId,
335
+ activeFrame: getActiveFrameMetadata(),
336
+ consoleEntries: consoleLogs,
337
+ networkEntries: networkLogs,
338
+ dialogEntries: dialogLogs,
339
+ consoleLimit: 1000,
340
+ networkLimit: 1000,
341
+ dialogLimit: 1000,
342
+ sessionStartedAt: getSessionStartedAt(),
343
+ now: Date.now(),
344
+ });
345
+ const failureHypothesis = buildFailureHypothesis({
346
+ timeline: actionTimeline,
347
+ consoleEntries: consoleLogs,
348
+ networkEntries: networkLogs,
349
+ dialogEntries: dialogLogs,
350
+ });
351
+ const accessibility = await deps.captureAccessibilityMarkdown(params.selector);
352
+ const screenshotPath = path.join(bundleDir, "screenshot.jpg");
353
+ await p.screenshot({ path: screenshotPath, type: "jpeg", quality: 80, fullPage: false });
354
+ const screenshotStat = await stat(screenshotPath);
355
+ const artifacts = {
356
+ screenshot: { path: screenshotPath, bytes: screenshotStat.size },
357
+ console: await deps.writeArtifactFile(path.join(bundleDir, "console.json"), JSON.stringify(consoleLogs, null, 2)),
358
+ network: await deps.writeArtifactFile(path.join(bundleDir, "network.json"), JSON.stringify(networkLogs, null, 2)),
359
+ dialog: await deps.writeArtifactFile(path.join(bundleDir, "dialog.json"), JSON.stringify(dialogLogs, null, 2)),
360
+ timeline: await deps.writeArtifactFile(path.join(bundleDir, "timeline.json"), JSON.stringify(timeline, null, 2)),
361
+ summary: await deps.writeArtifactFile(path.join(bundleDir, "summary.json"), JSON.stringify({
362
+ ...sessionSummary,
363
+ failureHypothesis,
364
+ trace: getActiveTraceSession(),
365
+ har: getHarState(),
366
+ sessionArtifactDir: sessionDir,
367
+ }, null, 2)),
368
+ pages: await deps.writeArtifactFile(path.join(bundleDir, "pages.json"), JSON.stringify(pages, null, 2)),
369
+ accessibility: await deps.writeArtifactFile(path.join(bundleDir, "accessibility.md"), accessibility.snapshot),
370
+ };
371
+ return {
372
+ content: [{ type: "text", text: `Debug bundle written: ${bundleDir}\n${sessionSummary.summary}\nFailure hypothesis: ${failureHypothesis}` }],
373
+ details: {
374
+ bundleDir,
375
+ artifacts,
376
+ accessibilityScope: accessibility.scope,
377
+ accessibilitySource: accessibility.source,
378
+ counts: {
379
+ console: consoleLogs.length,
380
+ network: networkLogs.length,
381
+ dialog: dialogLogs.length,
382
+ actions: timeline.count,
383
+ pages: pages.length,
384
+ },
385
+ elapsedMs: Date.now() - startedAt,
386
+ summary: sessionSummary,
387
+ failureHypothesis,
388
+ ...deps.getSessionArtifactMetadata(),
389
+ },
390
+ };
391
+ } catch (err: any) {
392
+ return {
393
+ content: [{ type: "text", text: `Debug bundle failed: ${err.message}` }],
394
+ details: { error: err.message, ...deps.getSessionArtifactMetadata() },
395
+ isError: true,
396
+ };
397
+ }
398
+ },
399
+ });
400
+ }
@@ -0,0 +1,247 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { StringEnum } from "@gsd/pi-ai";
4
+ import {
5
+ validateWaitParams,
6
+ createRegionStableScript,
7
+ parseThreshold,
8
+ includesNeedle,
9
+ } from "../core.js";
10
+ import type { ToolDeps } from "../state.js";
11
+ import {
12
+ getConsoleLogs,
13
+ } from "../state.js";
14
+
15
+ export function registerWaitTools(pi: ExtensionAPI, deps: ToolDeps): void {
16
+ pi.registerTool({
17
+ name: "browser_wait_for",
18
+ label: "Browser Wait For",
19
+ description:
20
+ "Wait for a condition before continuing. Use after actions that trigger async updates — data fetches, route changes, animations, loading spinners. Choose the appropriate condition: 'selector_visible' waits for an element to appear, 'selector_hidden' waits for it to disappear, 'url_contains' waits for the URL to match, 'network_idle' waits for all network requests to finish, 'delay' waits a fixed number of milliseconds, 'text_visible' waits for text to appear in the page body, 'text_hidden' waits for text to disappear from the page body, 'request_completed' waits for a network response whose URL contains the given substring, 'console_message' waits for a console log message containing the given substring, 'element_count' waits for the number of elements matching the CSS selector in 'value' to satisfy the 'threshold' expression (e.g. '>=3', '==0', '<5'), 'region_stable' waits for the DOM region matching the CSS selector in 'value' to stop changing.",
21
+ parameters: Type.Object({
22
+ condition: StringEnum([
23
+ "selector_visible",
24
+ "selector_hidden",
25
+ "url_contains",
26
+ "network_idle",
27
+ "delay",
28
+ "text_visible",
29
+ "text_hidden",
30
+ "request_completed",
31
+ "console_message",
32
+ "element_count",
33
+ "region_stable",
34
+ ] as const),
35
+ value: Type.Optional(
36
+ Type.String({
37
+ description:
38
+ "For selector_visible/selector_hidden/element_count/region_stable: CSS selector. For url_contains/request_completed: URL substring. For text_visible/text_hidden/console_message: text substring. For delay: milliseconds as a string (e.g. '1000'). Not used for network_idle.",
39
+ })
40
+ ),
41
+ threshold: Type.Optional(
42
+ Type.String({
43
+ description:
44
+ "Threshold expression for element_count (e.g. '>=3', '==0', '<5', or bare '3' which defaults to >=). Only used with element_count condition.",
45
+ })
46
+ ),
47
+ timeout: Type.Optional(
48
+ Type.Number({
49
+ description: "Maximum milliseconds to wait before failing (default: 10000)",
50
+ })
51
+ ),
52
+ }),
53
+
54
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
55
+ try {
56
+ const { page: p } = await deps.ensureBrowser();
57
+ const target = deps.getActiveTarget();
58
+ const timeout = params.timeout ?? 10000;
59
+
60
+ const validation = validateWaitParams({ condition: params.condition, value: params.value, threshold: (params as any).threshold });
61
+ if (validation) {
62
+ return {
63
+ content: [{ type: "text", text: validation.error }],
64
+ details: { error: validation.error, condition: params.condition },
65
+ isError: true,
66
+ };
67
+ }
68
+
69
+ switch (params.condition) {
70
+ case "selector_visible": {
71
+ if (!params.value) {
72
+ return {
73
+ content: [{ type: "text", text: "selector_visible requires a value (CSS selector)" }],
74
+ details: {},
75
+ isError: true,
76
+ };
77
+ }
78
+ await target.waitForSelector(params.value, { state: "visible", timeout });
79
+ return {
80
+ content: [{ type: "text", text: `Element "${params.value}" is now visible` }],
81
+ details: { condition: params.condition, value: params.value },
82
+ };
83
+ }
84
+
85
+ case "selector_hidden": {
86
+ if (!params.value) {
87
+ return {
88
+ content: [{ type: "text", text: "selector_hidden requires a value (CSS selector)" }],
89
+ details: {},
90
+ isError: true,
91
+ };
92
+ }
93
+ await target.waitForSelector(params.value, { state: "hidden", timeout });
94
+ return {
95
+ content: [{ type: "text", text: `Element "${params.value}" is now hidden` }],
96
+ details: { condition: params.condition, value: params.value },
97
+ };
98
+ }
99
+
100
+ case "url_contains": {
101
+ if (!params.value) {
102
+ return {
103
+ content: [{ type: "text", text: "url_contains requires a value (URL substring)" }],
104
+ details: {},
105
+ isError: true,
106
+ };
107
+ }
108
+ await p.waitForURL((url) => url.toString().includes(params.value!), { timeout });
109
+ return {
110
+ content: [{ type: "text", text: `URL now contains "${params.value}". Current URL: ${p.url()}` }],
111
+ details: { condition: params.condition, value: params.value, url: p.url() },
112
+ };
113
+ }
114
+
115
+ case "network_idle": {
116
+ await p.waitForLoadState("networkidle", { timeout });
117
+ return {
118
+ content: [{ type: "text", text: "Network is idle" }],
119
+ details: { condition: params.condition },
120
+ };
121
+ }
122
+
123
+ case "delay": {
124
+ const ms = parseInt(params.value ?? "1000", 10);
125
+ if (isNaN(ms)) {
126
+ return {
127
+ content: [{ type: "text", text: "delay requires a numeric value (milliseconds)" }],
128
+ details: {},
129
+ isError: true,
130
+ };
131
+ }
132
+ await new Promise((resolve) => setTimeout(resolve, ms));
133
+ return {
134
+ content: [{ type: "text", text: `Waited ${ms}ms` }],
135
+ details: { condition: params.condition, ms },
136
+ };
137
+ }
138
+
139
+ case "text_visible": {
140
+ await target.waitForFunction(
141
+ (needle: string) => {
142
+ const body = document.body?.innerText ?? "";
143
+ return body.toLowerCase().includes(needle.toLowerCase());
144
+ },
145
+ params.value!,
146
+ { timeout }
147
+ );
148
+ return {
149
+ content: [{ type: "text", text: `Text "${params.value}" is now visible on the page` }],
150
+ details: { condition: params.condition, value: params.value },
151
+ };
152
+ }
153
+
154
+ case "text_hidden": {
155
+ await target.waitForFunction(
156
+ (needle: string) => {
157
+ const body = document.body?.innerText ?? "";
158
+ return !body.toLowerCase().includes(needle.toLowerCase());
159
+ },
160
+ params.value!,
161
+ { timeout }
162
+ );
163
+ return {
164
+ content: [{ type: "text", text: `Text "${params.value}" is no longer visible on the page` }],
165
+ details: { condition: params.condition, value: params.value },
166
+ };
167
+ }
168
+
169
+ case "request_completed": {
170
+ const response = await deps.getActivePage().waitForResponse(
171
+ (resp) => resp.url().includes(params.value!),
172
+ { timeout }
173
+ );
174
+ return {
175
+ content: [{ type: "text", text: `Request completed: ${response.url()} (status ${response.status()})` }],
176
+ details: { condition: params.condition, value: params.value, url: response.url(), status: response.status() },
177
+ };
178
+ }
179
+
180
+ case "console_message": {
181
+ const needle = params.value!;
182
+ const startTime = Date.now();
183
+ while (Date.now() - startTime < timeout) {
184
+ const match = getConsoleLogs().find((entry) => includesNeedle(entry.text, needle));
185
+ if (match) {
186
+ return {
187
+ content: [{ type: "text", text: `Console message matching "${needle}" found: "${match.text}"` }],
188
+ details: { condition: params.condition, value: needle, matchedText: match.text, matchedType: match.type },
189
+ };
190
+ }
191
+ await new Promise((resolve) => setTimeout(resolve, 100));
192
+ }
193
+ throw new Error(`Timed out waiting for console message matching "${needle}" (${timeout}ms)`);
194
+ }
195
+
196
+ case "element_count": {
197
+ const threshold = parseThreshold((params as any).threshold ?? ">=1");
198
+ if (!threshold) {
199
+ return {
200
+ content: [{ type: "text", text: `element_count threshold is malformed: "${(params as any).threshold}"` }],
201
+ details: { error: "malformed threshold", condition: params.condition },
202
+ isError: true,
203
+ };
204
+ }
205
+ const selector = params.value!;
206
+ const op = threshold.op;
207
+ const n = threshold.n;
208
+ await target.waitForFunction(
209
+ ({ selector, op, n }: { selector: string; op: string; n: number }) => {
210
+ const count = document.querySelectorAll(selector).length;
211
+ switch (op) {
212
+ case ">=": return count >= n;
213
+ case "<=": return count <= n;
214
+ case "==": return count === n;
215
+ case ">": return count > n;
216
+ case "<": return count < n;
217
+ default: return false;
218
+ }
219
+ },
220
+ { selector, op, n },
221
+ { timeout }
222
+ );
223
+ return {
224
+ content: [{ type: "text", text: `Element count for "${selector}" satisfies ${op}${n}` }],
225
+ details: { condition: params.condition, value: selector, threshold: `${op}${n}` },
226
+ };
227
+ }
228
+
229
+ case "region_stable": {
230
+ const script = createRegionStableScript(params.value!);
231
+ await target.waitForFunction(script, undefined, { timeout, polling: 200 });
232
+ return {
233
+ content: [{ type: "text", text: `Region "${params.value}" is now stable` }],
234
+ details: { condition: params.condition, value: params.value },
235
+ };
236
+ }
237
+ }
238
+ } catch (err: any) {
239
+ return {
240
+ content: [{ type: "text", text: `Wait failed: ${err.message}` }],
241
+ details: { error: err.message, condition: params.condition, value: params.value },
242
+ isError: true,
243
+ };
244
+ }
245
+ },
246
+ });
247
+ }