@narumitw/pi-chrome-devtools 0.13.0 → 0.13.1

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.
@@ -1,38 +1,28 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { constants } from "node:fs";
4
- import { access, link, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
5
- import { homedir, tmpdir } from "node:os";
6
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { shutdownManagedBrowser } from "./browser-manager.js";
3
+ import { loadSettings } from "./settings.js";
4
+ import { state } from "./runtime.js";
7
5
  import {
8
- defineTool,
9
- type AgentToolResult,
10
- type ExtensionAPI,
11
- type ExtensionCommandContext,
12
- type ToolRenderResultOptions,
13
- withFileMutationQueue,
14
- } from "@earendil-works/pi-coding-agent";
15
- import { Type } from "typebox";
16
-
17
- const DEFAULT_HOST = "127.0.0.1";
18
- const DEFAULT_PORT = 9222;
19
- const DEFAULT_TIMEOUT_MS = 10_000;
20
- const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
21
- const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
22
- const DEFAULT_ENDPOINT_RETRY_MS = 250;
23
- const MANAGED_BROWSER_PROFILE_PREFIX = "pi-chrome-devtools-profile-";
24
- const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
25
- const BROWSER_SHUTDOWN_WAIT_MS = 1_500;
6
+ allChromeDevtoolsTools,
7
+ applyChromeDevtoolsTools,
8
+ buildCommandGuide,
9
+ buildQuickstartMessage,
10
+ buildToolStatusMessage,
11
+ setSelectedChromeDevtoolsTools,
12
+ showToolSelector,
13
+ updateChromeDevtoolsTools,
14
+ } from "./tool-selector.js";
15
+ import {
16
+ evaluateTool,
17
+ listPagesTool,
18
+ navigateTool,
19
+ screenshotTool,
20
+ selectPageTool,
21
+ } from "./tools.js";
22
+
23
+ type CommandAction = "menu" | "help" | "quickstart" | "status" | "tools" | "enable" | "disable";
24
+ type CommandContext = ExtensionCommandContext;
26
25
  const STATUS_KEY = "chrome-devtools";
27
- const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
28
- const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
29
- const CHROME_DEVTOOLS_TOOL_NAMES = [
30
- "chrome_devtools_list_pages",
31
- "chrome_devtools_select_page",
32
- "chrome_devtools_navigate",
33
- "chrome_devtools_evaluate",
34
- "chrome_devtools_screenshot",
35
- ] as const;
36
26
  const COMMAND_COMPLETIONS = [
37
27
  { value: "help", label: "help", description: "Show command usage" },
38
28
  { value: "quickstart", label: "quickstart", description: "Show endpoint and launch help" },
@@ -50,312 +40,6 @@ const MENU_OPTIONS = {
50
40
  enable: "Enable all Chrome DevTools tools",
51
41
  disable: "Disable all Chrome DevTools tools",
52
42
  } as const;
53
- const TOOL_SELECTOR_DONE = "Done";
54
- const TOOL_SELECTOR_ENABLE_ALL = "Enable all Chrome DevTools tools";
55
- const TOOL_SELECTOR_DISABLE_ALL = "Disable all Chrome DevTools tools";
56
-
57
- type ChromeDevToolsToolName = (typeof CHROME_DEVTOOLS_TOOL_NAMES)[number];
58
- type ToolRuntimeStatus = "enabled" | "disabled" | "partial";
59
- type CommandAction =
60
- | "menu"
61
- | "help"
62
- | "quickstart"
63
- | "status"
64
- | "tools"
65
- | "enable"
66
- | "disable";
67
- type CommandContext = ExtensionCommandContext;
68
- type ToolSelectorAction = "enableAll" | "disableAll" | "done";
69
- type BrowserCandidateSource = "env" | "path" | "wellKnownPath";
70
- type ToolSelectorRow =
71
- | { kind: "tool"; toolName: ChromeDevToolsToolName }
72
- | { kind: "action"; action: ToolSelectorAction; label: string };
73
-
74
- interface StatusContext {
75
- ui: { setStatus: (key: string, value: string | undefined) => void };
76
- }
77
-
78
- interface RenderTheme {
79
- bold(text: string): string;
80
- fg(color: string, text: string): string;
81
- }
82
-
83
- interface RenderComponent {
84
- invalidate(): void;
85
- render(width: number): string[];
86
- }
87
-
88
- interface DevToolsPage {
89
- id: string;
90
- type: string;
91
- title: string;
92
- url: string;
93
- webSocketDebuggerUrl?: string;
94
- }
95
-
96
- interface ChromeDevToolsState {
97
- host: string;
98
- port: number;
99
- configuredPort: number;
100
- hostConfigured: boolean;
101
- portConfigured: boolean;
102
- autoLaunchEnabled: boolean;
103
- browserExecutable?: string;
104
- activePageId?: string;
105
- managedBrowser?: ManagedBrowser;
106
- launchPromise?: Promise<void>;
107
- lastLaunchAttempt?: BrowserLaunchAttempt;
108
- shuttingDown: boolean;
109
- settingsNotice?: string;
110
- }
111
-
112
- interface ManagedBrowser {
113
- process: ChildProcess;
114
- userDataDir: string;
115
- port?: number;
116
- exited: boolean;
117
- ready: boolean;
118
- }
119
-
120
- interface BrowserLaunchAttempt {
121
- candidateLabels: string[];
122
- mode: "dynamic-port" | "explicit-port";
123
- selectedCandidate?: string;
124
- userDataDir?: string;
125
- lastError?: string;
126
- }
127
-
128
- interface ChromeDevToolsSettings {
129
- tools: ChromeDevToolsToolName[];
130
- updatedAt: number;
131
- }
132
-
133
- interface ToolStatusSummary {
134
- runtimeStatus: ToolRuntimeStatus;
135
- activeChromeToolCount: number;
136
- activeNonChromeToolCount: number;
137
- }
138
-
139
- interface CdpResponse<T = unknown> {
140
- id: number;
141
- result?: T;
142
- error?: {
143
- code: number;
144
- message: string;
145
- data?: unknown;
146
- };
147
- }
148
-
149
- interface ResolvedScreenshotPath {
150
- path: string;
151
- allowedRoots: string[];
152
- isDefault: boolean;
153
- }
154
-
155
- interface ScreenshotSaveResult {
156
- savedPath: string;
157
- bytes: number;
158
- isDefaultPath: boolean;
159
- }
160
-
161
- interface BrowserCandidateDefinition {
162
- label: string;
163
- executable: string;
164
- source: BrowserCandidateSource;
165
- }
166
-
167
- interface BrowserCandidate extends BrowserCandidateDefinition {
168
- resolvedExecutable: string;
169
- }
170
-
171
- const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
172
- const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
173
- const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
174
-
175
- const state: ChromeDevToolsState = {
176
- host: configuredHost,
177
- port: configuredPort,
178
- configuredPort,
179
- hostConfigured: process.env.PI_CHROME_DEVTOOLS_HOST !== undefined,
180
- portConfigured: configuredPortOverride !== undefined,
181
- autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
182
- browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
183
- shuttingDown: false,
184
- };
185
-
186
- const listPagesTool = defineTool({
187
- name: CHROME_DEVTOOLS_TOOL_NAMES[0],
188
- label: "Chrome DevTools: List Pages",
189
- description: "List Chrome tabs/pages from a running Chrome DevTools Protocol endpoint.",
190
- promptSnippet: "List Chrome tabs/pages available over Chrome DevTools Protocol",
191
- parameters: Type.Object({}),
192
- renderCall: renderToolCall("list pages"),
193
- renderResult: renderTextResult,
194
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
195
- return withStatus(ctx, "list pages", async () => {
196
- const pages = await listPages();
197
- return textResult(JSON.stringify(pages.map(formatPage), null, 2), { pages });
198
- });
199
- },
200
- });
201
-
202
- const selectPageTool = defineTool({
203
- name: CHROME_DEVTOOLS_TOOL_NAMES[1],
204
- label: "Chrome DevTools: Select Page",
205
- description: "Select the active Chrome page for later chrome_devtools_* tool calls.",
206
- promptSnippet: "Select the Chrome tab/page to inspect or control",
207
- parameters: Type.Object({
208
- pageId: Type.String({ description: "Page id from chrome_devtools_list_pages." }),
209
- }),
210
- renderCall: renderToolCall("select page"),
211
- renderResult: renderTextResult,
212
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
213
- return withStatus(ctx, "select page", async () => {
214
- const page = await getPage(params.pageId);
215
- state.activePageId = page.id;
216
- return textResult(`Selected page ${page.id}: ${page.title}\n${page.url}`, {
217
- page: formatPage(page),
218
- });
219
- });
220
- },
221
- });
222
-
223
- const navigateTool = defineTool({
224
- name: CHROME_DEVTOOLS_TOOL_NAMES[2],
225
- label: "Chrome DevTools: Navigate",
226
- description:
227
- "Navigate a Chrome page to a URL through Chrome DevTools Protocol, creating a page first if none is available.",
228
- promptSnippet: "Navigate the selected or first Chrome tab to a URL, creating one if needed",
229
- parameters: Type.Object({
230
- url: Type.String({ description: "URL to navigate to." }),
231
- pageId: Type.Optional(
232
- Type.String({ description: "Optional page id. Defaults to selected or first page." }),
233
- ),
234
- }),
235
- renderCall: renderToolCall("navigate"),
236
- renderResult: renderTextResult,
237
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
238
- return withStatus(ctx, "navigate", async () => {
239
- const { created, page } = await resolvePageForNavigation(params.pageId);
240
- const result = await withCdp(page, async (client) => {
241
- await client.send("Page.enable");
242
- return client.send("Page.navigate", { url: params.url });
243
- });
244
-
245
- state.activePageId = page.id;
246
- const action = created ? "Created page and navigated" : "Navigated";
247
- return textResult(`${action} ${page.id} to ${params.url}`, {
248
- created,
249
- page: formatPage(page),
250
- result,
251
- });
252
- });
253
- },
254
- });
255
-
256
- const evaluateTool = defineTool({
257
- name: CHROME_DEVTOOLS_TOOL_NAMES[3],
258
- label: "Chrome DevTools: Evaluate",
259
- description: "Evaluate JavaScript in a Chrome page through Chrome DevTools Protocol.",
260
- promptSnippet: "Evaluate JavaScript in the selected Chrome tab",
261
- parameters: Type.Object({
262
- expression: Type.String({ description: "JavaScript expression to evaluate." }),
263
- pageId: Type.Optional(
264
- Type.String({ description: "Optional page id. Defaults to selected or first page." }),
265
- ),
266
- awaitPromise: Type.Optional(
267
- Type.Boolean({ description: "Whether to await a returned Promise. Defaults to true." }),
268
- ),
269
- }),
270
- renderCall: renderToolCall("evaluate"),
271
- renderResult: renderTextResult,
272
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
273
- return withStatus(ctx, "evaluate", async () => {
274
- const page = await resolvePage(params.pageId);
275
- const result = await withCdp(page, (client) =>
276
- client.send("Runtime.evaluate", {
277
- expression: params.expression,
278
- awaitPromise: params.awaitPromise ?? true,
279
- returnByValue: true,
280
- }),
281
- );
282
-
283
- state.activePageId = page.id;
284
- return textResult(JSON.stringify(result, null, 2), { page: formatPage(page), result });
285
- });
286
- },
287
- });
288
-
289
- const screenshotTool = defineTool({
290
- name: CHROME_DEVTOOLS_TOOL_NAMES[4],
291
- label: "Chrome DevTools: Screenshot",
292
- description: "Capture a PNG screenshot from a Chrome page through Chrome DevTools Protocol.",
293
- promptSnippet: "Capture a screenshot from the selected Chrome tab",
294
- parameters: Type.Object({
295
- pageId: Type.Optional(
296
- Type.String({ description: "Optional page id. Defaults to selected or first page." }),
297
- ),
298
- fullPage: Type.Optional(
299
- Type.Boolean({ description: "Capture the full document, not just the viewport." }),
300
- ),
301
- savePath: Type.Optional(
302
- Type.String({
303
- description:
304
- "Screenshot is always saved as a PNG file. Optional output path; omitted defaults to a unique temp file. Relative paths resolve from the current working directory. A single leading @ is stripped to match Pi file-mention paths. Existing regular files are replaced.",
305
- }),
306
- ),
307
- }),
308
- renderCall: renderToolCall("screenshot"),
309
- renderResult: renderScreenshotResult,
310
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
311
- return withStatus(ctx, "screenshot", async () => {
312
- const page = await resolvePage(params.pageId);
313
- const result = await withCdp(page, async (client) => {
314
- throwIfAborted(signal);
315
- await client.send("Page.enable");
316
-
317
- if (!params.fullPage) {
318
- return client.send<{ data: string }>("Page.captureScreenshot", { format: "png" });
319
- }
320
-
321
- const metrics = await client.send<{
322
- contentSize: { x: number; y: number; width: number; height: number };
323
- }>("Page.getLayoutMetrics");
324
-
325
- throwIfAborted(signal);
326
- return client.send<{ data: string }>("Page.captureScreenshot", {
327
- captureBeyondViewport: true,
328
- format: "png",
329
- clip: {
330
- x: metrics.contentSize.x,
331
- y: metrics.contentSize.y,
332
- width: metrics.contentSize.width,
333
- height: metrics.contentSize.height,
334
- scale: 1,
335
- },
336
- });
337
- });
338
-
339
- state.activePageId = page.id;
340
- const savedScreenshot = await saveScreenshot(result.data, params.savePath, ctx.cwd, signal);
341
- return {
342
- content: [
343
- {
344
- type: "text",
345
- text: formatScreenshotText(page, savedScreenshot),
346
- },
347
- { type: "image", data: result.data, mimeType: "image/png" },
348
- ],
349
- details: {
350
- page: formatPage(page),
351
- bytes: savedScreenshot.bytes,
352
- savedPath: savedScreenshot.savedPath,
353
- isDefaultPath: savedScreenshot.isDefaultPath,
354
- },
355
- };
356
- });
357
- },
358
- });
359
43
 
360
44
  export default function chromeDevtools(pi: ExtensionAPI) {
361
45
  pi.registerTool(listPagesTool);
@@ -377,7 +61,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
377
61
  state.settingsNotice = undefined;
378
62
  ctx.ui.setStatus(STATUS_KEY, undefined);
379
63
  const settings = await loadSettings();
380
- recordSettingsNotice(settings);
64
+ state.settingsNotice = settings.notice;
381
65
  if (settings.notice) ctx.ui.notify(settings.notice, "warning");
382
66
  if (settings.kind === "loaded") {
383
67
  applyChromeDevtoolsTools(pi, settings.settings.tools);
@@ -478,1641 +162,17 @@ export function commandCompletions(prefix: string) {
478
162
  return matches.length > 0 ? matches : null;
479
163
  }
480
164
 
481
- async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
482
- if (!ctx.hasUI) {
483
- ctx.ui.notify(
484
- `Chrome DevTools tool selection needs an interactive UI.
485
-
486
- ${await buildToolStatusMessage(pi)}`,
487
- "info",
488
- );
489
- return;
490
- }
491
-
492
- let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
493
- let persistQueue = Promise.resolve();
494
- const commitSelectedTools = () => {
495
- const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
496
- applyChromeDevtoolsTools(pi, nextSelectedTools);
497
- persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
498
- };
499
-
500
- const customResult = await ctx.ui.custom<"closed" | undefined>(
501
- (tui, theme, keybindings, done) => {
502
- const rows = chromeDevtoolsToolSelectorRows();
503
- let selectedIndex = 0;
504
- const moveSelection = (delta: number) => {
505
- selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
506
- };
507
- const activateSelectedRow = () => {
508
- const row = rows[selectedIndex];
509
- if (!row) return;
510
-
511
- if (row.kind === "tool") {
512
- if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
513
- else selectedTools.add(row.toolName);
514
- commitSelectedTools();
515
- return;
516
- }
517
-
518
- if (row.action === "enableAll") {
519
- selectedTools = new Set(allChromeDevtoolsTools());
520
- commitSelectedTools();
521
- return;
522
- }
523
- if (row.action === "disableAll") {
524
- selectedTools = new Set();
525
- commitSelectedTools();
526
- return;
527
- }
528
-
529
- done("closed");
530
- };
531
-
532
- return {
533
- invalidate() {},
534
- render() {
535
- return [
536
- theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
537
- "",
538
- ...rows.map((row, index) => {
539
- const label = formatToolSelectorRow(row, selectedTools);
540
- if (index === selectedIndex) {
541
- return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
542
- }
543
- return ` ${label}`;
544
- }),
545
- "",
546
- theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
547
- ];
548
- },
549
- handleInput(data: string) {
550
- if (keybindings.matches(data, "tui.select.up")) {
551
- moveSelection(-1);
552
- tui.requestRender();
553
- return;
554
- }
555
- if (keybindings.matches(data, "tui.select.down")) {
556
- moveSelection(1);
557
- tui.requestRender();
558
- return;
559
- }
560
- if (keybindings.matches(data, "tui.select.pageUp")) {
561
- selectedIndex = 0;
562
- tui.requestRender();
563
- return;
564
- }
565
- if (keybindings.matches(data, "tui.select.pageDown")) {
566
- selectedIndex = rows.length - 1;
567
- tui.requestRender();
568
- return;
569
- }
570
- if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
571
- activateSelectedRow();
572
- tui.requestRender();
573
- return;
574
- }
575
- if (keybindings.matches(data, "tui.select.cancel")) {
576
- done("closed");
577
- }
578
- },
579
- };
580
- },
581
- );
582
-
583
- if (customResult !== "closed") {
584
- await showDialogToolSelector(pi, ctx);
585
- return;
586
- }
587
-
588
- await persistQueue;
589
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
590
- }
591
-
592
- async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
593
- let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
594
- while (true) {
595
- const rows = chromeDevtoolsToolSelectorRows();
596
- const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
597
- const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
598
- if (!choice) break;
599
-
600
- const row = rows[choices.indexOf(choice)];
601
- if (!row) continue;
602
- if (row.kind === "action" && row.action === "done") break;
603
-
604
- if (row.kind === "tool") {
605
- if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
606
- else selectedTools.add(row.toolName);
607
- } else if (row.action === "enableAll") {
608
- selectedTools = new Set(allChromeDevtoolsTools());
609
- } else if (row.action === "disableAll") {
610
- selectedTools = new Set();
611
- }
612
-
613
- await setSelectedChromeDevtoolsTools(pi, ctx, orderedChromeDevtoolsTools(selectedTools));
614
- }
615
-
616
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
617
- }
618
-
619
- async function updateChromeDevtoolsTools(
620
- pi: ExtensionAPI,
621
- ctx: CommandContext,
622
- selectedTools: readonly ChromeDevToolsToolName[],
623
- action: string,
624
- ) {
625
- await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools);
626
- ctx.ui.notify(`Chrome DevTools tools ${action}.
627
-
628
- ${await buildToolStatusMessage(pi)}`, "info");
629
- }
630
-
631
- async function setSelectedChromeDevtoolsTools(
632
- pi: ExtensionAPI,
633
- ctx: CommandContext,
634
- selectedTools: readonly ChromeDevToolsToolName[],
635
- ) {
636
- applyChromeDevtoolsTools(pi, selectedTools);
637
- await persistSettings(ctx, selectedTools);
638
- }
639
-
640
- function applyChromeDevtoolsTools(
641
- pi: ExtensionAPI,
642
- selectedTools: readonly ChromeDevToolsToolName[],
643
- ) {
644
- const activeToolNames = pi.getActiveTools();
645
- const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
646
- const activeNonChromeToolNames = activeToolNames.filter((name) => !chromeToolNames.has(name));
647
- pi.setActiveTools(unique([...activeNonChromeToolNames, ...selectedTools]));
648
- }
649
-
650
- function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
651
- const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
652
- const activeToolNames = new Set(pi.getActiveTools());
653
- const activeChromeToolCount = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) =>
654
- activeToolNames.has(name),
655
- ).length;
656
- const activeNonChromeToolCount = Array.from(activeToolNames).filter(
657
- (name) => !chromeToolNames.has(name),
658
- ).length;
659
- const runtimeStatus =
660
- activeChromeToolCount === CHROME_DEVTOOLS_TOOL_NAMES.length
661
- ? "enabled"
662
- : activeChromeToolCount === 0
663
- ? "disabled"
664
- : "partial";
665
-
666
- return { runtimeStatus, activeChromeToolCount, activeNonChromeToolCount };
667
- }
668
-
669
- async function buildToolStatusMessage(pi: ExtensionAPI) {
670
- const summary = getToolStatusSummary(pi);
671
- const persistedSetting = await persistedSettingLabel();
672
- return [
673
- `Chrome DevTools tools: ${formatRuntimeStatus(summary)}`,
674
- `Persisted selection: ${persistedSetting}`,
675
- `Settings file: ${settingsFilePath()}`,
676
- ...(state.settingsNotice ? [`Settings note: ${state.settingsNotice}`] : []),
677
- `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
678
- `Endpoint: ${devToolsEndpoint()}`,
679
- `Endpoint source: ${endpointSourceLabel()}`,
680
- `Launch mode: ${launchModeLabel()}`,
681
- ...launchAttemptLines(),
682
- ].join("\n");
683
- }
684
-
685
- function buildQuickstartMessage() {
686
- return [
687
- `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
688
- `Endpoint source: ${endpointSourceLabel()}`,
689
- `Launch mode: ${launchModeLabel()}`,
690
- launchHint(),
691
- browserCandidateHint(),
692
- ...launchAttemptLines(),
693
- endpointConfigHint(),
694
- ].join("\n");
695
- }
696
-
697
- function buildCommandGuide() {
698
- return [
699
- "Chrome DevTools commands:",
700
- "/chrome-devtools — open this menu",
701
- "/chrome-devtools help — show command usage",
702
- "/chrome-devtools quickstart — show endpoint and launch help",
703
- "/chrome-devtools status — show tool and settings status",
704
- "/chrome-devtools tools — select individual Chrome DevTools tools",
705
- "/chrome-devtools toggle — alias for /chrome-devtools tools",
706
- "/chrome-devtools enable — enable all Chrome DevTools tools",
707
- "/chrome-devtools disable — disable all Chrome DevTools tools",
708
- ].join("\n");
709
- }
710
-
711
- function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
712
- return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
713
- }
714
-
715
- function chromeDevtoolsToolSelectorRows(): ToolSelectorRow[] {
716
- return [
717
- ...CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
718
- { kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
719
- { kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
720
- { kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
721
- ];
722
- }
723
-
724
- function formatToolSelectorRow(
725
- row: ToolSelectorRow,
726
- selectedTools: ReadonlySet<ChromeDevToolsToolName>,
727
- ) {
728
- if (row.kind === "action") return row.label;
729
- return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
730
- }
731
-
732
- function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
733
- const activeToolNames = new Set(pi.getActiveTools());
734
- return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
735
- }
736
-
737
- function allChromeDevtoolsTools() {
738
- return [...CHROME_DEVTOOLS_TOOL_NAMES];
739
- }
740
-
741
- export function orderedChromeDevtoolsTools(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
742
- return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
743
- }
744
-
745
- function formatRuntimeStatus(summary: ToolStatusSummary) {
746
- return `${summary.runtimeStatus} (${summary.activeChromeToolCount}/${CHROME_DEVTOOLS_TOOL_NAMES.length} active)`;
747
- }
748
-
749
- async function persistedSettingLabel() {
750
- const settings = await loadSettings();
751
- recordSettingsNotice(settings);
752
- if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
753
- if (settings.kind === "invalid") {
754
- return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
755
- }
756
- return "none; current active-tool policy preserved";
757
- }
758
-
759
- function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
760
- if (tools.length === CHROME_DEVTOOLS_TOOL_NAMES.length) {
761
- return `all enabled (${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
762
- }
763
- if (tools.length === 0) return `all disabled (0/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
764
- return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
765
- }
766
-
767
- async function persistSettings(
768
- ctx: CommandContext,
769
- selectedTools: readonly ChromeDevToolsToolName[],
770
- ) {
771
- try {
772
- await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
773
- } catch (error) {
774
- ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
775
- }
776
- }
777
-
778
- type SettingsLoadResult =
779
- | { kind: "missing"; notice?: string }
780
- | { kind: "invalid"; reason: string; notice?: string }
781
- | { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
782
-
783
- type SettingsMigrationResult = {
784
- kind: "migrated" | "failed";
785
- notice: string;
786
- };
787
-
788
- async function loadSettings(): Promise<SettingsLoadResult> {
789
- const newPath = settingsFilePath();
790
- const newSettings = await readSettingsFile(newPath);
791
- if (newSettings.kind !== "missing") {
792
- return withLegacyIgnoredNotice(newSettings);
793
- }
794
-
795
- const legacyPath = legacySettingsFilePath();
796
- const legacySettings = await readSettingsFile(legacyPath);
797
- const concurrentlyCreatedSettings = await readSettingsFile(newPath);
798
- if (concurrentlyCreatedSettings.kind !== "missing") {
799
- return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
800
- }
801
- if (legacySettings.kind === "missing") return { kind: "missing" };
802
- if (legacySettings.kind === "invalid") return legacySettings;
803
-
804
- const migration = await migrateLegacySettings(legacyPath, legacySettings.settings);
805
- if (migration.kind === "failed") {
806
- const settingsCreatedDuringMigration = await readSettingsFile(newPath);
807
- if (settingsCreatedDuringMigration.kind !== "missing") {
808
- return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
809
- }
810
- }
811
-
812
- return { ...legacySettings, notice: migration.notice };
813
- }
814
-
815
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
816
- let text: string;
817
- try {
818
- text = await readFile(filePath, "utf8");
819
- } catch (error) {
820
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
821
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
822
- }
823
-
824
- try {
825
- const parsed = JSON.parse(text) as unknown;
826
- const settings = normalizeChromeDevtoolsSettings(parsed);
827
- if (settings) return { kind: "loaded", settings };
828
- return {
829
- kind: "invalid",
830
- reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
831
- };
832
- } catch (error) {
833
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
834
- }
835
- }
836
-
837
- async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
838
- if (!(await fileExists(legacySettingsFilePath()))) return settings;
839
- return {
840
- ...settings,
841
- notice: `Chrome DevTools legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
842
- };
843
- }
844
-
845
- export async function installSettingsFileExclusively(filePath: string, contents: string) {
846
- await mkdir(dirname(filePath), { recursive: true });
847
- const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
848
- try {
849
- await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
850
- await link(tempFile, filePath);
851
- } finally {
852
- await rm(tempFile, { force: true }).catch(() => undefined);
853
- }
854
- }
855
-
856
- async function migrateLegacySettings(
857
- legacyPath: string,
858
- settings: ChromeDevToolsSettings,
859
- ): Promise<SettingsMigrationResult> {
860
- const newPath = settingsFilePath();
861
- try {
862
- await installSettingsFileExclusively(
863
- newPath,
864
- `${JSON.stringify(settings, null, 2)}\n`,
865
- );
866
- } catch (error) {
867
- return {
868
- kind: "failed",
869
- notice: `Chrome DevTools legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE_NAME}.`,
870
- };
871
- }
872
-
873
- try {
874
- await rm(legacyPath, { force: true });
875
- } catch (error) {
876
- return {
877
- kind: "migrated",
878
- notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
879
- };
880
- }
881
-
882
- return {
883
- kind: "migrated",
884
- notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE_NAME} is deprecated and will be removed in a future major release.`,
885
- };
886
- }
887
-
888
- async function fileExists(filePath: string) {
889
- try {
890
- await access(filePath, constants.F_OK);
891
- return true;
892
- } catch {
893
- return false;
894
- }
895
- }
896
-
897
- function recordSettingsNotice(settings: SettingsLoadResult) {
898
- if (settings.notice) state.settingsNotice = settings.notice;
899
- }
900
-
901
- export function normalizeChromeDevtoolsSettings(value: unknown): ChromeDevToolsSettings | undefined {
902
- if (!value || typeof value !== "object") return undefined;
903
- const settings = value as { tools?: unknown; updatedAt?: unknown };
904
- if (typeof settings.updatedAt !== "number") return undefined;
905
-
906
- if (settings.tools === "enabled") {
907
- return { tools: allChromeDevtoolsTools(), updatedAt: settings.updatedAt };
908
- }
909
- if (settings.tools === "disabled") return { tools: [], updatedAt: settings.updatedAt };
910
-
911
- if (!Array.isArray(settings.tools)) return undefined;
912
- if (!settings.tools.every(isChromeDevtoolsToolName)) return undefined;
913
- return { tools: orderedUniqueChromeDevtoolsTools(settings.tools), updatedAt: settings.updatedAt };
914
- }
915
-
916
- function isChromeDevtoolsToolName(value: unknown): value is ChromeDevToolsToolName {
917
- return typeof value === "string" && CHROME_DEVTOOLS_TOOL_NAMES.includes(value as never);
918
- }
919
-
920
- function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName[]) {
921
- const selectedTools = new Set(tools);
922
- return orderedChromeDevtoolsTools(selectedTools);
923
- }
924
-
925
- async function saveSettings(settings: ChromeDevToolsSettings) {
926
- const filePath = settingsFilePath();
927
- await mkdir(dirname(filePath), { recursive: true });
928
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
929
- try {
930
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
931
- await rename(tempFile, filePath);
932
- } catch (error) {
933
- await rm(tempFile, { force: true }).catch(() => undefined);
934
- throw error;
935
- }
936
- }
937
-
938
- function settingsFilePath() {
939
- return join(agentDir(), NEW_SETTINGS_FILE_NAME);
940
- }
941
-
942
- function legacySettingsFilePath() {
943
- return join(agentDir(), LEGACY_SETTINGS_FILE_NAME);
944
- }
945
-
946
- function agentDir() {
947
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
948
- }
949
-
950
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
951
- return error instanceof Error && "code" in error;
952
- }
953
-
954
- function formatError(error: unknown) {
955
- return error instanceof Error ? error.message : String(error);
956
- }
957
-
958
- function unique<T>(values: T[]) {
959
- return Array.from(new Set(values));
960
- }
961
-
962
- export function parseConfiguredPort(value: string | undefined) {
963
- if (value === undefined) return undefined;
964
- const trimmedValue = value.trim();
965
- if (!/^\d+$/.test(trimmedValue)) return undefined;
966
-
967
- const port = Number(trimmedValue);
968
- if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
969
- return port;
970
- }
971
-
972
- async function listPages(options: { waitMs?: number } = {}) {
973
- const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
974
- await ensureDevToolsEndpoint(waitMs);
975
- return withEndpointRetry(async () => {
976
- const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
977
- return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
978
- }, waitMs);
979
- }
980
-
981
- async function getPage(pageId: string) {
982
- const pages = await listPages();
983
- return requirePage(pageId, pages);
984
- }
985
-
986
- async function resolvePage(pageId?: string) {
987
- const pages = await listPages();
988
- if (pageId) return requirePage(pageId, pages);
989
-
990
- const page = resolveDefaultPage(pages);
991
- if (!page) {
992
- throw new Error(
993
- [
994
- `No Chrome pages found at ${devToolsEndpoint()}.`,
995
- "Use chrome_devtools_navigate with a URL to create a page, or open a Chrome tab manually.",
996
- launchHint(),
997
- ].join("\n"),
998
- );
999
- }
1000
-
1001
- return page;
1002
- }
1003
-
1004
- async function resolvePageForNavigation(pageId?: string) {
1005
- const pages = await listPages();
1006
- if (pageId) return { created: false, page: requirePage(pageId, pages) };
1007
-
1008
- const page = resolveDefaultPage(pages);
1009
- if (page) return { created: false, page };
1010
-
1011
- return { created: true, page: await createPage("about:blank") };
1012
- }
1013
-
1014
- function resolveDefaultPage(pages: DevToolsPage[]) {
1015
- if (!state.activePageId) return pages[0];
1016
-
1017
- const activePage = pages.find((candidate) => candidate.id === state.activePageId);
1018
- if (activePage) return activePage;
1019
-
1020
- state.activePageId = undefined;
1021
- return pages[0];
1022
- }
1023
-
1024
- function requirePage(pageId: string, pages: DevToolsPage[]) {
1025
- const page = pages.find((candidate) => candidate.id === pageId);
1026
- if (page) return page;
1027
-
1028
- const availablePages = pages.map(formatPageListItem).join("\n");
1029
- throw new Error(
1030
- [
1031
- `Chrome DevTools page not found: ${pageId}.`,
1032
- availablePages
1033
- ? `Available pages:\n${availablePages}`
1034
- : "No inspectable Chrome pages are currently available.",
1035
- ].join("\n"),
1036
- );
1037
- }
1038
-
1039
- async function createPage(url: string, options: { waitMs?: number } = {}) {
1040
- const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
1041
- await ensureDevToolsEndpoint(waitMs);
1042
- const page = await withEndpointRetry(
1043
- () =>
1044
- fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
1045
- method: "PUT",
1046
- }),
1047
- waitMs,
1048
- );
1049
- if (page.type !== "page" || !page.webSocketDebuggerUrl) {
1050
- throw new Error("Chrome DevTools created a target that is not an inspectable page.");
1051
- }
1052
-
1053
- return page;
1054
- }
1055
-
1056
- async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS) {
1057
- if (canAutoLaunchBrowser()) {
1058
- try {
1059
- await withEndpointRetry(() => fetchDevToolsJson<unknown>("/json/version"), waitMs);
1060
- return;
1061
- } catch (error) {
1062
- if (shouldAutoLaunchAfterEndpointError(error)) {
1063
- await ensureManagedBrowserLaunched(waitMs);
1064
- return;
1065
- }
1066
- throw error;
1067
- }
1068
- }
1069
-
1070
- try {
1071
- await fetchDevToolsJson<unknown>("/json/version");
1072
- } catch (error) {
1073
- if (isRetryableEndpointError(error)) return;
1074
- throw error;
1075
- }
1076
- }
1077
-
1078
- async function ensureManagedBrowserLaunched(waitMs: number) {
1079
- if (state.launchPromise) return state.launchPromise;
1080
- if (state.managedBrowser && !state.managedBrowser.exited && state.managedBrowser.ready) return;
1081
- if (state.managedBrowser) {
1082
- await shutdownManagedBrowser(state.managedBrowser, { awaitLaunch: false });
1083
- }
1084
- throwIfBrowserLaunchCancelled();
1085
-
1086
- state.launchPromise = launchManagedBrowser(waitMs).finally(() => {
1087
- state.launchPromise = undefined;
1088
- });
1089
- return state.launchPromise;
1090
- }
1091
-
1092
- async function launchManagedBrowser(waitMs: number) {
1093
- throwIfBrowserLaunchCancelled();
1094
- const candidateDefinitions = browserCandidateDefinitions();
1095
- const candidates = await resolveBrowserCandidates(candidateDefinitions);
1096
- throwIfBrowserLaunchCancelled();
1097
- state.lastLaunchAttempt = {
1098
- candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
1099
- mode: state.portConfigured ? "explicit-port" : "dynamic-port",
1100
- };
1101
-
1102
- if (candidates.length === 0) {
1103
- throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions));
1104
- }
1105
-
1106
- let lastError: unknown;
1107
- for (const candidate of candidates) {
1108
- throwIfBrowserLaunchCancelled();
1109
- try {
1110
- await launchBrowserCandidate(candidate, waitMs);
1111
- state.lastLaunchAttempt = {
1112
- ...state.lastLaunchAttempt,
1113
- selectedCandidate: formatBrowserCandidate(candidate),
1114
- userDataDir: state.managedBrowser?.userDataDir,
1115
- };
1116
- return;
1117
- } catch (error) {
1118
- lastError = error;
1119
- state.lastLaunchAttempt = {
1120
- ...state.lastLaunchAttempt,
1121
- lastError: formatError(error),
1122
- };
1123
- }
1124
- }
1125
-
1126
- throw new DevToolsEndpointError(
1127
- [
1128
- "Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
1129
- `Tried: ${candidates.map(formatBrowserCandidate).join(", ")}`,
1130
- lastError ? `Last error: ${formatError(lastError)}` : undefined,
1131
- launchHint(),
1132
- endpointConfigHint(),
1133
- ]
1134
- .filter(Boolean)
1135
- .join("\n"),
1136
- );
1137
- }
1138
-
1139
- async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: number) {
1140
- throwIfBrowserLaunchCancelled();
1141
- const userDataDir = await mkdtemp(join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX));
1142
- let managedBrowser: ManagedBrowser | undefined;
1143
- try {
1144
- const portArgument = state.portConfigured ? String(state.port) : "0";
1145
- const args = [
1146
- `--remote-debugging-port=${portArgument}`,
1147
- `--user-data-dir=${userDataDir}`,
1148
- "--no-first-run",
1149
- "--no-default-browser-check",
1150
- "about:blank",
1151
- ];
1152
- throwIfBrowserLaunchCancelled();
1153
- const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
1154
- const launchedBrowser: ManagedBrowser = { process: child, userDataDir, exited: false, ready: false };
1155
- managedBrowser = launchedBrowser;
1156
- state.managedBrowser = launchedBrowser;
1157
-
1158
- child.once("exit", () => {
1159
- launchedBrowser.exited = true;
1160
- launchedBrowser.ready = false;
1161
- if (!state.portConfigured && launchedBrowser.port === state.port) {
1162
- state.port = state.configuredPort;
1163
- }
1164
- });
1165
-
1166
- await waitForBrowserSpawn(child);
1167
- if (state.portConfigured) {
1168
- launchedBrowser.port = state.port;
1169
- } else {
1170
- launchedBrowser.port = await readManagedBrowserPort(userDataDir, launchedBrowser, waitMs);
1171
- state.port = launchedBrowser.port;
1172
- }
1173
- await waitForDevToolsEndpoint(waitMs, launchedBrowser);
1174
- launchedBrowser.ready = true;
1175
- } catch (error) {
1176
- if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
1177
- else await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
1178
- throw error;
1179
- }
1180
- }
1181
-
1182
- function waitForBrowserSpawn(child: ChildProcess) {
1183
- return new Promise<void>((resolve, reject) => {
1184
- let settled = false;
1185
- const settle = (callback: () => void) => {
1186
- if (settled) return;
1187
- settled = true;
1188
- child.off("error", onError);
1189
- child.off("spawn", onSpawn);
1190
- callback();
1191
- };
1192
- const onError = (error: Error) => settle(() => reject(error));
1193
- const onSpawn = () => settle(resolve);
1194
- child.once("error", onError);
1195
- child.once("spawn", onSpawn);
1196
- });
1197
- }
1198
-
1199
- async function readManagedBrowserPort(
1200
- userDataDir: string,
1201
- managedBrowser: ManagedBrowser,
1202
- waitMs: number,
1203
- ) {
1204
- const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
1205
- const deadline = Date.now() + waitMs;
1206
- while (true) {
1207
- throwIfManagedBrowserExited(managedBrowser);
1208
- const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
1209
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
1210
- throw error;
1211
- });
1212
- const portText = text?.split(/\r?\n/, 1)[0]?.trim();
1213
- const port = Number(portText);
1214
- if (Number.isInteger(port) && port > 0) return port;
1215
-
1216
- throwIfBrowserLaunchCancelled();
1217
- const remainingMs = deadline - Date.now();
1218
- if (remainingMs <= 0) {
1219
- throw new DevToolsEndpointError(
1220
- [
1221
- "Timed out waiting for auto-launched browser DevToolsActivePort.",
1222
- `Expected file: ${activePortFile}`,
1223
- launchHint(),
1224
- ].join("\n"),
1225
- );
1226
- }
1227
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
1228
- }
1229
- }
1230
-
1231
- async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
1232
- const deadline = Date.now() + waitMs;
1233
- while (true) {
1234
- throwIfManagedBrowserExited(managedBrowser);
1235
- try {
1236
- await fetchDevToolsJson<unknown>("/json/version");
1237
- return;
1238
- } catch (error) {
1239
- if (!isRetryableEndpointError(error)) throw error;
1240
- }
1241
-
1242
- throwIfBrowserLaunchCancelled();
1243
- const remainingMs = deadline - Date.now();
1244
- if (remainingMs <= 0) {
1245
- throw new DevToolsEndpointError(
1246
- [
1247
- `Timed out waiting for auto-launched browser at ${devToolsEndpoint()}.`,
1248
- launchHint(),
1249
- ].join("\n"),
1250
- );
1251
- }
1252
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
1253
- }
1254
- }
1255
-
1256
- function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
1257
- if (!managedBrowser.exited) return;
1258
- throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
1259
- }
1260
-
1261
- function throwIfBrowserLaunchCancelled() {
1262
- if (!state.shuttingDown) return;
1263
- throw new DevToolsEndpointError("Chrome DevTools browser launch cancelled during shutdown.");
1264
- }
1265
-
1266
- async function shutdownManagedBrowser(
1267
- managedBrowser = state.managedBrowser,
1268
- options: { awaitLaunch?: boolean; cancelLaunch?: boolean } = {},
1269
- ) {
1270
- if (options.cancelLaunch) state.shuttingDown = true;
1271
- if (options.awaitLaunch !== false) {
1272
- await state.launchPromise?.catch(() => undefined);
1273
- managedBrowser = managedBrowser ?? state.managedBrowser;
1274
- }
1275
- if (!managedBrowser) return;
1276
- if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
1277
-
1278
- if (!managedBrowser.exited) {
1279
- killManagedBrowserProcess(managedBrowser);
1280
- await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
1281
- killManagedBrowserProcess(managedBrowser, "SIGKILL");
1282
- });
1283
- }
1284
- await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
1285
- if (!state.portConfigured && managedBrowser.port === state.port) state.port = state.configuredPort;
1286
- }
1287
-
1288
- function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
1289
- try {
1290
- managedBrowser.process.kill(signal);
1291
- } catch {
1292
- // Best-effort shutdown: the browser may have already exited or failed to spawn.
1293
- }
1294
- }
1295
-
1296
- function waitForManagedBrowserExit(managedBrowser: ManagedBrowser, waitMs: number) {
1297
- if (managedBrowser.exited) return Promise.resolve();
1298
- return new Promise<void>((resolve, reject) => {
1299
- const settle = (callback: () => void) => {
1300
- clearTimeout(timeout);
1301
- managedBrowser.process.off("exit", onExitOrClose);
1302
- managedBrowser.process.off("close", onExitOrClose);
1303
- callback();
1304
- };
1305
- const onExitOrClose = () => {
1306
- managedBrowser.exited = true;
1307
- settle(resolve);
1308
- };
1309
- const timeout = setTimeout(
1310
- () => settle(() => reject(new Error("Timed out waiting for browser shutdown."))),
1311
- waitMs,
1312
- );
1313
- managedBrowser.process.once("exit", onExitOrClose);
1314
- managedBrowser.process.once("close", onExitOrClose);
1315
- });
1316
- }
1317
-
1318
- async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
1319
- const url = `${devToolsEndpoint()}${path}`;
1320
- let response: Response;
1321
- try {
1322
- response = await fetch(url, {
1323
- ...init,
1324
- signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
1325
- });
1326
- } catch (error) {
1327
- throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
1328
- launchable: true,
1329
- retryable: true,
1330
- });
1331
- }
1332
-
1333
- if (!response.ok) {
1334
- const body = (await response.text().catch(() => "")).trim();
1335
- const suffix = body ? `: ${body.slice(0, 200)}` : "";
1336
- throw new DevToolsEndpointError(
1337
- [
1338
- `Chrome DevTools endpoint ${url} returned ${response.status} ${response.statusText}${suffix}.`,
1339
- endpointConfigHint(),
1340
- ].join("\n"),
1341
- { retryable: response.status === 429 || response.status >= 500 },
1342
- );
1343
- }
1344
-
1345
- try {
1346
- return (await response.json()) as T;
1347
- } catch (error) {
1348
- throw new DevToolsEndpointError(
1349
- [
1350
- `Chrome DevTools endpoint ${url} returned invalid JSON: ${formatError(error)}.`,
1351
- endpointConfigHint(),
1352
- ].join("\n"),
1353
- );
1354
- }
1355
- }
1356
-
1357
- async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
1358
- const deadline = Date.now() + waitMs;
1359
- while (true) {
1360
- try {
1361
- return await operation();
1362
- } catch (error) {
1363
- if (!isRetryableEndpointError(error)) throw error;
1364
-
1365
- const remainingMs = deadline - Date.now();
1366
- if (remainingMs <= 0) throw error;
1367
-
1368
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
1369
- }
1370
- }
1371
- }
1372
-
1373
- function isRetryableEndpointError(error: unknown) {
1374
- return error instanceof DevToolsEndpointError && error.retryable;
1375
- }
1376
-
1377
- function isLaunchableEndpointError(error: unknown) {
1378
- return error instanceof DevToolsEndpointError && error.launchable;
1379
- }
1380
-
1381
- function shouldAutoLaunchAfterEndpointError(error: unknown) {
1382
- if (!canAutoLaunchBrowser()) return false;
1383
- if (isLaunchableEndpointError(error)) return true;
1384
-
1385
- // After the attach-first attempt (including its retry window, when applicable) fails, treat any
1386
- // DevTools endpoint error on an unpinned port as a conflict we can avoid with a dynamic port.
1387
- return !state.portConfigured && error instanceof DevToolsEndpointError;
1388
- }
1389
-
1390
- function canAutoLaunchBrowser() {
1391
- return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
1392
- }
1393
-
1394
- function endpointConnectionErrorMessage(error: unknown) {
1395
- const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
1396
- return [
1397
- `Cannot connect to Chrome DevTools endpoint at ${devToolsEndpoint()} (${reason}).`,
1398
- launchHint(),
1399
- endpointConfigHint(),
1400
- ].join("\n");
1401
- }
1402
-
1403
- function isTimeoutError(error: unknown) {
1404
- return error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
1405
- }
1406
-
1407
- function devToolsEndpoint() {
1408
- return `http://${formatHostForUrl(state.host)}:${state.port}`;
1409
- }
1410
-
1411
- export function formatHostForUrl(host: string) {
1412
- if (host.startsWith("[") && host.endsWith("]")) return host;
1413
- return host.includes(":") ? `[${host}]` : host;
1414
- }
1415
-
1416
- function endpointSourceLabel() {
1417
- const hostSource = state.hostConfigured ? "PI_CHROME_DEVTOOLS_HOST" : "default host";
1418
- const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
1419
- return `${hostSource}; ${portSource}`;
1420
- }
1421
-
1422
- function launchModeLabel() {
1423
- if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
1424
- if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
1425
- if (state.managedBrowser && !state.managedBrowser.exited) {
1426
- return state.portConfigured ? "auto-launched on explicit port" : "auto-launched on dynamic port";
1427
- }
1428
- return state.portConfigured ? "attach first; auto-launch explicit port" : "attach first; auto-launch dynamic port";
1429
- }
1430
-
1431
- function launchAttemptLines() {
1432
- if (!state.lastLaunchAttempt) return [];
1433
-
1434
- const lines = [`Last launch attempt: ${state.lastLaunchAttempt.mode}`];
1435
- if (state.lastLaunchAttempt.selectedCandidate) {
1436
- lines.push(`Launched browser: ${state.lastLaunchAttempt.selectedCandidate}`);
1437
- } else {
1438
- lines.push(`Tried browser candidates: ${state.lastLaunchAttempt.candidateLabels.join(", ")}`);
1439
- }
1440
- if (state.lastLaunchAttempt.userDataDir) {
1441
- lines.push(`Managed browser profile: ${state.lastLaunchAttempt.userDataDir}`);
1442
- }
1443
- if (state.lastLaunchAttempt.lastError) {
1444
- lines.push(`Last launch error: ${state.lastLaunchAttempt.lastError}`);
1445
- }
1446
- return lines;
1447
- }
1448
-
1449
- function launchHint() {
1450
- if (!isLocalDevToolsHost(state.host)) {
1451
- return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
1452
- }
1453
- if (!state.autoLaunchEnabled) {
1454
- return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand()}`;
1455
- }
1456
- const managedMode = state.portConfigured ? `port ${state.port}` : "a dynamic DevTools port";
1457
- return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand()}`;
1458
- }
1459
-
1460
- function browserCandidateHint() {
1461
- return `Browser candidates: ${browserCandidateDefinitions().map((candidate) => candidate.label).join(", ")}`;
1462
- }
1463
-
1464
- function chromeLaunchCommand() {
1465
- const executable = state.browserExecutable ?? defaultManualBrowserExecutable();
1466
- const dataDir =
1467
- process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
1468
- return `${quoteCommandPart(executable)} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
1469
- }
1470
-
1471
- function defaultManualBrowserExecutable() {
1472
- return process.platform === "darwin"
1473
- ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
1474
- : process.platform === "win32"
1475
- ? "chrome.exe"
1476
- : "google-chrome";
1477
- }
1478
-
1479
- export function quoteCommandPart(value: string) {
1480
- return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
1481
- }
1482
-
1483
- function endpointConfigHint() {
1484
- return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT for a manual endpoint, PI_CHROME_DEVTOOLS_BROWSER to choose an executable, or PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 to disable auto-launch.";
1485
- }
1486
-
1487
- export function isLocalDevToolsHost(host: string) {
1488
- const normalizedHost = host.toLowerCase().replace(/^\[(.*)]$/, "$1");
1489
- return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
1490
- }
1491
-
1492
- function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
1493
- const explicitCandidate = explicitBrowserCandidateDefinition();
1494
- if (explicitCandidate.length > 0) return explicitCandidate;
1495
-
1496
- return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
1497
- }
1498
-
1499
- function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
1500
- if (!state.browserExecutable) return [];
1501
- return [{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" }];
1502
- }
1503
-
1504
- function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
1505
- if (process.platform === "darwin") {
1506
- return [
1507
- {
1508
- label: "Google Chrome",
1509
- executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1510
- source: "wellKnownPath",
1511
- },
1512
- {
1513
- label: "Chromium",
1514
- executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
1515
- source: "wellKnownPath",
1516
- },
1517
- {
1518
- label: "Brave Browser",
1519
- executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
1520
- source: "wellKnownPath",
1521
- },
1522
- {
1523
- label: "Microsoft Edge",
1524
- executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1525
- source: "wellKnownPath",
1526
- },
1527
- ];
1528
- }
1529
-
1530
- if (process.platform === "win32") {
1531
- return windowsBrowserCandidateDefinitions();
1532
- }
1533
-
1534
- return [
1535
- { label: "Google Chrome", executable: "google-chrome", source: "path" },
1536
- { label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
1537
- { label: "Chromium", executable: "chromium", source: "path" },
1538
- { label: "Chromium Browser", executable: "chromium-browser", source: "path" },
1539
- { label: "Brave Browser", executable: "brave-browser", source: "path" },
1540
- { label: "Brave", executable: "brave", source: "path" },
1541
- { label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
1542
- { label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
1543
- ];
1544
- }
1545
-
1546
- function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
1547
- const programFiles = [
1548
- process.env.PROGRAMFILES,
1549
- process.env["PROGRAMFILES(X86)"],
1550
- process.env.LOCALAPPDATA,
1551
- ].filter((value): value is string => typeof value === "string" && value.length > 0);
1552
- const wellKnownPaths = programFiles.flatMap((root) => [
1553
- join(root, "Google", "Chrome", "Application", "chrome.exe"),
1554
- join(root, "Chromium", "Application", "chrome.exe"),
1555
- join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
1556
- join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
1557
- ]);
1558
- return [
1559
- ...wellKnownPaths.map((executable) => ({
1560
- label: browserLabelFromExecutable(executable),
1561
- executable,
1562
- source: "wellKnownPath" as const,
1563
- })),
1564
- { label: "Google Chrome", executable: "chrome.exe", source: "path" },
1565
- { label: "Chromium", executable: "chromium.exe", source: "path" },
1566
- { label: "Brave Browser", executable: "brave.exe", source: "path" },
1567
- { label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
1568
- ];
1569
- }
1570
-
1571
- function browserLabelFromExecutable(executable: string) {
1572
- const normalizedExecutable = normalizePathForComparison(executable);
1573
- if (normalizedExecutable.includes("brave")) return "Brave Browser";
1574
- if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
1575
- return "Microsoft Edge";
1576
- }
1577
- if (normalizedExecutable.includes("chromium")) return "Chromium";
1578
- return "Google Chrome";
1579
- }
1580
-
1581
- function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
1582
- const seen = new Set<string>();
1583
- return candidates.filter((candidate) => {
1584
- const key = normalizePathForComparison(candidate.executable);
1585
- if (seen.has(key)) return false;
1586
- seen.add(key);
1587
- return true;
1588
- });
1589
- }
1590
-
1591
- async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
1592
- const candidates: BrowserCandidate[] = [];
1593
- for (const definition of definitions) {
1594
- const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
1595
- if (!resolvedExecutable) continue;
1596
- candidates.push({ ...definition, resolvedExecutable });
1597
- }
1598
- return uniqueBrowserCandidatesByResolvedPath(candidates);
1599
- }
1600
-
1601
- function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
1602
- const seen = new Set<string>();
1603
- return candidates.filter((candidate) => {
1604
- const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
1605
- if (seen.has(key)) return false;
1606
- seen.add(key);
1607
- return true;
1608
- });
1609
- }
1610
-
1611
- async function resolveBrowserExecutable(executable: string) {
1612
- if (hasPathSeparator(executable) || isAbsolute(executable)) {
1613
- const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
1614
- return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
1615
- }
1616
-
1617
- for (const directory of executableSearchPath()) {
1618
- for (const executableName of executableSearchNames(executable)) {
1619
- const candidate = join(directory, executableName);
1620
- if (await canAccessExecutable(candidate)) return candidate;
1621
- }
1622
- }
1623
- return undefined;
1624
- }
1625
-
1626
- function hasPathSeparator(path: string) {
1627
- return path.includes("/") || path.includes("\\");
1628
- }
1629
-
1630
- function executableSearchPath() {
1631
- return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
1632
- }
1633
-
1634
- function executableSearchNames(executable: string) {
1635
- if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
1636
- return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
1637
- }
1638
-
1639
- async function canAccessExecutable(path: string) {
1640
- try {
1641
- await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
1642
- return true;
1643
- } catch {
1644
- return false;
1645
- }
1646
- }
1647
-
1648
- function formatBrowserCandidate(candidate: BrowserCandidate) {
1649
- return `${candidate.label} (${candidate.resolvedExecutable})`;
1650
- }
1651
-
1652
- function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition) {
1653
- return `${candidate.label} (${candidate.executable})`;
1654
- }
1655
-
1656
- function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
1657
- return [
1658
- "Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
1659
- `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
1660
- endpointConfigHint(),
1661
- ].join("\n");
1662
- }
1663
-
1664
- function formatPageListItem(page: DevToolsPage) {
1665
- return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
1666
- }
1667
-
1668
- function sleep(ms: number) {
1669
- return new Promise((resolve) => setTimeout(resolve, ms));
1670
- }
1671
-
1672
- class DevToolsEndpointError extends Error {
1673
- readonly retryable: boolean;
1674
- readonly launchable: boolean;
1675
-
1676
- constructor(message: string, options: { retryable?: boolean; launchable?: boolean } = {}) {
1677
- super(message);
1678
- this.name = "DevToolsEndpointError";
1679
- this.retryable = options.retryable ?? false;
1680
- this.launchable = options.launchable ?? false;
1681
- }
1682
- }
1683
-
1684
- function formatPage(page: DevToolsPage) {
1685
- return {
1686
- id: page.id,
1687
- type: page.type,
1688
- title: page.title,
1689
- url: page.url,
1690
- };
1691
- }
1692
-
1693
- function textResult(text: string, details: unknown) {
1694
- return {
1695
- content: [{ type: "text" as const, text }],
1696
- details,
1697
- };
1698
- }
1699
-
1700
- async function saveScreenshot(
1701
- base64Png: string,
1702
- savePath: string | undefined,
1703
- cwd: string,
1704
- signal: AbortSignal | undefined,
1705
- ): Promise<ScreenshotSaveResult> {
1706
- const resolvedPath = resolveScreenshotPath(savePath, cwd);
1707
- const pngBytes = Buffer.from(base64Png, "base64");
1708
-
1709
- await withFileMutationQueue(resolvedPath.path, async () => {
1710
- throwIfAborted(signal);
1711
- await ensureSafeScreenshotParent(resolvedPath);
1712
- await assertSafeScreenshotTargetPath(resolvedPath);
1713
- await writeScreenshotFileSafely(resolvedPath, pngBytes, signal);
1714
- });
1715
-
1716
- return {
1717
- savedPath: resolvedPath.path,
1718
- bytes: pngBytes.byteLength,
1719
- isDefaultPath: resolvedPath.isDefault,
1720
- };
1721
- }
1722
-
1723
- export function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
1724
- const cwdRoot = resolve(cwd);
1725
- const tempRoot = resolve(tmpdir());
1726
-
1727
- if (savePath === undefined) {
1728
- return {
1729
- path: join(tempRoot, `pi-chrome-devtools-screenshot-${randomUUID()}.png`),
1730
- allowedRoots: [tempRoot],
1731
- isDefault: true,
1732
- };
1733
- }
1734
-
1735
- const normalizedPath = stripLeadingAtPath(savePath);
1736
- if (!normalizedPath.trim()) {
1737
- throw new Error("Screenshot savePath must not be empty.");
1738
- }
1739
- if (normalizedPath.includes("\0")) {
1740
- throw new Error("Screenshot savePath must not contain NUL bytes.");
1741
- }
1742
- if (hasParentPathSegment(normalizedPath)) {
1743
- throw new Error("Screenshot savePath must not contain '..' path segments.");
1744
- }
1745
-
1746
- const isAbsolutePath = isAbsolute(normalizedPath);
1747
- const path = isAbsolutePath ? resolve(normalizedPath) : resolve(cwdRoot, normalizedPath);
1748
- const allowedRoots = isAbsolutePath ? unique([cwdRoot, tempRoot]) : [cwdRoot];
1749
- if (!allowedRoots.some((root) => isPathInsideRoot(path, root))) {
1750
- throw new Error(
1751
- "Screenshot savePath must be relative to the current working directory, or an absolute path inside the current working directory or OS temp directory.",
1752
- );
1753
- }
1754
-
1755
- return { path, allowedRoots, isDefault: false };
1756
- }
1757
-
1758
- function stripLeadingAtPath(path: string) {
1759
- return path.startsWith("@") ? path.slice(1) : path;
1760
- }
1761
-
1762
- export function hasParentPathSegment(path: string) {
1763
- return path.split(/[\\/]+/).some((part) => part === "..");
1764
- }
1765
-
1766
- async function ensureSafeScreenshotParent(resolvedPath: ResolvedScreenshotPath) {
1767
- const parentPath = dirname(resolvedPath.path);
1768
- const rootPath = selectAllowedRoot(parentPath, resolvedPath.allowedRoots);
1769
- if (!rootPath) {
1770
- throw new Error(
1771
- "Screenshot savePath parent must stay inside the current working directory or OS temp directory.",
1772
- );
1773
- }
1774
-
1775
- const realRootPath = await realpath(rootPath);
1776
- let currentPath = rootPath;
1777
- const parentSegments = relative(rootPath, parentPath)
1778
- .split(/[\\/]+/)
1779
- .filter((part) => part.length > 0);
1780
-
1781
- for (const segment of parentSegments) {
1782
- currentPath = join(currentPath, segment);
1783
- await ensureSafeDirectorySegment(currentPath, realRootPath);
1784
- }
1785
- }
1786
-
1787
- export function selectAllowedRoot(path: string, roots: readonly string[]) {
1788
- const matchingRoots = roots.filter((root) => isPathInsideRoot(path, root));
1789
- matchingRoots.sort(
1790
- (left, right) =>
1791
- normalizePathForComparison(resolve(right)).length -
1792
- normalizePathForComparison(resolve(left)).length,
1793
- );
1794
- return matchingRoots[0];
1795
- }
1796
-
1797
- async function ensureSafeDirectorySegment(path: string, realRootPath: string) {
1798
- const existingDirectory = await lstat(path).catch(async (error: unknown) => {
1799
- if (!isNodeError(error) || error.code !== "ENOENT") throw error;
1800
- await mkdir(path).catch((mkdirError: unknown) => {
1801
- if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") throw mkdirError;
1802
- });
1803
- return lstat(path);
1804
- });
1805
-
1806
- if (existingDirectory.isSymbolicLink()) {
1807
- throw new Error("Screenshot savePath parent directories must not contain symbolic links.");
1808
- }
1809
- if (!existingDirectory.isDirectory()) {
1810
- throw new Error("Screenshot savePath parent must be a directory.");
1811
- }
1812
- await assertPathWithinRealRoot(path, realRootPath);
1813
- }
1814
-
1815
- async function assertSafeScreenshotTargetPath(resolvedPath: ResolvedScreenshotPath) {
1816
- const existingTarget = await lstat(resolvedPath.path).catch((error: unknown) => {
1817
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
1818
- throw error;
1819
- });
1820
- if (existingTarget?.isSymbolicLink()) {
1821
- throw new Error("Screenshot savePath must not point to a symbolic link.");
1822
- }
1823
- if (existingTarget?.isDirectory()) {
1824
- throw new Error("Screenshot savePath must point to a file, not a directory.");
1825
- }
1826
- if (existingTarget && !existingTarget.isFile()) {
1827
- throw new Error("Screenshot savePath may only replace regular files.");
1828
- }
1829
-
1830
- const realAllowedRoots = await Promise.all(resolvedPath.allowedRoots.map(realpathOrResolvedPath));
1831
- const realParent = await realpath(dirname(resolvedPath.path));
1832
- const realTargetPath = join(realParent, basename(resolvedPath.path));
1833
- if (!realAllowedRoots.some((root) => isPathInsideRoot(realTargetPath, root))) {
1834
- throw new Error(
1835
- "Screenshot savePath resolves outside the current working directory or OS temp directory.",
1836
- );
1837
- }
1838
- }
1839
-
1840
- async function assertPathWithinRealRoot(path: string, realRootPath: string) {
1841
- const realPath = await realpath(path);
1842
- if (!isPathInsideRoot(realPath, realRootPath)) {
1843
- throw new Error(
1844
- "Screenshot savePath parent resolves outside the current working directory or OS temp directory.",
1845
- );
1846
- }
1847
- }
1848
-
1849
- async function writeScreenshotFileSafely(
1850
- resolvedPath: ResolvedScreenshotPath,
1851
- pngBytes: Buffer,
1852
- signal: AbortSignal | undefined,
1853
- ) {
1854
- const tempFile = join(
1855
- dirname(resolvedPath.path),
1856
- `.${basename(resolvedPath.path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
1857
- );
1858
- try {
1859
- await writeFile(tempFile, pngBytes, { flag: "wx", signal });
1860
- throwIfAborted(signal);
1861
- await replaceScreenshotFile(resolvedPath, tempFile, signal);
1862
- } catch (error) {
1863
- await rm(tempFile, { force: true }).catch(() => undefined);
1864
- throw error;
1865
- }
1866
- }
1867
-
1868
- async function replaceScreenshotFile(
1869
- resolvedPath: ResolvedScreenshotPath,
1870
- tempFile: string,
1871
- signal: AbortSignal | undefined,
1872
- ) {
1873
- try {
1874
- await rename(tempFile, resolvedPath.path);
1875
- return;
1876
- } catch (error) {
1877
- if (!shouldRetryRenameAfterRemovingDestination(error)) throw error;
1878
- }
1879
-
1880
- // Some Windows filesystems reject renaming over an existing file. Revalidate before
1881
- // removing the destination so the fallback still refuses directories and symlinks.
1882
- await assertSafeScreenshotTargetPath(resolvedPath);
1883
- throwIfAborted(signal);
1884
- await rm(resolvedPath.path, { force: true });
1885
- await rename(tempFile, resolvedPath.path);
1886
- }
1887
-
1888
- function shouldRetryRenameAfterRemovingDestination(error: unknown) {
1889
- return (
1890
- process.platform === "win32" &&
1891
- isNodeError(error) &&
1892
- ["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "")
1893
- );
1894
- }
1895
-
1896
- async function realpathOrResolvedPath(path: string) {
1897
- return realpath(path).catch(() => resolve(path));
1898
- }
1899
-
1900
- export function isPathInsideRoot(path: string, root: string) {
1901
- const normalizedPath = normalizePathForComparison(resolve(path));
1902
- const normalizedRoot = normalizePathForComparison(resolve(root));
1903
- if (normalizedPath === normalizedRoot) return true;
1904
- const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
1905
- return normalizedPath.startsWith(rootWithSeparator);
1906
- }
1907
-
1908
- function normalizePathForComparison(path: string) {
1909
- return process.platform === "win32" ? path.toLowerCase() : path;
1910
- }
1911
-
1912
- function throwIfAborted(signal: AbortSignal | undefined) {
1913
- if (!signal?.aborted) return;
1914
- throw signal.reason instanceof Error ? signal.reason : new Error("Screenshot capture cancelled.");
1915
- }
1916
-
1917
- function formatScreenshotText(page: DevToolsPage, screenshot: ScreenshotSaveResult) {
1918
- const pathLabel = screenshot.isDefaultPath ? "Saved to temp file" : "Saved to";
1919
- return [
1920
- `Captured PNG screenshot from ${page.title || page.url || page.id}.`,
1921
- `${pathLabel}: ${screenshot.savedPath}`,
1922
- `Bytes: ${screenshot.bytes}`,
1923
- `Use read({ path: ${JSON.stringify(screenshot.savedPath)} }) to inspect the saved screenshot if inline image content is not available.`,
1924
- ].join("\n");
1925
- }
1926
-
1927
- function renderToolCall(action: string) {
1928
- return () => new PiTextComponent(`Chrome DevTools: ${action}`);
1929
- }
1930
-
1931
- function renderTextResult(
1932
- result: AgentToolResult<unknown>,
1933
- options: ToolRenderResultOptions,
1934
- theme: RenderTheme,
1935
- ) {
1936
- const output = formatCollapsibleOutput(textContent(result), options);
1937
- return new PiTextComponent(output.text, theme, output.color);
1938
- }
1939
-
1940
- function renderScreenshotResult(
1941
- result: AgentToolResult<unknown>,
1942
- options: ToolRenderResultOptions,
1943
- theme: RenderTheme,
1944
- ): RenderComponent {
1945
- const output = formatCollapsibleOutput(screenshotTextContent(result), options);
1946
- return new PiTextComponent(output.text, theme, output.color);
1947
- }
1948
-
1949
- function textContent(result: AgentToolResult<unknown>) {
1950
- return result.content
1951
- .flatMap((content) => (content.type === "text" ? [content.text] : []))
1952
- .join("\n");
1953
- }
1954
-
1955
- function screenshotTextContent(result: AgentToolResult<unknown>) {
1956
- const text = textContent(result);
1957
- if (text.trim()) return text;
1958
-
1959
- const details = result.details as { savedPath?: unknown; bytes?: unknown } | undefined;
1960
- if (typeof details?.savedPath !== "string") return text;
1961
- const bytes = typeof details.bytes === "number" ? ` (${details.bytes} bytes)` : "";
1962
- return `Saved screenshot to ${details.savedPath}${bytes}`;
1963
- }
1964
-
1965
- function formatCollapsibleOutput(
1966
- text: string,
1967
- options: ToolRenderResultOptions,
1968
- ): { text: string; color?: string } {
1969
- if (options.isPartial) return { text: "Running...", color: "warning" };
1970
- if (options.expanded) return { text, color: "toolOutput" };
1971
-
1972
- return { text: "" };
1973
- }
1974
-
1975
- class PiTextComponent implements RenderComponent {
1976
- private text: string;
1977
- private readonly theme?: RenderTheme;
1978
- private readonly color?: string;
1979
-
1980
- constructor(text = "", theme?: RenderTheme, color?: string) {
1981
- this.text = text;
1982
- this.theme = theme;
1983
- this.color = color;
1984
- }
1985
-
1986
- setText(text: string) {
1987
- this.text = text;
1988
- }
1989
-
1990
- invalidate() {
1991
- // Stateless renderer: no cached layout to invalidate.
1992
- }
1993
-
1994
- render(width: number) {
1995
- if (!this.text.trim()) return [];
1996
- return this.text
1997
- .replace(/\t/g, " ")
1998
- .split(/\r?\n/)
1999
- .map((line) => {
2000
- const truncatedLine = truncateLine(line, Math.max(1, width));
2001
- return this.theme && this.color
2002
- ? this.theme.fg(this.color, truncatedLine)
2003
- : truncatedLine;
2004
- });
2005
- }
2006
- }
2007
-
2008
- function truncateLine(line: string, maxWidth: number) {
2009
- return Array.from(line).slice(0, maxWidth).join("");
2010
- }
2011
-
2012
- async function withStatus<T>(ctx: StatusContext, status: string, callback: () => Promise<T>) {
2013
- ctx.ui.setStatus(STATUS_KEY, status);
2014
- try {
2015
- return await callback();
2016
- } finally {
2017
- ctx.ui.setStatus(STATUS_KEY, undefined);
2018
- }
2019
- }
2020
-
2021
- async function withCdp<T>(page: DevToolsPage, callback: (client: CdpClient) => Promise<T>) {
2022
- if (!page.webSocketDebuggerUrl) throw new Error(`Page has no webSocketDebuggerUrl: ${page.id}`);
2023
-
2024
- const client = await CdpClient.connect(page.webSocketDebuggerUrl);
2025
- try {
2026
- return await callback(client);
2027
- } finally {
2028
- client.close();
2029
- }
2030
- }
2031
-
2032
- class CdpClient {
2033
- #nextId = 1;
2034
- #pending = new Map<
2035
- number,
2036
- {
2037
- resolve: (value: unknown) => void;
2038
- reject: (reason: unknown) => void;
2039
- timeout: NodeJS.Timeout;
2040
- }
2041
- >();
2042
- private readonly socket: WebSocket;
2043
-
2044
- private constructor(socket: WebSocket) {
2045
- this.socket = socket;
2046
- socket.addEventListener("message", (event) => {
2047
- const response = JSON.parse(String(event.data)) as CdpResponse;
2048
- if (typeof response.id !== "number") return;
2049
-
2050
- const pending = this.#pending.get(response.id);
2051
- if (!pending) return;
2052
-
2053
- clearTimeout(pending.timeout);
2054
- this.#pending.delete(response.id);
2055
-
2056
- if (response.error) {
2057
- pending.reject(new Error(`CDP error ${response.error.code}: ${response.error.message}`));
2058
- } else {
2059
- pending.resolve(response.result);
2060
- }
2061
- });
2062
-
2063
- socket.addEventListener("close", () => {
2064
- this.rejectAll(new Error("Chrome DevTools WebSocket closed"));
2065
- });
2066
-
2067
- socket.addEventListener("error", () => {
2068
- this.rejectAll(new Error("Chrome DevTools WebSocket error"));
2069
- });
2070
- }
2071
-
2072
- static connect(url: string) {
2073
- return new Promise<CdpClient>((resolve, reject) => {
2074
- const socket = new WebSocket(url);
2075
- const timeout = setTimeout(() => {
2076
- socket.close();
2077
- reject(new Error(`Timed out connecting to Chrome DevTools WebSocket: ${url}`));
2078
- }, DEFAULT_TIMEOUT_MS);
2079
-
2080
- socket.addEventListener("open", () => {
2081
- clearTimeout(timeout);
2082
- resolve(new CdpClient(socket));
2083
- });
2084
-
2085
- socket.addEventListener("error", () => {
2086
- clearTimeout(timeout);
2087
- reject(new Error(`Failed to connect to Chrome DevTools WebSocket: ${url}`));
2088
- });
2089
- });
2090
- }
2091
-
2092
- send<T = unknown>(method: string, params?: Record<string, unknown>) {
2093
- const id = this.#nextId;
2094
- this.#nextId += 1;
2095
-
2096
- return new Promise<T>((resolve, reject) => {
2097
- const timeout = setTimeout(() => {
2098
- this.#pending.delete(id);
2099
- reject(new Error(`Timed out waiting for CDP response: ${method}`));
2100
- }, DEFAULT_TIMEOUT_MS);
2101
-
2102
- this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout });
2103
- this.socket.send(JSON.stringify({ id, method, params: params ?? {} }));
2104
- });
2105
- }
2106
-
2107
- close() {
2108
- this.socket.close();
2109
- }
2110
-
2111
- private rejectAll(error: Error) {
2112
- for (const [id, pending] of this.#pending) {
2113
- clearTimeout(pending.timeout);
2114
- pending.reject(error);
2115
- this.#pending.delete(id);
2116
- }
2117
- }
2118
- }
165
+ export {
166
+ formatHostForUrl,
167
+ isLocalDevToolsHost,
168
+ quoteCommandPart,
169
+ } from "./browser-manager.js";
170
+ export { parseConfiguredPort } from "./runtime.js";
171
+ export {
172
+ hasParentPathSegment,
173
+ isPathInsideRoot,
174
+ resolveScreenshotPath,
175
+ selectAllowedRoot,
176
+ } from "./screenshot.js";
177
+ export { installSettingsFileExclusively, normalizeChromeDevtoolsSettings } from "./settings.js";
178
+ export { orderedChromeDevtoolsTools } from "./tool-selector.js";