@narumitw/pi-chrome-devtools 0.52.3 → 0.53.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.
Files changed (35) hide show
  1. package/README.md +62 -9
  2. package/dist/chunks/{browser-settings-menu-VWCNG4HB.js → browser-settings-menu-REIU7C3Q.js} +83 -7
  3. package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +7 -0
  4. package/dist/chunks/chunk-GTTYS5GT.js +384 -0
  5. package/dist/chunks/chunk-GTTYS5GT.js.map +7 -0
  6. package/dist/chunks/{chunk-RH6KESL3.js → chunk-SRT3UUIP.js} +473 -574
  7. package/dist/chunks/chunk-SRT3UUIP.js.map +7 -0
  8. package/dist/chunks/chunk-YJVYJ2L2.js +469 -0
  9. package/dist/chunks/chunk-YJVYJ2L2.js.map +7 -0
  10. package/dist/chunks/{menu-PLD3S3EO.js → menu-H5HMVGHI.js} +72 -39
  11. package/dist/chunks/menu-H5HMVGHI.js.map +7 -0
  12. package/dist/chunks/tools-6P7EUX37.js +964 -0
  13. package/dist/chunks/tools-6P7EUX37.js.map +7 -0
  14. package/dist/index.ts +166 -199
  15. package/dist/index.ts.map +4 -4
  16. package/package.json +4 -3
  17. package/src/browser-manager.ts +408 -169
  18. package/src/browser-settings-menu.ts +100 -5
  19. package/src/cdp-client.ts +347 -90
  20. package/src/chrome-devtools.ts +58 -11
  21. package/src/lazy-tools.ts +37 -4
  22. package/src/menu.ts +80 -39
  23. package/src/render.ts +12 -1
  24. package/src/runtime.ts +92 -0
  25. package/src/settings.ts +47 -10
  26. package/src/tool-names.ts +16 -1
  27. package/src/tool-selector.ts +85 -44
  28. package/src/tools.ts +91 -15
  29. package/src/webmcp/discovery.ts +235 -0
  30. package/src/webmcp/policy.ts +409 -0
  31. package/src/webmcp/protocol.ts +426 -0
  32. package/src/webmcp/tools.ts +190 -0
  33. package/dist/chunks/browser-settings-menu-VWCNG4HB.js.map +0 -7
  34. package/dist/chunks/chunk-RH6KESL3.js.map +0 -7
  35. package/dist/chunks/menu-PLD3S3EO.js.map +0 -7
@@ -1,14 +1,27 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { shutdownManagedBrowser } from "./browser-manager.js";
2
+ import {
3
+ shutdownManagedBrowser,
4
+ startManagedBrowserSession,
5
+ syncManagedBrowserSettings,
6
+ } from "./browser-manager.js";
7
+ import { setActivePageId } from "./cdp-client.js";
3
8
  import {
4
9
  availableChromeDevtoolsTools,
5
10
  configureChromeDevtoolsToolExposure,
6
11
  createChromeDevtoolsLoadTool,
7
12
  initializeAvailableChromeDevtoolsTools,
8
13
  requireEagerChromeDevtoolsToolExposure,
14
+ setChromeDevtoolsSessionOwner,
9
15
  supportsNativeDeferredToolLoading,
10
16
  } from "./lazy-tools.js";
11
- import { applyRuntimeBrowserSettings, state } from "./runtime.js";
17
+ import {
18
+ applyRuntimeBrowserSettings,
19
+ applyRuntimeWebMcpSetting,
20
+ invalidateWebMcpOperations,
21
+ setWebMcpSessionOwner,
22
+ state,
23
+ webMcpEnabled,
24
+ } from "./runtime.js";
12
25
  import { loadSettings, waitForSettingsWrites } from "./settings.js";
13
26
  import {
14
27
  allChromeDevtoolsTools,
@@ -25,6 +38,8 @@ import {
25
38
  navigateTool,
26
39
  screenshotTool,
27
40
  selectPageTool,
41
+ webMcpCallTool,
42
+ webMcpListToolsTool,
28
43
  } from "./tools.js";
29
44
 
30
45
  type CommandAction =
@@ -57,12 +72,15 @@ export default function chromeDevtools(pi: ExtensionAPI) {
57
72
  pi.registerTool(navigateTool);
58
73
  pi.registerTool(evaluateTool);
59
74
  pi.registerTool(screenshotTool);
75
+ pi.registerTool(webMcpListToolsTool);
76
+ pi.registerTool(webMcpCallTool);
60
77
  pi.registerTool(createChromeDevtoolsLoadTool(pi));
61
78
 
62
79
  pi.registerCommand("chrome-devtools", {
63
80
  description: "Open Chrome DevTools help and tool controls",
64
81
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
65
82
  handler: async (args, ctx) => {
83
+ setChromeDevtoolsSessionOwner(pi, ctx.sessionManager);
66
84
  initializeAvailableChromeDevtoolsTools(pi);
67
85
  const generation = state.sessionGeneration;
68
86
  await handleChromeDevtoolsCommand(pi, args, ctx, generation);
@@ -71,23 +89,39 @@ export default function chromeDevtools(pi: ExtensionAPI) {
71
89
 
72
90
  pi.on("session_start", async (_event, ctx) => {
73
91
  const generation = ++state.sessionGeneration;
92
+ setChromeDevtoolsSessionOwner(pi, ctx.sessionManager);
74
93
  initializeAvailableChromeDevtoolsTools(pi);
94
+ setWebMcpSessionOwner(ctx.sessionManager);
75
95
  replaceSessionController("Chrome DevTools session replaced");
96
+ invalidateWebMcpOperations(ctx.sessionManager, "Chrome DevTools session replaced");
76
97
  state.shuttingDown = false;
77
98
  state.settingsNotice = undefined;
78
99
  ctx.ui.setStatus(STATUS_KEY, undefined);
79
- await shutdownManagedBrowser();
100
+ await shutdownManagedBrowser(undefined, {
101
+ cancelLaunch: true,
102
+ owner: ctx.sessionManager,
103
+ });
104
+ startManagedBrowserSession(ctx.sessionManager);
80
105
  if (generation !== state.sessionGeneration) return;
106
+ setActivePageId(ctx.sessionManager, undefined);
81
107
  state.activePageId = undefined;
82
108
  state.lastLaunchAttempt = undefined;
83
109
  const projectTrusted = ctx.isProjectTrusted();
84
110
  const settings = await loadSettings({ cwd: ctx.cwd, projectTrusted });
85
111
  if (generation !== state.sessionGeneration) return;
86
112
  applyRuntimeBrowserSettings(settings.effectiveBrowser, settings.paths, projectTrusted);
113
+ syncManagedBrowserSettings(ctx.sessionManager, settings.effectiveBrowser);
114
+ applyRuntimeWebMcpSetting(settings.effectiveWebMcpEnabled, ctx.sessionManager);
87
115
  state.settingsNotice = settings.notice;
88
116
  for (const warning of settings.warnings) {
89
117
  ctx.ui.notify(sanitizeChromeDevtoolsDisplay(warning), "warning");
90
118
  }
119
+ if (webMcpEnabled(ctx.sessionManager)) {
120
+ ctx.ui.notify(
121
+ "Experimental WebMCP is enabled. Page-provided tools use the visible browser session and require confirmation for every call.",
122
+ "warning",
123
+ );
124
+ }
91
125
  const availableTools =
92
126
  settings.kind === "loaded" && settings.settings.tools
93
127
  ? settings.settings.tools
@@ -95,7 +129,11 @@ export default function chromeDevtools(pi: ExtensionAPI) {
95
129
  configureChromeDevtoolsToolExposure(pi, availableTools, ctx.model);
96
130
  });
97
131
 
98
- pi.on("model_select", (event) => {
132
+ pi.on("model_select", (event, ctx) => {
133
+ invalidateWebMcpOperations(
134
+ ctx.sessionManager,
135
+ "Chrome DevTools model and tool exposure changed",
136
+ );
99
137
  if (!supportsNativeDeferredToolLoading(event.model)) {
100
138
  requireEagerChromeDevtoolsToolExposure(pi);
101
139
  }
@@ -104,8 +142,12 @@ export default function chromeDevtools(pi: ExtensionAPI) {
104
142
  pi.on("session_shutdown", async (_event, ctx) => {
105
143
  state.sessionGeneration += 1;
106
144
  replaceSessionController("Chrome DevTools session shut down");
145
+ invalidateWebMcpOperations(ctx.sessionManager, "Chrome DevTools session shut down");
107
146
  ctx.ui.setStatus(STATUS_KEY, undefined);
108
- const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
147
+ const browserShutdown = shutdownManagedBrowser(undefined, {
148
+ cancelLaunch: true,
149
+ owner: ctx.sessionManager,
150
+ });
109
151
  await waitForChromeDevtoolsSettings();
110
152
  await waitForSettingsWrites();
111
153
  await browserShutdown;
@@ -125,15 +167,15 @@ async function handleChromeDevtoolsCommand(
125
167
  return;
126
168
  case "help":
127
169
  requireObservableUi(ctx, "help");
128
- ctx.ui.notify(buildCommandGuide(), "info");
170
+ ctx.ui.notify(buildCommandGuide(ctx.sessionManager), "info");
129
171
  return;
130
172
  case "quickstart":
131
173
  requireObservableUi(ctx, "quickstart");
132
- ctx.ui.notify(buildQuickstartMessage(), "info");
174
+ ctx.ui.notify(buildQuickstartMessage(ctx.sessionManager), "info");
133
175
  return;
134
176
  case "status": {
135
177
  requireObservableUi(ctx, "status");
136
- const status = await buildToolStatusMessage(pi);
178
+ const status = await buildToolStatusMessage(pi, ctx.sessionManager);
137
179
  if (generation !== state.sessionGeneration) return;
138
180
  ctx.ui.notify(status, "info");
139
181
  return;
@@ -144,7 +186,7 @@ async function handleChromeDevtoolsCommand(
144
186
  }
145
187
  const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
146
188
  if (generation !== state.sessionGeneration) return;
147
- await showChromeDevtoolsBrowserSettings(ctx, generation);
189
+ await showChromeDevtoolsBrowserSettings(pi, ctx, generation);
148
190
  return;
149
191
  }
150
192
  case "tools": {
@@ -157,7 +199,12 @@ async function handleChromeDevtoolsCommand(
157
199
  return;
158
200
  }
159
201
  case "enable":
160
- await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "made all available");
202
+ await updateChromeDevtoolsTools(
203
+ pi,
204
+ ctx,
205
+ allChromeDevtoolsTools(ctx.sessionManager),
206
+ "made all available",
207
+ );
161
208
  return;
162
209
  case "disable":
163
210
  await updateChromeDevtoolsTools(pi, ctx, [], "made all unavailable");
@@ -170,7 +217,7 @@ async function handleChromeDevtoolsCommand(
170
217
  ctx.ui.notify(
171
218
  `Unknown /chrome-devtools command: ${args.trim()}
172
219
 
173
- ${buildCommandGuide()}`,
220
+ ${buildCommandGuide(ctx.sessionManager)}`,
174
221
  "warning",
175
222
  );
176
223
  }
package/src/lazy-tools.ts CHANGED
@@ -4,7 +4,12 @@ import {
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
7
+ import { webMcpEnabled } from "./runtime.js";
8
+ import {
9
+ CHROME_DEVTOOLS_TOOL_NAMES,
10
+ type ChromeDevToolsToolName,
11
+ isWebMcpToolName,
12
+ } from "./tool-names.js";
8
13
 
9
14
  export const CHROME_DEVTOOLS_LOAD_TOOL_NAME = "chrome_devtools_load";
10
15
 
@@ -18,6 +23,7 @@ const availableToolsByApi =
18
23
  existingAvailableToolsStore ?? new WeakMap<ExtensionAPI, Set<ChromeDevToolsToolName>>();
19
24
  if (!existingAvailableToolsStore) sharedGlobal[AVAILABLE_TOOLS_STORE] = availableToolsByApi;
20
25
  const lazyExposureByApi = new WeakMap<ExtensionAPI, boolean>();
26
+ const sessionOwnerByApi = new WeakMap<ExtensionAPI, object>();
21
27
 
22
28
  const SEARCH_TEXT: Record<ChromeDevToolsToolName, string> = {
23
29
  chrome_devtools_list_pages: "list open inspectable chrome browser pages tabs targets",
@@ -25,8 +31,16 @@ const SEARCH_TEXT: Record<ChromeDevToolsToolName, string> = {
25
31
  chrome_devtools_navigate: "navigate open create chrome browser page url website",
26
32
  chrome_devtools_evaluate: "evaluate run javascript expression dom inspect chrome browser page",
27
33
  chrome_devtools_screenshot: "capture screenshot png image visual chrome browser page",
34
+ chrome_devtools_webmcp_list_tools:
35
+ "list discover page provided website webmcp tools capabilities experimental",
36
+ chrome_devtools_webmcp_call_tool:
37
+ "call invoke page provided website webmcp tool confirmation experimental",
28
38
  };
29
39
 
40
+ export function setChromeDevtoolsSessionOwner(pi: ExtensionAPI, owner: object) {
41
+ sessionOwnerByApi.set(pi, owner);
42
+ }
43
+
30
44
  export function initializeAvailableChromeDevtoolsTools(pi: ExtensionAPI) {
31
45
  if (availableToolsByApi.has(pi)) return;
32
46
  const activeTools = new Set(pi.getActiveTools());
@@ -41,7 +55,8 @@ export function configureChromeDevtoolsToolExposure(
41
55
  availableTools: readonly ChromeDevToolsToolName[],
42
56
  model?: ExtensionContext["model"],
43
57
  ) {
44
- const available = setAvailableTools(pi, availableTools);
58
+ setAvailableTools(pi, availableTools);
59
+ const available = effectiveAvailableTools(pi);
45
60
  const lazyExposure = supportsNativeDeferredToolLoading(model);
46
61
  lazyExposureByApi.set(pi, lazyExposure);
47
62
  const exposedTools = lazyExposure
@@ -66,7 +81,8 @@ export function applyAvailableChromeDevtoolsTools(
66
81
  pi: ExtensionAPI,
67
82
  availableTools: readonly ChromeDevToolsToolName[],
68
83
  ) {
69
- const available = setAvailableTools(pi, availableTools);
84
+ setAvailableTools(pi, availableTools);
85
+ const available = effectiveAvailableTools(pi);
70
86
  const lazyExposure = lazyExposureByApi.get(pi) === true;
71
87
  const active = pi
72
88
  .getActiveTools()
@@ -110,6 +126,11 @@ export function supportsNativeDeferredToolLoading(model: ExtensionContext["model
110
126
  }
111
127
 
112
128
  export function availableChromeDevtoolsTools(pi: ExtensionAPI) {
129
+ const available = effectiveAvailableTools(pi);
130
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
131
+ }
132
+
133
+ export function configuredChromeDevtoolsTools(pi: ExtensionAPI) {
113
134
  const available = availableToolsByApi.get(pi) ?? new Set();
114
135
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
115
136
  }
@@ -137,8 +158,9 @@ export function createChromeDevtoolsLoadTool(pi: ExtensionAPI) {
137
158
  }),
138
159
  ),
139
160
  }),
140
- async execute(_toolCallId, params, signal) {
161
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
141
162
  signal?.throwIfAborted();
163
+ setChromeDevtoolsSessionOwner(pi, ctx.sessionManager);
142
164
  const available = new Set(availableChromeDevtoolsTools(pi));
143
165
  const matches = matchChromeDevtoolsTools(params.query, params.limit ?? 3, available);
144
166
  const active = pi.getActiveTools();
@@ -190,6 +212,17 @@ function setAvailableTools(pi: ExtensionAPI, availableTools: readonly ChromeDevT
190
212
  return available;
191
213
  }
192
214
 
215
+ function effectiveAvailableTools(pi: ExtensionAPI) {
216
+ const configured = availableToolsByApi.get(pi) ?? new Set();
217
+ const owner = sessionOwnerByApi.get(pi);
218
+ const enabled = owner !== undefined && webMcpEnabled(owner);
219
+ return new Set(
220
+ CHROME_DEVTOOLS_TOOL_NAMES.filter(
221
+ (name) => configured.has(name) && (enabled || !isWebMcpToolName(name)),
222
+ ),
223
+ );
224
+ }
225
+
193
226
  function compatBoolean(value: unknown, key: string) {
194
227
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
195
228
  const record = value as Record<string, unknown>;
package/src/menu.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
3
- import { browserLifecycleState, devToolsEndpoint, launchModeLabel } from "./browser-manager.js";
3
+ import {
4
+ browserLifecycleState,
5
+ devToolsEndpoint,
6
+ launchAttemptLines,
7
+ launchModeLabel,
8
+ } from "./browser-manager.js";
4
9
  import { availableChromeDevtoolsTools } from "./lazy-tools.js";
5
- import { state } from "./runtime.js";
10
+ import { state, webMcpEnabled } from "./runtime.js";
6
11
  import { loadSettings, type SettingsLoadResult } from "./settings.js";
7
- import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
12
+ import {
13
+ CHROME_DEVTOOLS_TOOL_NAMES,
14
+ type ChromeDevToolsToolName,
15
+ CORE_CHROME_DEVTOOLS_TOOL_NAMES,
16
+ } from "./tool-names.js";
8
17
  import {
9
18
  buildBrowserStatusMessage,
10
19
  buildCommandGuide,
@@ -54,6 +63,14 @@ const TOOL_PRESENTATION: Record<ChromeDevToolsToolName, { label: string; descrip
54
63
  label: "Capture a screenshot",
55
64
  description: "Save a PNG of the selected page (chrome_devtools_screenshot).",
56
65
  },
66
+ chrome_devtools_webmcp_list_tools: {
67
+ label: "List page WebMCP tools · Experimental",
68
+ description: "Discover page-provided WebMCP capabilities (chrome_devtools_webmcp_list_tools).",
69
+ },
70
+ chrome_devtools_webmcp_call_tool: {
71
+ label: "Call a page WebMCP tool · Experimental",
72
+ description: "Invoke a listed page tool after confirmation (chrome_devtools_webmcp_call_tool).",
73
+ },
57
74
  };
58
75
 
59
76
  export async function showChromeDevtoolsMenu(
@@ -71,7 +88,7 @@ export async function showChromeDevtoolsMenu(
71
88
  main: ({ state: current }) => ({
72
89
  kind: "actions",
73
90
  title: "Chrome DevTools",
74
- lines: mainStateLines(current),
91
+ lines: mainStateLines(current, ctx.sessionManager),
75
92
  items: [
76
93
  {
77
94
  id: "tools",
@@ -84,13 +101,13 @@ export async function showChromeDevtoolsMenu(
84
101
  {
85
102
  id: "bulk",
86
103
  label:
87
- current.activeTools.length === CHROME_DEVTOOLS_TOOL_NAMES.length
104
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
88
105
  ? "Make all browser tools unavailable…"
89
106
  : "Make all browser tools available…",
90
107
  description:
91
- current.activeTools.length === CHROME_DEVTOOLS_TOOL_NAMES.length
92
- ? "Preview 0 of 5; other active tools stay enabled."
93
- : "Preview 5 of 5; other active tools stay enabled.",
108
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
109
+ ? `Preview 0 of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`
110
+ : `Preview ${availableCatalogNames(ctx.sessionManager).length} of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`,
94
111
  disabled: Boolean(current.mutationBlockedReason),
95
112
  disabledReason: current.mutationBlockedReason,
96
113
  action: "bulk",
@@ -119,7 +136,7 @@ export async function showChromeDevtoolsMenu(
119
136
  browserStatus: () => ({
120
137
  kind: "review",
121
138
  title: "Browser status",
122
- content: buildBrowserStatusMessage(),
139
+ content: buildBrowserStatusMessage(ctx.sessionManager),
123
140
  format: { kind: "text" },
124
141
  viewportSize: "adaptive",
125
142
  hint: "back",
@@ -127,7 +144,7 @@ export async function showChromeDevtoolsMenu(
127
144
  help: () => ({
128
145
  kind: "review",
129
146
  title: "Chrome DevTools help",
130
- content: buildCommandGuide(),
147
+ content: buildCommandGuide(ctx.sessionManager),
131
148
  format: { kind: "text" },
132
149
  viewportSize: "adaptive",
133
150
  hint: "back",
@@ -143,9 +160,9 @@ export async function showChromeDevtoolsMenu(
143
160
  },
144
161
  bulk: async ({ state: current }) => {
145
162
  const selectedTools =
146
- current.activeTools.length === CHROME_DEVTOOLS_TOOL_NAMES.length
163
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
147
164
  ? []
148
- : [...CHROME_DEVTOOLS_TOOL_NAMES];
165
+ : availableCatalogNames(ctx.sessionManager);
149
166
  const result = await showChromeDevtoolsToolWorkflow(pi, ctx, generation, {
150
167
  snapshot: current,
151
168
  initialDraft: selectedTools,
@@ -154,11 +171,14 @@ export async function showChromeDevtoolsMenu(
154
171
  if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
155
172
  return result?.closeParent ? { kind: "close" } : { kind: "stay" };
156
173
  },
157
- settings: async () => {
174
+ settings: async ({ state: current }) => {
158
175
  const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
159
176
  if (!isCurrent()) return { kind: "stay" };
160
- const result = await showChromeDevtoolsBrowserSettings(ctx, generation);
177
+ const result = await showChromeDevtoolsBrowserSettings(pi, ctx, generation);
161
178
  if (!isCurrent()) return { kind: "stay" };
179
+ const refreshed = await loadChromeDevtoolsMenuSnapshot(pi, ctx);
180
+ if (!isCurrent()) return { kind: "stay" };
181
+ Object.assign(current, refreshed);
162
182
  return result.closeParent ? { kind: "close" } : { kind: "stay" };
163
183
  },
164
184
  },
@@ -204,9 +224,9 @@ export async function showChromeDevtoolsToolWorkflow(
204
224
  screens: {
205
225
  tools: ({ state: current }) => ({
206
226
  kind: "multiSelect",
207
- title: `Browser tools (${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length})`,
208
- lines: toolDraftLines(current),
209
- items: CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({
227
+ title: `Browser tools (${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length})`,
228
+ lines: toolDraftLines(current, ctx.sessionManager),
229
+ items: availableCatalogNames(ctx.sessionManager).map((toolName) => ({
210
230
  id: toolName,
211
231
  label: TOOL_PRESENTATION[toolName].label,
212
232
  description: TOOL_PRESENTATION[toolName].description,
@@ -249,10 +269,10 @@ export async function showChromeDevtoolsToolWorkflow(
249
269
  kind: "review",
250
270
  title: "Review tool changes",
251
271
  lines: [
252
- `Currently available: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
253
- `Proposed availability: ${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
272
+ `Currently available: ${current.accepted.size}/${availableCatalogNames(ctx.sessionManager).length}`,
273
+ `Proposed availability: ${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length}`,
254
274
  ],
255
- content: buildToolReview(current),
275
+ content: buildToolReview(current, ctx.sessionManager),
256
276
  format: { kind: "text" },
257
277
  viewportSize: "adaptive",
258
278
  ...(current.mutationBlockedReason || setsEqual(current.accepted, current.draft)
@@ -276,7 +296,7 @@ export async function showChromeDevtoolsToolWorkflow(
276
296
  return { kind: "stay" };
277
297
  },
278
298
  selectAll: ({ state: current }) => {
279
- current.draft = new Set(CHROME_DEVTOOLS_TOOL_NAMES);
299
+ current.draft = new Set(availableCatalogNames(ctx.sessionManager));
280
300
  return { kind: "stay" };
281
301
  },
282
302
  selectNone: ({ state: current }) => {
@@ -306,7 +326,7 @@ export async function showChromeDevtoolsToolWorkflow(
306
326
  current.draft = new Set(selectedTools);
307
327
  current.applied = true;
308
328
  ctx.ui.notify(
309
- `Saved: ${selectedTools.length} of ${CHROME_DEVTOOLS_TOOL_NAMES.length} browser tools available.`,
329
+ `Saved: ${selectedTools.length} of ${availableCatalogNames(ctx.sessionManager).length} browser tools available.`,
310
330
  "info",
311
331
  );
312
332
  return { kind: "close" };
@@ -349,6 +369,17 @@ async function loadSnapshotWithFeedback(pi: ExtensionAPI, ctx: CommandContext, g
349
369
  return result.kind === "completed" ? result.value : undefined;
350
370
  }
351
371
 
372
+ export async function loadChromeDevtoolsMenuSnapshot(
373
+ pi: ExtensionAPI,
374
+ ctx: ExtensionCommandContext,
375
+ ) {
376
+ const settings = await loadSettings({
377
+ cwd: ctx.cwd,
378
+ projectTrusted: ctx.isProjectTrusted(),
379
+ });
380
+ return buildMenuSnapshot(pi, settings);
381
+ }
382
+
352
383
  function buildMenuSnapshot(pi: ExtensionAPI, settings: SettingsLoadResult): MenuSnapshot {
353
384
  const activeTools = activeChromeTools(pi);
354
385
  const persistedTools =
@@ -376,50 +407,54 @@ function buildMenuSnapshot(pi: ExtensionAPI, settings: SettingsLoadResult): Menu
376
407
  };
377
408
  }
378
409
 
379
- function mainStateLines(snapshot: MenuSnapshot) {
410
+ function mainStateLines(snapshot: MenuSnapshot, owner: object) {
411
+ const launchError = launchAttemptLines(owner).find((line) =>
412
+ line.startsWith("Last launch error:"),
413
+ );
380
414
  return sanitizeLines([
381
- `Tool catalog: ${snapshot.activeTools.length} of ${CHROME_DEVTOOLS_TOOL_NAMES.length} available · ${snapshot.persistenceLabel}`,
382
- `Browser: ${browserLifecycleSummary()}`,
383
- `Endpoint: ${devToolsEndpoint()}`,
415
+ `Tool catalog: ${snapshot.activeTools.length} of ${availableCatalogNames(owner).length} available · ${snapshot.persistenceLabel}`,
416
+ `WebMCP: ${webMcpEnabled(owner) ? "enabled · experimental · confirmation required for every call" : "disabled · experimental"}`,
417
+ `Browser: ${browserLifecycleSummary(owner)}`,
418
+ `Endpoint: ${devToolsEndpoint(owner)}`,
384
419
  ...(snapshot.settingsWarning ? [`Settings warning: ${snapshot.settingsWarning}`] : []),
385
- ...(state.lastLaunchAttempt?.lastError
386
- ? [`Launch warning: ${state.lastLaunchAttempt.lastError}`]
387
- : []),
420
+ ...(launchError ? [`Launch warning: ${launchError.slice("Last launch error: ".length)}`] : []),
388
421
  ]);
389
422
  }
390
423
 
391
- function browserLifecycleSummary() {
392
- const lifecycle = browserLifecycleState();
424
+ function browserLifecycleSummary(owner: object) {
425
+ const lifecycle = browserLifecycleState(owner);
393
426
  if (lifecycle === "starting") return "starting managed browser";
394
427
  if (lifecycle === "running") return "managed browser running";
395
428
  if (lifecycle === "exited") return "managed browser exited · open Browser status to recover";
396
429
  if (lifecycle === "failed") return "last launch failed · open Browser status to recover";
397
- const launchMode = launchModeLabel();
430
+ const launchMode = launchModeLabel(owner);
398
431
  return launchMode.startsWith("attach first")
399
432
  ? "not started · attaches or launches on first use"
400
433
  : `not started · ${launchMode}`;
401
434
  }
402
435
 
403
- function toolDraftLines(current: ToolWorkflowState) {
436
+ function toolDraftLines(current: ToolWorkflowState, owner: object) {
404
437
  if (current.mutationBlockedReason)
405
438
  return sanitizeLines([`Unavailable: ${current.mutationBlockedReason}`]);
406
439
  const changes = symmetricDifferenceSize(current.accepted, current.draft);
407
440
  return [
408
- `Currently available: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
441
+ `Currently available: ${current.accepted.size}/${availableCatalogNames(owner).length}`,
409
442
  changes === 0
410
443
  ? "No unapplied changes · Escape cancels"
411
444
  : `${changes} unapplied ${changes === 1 ? "change" : "changes"} · Escape cancels`,
445
+ `WebMCP gateways: ${webMcpEnabled(owner) ? "available for selection · experimental" : "hidden while experimental WebMCP is disabled"}`,
412
446
  "Changes are not applied until Review changes and Apply tool changes.",
413
447
  ];
414
448
  }
415
449
 
416
- function buildToolReview(current: ToolWorkflowState) {
417
- const available = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => current.draft.has(name));
418
- const unavailable = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => !current.draft.has(name));
450
+ function buildToolReview(current: ToolWorkflowState, owner: object) {
451
+ const catalog = availableCatalogNames(owner);
452
+ const available = catalog.filter((name) => current.draft.has(name));
453
+ const unavailable = catalog.filter((name) => !current.draft.has(name));
419
454
  return sanitizeChromeDevtoolsDisplay(
420
455
  [
421
- `Current available browser tools: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
422
- `Proposed available browser tools: ${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
456
+ `Current available browser tools: ${current.accepted.size}/${catalog.length}`,
457
+ `Proposed available browser tools: ${current.draft.size}/${catalog.length}`,
423
458
  "",
424
459
  "Available after apply:",
425
460
  ...(available.length > 0
@@ -478,6 +513,12 @@ function sanitizeLines(lines: readonly string[]) {
478
513
  return sanitizeChromeDevtoolsDisplay(lines.join("\n")).split("\n");
479
514
  }
480
515
 
516
+ function availableCatalogNames(owner: object): ChromeDevToolsToolName[] {
517
+ return webMcpEnabled(owner)
518
+ ? [...CHROME_DEVTOOLS_TOOL_NAMES]
519
+ : [...CORE_CHROME_DEVTOOLS_TOOL_NAMES];
520
+ }
521
+
481
522
  function formatError(error: unknown) {
482
523
  return error instanceof Error ? error.message : String(error);
483
524
  }
package/src/render.ts CHANGED
@@ -2,12 +2,15 @@ import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/p
2
2
 
3
3
  const STATUS_KEY = "chrome-devtools";
4
4
  interface StatusContext {
5
+ sessionManager: object;
5
6
  ui: { setStatus: (key: string, value: string | undefined) => void };
6
7
  }
7
8
  interface RenderTheme {
8
9
  bold(text: string): string;
9
10
  fg(color: string, text: string): string;
10
11
  }
12
+ const activeStatuses = new WeakMap<object, Array<{ status: string; token: symbol }>>();
13
+
11
14
  interface RenderComponent {
12
15
  invalidate(): void;
13
16
  render(width: number): string[];
@@ -101,10 +104,18 @@ export async function withStatus<T>(
101
104
  status: string,
102
105
  callback: () => Promise<T>,
103
106
  ) {
107
+ const token = Symbol(status);
108
+ const statuses = activeStatuses.get(ctx.sessionManager) ?? [];
109
+ statuses.push({ status, token });
110
+ activeStatuses.set(ctx.sessionManager, statuses);
104
111
  ctx.ui.setStatus(STATUS_KEY, status);
105
112
  try {
106
113
  return await callback();
107
114
  } finally {
108
- ctx.ui.setStatus(STATUS_KEY, undefined);
115
+ const index = statuses.findIndex((entry) => entry.token === token);
116
+ if (index >= 0) statuses.splice(index, 1);
117
+ const remaining = statuses.at(-1)?.status;
118
+ if (statuses.length === 0) activeStatuses.delete(ctx.sessionManager);
119
+ ctx.ui.setStatus(STATUS_KEY, remaining);
109
120
  }
110
121
  }
package/src/runtime.ts CHANGED
@@ -57,6 +57,7 @@ export interface ManagedBrowser {
57
57
  exited: boolean;
58
58
  ready: boolean;
59
59
  ownerGeneration: number;
60
+ sessionOwner?: object;
60
61
  cleanupPromise?: Promise<void>;
61
62
  }
62
63
 
@@ -98,6 +99,97 @@ export const state: ChromeDevToolsState = {
98
99
  sessionController: new AbortController(),
99
100
  };
100
101
 
102
+ interface WebMcpSessionRuntime {
103
+ controller: AbortController;
104
+ enabled: boolean;
105
+ controllers: Set<AbortController>;
106
+ generation: number;
107
+ sessionGeneration: number;
108
+ }
109
+
110
+ const webMcpRuntimeByOwner = new WeakMap<object, WebMcpSessionRuntime>();
111
+
112
+ export function setWebMcpSessionOwner(owner: object) {
113
+ const runtime = webMcpRuntime(owner);
114
+ runtime.enabled = false;
115
+ runtime.sessionGeneration += 1;
116
+ }
117
+
118
+ export function beginWebMcpOperation(owner: object, toolSignal?: AbortSignal) {
119
+ const runtime = webMcpRuntime(owner);
120
+ const controller = new AbortController();
121
+ runtime.controllers.add(controller);
122
+ const signals = [controller.signal, runtime.controller.signal];
123
+ if (toolSignal) signals.push(toolSignal);
124
+ const signal = AbortSignal.any(signals);
125
+ return {
126
+ signal,
127
+ owner,
128
+ sessionGeneration: runtime.sessionGeneration,
129
+ webMcpGeneration: runtime.generation,
130
+ dispose() {
131
+ runtime.controllers.delete(controller);
132
+ },
133
+ };
134
+ }
135
+
136
+ export function invalidateWebMcpOperations(owner: object, reason: string) {
137
+ const runtime = webMcpRuntime(owner);
138
+ runtime.generation += 1;
139
+ const controllers = [...runtime.controllers];
140
+ runtime.controllers.clear();
141
+ const error = new DOMException(reason, "AbortError");
142
+ runtime.controller.abort(error);
143
+ runtime.controller = new AbortController();
144
+ for (const controller of controllers) controller.abort(error);
145
+ }
146
+
147
+ export function currentWebMcpGeneration(owner: object) {
148
+ return webMcpRuntime(owner).generation;
149
+ }
150
+
151
+ export function webMcpSessionSignal(owner: object) {
152
+ return webMcpRuntime(owner).controller.signal;
153
+ }
154
+
155
+ export function applyRuntimeWebMcpSetting(enabled: boolean, owner: object) {
156
+ const runtime = webMcpRuntime(owner);
157
+ if (runtime.enabled !== enabled) {
158
+ invalidateWebMcpOperations(owner, `WebMCP ${enabled ? "enabled" : "disabled"}`);
159
+ }
160
+ runtime.enabled = enabled;
161
+ }
162
+
163
+ export function webMcpEnabled(owner: object) {
164
+ return webMcpRuntime(owner).enabled;
165
+ }
166
+
167
+ export function webMcpOperationIsCurrent(operation: {
168
+ owner: object;
169
+ sessionGeneration: number;
170
+ webMcpGeneration: number;
171
+ }) {
172
+ const runtime = webMcpRuntime(operation.owner);
173
+ return (
174
+ operation.sessionGeneration === runtime.sessionGeneration &&
175
+ operation.webMcpGeneration === runtime.generation
176
+ );
177
+ }
178
+
179
+ function webMcpRuntime(owner: object) {
180
+ const existing = webMcpRuntimeByOwner.get(owner);
181
+ if (existing) return existing;
182
+ const created: WebMcpSessionRuntime = {
183
+ controller: new AbortController(),
184
+ controllers: new Set(),
185
+ enabled: false,
186
+ generation: 0,
187
+ sessionGeneration: 0,
188
+ };
189
+ webMcpRuntimeByOwner.set(owner, created);
190
+ return created;
191
+ }
192
+
101
193
  export function applyRuntimeBrowserSettings(
102
194
  browser: EffectiveBrowserSettings,
103
195
  paths: { user: string; project?: string },