@narumitw/pi-chrome-devtools 0.28.0 → 0.35.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/README.md CHANGED
@@ -169,11 +169,14 @@ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json
169
169
 
170
170
  When the file is missing or invalid, the extension preserves Pi's current active-tool policy
171
171
  instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
172
- `/reload`.
173
-
174
- Compatibility: older versions used `pi-chrome-devtools-settings.json`. During the migration
175
- window, a legacy-only file is automatically migrated to `pi-chrome-devtools.json` with a warning.
176
- If both files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
172
+ `/reload`. A missing file is created by the first successful selection change. Within one Pi process, selection saves run in invocation order, reread the latest valid document, and preserve unknown fields. Malformed JSON
173
+ or invalid recognized fields block the save without replacement; a failed save restores the prior
174
+ Chrome DevTools tool selection while preserving other extensions' current tools.
175
+
176
+ Compatibility: older versions used `pi-chrome-devtools-settings.json`. A legacy-only file remains
177
+ readable with a warning and is never modified automatically; rename it to
178
+ `pi-chrome-devtools.json`. The first subsequent settings save writes the canonical file. If both
179
+ files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
177
180
  filename is deprecated and will be removed in a future major release.
178
181
 
179
182
  ## 🧠 Use cases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.28.0",
3
+ "version": "0.35.0",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,11 +29,11 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "typebox": "^1.3.3"
32
+ "typebox": "^1.3.8"
33
33
  },
34
34
  "devDependencies": {
35
- "@biomejs/biome": "2.5.3",
36
- "@earendil-works/pi-coding-agent": "0.80.10",
35
+ "@biomejs/biome": "2.5.5",
36
+ "@earendil-works/pi-coding-agent": "0.82.1",
37
37
  "typescript": "7.0.2"
38
38
  },
39
39
  "repository": {
@@ -1,21 +1,20 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { access, mkdtemp, readFile, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, delimiter, isAbsolute, join, resolve } from "node:path";
5
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
6
6
  import {
7
7
  BROWSER_SHUTDOWN_WAIT_MS,
8
+ type BrowserCandidate,
9
+ type BrowserCandidateDefinition,
8
10
  DEFAULT_ENDPOINT_RETRY_MS,
9
11
  DEFAULT_ENDPOINT_WAIT_MS,
10
- DEFAULT_HOST,
11
12
  DEFAULT_HTTP_TIMEOUT_MS,
12
13
  DEVTOOLS_ACTIVE_PORT_FILE,
13
- MANAGED_BROWSER_PROFILE_PREFIX,
14
- state,
15
- type BrowserCandidate,
16
- type BrowserCandidateDefinition,
17
14
  type DevToolsPage,
15
+ MANAGED_BROWSER_PROFILE_PREFIX,
18
16
  type ManagedBrowser,
17
+ state,
19
18
  } from "./runtime.js";
20
19
 
21
20
  function formatError(error: unknown) {
@@ -128,7 +127,12 @@ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: numbe
128
127
  ];
129
128
  throwIfBrowserLaunchCancelled();
130
129
  const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
131
- const launchedBrowser: ManagedBrowser = { process: child, userDataDir, exited: false, ready: false };
130
+ const launchedBrowser: ManagedBrowser = {
131
+ process: child,
132
+ userDataDir,
133
+ exited: false,
134
+ ready: false,
135
+ };
132
136
  managedBrowser = launchedBrowser;
133
137
  state.managedBrowser = launchedBrowser;
134
138
 
@@ -259,7 +263,8 @@ export async function shutdownManagedBrowser(
259
263
  });
260
264
  }
261
265
  await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
262
- if (!state.portConfigured && managedBrowser.port === state.port) state.port = state.configuredPort;
266
+ if (!state.portConfigured && managedBrowser.port === state.port)
267
+ state.port = state.configuredPort;
263
268
  }
264
269
 
265
270
  function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
@@ -400,9 +405,13 @@ export function launchModeLabel() {
400
405
  if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
401
406
  if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
402
407
  if (state.managedBrowser && !state.managedBrowser.exited) {
403
- return state.portConfigured ? "auto-launched on explicit port" : "auto-launched on dynamic port";
408
+ return state.portConfigured
409
+ ? "auto-launched on explicit port"
410
+ : "auto-launched on dynamic port";
404
411
  }
405
- return state.portConfigured ? "attach first; auto-launch explicit port" : "attach first; auto-launch dynamic port";
412
+ return state.portConfigured
413
+ ? "attach first; auto-launch explicit port"
414
+ : "attach first; auto-launch dynamic port";
406
415
  }
407
416
 
408
417
  export function launchAttemptLines() {
@@ -435,7 +444,9 @@ export function launchHint() {
435
444
  }
436
445
 
437
446
  export function browserCandidateHint() {
438
- return `Browser candidates: ${browserCandidateDefinitions().map((candidate) => candidate.label).join(", ")}`;
447
+ return `Browser candidates: ${browserCandidateDefinitions()
448
+ .map((candidate) => candidate.label)
449
+ .join(", ")}`;
439
450
  }
440
451
 
441
452
  export function chromeLaunchCommand() {
@@ -475,7 +486,9 @@ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
475
486
 
476
487
  function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
477
488
  if (!state.browserExecutable) return [];
478
- return [{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" }];
489
+ return [
490
+ { label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" },
491
+ ];
479
492
  }
480
493
 
481
494
  function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
package/src/cdp-client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
- ensureDevToolsEndpoint,
3
2
  devToolsEndpoint,
3
+ ensureDevToolsEndpoint,
4
4
  fetchDevToolsJson,
5
5
  formatPageListItem,
6
6
  launchHint,
@@ -9,8 +9,8 @@ import {
9
9
  import {
10
10
  DEFAULT_ENDPOINT_WAIT_MS,
11
11
  DEFAULT_TIMEOUT_MS,
12
- state,
13
12
  type DevToolsPage,
13
+ state,
14
14
  } from "./runtime.js";
15
15
 
16
16
  interface CdpResponse<T = unknown> {
@@ -1,16 +1,16 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { shutdownManagedBrowser } from "./browser-manager.js";
3
- import { loadSettings } from "./settings.js";
4
3
  import { state } from "./runtime.js";
4
+ import { loadSettings } from "./settings.js";
5
5
  import {
6
6
  allChromeDevtoolsTools,
7
7
  applyChromeDevtoolsTools,
8
8
  buildCommandGuide,
9
9
  buildQuickstartMessage,
10
10
  buildToolStatusMessage,
11
- setSelectedChromeDevtoolsTools,
12
11
  showToolSelector,
13
12
  updateChromeDevtoolsTools,
13
+ waitForChromeDevtoolsSettings,
14
14
  } from "./tool-selector.js";
15
15
  import {
16
16
  evaluateTool,
@@ -52,15 +52,18 @@ export default function chromeDevtools(pi: ExtensionAPI) {
52
52
  description: "Open Chrome DevTools help and tool controls",
53
53
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
54
54
  handler: async (args, ctx) => {
55
- await handleChromeDevtoolsCommand(pi, args, ctx);
55
+ const generation = state.sessionGeneration;
56
+ await handleChromeDevtoolsCommand(pi, args, ctx, generation);
56
57
  },
57
58
  });
58
59
 
59
60
  pi.on("session_start", async (_event, ctx) => {
61
+ const generation = ++state.sessionGeneration;
60
62
  state.shuttingDown = false;
61
63
  state.settingsNotice = undefined;
62
64
  ctx.ui.setStatus(STATUS_KEY, undefined);
63
65
  const settings = await loadSettings();
66
+ if (generation !== state.sessionGeneration) return;
64
67
  state.settingsNotice = settings.notice;
65
68
  if (settings.notice) ctx.ui.notify(settings.notice, "warning");
66
69
  if (settings.kind === "loaded") {
@@ -73,16 +76,24 @@ export default function chromeDevtools(pi: ExtensionAPI) {
73
76
  });
74
77
 
75
78
  pi.on("session_shutdown", async (_event, ctx) => {
79
+ state.sessionGeneration += 1;
76
80
  ctx.ui.setStatus(STATUS_KEY, undefined);
77
- await shutdownManagedBrowser(undefined, { cancelLaunch: true });
81
+ const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
82
+ await waitForChromeDevtoolsSettings();
83
+ await browserShutdown;
78
84
  });
79
85
  }
80
86
 
81
- async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx: CommandContext) {
87
+ async function handleChromeDevtoolsCommand(
88
+ pi: ExtensionAPI,
89
+ args: string,
90
+ ctx: CommandContext,
91
+ generation: number,
92
+ ) {
82
93
  const command = parseCommand(args);
83
94
  switch (command) {
84
95
  case "menu":
85
- await showMenu(pi, ctx);
96
+ await showMenu(pi, ctx, generation);
86
97
  return;
87
98
  case "help":
88
99
  ctx.ui.notify(buildCommandGuide(), "info");
@@ -90,9 +101,12 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
90
101
  case "quickstart":
91
102
  ctx.ui.notify(buildQuickstartMessage(), "info");
92
103
  return;
93
- case "status":
94
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
104
+ case "status": {
105
+ const status = await buildToolStatusMessage(pi);
106
+ if (generation !== state.sessionGeneration) return;
107
+ ctx.ui.notify(status, "info");
95
108
  return;
109
+ }
96
110
  case "tools":
97
111
  await showToolSelector(pi, ctx);
98
112
  return;
@@ -104,20 +118,24 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
104
118
  return;
105
119
  }
106
120
 
107
- ctx.ui.notify(`Unknown /chrome-devtools command: ${args.trim()}
121
+ ctx.ui.notify(
122
+ `Unknown /chrome-devtools command: ${args.trim()}
108
123
 
109
- ${buildCommandGuide()}`, "warning");
124
+ ${buildCommandGuide()}`,
125
+ "warning",
126
+ );
110
127
  }
111
128
 
112
- async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
129
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
113
130
  if (!ctx.hasUI) {
114
- ctx.ui.notify(`${buildCommandGuide()}
115
-
116
- ${await buildToolStatusMessage(pi)}`, "info");
131
+ const status = await buildToolStatusMessage(pi);
132
+ if (generation !== state.sessionGeneration) return;
133
+ ctx.ui.notify(`${buildCommandGuide()}\n\n${status}`, "info");
117
134
  return;
118
135
  }
119
136
 
120
137
  const choice = await ctx.ui.select("Chrome DevTools", Object.values(MENU_OPTIONS));
138
+ if (generation !== state.sessionGeneration) return;
121
139
  switch (choice) {
122
140
  case MENU_OPTIONS.quickstart:
123
141
  ctx.ui.notify(buildQuickstartMessage(), "info");
@@ -125,9 +143,12 @@ ${await buildToolStatusMessage(pi)}`, "info");
125
143
  case MENU_OPTIONS.help:
126
144
  ctx.ui.notify(buildCommandGuide(), "info");
127
145
  return;
128
- case MENU_OPTIONS.status:
129
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
146
+ case MENU_OPTIONS.status: {
147
+ const status = await buildToolStatusMessage(pi);
148
+ if (generation !== state.sessionGeneration) return;
149
+ ctx.ui.notify(status, "info");
130
150
  return;
151
+ }
131
152
  case MENU_OPTIONS.tools:
132
153
  await showToolSelector(pi, ctx);
133
154
  return;
@@ -174,5 +195,5 @@ export {
174
195
  resolveScreenshotPath,
175
196
  selectAllowedRoot,
176
197
  } from "./screenshot.js";
177
- export { installSettingsFileExclusively, normalizeChromeDevtoolsSettings } from "./settings.js";
198
+ export { normalizeChromeDevtoolsSettings } from "./settings.js";
178
199
  export { orderedChromeDevtoolsTools } from "./tool-selector.js";
package/src/render.ts CHANGED
@@ -1,7 +1,4 @@
1
- import type {
2
- AgentToolResult,
3
- ToolRenderResultOptions,
4
- } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
5
2
 
6
3
  const STATUS_KEY = "chrome-devtools";
7
4
  interface StatusContext {
@@ -90,9 +87,7 @@ class PiTextComponent implements RenderComponent {
90
87
  .split(/\r?\n/)
91
88
  .map((line) => {
92
89
  const truncatedLine = truncateLine(line, Math.max(1, width));
93
- return this.theme && this.color
94
- ? this.theme.fg(this.color, truncatedLine)
95
- : truncatedLine;
90
+ return this.theme && this.color ? this.theme.fg(this.color, truncatedLine) : truncatedLine;
96
91
  });
97
92
  }
98
93
  }
@@ -101,7 +96,11 @@ function truncateLine(line: string, maxWidth: number) {
101
96
  return Array.from(line).slice(0, maxWidth).join("");
102
97
  }
103
98
 
104
- export async function withStatus<T>(ctx: StatusContext, status: string, callback: () => Promise<T>) {
99
+ export async function withStatus<T>(
100
+ ctx: StatusContext,
101
+ status: string,
102
+ callback: () => Promise<T>,
103
+ ) {
105
104
  ctx.ui.setStatus(STATUS_KEY, status);
106
105
  try {
107
106
  return await callback();
package/src/runtime.ts CHANGED
@@ -31,6 +31,7 @@ export interface ChromeDevToolsState {
31
31
  launchPromise?: Promise<void>;
32
32
  lastLaunchAttempt?: BrowserLaunchAttempt;
33
33
  shuttingDown: boolean;
34
+ sessionGeneration: number;
34
35
  settingsNotice?: string;
35
36
  }
36
37
 
@@ -82,4 +83,5 @@ export const state: ChromeDevToolsState = {
82
83
  autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
83
84
  browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
84
85
  shuttingDown: false,
86
+ sessionGeneration: 0,
85
87
  };
package/src/screenshot.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { link, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
@@ -47,7 +47,10 @@ export async function saveScreenshot(
47
47
  };
48
48
  }
49
49
 
50
- export function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
50
+ export function resolveScreenshotPath(
51
+ savePath: string | undefined,
52
+ cwd: string,
53
+ ): ResolvedScreenshotPath {
51
54
  const cwdRoot = resolve(cwd);
52
55
  const tempRoot = resolve(tmpdir());
53
56
 
@@ -228,7 +231,9 @@ export function isPathInsideRoot(path: string, root: string) {
228
231
  const normalizedPath = normalizePathForComparison(resolve(path));
229
232
  const normalizedRoot = normalizePathForComparison(resolve(root));
230
233
  if (normalizedPath === normalizedRoot) return true;
231
- const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
234
+ const rootWithSeparator = normalizedRoot.endsWith(sep)
235
+ ? normalizedRoot
236
+ : `${normalizedRoot}${sep}`;
232
237
  return normalizedPath.startsWith(rootWithSeparator);
233
238
  }
234
239
 
package/src/settings.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
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";
3
+ import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
4
  import { dirname, join } from "node:path";
6
- import {
7
- CHROME_DEVTOOLS_TOOL_NAMES,
8
- type ChromeDevToolsToolName,
9
- } from "./tool-names.js";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
10
7
 
11
8
  const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
12
9
  const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
@@ -15,17 +12,23 @@ export interface ChromeDevToolsSettings {
15
12
  updatedAt: number;
16
13
  }
17
14
 
15
+ export interface SettingsFileOperations {
16
+ write(path: string, data: string): Promise<void>;
17
+ rename(source: string, destination: string): Promise<void>;
18
+ }
19
+
20
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
21
+ write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
22
+ rename,
23
+ };
24
+
18
25
  export type SettingsLoadResult =
19
26
  | { kind: "missing"; notice?: string }
20
27
  | { kind: "invalid"; reason: string; notice?: string }
21
28
  | { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
22
29
 
23
- type SettingsMigrationResult = {
24
- kind: "migrated" | "failed";
25
- notice: string;
26
- };
27
-
28
30
  export async function loadSettings(): Promise<SettingsLoadResult> {
31
+ await settingsSaveQueue;
29
32
  const newPath = settingsFilePath();
30
33
  const newSettings = await readSettingsFile(newPath);
31
34
  if (newSettings.kind !== "missing") {
@@ -41,39 +44,50 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
41
44
  if (legacySettings.kind === "missing") return { kind: "missing" };
42
45
  if (legacySettings.kind === "invalid") return legacySettings;
43
46
 
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
- }
47
+ return {
48
+ ...legacySettings,
49
+ notice: `Using legacy ${LEGACY_SETTINGS_FILE_NAME}; rename it to ${NEW_SETTINGS_FILE_NAME}. Future saves write ${NEW_SETTINGS_FILE_NAME} without modifying the legacy file.`,
50
+ };
51
+ }
51
52
 
52
- return { ...legacySettings, notice: migration.notice };
53
+ interface SettingsDocumentResult {
54
+ result: SettingsLoadResult;
55
+ document?: Record<string, unknown>;
53
56
  }
54
57
 
55
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
58
+ async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
56
59
  let text: string;
57
60
  try {
58
61
  text = await readFile(filePath, "utf8");
59
62
  } catch (error) {
60
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
61
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
63
+ if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
64
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
62
65
  }
63
66
 
64
67
  try {
65
68
  const parsed = JSON.parse(text) as unknown;
66
69
  const settings = normalizeChromeDevtoolsSettings(parsed);
67
- if (settings) return { kind: "loaded", settings };
70
+ if (settings) {
71
+ return {
72
+ result: { kind: "loaded", settings },
73
+ document: { ...(parsed as Record<string, unknown>) },
74
+ };
75
+ }
68
76
  return {
69
- kind: "invalid",
70
- reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
77
+ result: {
78
+ kind: "invalid",
79
+ reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
80
+ },
71
81
  };
72
82
  } catch (error) {
73
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
83
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
74
84
  }
75
85
  }
76
86
 
87
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
88
+ return (await readSettingsDocument(filePath)).result;
89
+ }
90
+
77
91
  async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
78
92
  if (!(await fileExists(legacySettingsFilePath()))) return settings;
79
93
  return {
@@ -82,49 +96,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
82
96
  };
83
97
  }
84
98
 
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
99
  async function fileExists(filePath: string) {
129
100
  try {
130
101
  await access(filePath, constants.F_OK);
@@ -134,7 +105,19 @@ async function fileExists(filePath: string) {
134
105
  }
135
106
  }
136
107
 
137
- export function normalizeChromeDevtoolsSettings(value: unknown): ChromeDevToolsSettings | undefined {
108
+ async function pathEntryExists(filePath: string) {
109
+ try {
110
+ await lstat(filePath);
111
+ return true;
112
+ } catch (error) {
113
+ if (isNodeError(error) && error.code === "ENOENT") return false;
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ export function normalizeChromeDevtoolsSettings(
119
+ value: unknown,
120
+ ): ChromeDevToolsSettings | undefined {
138
121
  if (!value || typeof value !== "object") return undefined;
139
122
  const settings = value as { tools?: unknown; updatedAt?: unknown };
140
123
  if (typeof settings.updatedAt !== "number") return undefined;
@@ -158,13 +141,48 @@ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName
158
141
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
159
142
  }
160
143
 
161
- export async function saveSettings(settings: ChromeDevToolsSettings) {
144
+ let settingsSaveQueue = Promise.resolve();
145
+
146
+ export function saveSettings(
147
+ settings: ChromeDevToolsSettings,
148
+ operations: Partial<SettingsFileOperations> = {},
149
+ ): Promise<void> {
150
+ const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
151
+ settingsSaveQueue = operation.catch(() => undefined);
152
+ return operation;
153
+ }
154
+
155
+ async function saveSettingsNow(
156
+ settings: ChromeDevToolsSettings,
157
+ operations: Partial<SettingsFileOperations>,
158
+ ): Promise<void> {
162
159
  const filePath = settingsFilePath();
160
+ let current = await readSettingsDocument(filePath);
161
+ const replaceCanonical = current.result.kind !== "missing";
162
+ if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
163
+ if (current.result.kind === "invalid") {
164
+ throw new Error(
165
+ `Cannot save Chrome DevTools settings until you repair ${current.result.reason}`,
166
+ );
167
+ }
168
+ const nextDocument = {
169
+ ...(current.document ?? {}),
170
+ tools: [...settings.tools],
171
+ updatedAt: settings.updatedAt,
172
+ };
163
173
  await mkdir(dirname(filePath), { recursive: true });
164
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
174
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
165
175
  try {
166
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
167
- await rename(tempFile, filePath);
176
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
177
+ tempFile,
178
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
179
+ );
180
+ if (!replaceCanonical && (await pathEntryExists(filePath))) {
181
+ throw new Error(
182
+ `${NEW_SETTINGS_FILE_NAME} was created concurrently; reopen settings and retry.`,
183
+ );
184
+ }
185
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
168
186
  } catch (error) {
169
187
  await rm(tempFile, { force: true }).catch(() => undefined);
170
188
  throw error;
@@ -180,7 +198,7 @@ function legacySettingsFilePath() {
180
198
  }
181
199
 
182
200
  function agentDir() {
183
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
201
+ return getAgentDir();
184
202
  }
185
203
 
186
204
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -190,7 +208,3 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
190
208
  function formatError(error: unknown) {
191
209
  return error instanceof Error ? error.message : String(error);
192
210
  }
193
-
194
- function unique<T>(values: T[]) {
195
- return Array.from(new Set(values));
196
- }
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  browserCandidateHint,
4
- chromeLaunchCommand,
5
4
  devToolsEndpoint,
6
5
  endpointConfigHint,
7
6
  endpointSourceLabel,
@@ -9,12 +8,9 @@ import {
9
8
  launchHint,
10
9
  launchModeLabel,
11
10
  } from "./browser-manager.js";
12
- import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
13
11
  import { state } from "./runtime.js";
14
- import {
15
- CHROME_DEVTOOLS_TOOL_NAMES,
16
- type ChromeDevToolsToolName,
17
- } from "./tool-names.js";
12
+ import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
13
+ import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
18
14
 
19
15
  type CommandContext = ExtensionCommandContext;
20
16
  const TOOL_SELECTOR_DONE = "Done";
@@ -43,26 +39,32 @@ interface ToolStatusSummary {
43
39
  }
44
40
 
45
41
  export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
46
- if (!ctx.hasUI) {
47
- ctx.ui.notify(
48
- `Chrome DevTools tool selection needs an interactive UI.
49
-
50
- ${await buildToolStatusMessage(pi)}`,
51
- "info",
52
- );
42
+ const generation = state.sessionGeneration;
43
+ if (!ctx.hasUI) return;
44
+ if (ctx.mode !== "tui") {
45
+ await showDialogToolSelector(pi, ctx, generation);
53
46
  return;
54
47
  }
55
48
 
56
49
  let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
57
50
  let persistQueue = Promise.resolve();
51
+ let requestedRevision = 0;
52
+ let requestRender: () => void = () => undefined;
58
53
  const commitSelectedTools = () => {
59
54
  const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
60
- applyChromeDevtoolsTools(pi, nextSelectedTools);
61
- persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
55
+ const revision = ++requestedRevision;
56
+ persistQueue = persistQueue.then(async () => {
57
+ const saved = await transactSelectedTools(pi, ctx, nextSelectedTools, generation);
58
+ if (!saved && generation === state.sessionGeneration && revision === requestedRevision) {
59
+ selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
60
+ requestRender();
61
+ }
62
+ });
62
63
  };
63
64
 
64
65
  const customResult = await ctx.ui.custom<"closed" | undefined>(
65
66
  (tui, theme, keybindings, done) => {
67
+ requestRender = () => tui.requestRender();
66
68
  const rows = chromeDevtoolsToolSelectorRows();
67
69
  let selectedIndex = 0;
68
70
  const moveSelection = (delta: number) => {
@@ -144,22 +146,26 @@ ${await buildToolStatusMessage(pi)}`,
144
146
  },
145
147
  );
146
148
 
149
+ if (generation !== state.sessionGeneration) return;
147
150
  if (customResult !== "closed") {
148
- await showDialogToolSelector(pi, ctx);
151
+ await showDialogToolSelector(pi, ctx, generation);
149
152
  return;
150
153
  }
151
154
 
152
155
  await persistQueue;
153
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
156
+ if (generation !== state.sessionGeneration) return;
157
+ const status = await buildToolStatusMessage(pi);
158
+ if (generation !== state.sessionGeneration) return;
159
+ ctx.ui.notify(status, "info");
154
160
  }
155
161
 
156
- async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
162
+ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
157
163
  let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
158
164
  while (true) {
159
165
  const rows = chromeDevtoolsToolSelectorRows();
160
166
  const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
161
167
  const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
162
- if (!choice) break;
168
+ if (generation !== state.sessionGeneration || !choice) break;
163
169
 
164
170
  const row = rows[choices.indexOf(choice)];
165
171
  if (!row) continue;
@@ -174,10 +180,20 @@ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
174
180
  selectedTools = new Set();
175
181
  }
176
182
 
177
- await setSelectedChromeDevtoolsTools(pi, ctx, orderedChromeDevtoolsTools(selectedTools));
183
+ const saved = await transactSelectedTools(
184
+ pi,
185
+ ctx,
186
+ orderedChromeDevtoolsTools(selectedTools),
187
+ generation,
188
+ );
189
+ if (generation !== state.sessionGeneration) return;
190
+ if (!saved) selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
178
191
  }
179
192
 
180
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
193
+ if (generation !== state.sessionGeneration) return;
194
+ const status = await buildToolStatusMessage(pi);
195
+ if (generation !== state.sessionGeneration) return;
196
+ ctx.ui.notify(status, "info");
181
197
  }
182
198
 
183
199
  export async function updateChromeDevtoolsTools(
@@ -186,19 +202,75 @@ export async function updateChromeDevtoolsTools(
186
202
  selectedTools: readonly ChromeDevToolsToolName[],
187
203
  action: string,
188
204
  ) {
189
- await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools);
190
- ctx.ui.notify(`Chrome DevTools tools ${action}.
191
-
192
- ${await buildToolStatusMessage(pi)}`, "info");
205
+ const generation = state.sessionGeneration;
206
+ const saved = await transactSelectedTools(pi, ctx, selectedTools, generation);
207
+ if (!saved || generation !== state.sessionGeneration) return;
208
+ const status = await buildToolStatusMessage(pi);
209
+ if (generation !== state.sessionGeneration) return;
210
+ ctx.ui.notify(`Chrome DevTools tools ${action}.\n\n${status}`, "info");
193
211
  }
194
212
 
195
213
  export async function setSelectedChromeDevtoolsTools(
196
214
  pi: ExtensionAPI,
197
215
  ctx: CommandContext,
198
216
  selectedTools: readonly ChromeDevToolsToolName[],
199
- ) {
200
- applyChromeDevtoolsTools(pi, selectedTools);
201
- await persistSettings(ctx, selectedTools);
217
+ ): Promise<boolean> {
218
+ return transactSelectedTools(pi, ctx, selectedTools, state.sessionGeneration);
219
+ }
220
+
221
+ let toolTransactionQueue = Promise.resolve();
222
+
223
+ export async function waitForChromeDevtoolsSettings(): Promise<void> {
224
+ await toolTransactionQueue;
225
+ }
226
+
227
+ function transactSelectedTools(
228
+ pi: ExtensionAPI,
229
+ ctx: CommandContext,
230
+ selectedTools: readonly ChromeDevToolsToolName[],
231
+ expectedGeneration: number,
232
+ ): Promise<boolean> {
233
+ const operation = toolTransactionQueue.then(() =>
234
+ transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration),
235
+ );
236
+ toolTransactionQueue = operation.then(
237
+ () => undefined,
238
+ () => undefined,
239
+ );
240
+ return operation;
241
+ }
242
+
243
+ async function transactSelectedToolsNow(
244
+ pi: ExtensionAPI,
245
+ ctx: CommandContext,
246
+ selectedTools: readonly ChromeDevToolsToolName[],
247
+ expectedGeneration: number,
248
+ ): Promise<boolean> {
249
+ if (expectedGeneration !== state.sessionGeneration) return false;
250
+ const previousActiveTools = pi.getActiveTools();
251
+ try {
252
+ applyChromeDevtoolsTools(pi, selectedTools);
253
+ await persistSettings(selectedTools);
254
+ return expectedGeneration === state.sessionGeneration;
255
+ } catch (error) {
256
+ let rollbackError: unknown;
257
+ try {
258
+ const previousChromeTools = previousActiveTools.filter((name) =>
259
+ CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName),
260
+ ) as ChromeDevToolsToolName[];
261
+ applyChromeDevtoolsTools(pi, previousChromeTools);
262
+ } catch (caught) {
263
+ rollbackError = caught;
264
+ }
265
+ if (expectedGeneration !== state.sessionGeneration) return false;
266
+ ctx.ui.notify(
267
+ rollbackError
268
+ ? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
269
+ : `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
270
+ "warning",
271
+ );
272
+ return false;
273
+ }
202
274
  }
203
275
 
204
276
  export function applyChromeDevtoolsTools(
@@ -328,13 +400,6 @@ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
328
400
  return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
329
401
  }
330
402
 
331
- async function persistSettings(
332
- ctx: CommandContext,
333
- selectedTools: readonly ChromeDevToolsToolName[],
334
- ) {
335
- try {
336
- await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
337
- } catch (error) {
338
- ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
339
- }
403
+ async function persistSettings(selectedTools: readonly ChromeDevToolsToolName[]) {
404
+ await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
340
405
  }