@narumitw/pi-chrome-devtools 0.50.1 → 0.52.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
@@ -26,10 +26,12 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
26
26
  explicit endpoint overrides.
27
27
  - Retries briefly while Chrome is starting and reports actionable endpoint errors.
28
28
  - Shows statusline activity only while Chrome DevTools tools are running.
29
- - Provides a state-first `/chrome-devtools` menu for tool access, browser status, setup, and help.
30
- - Stages menu-based tool changes for exact review before one confirmed apply.
29
+ - Keeps one loader tool active and exposes matching browser capabilities only when the agent needs them.
30
+ - Provides a state-first `/chrome-devtools` menu for tool availability, editable browser settings,
31
+ browser status, setup, and help.
32
+ - Stages menu-based availability changes for exact review before one confirmed apply.
31
33
  - Uses `@narumitw/pi-tui-kit` for width-safe TUI menus and equivalent RPC dialogs.
32
- - Persists the selected Chrome DevTools tools across Pi restarts.
34
+ - Persists the Chrome DevTools lazy-load catalog across Pi restarts.
33
35
 
34
36
  ## 📦 Install
35
37
 
@@ -51,15 +53,33 @@ pi -e ./packages/pi-chrome-devtools
51
53
 
52
54
  ## 🚀 Browser startup
53
55
 
54
- Without unpacked extensions, the extension first tries the configured endpoint, defaulting to
55
- `127.0.0.1:9222`. If that local endpoint is unavailable, it lazily launches an extension-owned
56
- Chromium-family browser with an isolated temp profile and retries the CDP request. Existing endpoints
57
- are reused and are never terminated by the extension.
56
+ Without unpacked extensions, the extension first tries `browser.endpoint`, defaulting to
57
+ `http://127.0.0.1:9222`. If that local endpoint is unavailable and `browser.autoLaunch` is `true`, it
58
+ lazily launches an extension-owned Chromium-family browser with an isolated temp profile and retries
59
+ the CDP request. Existing endpoints are reused and are never terminated by the extension.
58
60
 
59
- When `PI_CHROME_DEVTOOLS_PORT` is not set, auto-launch uses Chrome's dynamic DevTools port mode
60
- (`--remote-debugging-port=0`) and reads `DevToolsActivePort` from the temp profile. If you set a valid
61
- `PI_CHROME_DEVTOOLS_PORT` (`1`-`65535`), the extension uses that explicit port. Empty or invalid values
62
- fall back to the default attach-first behavior.
61
+ Configure the canonical user file at
62
+ `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json`:
63
+
64
+ ```json
65
+ {
66
+ "browser": {
67
+ "endpoint": "http://127.0.0.1:9222",
68
+ "autoLaunch": true,
69
+ "executablePath": "/absolute/path/to/chromium"
70
+ }
71
+ }
72
+ ```
73
+
74
+ `browser.endpoint` must be an HTTP origin with an explicit port and no credentials, path, query, or
75
+ fragment. Omitting it keeps attach-first behavior on `127.0.0.1:9222` and lets a managed launch use
76
+ Chrome's dynamic DevTools port mode (`--remote-debugging-port=0`). Explicitly saving an endpoint pins
77
+ managed launches to that port. `browser.autoLaunch` defaults to `true`. `browser.executablePath` is
78
+ optional and must be an absolute path; when absent, normal browser discovery applies.
79
+
80
+ The configured endpoint must expose the standard CDP HTTP discovery routes such as `/json/version`
81
+ and `/json/list`. Chrome's newer built-in permission flow can listen on port `9222` while returning
82
+ `404` from those routes; setting the same HTTP origin does not by itself make that flow compatible.
63
83
 
64
84
  ### Unpacked extensions
65
85
 
@@ -67,8 +87,7 @@ fall back to the default attach-first behavior.
67
87
  > An unpacked extension executes privileged browser code. Load only code you trust. Project settings
68
88
  > are honored only when Pi reports the project as trusted.
69
89
 
70
- Configure the canonical user file at
71
- `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json`:
90
+ Add trusted unpacked-extension paths to the same canonical user file:
72
91
 
73
92
  ```json
74
93
  {
@@ -100,9 +119,10 @@ A trusted project can replace the user extension list in
100
119
  ```
101
120
 
102
121
  Project `extensionPaths` replace, rather than append to, the user array. A project file cannot
103
- override `browser.executablePath`; browser selection remains machine-owned user configuration.
104
- Effective precedence is defaults, user settings, trusted project override, then existing explicit
105
- runtime environment overrides. No new environment variable is required.
122
+ override `browser.endpoint`, `browser.autoLaunch`, or `browser.executablePath`; browser connection
123
+ settings remain machine-owned user configuration. Effective precedence is defaults, user settings,
124
+ trusted project extension paths, then deprecated environment overrides. No new environment variable
125
+ is required.
106
126
 
107
127
  When `extensionPaths` is non-empty, the extension skips attach-first behavior and starts an isolated,
108
128
  extension-owned managed browser with `--disable-extensions-except` and `--load-extension`. It fails
@@ -115,24 +135,17 @@ old managed browser is closed before the new configuration is applied. Missing f
115
135
  existing no-extension behavior. Invalid JSON, invalid browser values, and missing manifests are left
116
136
  unchanged and ignored with an actionable warning.
117
137
 
118
- ### Environment overrides and manual endpoints
119
-
120
- `PI_CHROME_DEVTOOLS_BROWSER` remains the explicit runtime executable override. Without unpacked
121
- extensions, browser discovery still checks platform-specific Chrome, Chromium, Brave, and Microsoft
122
- Edge candidates. Disable auto-launch to keep the manual flow:
138
+ ### Deprecated environment overrides and manual endpoints
123
139
 
124
- ```bash
125
- PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 pi -e ./packages/pi-chrome-devtools
126
- ```
140
+ The existing `PI_CHROME_DEVTOOLS_HOST`, `PI_CHROME_DEVTOOLS_PORT`,
141
+ `PI_CHROME_DEVTOOLS_AUTO_LAUNCH`, and `PI_CHROME_DEVTOOLS_BROWSER` variables remain temporary
142
+ compatibility overrides. They still take precedence over JSON, but every session that sees one emits
143
+ a deprecation warning. Move their values to `browser.endpoint`, `browser.autoLaunch`, and
144
+ `browser.executablePath`; the variables will be removed in a future version.
127
145
 
128
- Force an executable or endpoint if needed:
129
-
130
- ```bash
131
- PI_CHROME_DEVTOOLS_BROWSER=/usr/bin/chromium pi -e ./packages/pi-chrome-devtools
132
- PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./packages/pi-chrome-devtools
133
- ```
134
-
135
- Manual launch remains available when no unpacked extensions are configured:
146
+ Without unpacked extensions, browser discovery still checks platform-specific Chrome, Chromium,
147
+ Brave, and Microsoft Edge candidates. Manual launch remains available when no unpacked extensions are
148
+ configured:
136
149
 
137
150
  ```bash
138
151
  google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pi-chrome-devtools
@@ -143,12 +156,31 @@ removes their temporary profiles. It never closes user-started browsers or remot
143
156
 
144
157
  ## 🛠️ Pi tools
145
158
 
159
+ - `chrome_devtools_load` — find and load browser capabilities relevant to a task.
146
160
  - `chrome_devtools_list_pages` — list inspectable Chrome tabs/pages.
147
161
  - `chrome_devtools_select_page` — select the active page for later tool calls.
148
162
  - `chrome_devtools_navigate` — navigate a page to a URL; if no page exists, create one first.
149
163
  - `chrome_devtools_evaluate` — evaluate JavaScript in the selected page.
150
164
  - `chrome_devtools_screenshot` — capture a PNG screenshot and save it as a PNG file.
151
165
 
166
+ ### Lazy tool loading
167
+
168
+ All six tools are registered, but only `chrome_devtools_load` starts active for this extension.
169
+
170
+ The loader accepts a task-oriented `query`, matches it against the five capability tools, and adds matching available tools without removing any active Pi tool.
171
+
172
+ Loaded capability tools remain active for the rest of the session unless the user makes them unavailable through `/chrome-devtools`.
173
+
174
+ Pi uses native deferred tool references on compatible Anthropic and OpenAI models.
175
+
176
+ Other models receive Pi's safe fallback: the newly active definitions appear in the normal tool list on the next model request.
177
+
178
+ The capability tools omit active-only prompt snippets so a lazy load does not rebuild the system prompt prefix.
179
+
180
+ The saved `tools` array controls which capabilities the loader may expose.
181
+
182
+ An empty array leaves the loader active but makes every browser capability unavailable.
183
+
152
184
  ### Screenshot files
153
185
 
154
186
  `chrome_devtools_screenshot` always saves the captured PNG to disk. If `savePath` is omitted,
@@ -182,17 +214,19 @@ to read the saved path, for example `read({ path: "artifacts/homepage.png" })`.
182
214
  /chrome-devtools
183
215
  ```
184
216
 
185
- Opens a menu that shows the current browser-tool count, whether that selection is saved, the
186
- configured endpoint, the observed managed-browser state, and any settings or launch warning before
187
- you choose an action. The five actions stay on one level:
217
+ Opens a menu that shows the lazy catalog size, whether that catalog is saved, the configured
218
+ endpoint, the observed managed-browser state, and any settings or launch warning before you choose
219
+ an action. The five actions stay on one level:
188
220
 
189
- - **Choose browser tools…** — stage any combination of the five capabilities, then review the exact
190
- enabled/disabled result before selecting **Apply tool changes**.
191
- - **Enable all browser tools…** or **Disable all browser tools…** — preview the context-appropriate
192
- bulk change before applying it.
221
+ - **Choose available browser tools…** — stage any combination of the five capabilities, then review
222
+ the exact available/unavailable result before selecting **Apply tool changes**.
223
+ - **Make all browser tools available…** or **Make all browser tools unavailable…** — preview the
224
+ context-appropriate bulk change before applying it.
193
225
  - **Browser status** — inspect runtime, endpoint, launch mode, and the last launch attempt without
194
226
  probing the endpoint or starting Chrome.
195
- - **Settings & setup** — inspect effective files, sources, trust, reload steps, and recovery guidance.
227
+ - **Browser settings** — immediately save the endpoint, auto-launch policy, or browser executable;
228
+ inspect unpacked-extension paths and effective sources. A deprecated environment override remains
229
+ effective until removed even when its underlying JSON value changes.
196
230
  - **Help** — view command usage and return to the menu.
197
231
 
198
232
  In the tool screen, **Select all** and **Select none** are unambiguous shortcuts; individual rows use
@@ -200,8 +234,8 @@ friendly task labels while retaining their raw `chrome_devtools_*` identity in t
200
234
  Toggles remain a command-local draft. **Review changes** previews the exact effect, **Apply tool
201
235
  changes** saves it, and Cancel, Escape, Ctrl+C, disposal, or session replacement discards an
202
236
  unconfirmed draft without changing runtime tools or settings. A failed apply restores the previous
203
- active-tool state, preserves the settings file, retains the draft for retry, and reports how to
204
- recover.
237
+ availability and loaded-tool state, preserves the settings file, retains the draft for retry, and
238
+ reports how to recover.
205
239
 
206
240
  Direct subcommands are also available:
207
241
 
@@ -209,6 +243,7 @@ Direct subcommands are also available:
209
243
  /chrome-devtools help
210
244
  /chrome-devtools quickstart
211
245
  /chrome-devtools status
246
+ /chrome-devtools settings
212
247
  /chrome-devtools tools
213
248
  /chrome-devtools toggle
214
249
  /chrome-devtools enable
@@ -221,34 +256,44 @@ Compatibility aliases remain available: `toggle` and `select` mean `tools`, `on`
221
256
  - `help` shows command usage.
222
257
  - `quickstart` shows the configured CDP endpoint, endpoint source, auto-launch mode, browser
223
258
  candidates, last launch attempt, and launch hints.
224
- - `status` shows runtime tool state, persisted selection, settings file path, endpoint source,
225
- launch mode, last launch attempt, and active non-Chrome tool count.
226
- - `tools` opens the same staged, width-safe selection and review flow used by the menu.
259
+ - `status` shows available and loaded capability counts, loader state, the persisted catalog,
260
+ settings file path, endpoint source, launch mode, last launch attempt, and active non-Chrome tool
261
+ count.
262
+ - `settings` opens the same immediate-save browser settings flow used by the menu.
263
+ - `tools` opens the same staged, width-safe availability and review flow used by the menu.
227
264
  - `toggle` and `select` are compatibility aliases for `tools`.
228
- - `enable` immediately enables and saves all `chrome_devtools_*` tools for future turns; `on` is a
265
+ - `enable` makes all five capability tools available to the loader and saves that catalog; `on` is a
229
266
  compatibility alias.
230
- - `disable` immediately disables and saves all `chrome_devtools_*` tools for future turns; `off` is a
231
- compatibility alias. The slash command remains available.
267
+ - `disable` makes all five capability tools unavailable and saves the empty catalog; `off` is a
268
+ compatibility alias. The slash command and `chrome_devtools_load` remain available.
269
+
270
+ The menu, `settings`, `tools`, `help`, `quickstart`, and `status` require TUI or RPC mode so their
271
+ result is observable. TUI uses keyboard navigation and injected Pi keybindings; RPC receives
272
+ equivalent standard dialogs. In print and JSON modes, interactive and informational routes reject
273
+ explicitly instead of silently opening unavailable UI. The immediate `enable`/`disable` routes remain
274
+ available for deterministic non-interactive use.
232
275
 
233
- The menu, `tools`, `help`, `quickstart`, and `status` require TUI or RPC mode so their result is
234
- observable. TUI uses keyboard navigation and injected Pi keybindings; RPC receives equivalent
235
- standard dialogs. In print and JSON modes, interactive and informational routes reject explicitly
236
- instead of silently opening unavailable UI. The immediate `enable`/`disable` routes remain available
237
- for deterministic non-interactive use.
276
+ ## ⚙️ Settings
238
277
 
239
- The selected tool names are saved to:
278
+ The available capability names are saved to:
240
279
 
241
280
  ```text
242
281
  ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json
243
282
  ```
244
283
 
245
- When the file is missing or invalid, the extension preserves Pi's current active-tool policy
246
- instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
247
- `/reload`. A missing file is created by the first confirmed menu apply or successful direct
248
- selection change. Within one Pi process, selection saves run in invocation order, reread the latest
249
- valid document, and preserve unknown fields. Malformed JSON or invalid recognized fields make menu
250
- mutation unavailable and block direct saves without replacement; a failed save restores the prior
251
- Chrome DevTools tool selection while preserving other extensions' current tools.
284
+ The same file owns `browser.endpoint`, `browser.autoLaunch`, `browser.executablePath`, and
285
+ `browser.extensionPaths`. Browser connection fields are machine-owned user settings; trusted project
286
+ files may replace only `browser.extensionPaths`. Confirmed menu changes apply before the next browser
287
+ connection and close only an extension-owned managed browser. Manual JSON edits and unpacked-extension
288
+ changes apply after `/reload` or session replacement.
289
+
290
+ When the file is missing or invalid, the extension preserves Pi's current Chrome DevTools
291
+ availability policy instead of replacing it. A valid saved catalog is restored on Pi startup and
292
+ `/reload`, while its capability definitions remain deferred. A missing file is created by the first
293
+ confirmed browser or tool setting. Within one Pi process, all browser and tool saves run in invocation
294
+ order, reread the latest valid document, publish by temporary-file rename, and preserve unknown
295
+ fields. Malformed JSON or invalid recognized fields make menu mutation unavailable and block direct
296
+ saves without replacement; a failed save restores the prior displayed and effective state.
252
297
 
253
298
  Compatibility: older versions used `pi-chrome-devtools-settings.json`. A legacy-only file remains
254
299
  readable with a warning and is never modified automatically; rename it to
@@ -271,6 +316,7 @@ packages/pi-chrome-devtools/
271
316
  ├── src/
272
317
  │ ├── index.ts # Pi package entrypoint
273
318
  │ ├── chrome-devtools.ts # Extension registration and command orchestration
319
+ │ ├── lazy-tools.ts # Deferred capability catalog and loader tool
274
320
  │ └── *.ts # Package-local browser, CDP, tool, and storage modules
275
321
  ├── README.md
276
322
  ├── LICENSE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.50.1",
3
+ "version": "0.52.0",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -143,7 +143,7 @@ async function prepareManagedBrowserLaunch(
143
143
  throwIfBrowserLaunchCancelled(generation, signal);
144
144
  if (!available) {
145
145
  throw new DevToolsEndpointError(
146
- `Cannot launch the extension-owned browser because explicit port ${state.port} is already in use. Choose a free PI_CHROME_DEVTOOLS_PORT or remove the override for a dynamic port.`,
146
+ `Cannot launch the extension-owned browser because explicit port ${state.port} is already in use. Choose a free browser.endpoint port or reset the endpoint to use a dynamic managed port.`,
147
147
  );
148
148
  }
149
149
  }
@@ -609,9 +609,7 @@ export function formatHostForUrl(host: string) {
609
609
  }
610
610
 
611
611
  export function endpointSourceLabel() {
612
- const hostSource = state.hostConfigured ? "PI_CHROME_DEVTOOLS_HOST" : "default host";
613
- const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
614
- return `${hostSource}; ${portSource}`;
612
+ return state.endpointSource;
615
613
  }
616
614
 
617
615
  export type BrowserLifecycleState = "starting" | "running" | "exited" | "failed" | "unobserved";
@@ -710,7 +708,7 @@ export function quoteCommandPart(value: string) {
710
708
  }
711
709
 
712
710
  export function endpointConfigHint() {
713
- 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.";
711
+ return "Configure browser.endpoint, browser.executablePath, and browser.autoLaunch in pi-chrome-devtools.json or the /chrome-devtools Settings flow.";
714
712
  }
715
713
 
716
714
  export function isLocalDevToolsHost(host: string) {
@@ -0,0 +1,311 @@
1
+ import { isAbsolute } from "node:path";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
4
+ import { shutdownManagedBrowser } from "./browser-manager.js";
5
+ import { applyRuntimeBrowserSettings, state } from "./runtime.js";
6
+ import {
7
+ type BrowserSettingsPatch,
8
+ type BrowserSettingsSource,
9
+ loadSettings,
10
+ parseBrowserEndpoint,
11
+ type SettingsLoadResult,
12
+ saveBrowserSettings,
13
+ } from "./settings.js";
14
+ import { sanitizeChromeDevtoolsDisplay } from "./tool-selector.js";
15
+
16
+ type BrowserSettingsScreen = "settings" | "endpoint" | "executable" | "extensions";
17
+ type BrowserSettingsAction =
18
+ | "open-endpoint"
19
+ | "save-endpoint"
20
+ | "set-auto-launch"
21
+ | "open-executable"
22
+ | "save-executable"
23
+ | "show-extensions";
24
+
25
+ interface BrowserSettingsMenuState {
26
+ load: SettingsLoadResult;
27
+ }
28
+
29
+ type CommandContext = ExtensionCommandContext;
30
+
31
+ export async function showChromeDevtoolsBrowserSettings(
32
+ ctx: CommandContext,
33
+ generation: number,
34
+ ): Promise<{ closeParent: boolean }> {
35
+ const ownerSignal = state.sessionController.signal;
36
+ const isCurrent = () => generation === state.sessionGeneration && !ownerSignal.aborted;
37
+ const projectTrusted = ctx.isProjectTrusted();
38
+ const menu = defineMenu<BrowserSettingsMenuState, BrowserSettingsScreen, BrowserSettingsAction>({
39
+ start: "settings",
40
+ screens: {
41
+ settings: ({ state: current }) => {
42
+ if (current.load.userFile.kind === "invalid") return invalidSettingsScreen(current.load);
43
+ const browser = current.load.effectiveBrowser;
44
+ return {
45
+ kind: "settings",
46
+ title: "Chrome DevTools Browser settings",
47
+ lines: settingsContextLines(current.load),
48
+ items: [
49
+ {
50
+ id: "endpoint",
51
+ label: "DevTools endpoint",
52
+ description:
53
+ 'HTTP origin with an explicit port; enter "default" to restore 127.0.0.1:9222.',
54
+ currentValue: sanitizeChromeDevtoolsDisplay(browser.endpoint),
55
+ action: "open-endpoint",
56
+ },
57
+ {
58
+ id: "auto-launch",
59
+ label: "Auto-launch",
60
+ description:
61
+ "Launch an isolated managed browser when a local endpoint is unavailable.",
62
+ currentValue: browser.autoLaunchEnabled ? "On" : "Off",
63
+ values: ["On", "Off"],
64
+ action: "set-auto-launch",
65
+ },
66
+ {
67
+ id: "executable",
68
+ label: "Browser executable",
69
+ description:
70
+ 'Absolute executable path; enter "automatic" to restore browser discovery.',
71
+ currentValue: sanitizeChromeDevtoolsDisplay(browser.executablePath ?? "Automatic"),
72
+ action: "open-executable",
73
+ },
74
+ {
75
+ id: "extensions",
76
+ label: "Unpacked extensions",
77
+ description: "Inspect trusted extension paths configured in user or project JSON.",
78
+ currentValue: `${browser.extensionPaths.length} configured`,
79
+ action: "show-extensions",
80
+ },
81
+ ],
82
+ };
83
+ },
84
+ endpoint: ({ state: current }) => ({
85
+ kind: "input",
86
+ title: "DevTools endpoint",
87
+ lines: [
88
+ sanitizeChromeDevtoolsDisplay(
89
+ `Current effective endpoint: ${current.load.effectiveBrowser.endpoint}`,
90
+ ),
91
+ 'Enter an HTTP origin with an explicit port, or "default".',
92
+ ],
93
+ placeholder: "http://127.0.0.1:9222",
94
+ action: "save-endpoint",
95
+ hint: "back",
96
+ }),
97
+ executable: ({ state: current }) => ({
98
+ kind: "input",
99
+ title: "Browser executable",
100
+ lines: [
101
+ sanitizeChromeDevtoolsDisplay(
102
+ `Current effective executable: ${current.load.effectiveBrowser.executablePath ?? "automatic discovery"}`,
103
+ ),
104
+ 'Enter an absolute path, or "automatic".',
105
+ ],
106
+ placeholder: "absolute browser executable path",
107
+ action: "save-executable",
108
+ hint: "back",
109
+ }),
110
+ extensions: ({ state: current }) => ({
111
+ kind: "detail",
112
+ title: "Unpacked extensions",
113
+ lines: extensionDetailLines(current.load),
114
+ hint: "back",
115
+ }),
116
+ },
117
+ actions: {
118
+ "open-endpoint": () => ({ kind: "to", screen: "endpoint" }),
119
+ "save-endpoint": async ({ value, signal }) => {
120
+ if (value === undefined) return { kind: "rejected" };
121
+ let endpoint: string | null;
122
+ try {
123
+ const trimmed = value.trim();
124
+ endpoint =
125
+ trimmed.toLowerCase() === "default" ? null : parseBrowserEndpoint(trimmed).endpoint;
126
+ } catch (error) {
127
+ notifySaveFailure(ctx, error);
128
+ return { kind: "rejected" };
129
+ }
130
+ const saved = await saveAndApplyBrowserPatch(
131
+ ctx,
132
+ generation,
133
+ ownerSignal,
134
+ signal,
135
+ projectTrusted,
136
+ { endpoint },
137
+ );
138
+ if (!saved) return { kind: "rejected" };
139
+ notifyEffectiveSave(
140
+ ctx,
141
+ "endpoint",
142
+ saved.effectiveBrowser.endpoint,
143
+ saved.effectiveBrowser.endpointSource,
144
+ );
145
+ return { kind: "back" };
146
+ },
147
+ "set-auto-launch": async ({ value, signal }) => {
148
+ if (value !== "On" && value !== "Off") return { kind: "rejected" };
149
+ const saved = await saveAndApplyBrowserPatch(
150
+ ctx,
151
+ generation,
152
+ ownerSignal,
153
+ signal,
154
+ projectTrusted,
155
+ { autoLaunch: value === "On" },
156
+ );
157
+ if (!saved) return { kind: "rejected" };
158
+ notifyEffectiveSave(
159
+ ctx,
160
+ "auto-launch",
161
+ saved.effectiveBrowser.autoLaunchEnabled ? "On" : "Off",
162
+ saved.effectiveBrowser.autoLaunchSource,
163
+ );
164
+ return { kind: "stay" };
165
+ },
166
+ "open-executable": () => ({ kind: "to", screen: "executable" }),
167
+ "save-executable": async ({ value, signal }) => {
168
+ if (value === undefined) return { kind: "rejected" };
169
+ const trimmed = value.trim();
170
+ const reset = ["automatic", "default", "none"].includes(trimmed.toLowerCase());
171
+ if (!reset && (!trimmed || !isAbsolute(trimmed))) {
172
+ notifySaveFailure(ctx, new Error('Enter an absolute path or "automatic".'));
173
+ return { kind: "rejected" };
174
+ }
175
+ const executablePath = reset ? null : trimmed;
176
+ const saved = await saveAndApplyBrowserPatch(
177
+ ctx,
178
+ generation,
179
+ ownerSignal,
180
+ signal,
181
+ projectTrusted,
182
+ { executablePath },
183
+ );
184
+ if (!saved) return { kind: "rejected" };
185
+ notifyEffectiveSave(
186
+ ctx,
187
+ "browser executable",
188
+ saved.effectiveBrowser.executablePath ?? "automatic discovery",
189
+ saved.effectiveBrowser.executablePathSource,
190
+ );
191
+ return { kind: "back" };
192
+ },
193
+ "show-extensions": () => ({ kind: "to", screen: "extensions" }),
194
+ },
195
+ });
196
+ const result = await runMenu(ctx, menu, {
197
+ getState: async ({ signal }) => {
198
+ signal.throwIfAborted();
199
+ const load = await loadSettings({ cwd: ctx.cwd, projectTrusted });
200
+ signal.throwIfAborted();
201
+ return { load };
202
+ },
203
+ signal: ownerSignal,
204
+ isCurrent,
205
+ onError: (currentCtx, error) =>
206
+ currentCtx.ui.notify(
207
+ sanitizeChromeDevtoolsDisplay(
208
+ `Chrome DevTools browser settings failed: ${formatError(error)}`,
209
+ ),
210
+ "error",
211
+ ),
212
+ });
213
+ return { closeParent: result.kind === "closed" && result.reason === "close" };
214
+ }
215
+
216
+ async function saveAndApplyBrowserPatch(
217
+ ctx: CommandContext,
218
+ generation: number,
219
+ ownerSignal: AbortSignal,
220
+ actionSignal: AbortSignal,
221
+ projectTrusted: boolean,
222
+ patch: BrowserSettingsPatch,
223
+ ) {
224
+ try {
225
+ await ctx.waitForIdle();
226
+ if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
227
+ await saveBrowserSettings(patch);
228
+ if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
229
+ await shutdownManagedBrowser();
230
+ if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
231
+ const loaded = await loadSettings({ cwd: ctx.cwd, projectTrusted });
232
+ if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
233
+ applyRuntimeBrowserSettings(loaded.effectiveBrowser, loaded.paths, projectTrusted);
234
+ state.settingsNotice = loaded.notice;
235
+ return loaded;
236
+ } catch (error) {
237
+ if (isCurrent(generation, ownerSignal, actionSignal)) notifySaveFailure(ctx, error);
238
+ return false;
239
+ }
240
+ }
241
+
242
+ function isCurrent(generation: number, ownerSignal: AbortSignal, actionSignal: AbortSignal) {
243
+ return generation === state.sessionGeneration && !ownerSignal.aborted && !actionSignal.aborted;
244
+ }
245
+
246
+ function invalidSettingsScreen(load: SettingsLoadResult) {
247
+ const reason =
248
+ load.userFile.kind === "invalid"
249
+ ? load.userFile.reason
250
+ : "The user settings file is unavailable.";
251
+ return {
252
+ kind: "detail" as const,
253
+ title: "Chrome DevTools Browser settings · Read only",
254
+ lines: [
255
+ sanitizeChromeDevtoolsDisplay(
256
+ `Fix the active user settings before saving; the file will not be overwritten. ${reason}`,
257
+ ),
258
+ ],
259
+ hint: "back" as const,
260
+ };
261
+ }
262
+
263
+ function settingsContextLines(load: SettingsLoadResult) {
264
+ const browser = load.effectiveBrowser;
265
+ return [
266
+ `User settings · ${load.paths.user}`,
267
+ `Effective sources: endpoint ${browser.endpointSource} · auto-launch ${browser.autoLaunchSource} · executable ${browser.executablePathSource}`,
268
+ ...(load.notice ? [`Warning: ${load.notice}`] : []),
269
+ ].map((line) => sanitizeChromeDevtoolsDisplay(line));
270
+ }
271
+
272
+ function extensionDetailLines(load: SettingsLoadResult) {
273
+ const browser = load.effectiveBrowser;
274
+ return [
275
+ `Effective source: ${browser.extensionPathsSource}`,
276
+ ...(browser.extensionPaths.length > 0
277
+ ? browser.extensionPaths.map((extensionPath) => `- ${extensionPath}`)
278
+ : ["No unpacked extensions are configured."]),
279
+ "Edit browser.extensionPaths in the user JSON or a trusted project JSON, then run /reload.",
280
+ "Unpacked extensions execute privileged browser code; load only paths you trust.",
281
+ ].map((line) => sanitizeChromeDevtoolsDisplay(line));
282
+ }
283
+
284
+ function notifyEffectiveSave(
285
+ ctx: CommandContext,
286
+ setting: string,
287
+ effectiveValue: string,
288
+ source: BrowserSettingsSource,
289
+ ) {
290
+ const overrideNotice =
291
+ source === "environment" ? " The deprecated environment override remains effective." : "";
292
+ ctx.ui.notify(
293
+ sanitizeChromeDevtoolsDisplay(
294
+ `Chrome DevTools ${setting} saved. Effective ${setting}: ${effectiveValue} (${source}).${overrideNotice}`,
295
+ ),
296
+ "info",
297
+ );
298
+ }
299
+
300
+ function notifySaveFailure(ctx: CommandContext, error: unknown) {
301
+ ctx.ui.notify(
302
+ sanitizeChromeDevtoolsDisplay(
303
+ `Chrome DevTools browser settings save failed; previous settings remain active: ${formatError(error)}`,
304
+ ),
305
+ "warning",
306
+ );
307
+ }
308
+
309
+ function formatError(error: unknown) {
310
+ return error instanceof Error ? error.message : String(error);
311
+ }