@narumitw/pi-chrome-devtools 0.13.0 → 0.14.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.
package/src/render.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ToolRenderResultOptions,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ const STATUS_KEY = "chrome-devtools";
7
+ interface StatusContext {
8
+ ui: { setStatus: (key: string, value: string | undefined) => void };
9
+ }
10
+ interface RenderTheme {
11
+ bold(text: string): string;
12
+ fg(color: string, text: string): string;
13
+ }
14
+ interface RenderComponent {
15
+ invalidate(): void;
16
+ render(width: number): string[];
17
+ }
18
+
19
+ export function renderToolCall(action: string) {
20
+ return () => new PiTextComponent(`Chrome DevTools: ${action}`);
21
+ }
22
+
23
+ export function renderTextResult(
24
+ result: AgentToolResult<unknown>,
25
+ options: ToolRenderResultOptions,
26
+ theme: RenderTheme,
27
+ ) {
28
+ const output = formatCollapsibleOutput(textContent(result), options);
29
+ return new PiTextComponent(output.text, theme, output.color);
30
+ }
31
+
32
+ export function renderScreenshotResult(
33
+ result: AgentToolResult<unknown>,
34
+ options: ToolRenderResultOptions,
35
+ theme: RenderTheme,
36
+ ): RenderComponent {
37
+ const output = formatCollapsibleOutput(screenshotTextContent(result), options);
38
+ return new PiTextComponent(output.text, theme, output.color);
39
+ }
40
+
41
+ function textContent(result: AgentToolResult<unknown>) {
42
+ return result.content
43
+ .flatMap((content) => (content.type === "text" ? [content.text] : []))
44
+ .join("\n");
45
+ }
46
+
47
+ function screenshotTextContent(result: AgentToolResult<unknown>) {
48
+ const text = textContent(result);
49
+ if (text.trim()) return text;
50
+
51
+ const details = result.details as { savedPath?: unknown; bytes?: unknown } | undefined;
52
+ if (typeof details?.savedPath !== "string") return text;
53
+ const bytes = typeof details.bytes === "number" ? ` (${details.bytes} bytes)` : "";
54
+ return `Saved screenshot to ${details.savedPath}${bytes}`;
55
+ }
56
+
57
+ function formatCollapsibleOutput(
58
+ text: string,
59
+ options: ToolRenderResultOptions,
60
+ ): { text: string; color?: string } {
61
+ if (options.isPartial) return { text: "Running...", color: "warning" };
62
+ if (options.expanded) return { text, color: "toolOutput" };
63
+
64
+ return { text: "" };
65
+ }
66
+
67
+ class PiTextComponent implements RenderComponent {
68
+ private text: string;
69
+ private readonly theme?: RenderTheme;
70
+ private readonly color?: string;
71
+
72
+ constructor(text = "", theme?: RenderTheme, color?: string) {
73
+ this.text = text;
74
+ this.theme = theme;
75
+ this.color = color;
76
+ }
77
+
78
+ setText(text: string) {
79
+ this.text = text;
80
+ }
81
+
82
+ invalidate() {
83
+ // Stateless renderer: no cached layout to invalidate.
84
+ }
85
+
86
+ render(width: number) {
87
+ if (!this.text.trim()) return [];
88
+ return this.text
89
+ .replace(/\t/g, " ")
90
+ .split(/\r?\n/)
91
+ .map((line) => {
92
+ const truncatedLine = truncateLine(line, Math.max(1, width));
93
+ return this.theme && this.color
94
+ ? this.theme.fg(this.color, truncatedLine)
95
+ : truncatedLine;
96
+ });
97
+ }
98
+ }
99
+
100
+ function truncateLine(line: string, maxWidth: number) {
101
+ return Array.from(line).slice(0, maxWidth).join("");
102
+ }
103
+
104
+ export async function withStatus<T>(ctx: StatusContext, status: string, callback: () => Promise<T>) {
105
+ ctx.ui.setStatus(STATUS_KEY, status);
106
+ try {
107
+ return await callback();
108
+ } finally {
109
+ ctx.ui.setStatus(STATUS_KEY, undefined);
110
+ }
111
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ export const DEFAULT_HOST = "127.0.0.1";
4
+ export const DEFAULT_PORT = 9222;
5
+ export const DEFAULT_TIMEOUT_MS = 10_000;
6
+ export const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
7
+ export const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
8
+ export const DEFAULT_ENDPOINT_RETRY_MS = 250;
9
+ export const MANAGED_BROWSER_PROFILE_PREFIX = "pi-chrome-devtools-profile-";
10
+ export const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
11
+ export const BROWSER_SHUTDOWN_WAIT_MS = 1_500;
12
+
13
+ export interface DevToolsPage {
14
+ id: string;
15
+ type: string;
16
+ title: string;
17
+ url: string;
18
+ webSocketDebuggerUrl?: string;
19
+ }
20
+
21
+ export interface ChromeDevToolsState {
22
+ host: string;
23
+ port: number;
24
+ configuredPort: number;
25
+ hostConfigured: boolean;
26
+ portConfigured: boolean;
27
+ autoLaunchEnabled: boolean;
28
+ browserExecutable?: string;
29
+ activePageId?: string;
30
+ managedBrowser?: ManagedBrowser;
31
+ launchPromise?: Promise<void>;
32
+ lastLaunchAttempt?: BrowserLaunchAttempt;
33
+ shuttingDown: boolean;
34
+ settingsNotice?: string;
35
+ }
36
+
37
+ export interface ManagedBrowser {
38
+ process: ChildProcess;
39
+ userDataDir: string;
40
+ port?: number;
41
+ exited: boolean;
42
+ ready: boolean;
43
+ }
44
+
45
+ export interface BrowserLaunchAttempt {
46
+ candidateLabels: string[];
47
+ mode: "dynamic-port" | "explicit-port";
48
+ selectedCandidate?: string;
49
+ userDataDir?: string;
50
+ lastError?: string;
51
+ }
52
+
53
+ export interface BrowserCandidateDefinition {
54
+ label: string;
55
+ executable: string;
56
+ source: "env" | "path" | "wellKnownPath";
57
+ }
58
+
59
+ export interface BrowserCandidate extends BrowserCandidateDefinition {
60
+ resolvedExecutable: string;
61
+ }
62
+
63
+ export function parseConfiguredPort(value: string | undefined) {
64
+ if (value === undefined) return undefined;
65
+ const trimmedValue = value.trim();
66
+ if (!/^\d+$/.test(trimmedValue)) return undefined;
67
+ const port = Number(trimmedValue);
68
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
69
+ return port;
70
+ }
71
+
72
+ const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
73
+ const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
74
+ const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
75
+
76
+ export const state: ChromeDevToolsState = {
77
+ host: configuredHost,
78
+ port: configuredPort,
79
+ configuredPort,
80
+ hostConfigured: process.env.PI_CHROME_DEVTOOLS_HOST !== undefined,
81
+ portConfigured: configuredPortOverride !== undefined,
82
+ autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
83
+ browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
84
+ shuttingDown: false,
85
+ };
@@ -0,0 +1,252 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+ import type { DevToolsPage } from "./runtime.js";
7
+
8
+ function unique<T>(values: T[]) {
9
+ return Array.from(new Set(values));
10
+ }
11
+
12
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
13
+ return error instanceof Error && "code" in error;
14
+ }
15
+
16
+ export interface ResolvedScreenshotPath {
17
+ path: string;
18
+ allowedRoots: string[];
19
+ isDefault: boolean;
20
+ }
21
+ export interface ScreenshotSaveResult {
22
+ savedPath: string;
23
+ bytes: number;
24
+ isDefaultPath: boolean;
25
+ }
26
+
27
+ export async function saveScreenshot(
28
+ base64Png: string,
29
+ savePath: string | undefined,
30
+ cwd: string,
31
+ signal: AbortSignal | undefined,
32
+ ): Promise<ScreenshotSaveResult> {
33
+ const resolvedPath = resolveScreenshotPath(savePath, cwd);
34
+ const pngBytes = Buffer.from(base64Png, "base64");
35
+
36
+ await withFileMutationQueue(resolvedPath.path, async () => {
37
+ throwIfAborted(signal);
38
+ await ensureSafeScreenshotParent(resolvedPath);
39
+ await assertSafeScreenshotTargetPath(resolvedPath);
40
+ await writeScreenshotFileSafely(resolvedPath, pngBytes, signal);
41
+ });
42
+
43
+ return {
44
+ savedPath: resolvedPath.path,
45
+ bytes: pngBytes.byteLength,
46
+ isDefaultPath: resolvedPath.isDefault,
47
+ };
48
+ }
49
+
50
+ export function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
51
+ const cwdRoot = resolve(cwd);
52
+ const tempRoot = resolve(tmpdir());
53
+
54
+ if (savePath === undefined) {
55
+ return {
56
+ path: join(tempRoot, `pi-chrome-devtools-screenshot-${randomUUID()}.png`),
57
+ allowedRoots: [tempRoot],
58
+ isDefault: true,
59
+ };
60
+ }
61
+
62
+ const normalizedPath = stripLeadingAtPath(savePath);
63
+ if (!normalizedPath.trim()) {
64
+ throw new Error("Screenshot savePath must not be empty.");
65
+ }
66
+ if (normalizedPath.includes("\0")) {
67
+ throw new Error("Screenshot savePath must not contain NUL bytes.");
68
+ }
69
+ if (hasParentPathSegment(normalizedPath)) {
70
+ throw new Error("Screenshot savePath must not contain '..' path segments.");
71
+ }
72
+
73
+ const isAbsolutePath = isAbsolute(normalizedPath);
74
+ const path = isAbsolutePath ? resolve(normalizedPath) : resolve(cwdRoot, normalizedPath);
75
+ const allowedRoots = isAbsolutePath ? unique([cwdRoot, tempRoot]) : [cwdRoot];
76
+ if (!allowedRoots.some((root) => isPathInsideRoot(path, root))) {
77
+ throw new Error(
78
+ "Screenshot savePath must be relative to the current working directory, or an absolute path inside the current working directory or OS temp directory.",
79
+ );
80
+ }
81
+
82
+ return { path, allowedRoots, isDefault: false };
83
+ }
84
+
85
+ function stripLeadingAtPath(path: string) {
86
+ return path.startsWith("@") ? path.slice(1) : path;
87
+ }
88
+
89
+ export function hasParentPathSegment(path: string) {
90
+ return path.split(/[\\/]+/).some((part) => part === "..");
91
+ }
92
+
93
+ async function ensureSafeScreenshotParent(resolvedPath: ResolvedScreenshotPath) {
94
+ const parentPath = dirname(resolvedPath.path);
95
+ const rootPath = selectAllowedRoot(parentPath, resolvedPath.allowedRoots);
96
+ if (!rootPath) {
97
+ throw new Error(
98
+ "Screenshot savePath parent must stay inside the current working directory or OS temp directory.",
99
+ );
100
+ }
101
+
102
+ const realRootPath = await realpath(rootPath);
103
+ let currentPath = rootPath;
104
+ const parentSegments = relative(rootPath, parentPath)
105
+ .split(/[\\/]+/)
106
+ .filter((part) => part.length > 0);
107
+
108
+ for (const segment of parentSegments) {
109
+ currentPath = join(currentPath, segment);
110
+ await ensureSafeDirectorySegment(currentPath, realRootPath);
111
+ }
112
+ }
113
+
114
+ export function selectAllowedRoot(path: string, roots: readonly string[]) {
115
+ const matchingRoots = roots.filter((root) => isPathInsideRoot(path, root));
116
+ matchingRoots.sort(
117
+ (left, right) =>
118
+ normalizePathForComparison(resolve(right)).length -
119
+ normalizePathForComparison(resolve(left)).length,
120
+ );
121
+ return matchingRoots[0];
122
+ }
123
+
124
+ async function ensureSafeDirectorySegment(path: string, realRootPath: string) {
125
+ const existingDirectory = await lstat(path).catch(async (error: unknown) => {
126
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
127
+ await mkdir(path).catch((mkdirError: unknown) => {
128
+ if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") throw mkdirError;
129
+ });
130
+ return lstat(path);
131
+ });
132
+
133
+ if (existingDirectory.isSymbolicLink()) {
134
+ throw new Error("Screenshot savePath parent directories must not contain symbolic links.");
135
+ }
136
+ if (!existingDirectory.isDirectory()) {
137
+ throw new Error("Screenshot savePath parent must be a directory.");
138
+ }
139
+ await assertPathWithinRealRoot(path, realRootPath);
140
+ }
141
+
142
+ async function assertSafeScreenshotTargetPath(resolvedPath: ResolvedScreenshotPath) {
143
+ const existingTarget = await lstat(resolvedPath.path).catch((error: unknown) => {
144
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
145
+ throw error;
146
+ });
147
+ if (existingTarget?.isSymbolicLink()) {
148
+ throw new Error("Screenshot savePath must not point to a symbolic link.");
149
+ }
150
+ if (existingTarget?.isDirectory()) {
151
+ throw new Error("Screenshot savePath must point to a file, not a directory.");
152
+ }
153
+ if (existingTarget && !existingTarget.isFile()) {
154
+ throw new Error("Screenshot savePath may only replace regular files.");
155
+ }
156
+
157
+ const realAllowedRoots = await Promise.all(resolvedPath.allowedRoots.map(realpathOrResolvedPath));
158
+ const realParent = await realpath(dirname(resolvedPath.path));
159
+ const realTargetPath = join(realParent, basename(resolvedPath.path));
160
+ if (!realAllowedRoots.some((root) => isPathInsideRoot(realTargetPath, root))) {
161
+ throw new Error(
162
+ "Screenshot savePath resolves outside the current working directory or OS temp directory.",
163
+ );
164
+ }
165
+ }
166
+
167
+ async function assertPathWithinRealRoot(path: string, realRootPath: string) {
168
+ const realPath = await realpath(path);
169
+ if (!isPathInsideRoot(realPath, realRootPath)) {
170
+ throw new Error(
171
+ "Screenshot savePath parent resolves outside the current working directory or OS temp directory.",
172
+ );
173
+ }
174
+ }
175
+
176
+ async function writeScreenshotFileSafely(
177
+ resolvedPath: ResolvedScreenshotPath,
178
+ pngBytes: Buffer,
179
+ signal: AbortSignal | undefined,
180
+ ) {
181
+ const tempFile = join(
182
+ dirname(resolvedPath.path),
183
+ `.${basename(resolvedPath.path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
184
+ );
185
+ try {
186
+ await writeFile(tempFile, pngBytes, { flag: "wx", signal });
187
+ throwIfAborted(signal);
188
+ await replaceScreenshotFile(resolvedPath, tempFile, signal);
189
+ } catch (error) {
190
+ await rm(tempFile, { force: true }).catch(() => undefined);
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ async function replaceScreenshotFile(
196
+ resolvedPath: ResolvedScreenshotPath,
197
+ tempFile: string,
198
+ signal: AbortSignal | undefined,
199
+ ) {
200
+ try {
201
+ await rename(tempFile, resolvedPath.path);
202
+ return;
203
+ } catch (error) {
204
+ if (!shouldRetryRenameAfterRemovingDestination(error)) throw error;
205
+ }
206
+
207
+ // Some Windows filesystems reject renaming over an existing file. Revalidate before
208
+ // removing the destination so the fallback still refuses directories and symlinks.
209
+ await assertSafeScreenshotTargetPath(resolvedPath);
210
+ throwIfAborted(signal);
211
+ await rm(resolvedPath.path, { force: true });
212
+ await rename(tempFile, resolvedPath.path);
213
+ }
214
+
215
+ function shouldRetryRenameAfterRemovingDestination(error: unknown) {
216
+ return (
217
+ process.platform === "win32" &&
218
+ isNodeError(error) &&
219
+ ["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "")
220
+ );
221
+ }
222
+
223
+ async function realpathOrResolvedPath(path: string) {
224
+ return realpath(path).catch(() => resolve(path));
225
+ }
226
+
227
+ export function isPathInsideRoot(path: string, root: string) {
228
+ const normalizedPath = normalizePathForComparison(resolve(path));
229
+ const normalizedRoot = normalizePathForComparison(resolve(root));
230
+ if (normalizedPath === normalizedRoot) return true;
231
+ const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
232
+ return normalizedPath.startsWith(rootWithSeparator);
233
+ }
234
+
235
+ function normalizePathForComparison(path: string) {
236
+ return process.platform === "win32" ? path.toLowerCase() : path;
237
+ }
238
+
239
+ export function throwIfAborted(signal: AbortSignal | undefined) {
240
+ if (!signal?.aborted) return;
241
+ throw signal.reason instanceof Error ? signal.reason : new Error("Screenshot capture cancelled.");
242
+ }
243
+
244
+ export function formatScreenshotText(page: DevToolsPage, screenshot: ScreenshotSaveResult) {
245
+ const pathLabel = screenshot.isDefaultPath ? "Saved to temp file" : "Saved to";
246
+ return [
247
+ `Captured PNG screenshot from ${page.title || page.url || page.id}.`,
248
+ `${pathLabel}: ${screenshot.savedPath}`,
249
+ `Bytes: ${screenshot.bytes}`,
250
+ `Use read({ path: ${JSON.stringify(screenshot.savedPath)} }) to inspect the saved screenshot if inline image content is not available.`,
251
+ ].join("\n");
252
+ }
@@ -0,0 +1,196 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import {
7
+ CHROME_DEVTOOLS_TOOL_NAMES,
8
+ type ChromeDevToolsToolName,
9
+ } from "./tool-names.js";
10
+
11
+ const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
12
+ const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
13
+ export interface ChromeDevToolsSettings {
14
+ tools: ChromeDevToolsToolName[];
15
+ updatedAt: number;
16
+ }
17
+
18
+ export type SettingsLoadResult =
19
+ | { kind: "missing"; notice?: string }
20
+ | { kind: "invalid"; reason: string; notice?: string }
21
+ | { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
22
+
23
+ type SettingsMigrationResult = {
24
+ kind: "migrated" | "failed";
25
+ notice: string;
26
+ };
27
+
28
+ export async function loadSettings(): Promise<SettingsLoadResult> {
29
+ const newPath = settingsFilePath();
30
+ const newSettings = await readSettingsFile(newPath);
31
+ if (newSettings.kind !== "missing") {
32
+ return withLegacyIgnoredNotice(newSettings);
33
+ }
34
+
35
+ const legacyPath = legacySettingsFilePath();
36
+ const legacySettings = await readSettingsFile(legacyPath);
37
+ const concurrentlyCreatedSettings = await readSettingsFile(newPath);
38
+ if (concurrentlyCreatedSettings.kind !== "missing") {
39
+ return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
40
+ }
41
+ if (legacySettings.kind === "missing") return { kind: "missing" };
42
+ if (legacySettings.kind === "invalid") return legacySettings;
43
+
44
+ const migration = await migrateLegacySettings(legacyPath, legacySettings.settings);
45
+ if (migration.kind === "failed") {
46
+ const settingsCreatedDuringMigration = await readSettingsFile(newPath);
47
+ if (settingsCreatedDuringMigration.kind !== "missing") {
48
+ return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
49
+ }
50
+ }
51
+
52
+ return { ...legacySettings, notice: migration.notice };
53
+ }
54
+
55
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
56
+ let text: string;
57
+ try {
58
+ text = await readFile(filePath, "utf8");
59
+ } catch (error) {
60
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
61
+ return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
62
+ }
63
+
64
+ try {
65
+ const parsed = JSON.parse(text) as unknown;
66
+ const settings = normalizeChromeDevtoolsSettings(parsed);
67
+ if (settings) return { kind: "loaded", settings };
68
+ return {
69
+ kind: "invalid",
70
+ reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
71
+ };
72
+ } catch (error) {
73
+ return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
74
+ }
75
+ }
76
+
77
+ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
78
+ if (!(await fileExists(legacySettingsFilePath()))) return settings;
79
+ return {
80
+ ...settings,
81
+ notice: `Chrome DevTools legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
82
+ };
83
+ }
84
+
85
+ export async function installSettingsFileExclusively(filePath: string, contents: string) {
86
+ await mkdir(dirname(filePath), { recursive: true });
87
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
88
+ try {
89
+ await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
90
+ await link(tempFile, filePath);
91
+ } finally {
92
+ await rm(tempFile, { force: true }).catch(() => undefined);
93
+ }
94
+ }
95
+
96
+ async function migrateLegacySettings(
97
+ legacyPath: string,
98
+ settings: ChromeDevToolsSettings,
99
+ ): Promise<SettingsMigrationResult> {
100
+ const newPath = settingsFilePath();
101
+ try {
102
+ await installSettingsFileExclusively(
103
+ newPath,
104
+ `${JSON.stringify(settings, null, 2)}\n`,
105
+ );
106
+ } catch (error) {
107
+ return {
108
+ kind: "failed",
109
+ 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}.`,
110
+ };
111
+ }
112
+
113
+ try {
114
+ await rm(legacyPath, { force: true });
115
+ } catch (error) {
116
+ return {
117
+ kind: "migrated",
118
+ 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.`,
119
+ };
120
+ }
121
+
122
+ return {
123
+ kind: "migrated",
124
+ notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE_NAME} is deprecated and will be removed in a future major release.`,
125
+ };
126
+ }
127
+
128
+ async function fileExists(filePath: string) {
129
+ try {
130
+ await access(filePath, constants.F_OK);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ export function normalizeChromeDevtoolsSettings(value: unknown): ChromeDevToolsSettings | undefined {
138
+ if (!value || typeof value !== "object") return undefined;
139
+ const settings = value as { tools?: unknown; updatedAt?: unknown };
140
+ if (typeof settings.updatedAt !== "number") return undefined;
141
+
142
+ if (settings.tools === "enabled") {
143
+ return { tools: [...CHROME_DEVTOOLS_TOOL_NAMES], updatedAt: settings.updatedAt };
144
+ }
145
+ if (settings.tools === "disabled") return { tools: [], updatedAt: settings.updatedAt };
146
+
147
+ if (!Array.isArray(settings.tools)) return undefined;
148
+ if (!settings.tools.every(isChromeDevtoolsToolName)) return undefined;
149
+ return { tools: orderedUniqueChromeDevtoolsTools(settings.tools), updatedAt: settings.updatedAt };
150
+ }
151
+
152
+ function isChromeDevtoolsToolName(value: unknown): value is ChromeDevToolsToolName {
153
+ return typeof value === "string" && CHROME_DEVTOOLS_TOOL_NAMES.includes(value as never);
154
+ }
155
+
156
+ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName[]) {
157
+ const selectedTools = new Set(tools);
158
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
159
+ }
160
+
161
+ export async function saveSettings(settings: ChromeDevToolsSettings) {
162
+ const filePath = settingsFilePath();
163
+ await mkdir(dirname(filePath), { recursive: true });
164
+ const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
165
+ try {
166
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
167
+ await rename(tempFile, filePath);
168
+ } catch (error) {
169
+ await rm(tempFile, { force: true }).catch(() => undefined);
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ export function settingsFilePath() {
175
+ return join(agentDir(), NEW_SETTINGS_FILE_NAME);
176
+ }
177
+
178
+ function legacySettingsFilePath() {
179
+ return join(agentDir(), LEGACY_SETTINGS_FILE_NAME);
180
+ }
181
+
182
+ function agentDir() {
183
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
184
+ }
185
+
186
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
187
+ return error instanceof Error && "code" in error;
188
+ }
189
+
190
+ function formatError(error: unknown) {
191
+ return error instanceof Error ? error.message : String(error);
192
+ }
193
+
194
+ function unique<T>(values: T[]) {
195
+ return Array.from(new Set(values));
196
+ }
@@ -0,0 +1,9 @@
1
+ export const CHROME_DEVTOOLS_TOOL_NAMES = [
2
+ "chrome_devtools_list_pages",
3
+ "chrome_devtools_select_page",
4
+ "chrome_devtools_navigate",
5
+ "chrome_devtools_evaluate",
6
+ "chrome_devtools_screenshot",
7
+ ] as const;
8
+
9
+ export type ChromeDevToolsToolName = (typeof CHROME_DEVTOOLS_TOOL_NAMES)[number];