@narumitw/pi-chrome-devtools 0.53.4 → 0.54.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 (31) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +34 -16
  3. package/dist/chunks/{browser-settings-menu-FVMCCUPM.ts → browser-settings-menu-7M3VM7QN.ts} +25 -3
  4. package/dist/chunks/browser-settings-menu-7M3VM7QN.ts.map +7 -0
  5. package/dist/chunks/{chunk-V7N3WI5B.ts → chunk-JKPJYUVG.ts} +340 -108
  6. package/dist/chunks/chunk-JKPJYUVG.ts.map +7 -0
  7. package/dist/chunks/{chunk-4B3EQHOH.ts → chunk-JLEJ6WFO.ts} +2 -2
  8. package/dist/chunks/{chunk-LSJSDY2M.ts → chunk-WTMZID3G.ts} +382 -246
  9. package/dist/chunks/chunk-WTMZID3G.ts.map +7 -0
  10. package/dist/chunks/{menu-MTPSLDHZ.ts → menu-HGG26E3X.ts} +7 -7
  11. package/dist/chunks/{menu-MTPSLDHZ.ts.map → menu-HGG26E3X.ts.map} +1 -1
  12. package/dist/chunks/{tools-OVNH45IJ.ts → tools-O5N4KIAK.ts} +3 -3
  13. package/dist/index.ts +45 -21
  14. package/dist/index.ts.map +2 -2
  15. package/docs/browser-setup.md +7 -1
  16. package/package.json +6 -5
  17. package/src/browser-candidates.ts +184 -0
  18. package/src/browser-manager.ts +93 -276
  19. package/src/browser-settings-menu.ts +23 -0
  20. package/src/browser-startup.ts +122 -0
  21. package/src/chrome-devtools.ts +48 -15
  22. package/src/lazy-tools.ts +358 -48
  23. package/src/menu.ts +3 -3
  24. package/src/runtime.ts +2 -2
  25. package/src/settings.ts +65 -1
  26. package/src/tool-selector.ts +95 -40
  27. package/dist/chunks/browser-settings-menu-FVMCCUPM.ts.map +0 -7
  28. package/dist/chunks/chunk-LSJSDY2M.ts.map +0 -7
  29. package/dist/chunks/chunk-V7N3WI5B.ts.map +0 -7
  30. /package/dist/chunks/{chunk-4B3EQHOH.ts.map → chunk-JLEJ6WFO.ts.map} +0 -0
  31. /package/dist/chunks/{tools-OVNH45IJ.ts.map → tools-O5N4KIAK.ts.map} +0 -0
package/src/lazy-tools.ts CHANGED
@@ -1,6 +1,13 @@
1
- import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ defineTool,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
2
7
  import { Type } from "typebox";
8
+ import { sanitizeChromeDevtoolsDisplay } from "./display.js";
3
9
  import { webMcpEnabled } from "./runtime.js";
10
+ import type { ChromeDevToolsToolMode } from "./settings.js";
4
11
  import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName, isWebMcpToolName } from "./tool-names.js";
5
12
 
6
13
  export const CHROME_DEVTOOLS_LOAD_TOOL_NAME = "chrome_devtools_load";
@@ -15,6 +22,208 @@ const availableToolsByApi = existingAvailableToolsStore ?? new WeakMap<Extension
15
22
  if (!existingAvailableToolsStore) sharedGlobal[AVAILABLE_TOOLS_STORE] = availableToolsByApi;
16
23
  const lazyExposureByApi = new WeakMap<ExtensionAPI, boolean>();
17
24
  const sessionOwnerByApi = new WeakMap<ExtensionAPI, object>();
25
+ // Public sessionManager identity survives factory/API rebuilds during /reload.
26
+ // A redundant host selection of an already-owned active name has no public
27
+ // provenance signal; keep known ownership rather than treating carryover as intent.
28
+ const PROVENANCE_STORE = Symbol.for("@narumitw/pi-chrome-devtools.activation-provenance");
29
+ const PROVENANCE_ENTRY = "chrome-devtools.activation-provenance";
30
+ type ActivationOwnership = {
31
+ explicit: Set<string>;
32
+ owned: Set<string>;
33
+ available?: Set<ChromeDevToolsToolName>;
34
+ mode?: ChromeDevToolsToolMode;
35
+ published?: Set<ChromeDevToolsToolName>;
36
+ serialized?: string;
37
+ };
38
+ const provenanceGlobal = globalThis as typeof globalThis & {
39
+ [PROVENANCE_STORE]?: WeakMap<object, ActivationOwnership>;
40
+ };
41
+ const ownedBySession = provenanceGlobal[PROVENANCE_STORE] ?? new WeakMap<object, ActivationOwnership>();
42
+ provenanceGlobal[PROVENANCE_STORE] = ownedBySession;
43
+ const modeByApi = new WeakMap<ExtensionAPI, ChromeDevToolsToolMode>();
44
+ const loaderRegistered = new WeakSet<ExtensionAPI>();
45
+
46
+ function retainedExplicitTools(pi: ExtensionAPI, before: readonly string[]) {
47
+ const owner = sessionOwnerByApi.get(pi);
48
+ const ownership = owner ? ownedBySession.get(owner) : undefined;
49
+ const observed = new Set(before);
50
+ // Only our own last publication can prove suppression rather than withdrawal.
51
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter(
52
+ (name) => ownership?.explicit.has(name) && (observed.has(name) || ownership.published?.has(name) === false),
53
+ );
54
+ }
55
+
56
+ function publishActiveTools(pi: ExtensionAPI, names: string[], before: readonly string[]) {
57
+ pi.setActiveTools(names);
58
+ recordOwnership(pi, before);
59
+ }
60
+
61
+ function recordOwnership(pi: ExtensionAPI, before: readonly string[]) {
62
+ const names = pi.getActiveTools();
63
+ const owner = sessionOwnerByApi.get(pi);
64
+ if (!owner) return;
65
+ const ownership: ActivationOwnership = ownedBySession.get(owner) ?? {
66
+ explicit: new Set<string>(),
67
+ owned: new Set<string>(),
68
+ };
69
+ const observed = new Set(before);
70
+ for (const name of ownership.explicit) {
71
+ // Absence after our own suppression is not a host withdrawal. Only a
72
+ // previously published activation disappearing is observable withdrawal.
73
+ if (!observed.has(name) && (ownership.published?.has(name as ChromeDevToolsToolName) ?? true))
74
+ ownership.explicit.delete(name);
75
+ }
76
+ for (const name of before)
77
+ if (CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName) && !ownership.owned.has(name))
78
+ ownership.explicit.add(name);
79
+ if (chromeDevtoolsToolMode(pi) !== "codemode") {
80
+ for (const name of names)
81
+ if (CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName) && !ownership.explicit.has(name))
82
+ ownership.owned.add(name);
83
+ }
84
+ if (chromeDevtoolsToolMode(pi) === "codemode") ownership.owned.clear();
85
+ const published = new Set(names);
86
+ ownership.published = new Set(CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => published.has(name)));
87
+ ownedBySession.set(owner, ownership);
88
+ persistOwnership(pi, ownership);
89
+ }
90
+
91
+ function ownershipData(ownership: ActivationOwnership) {
92
+ return {
93
+ version: 1,
94
+ explicit: CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => ownership.explicit.has(name)),
95
+ owned: CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => ownership.owned.has(name)),
96
+ available: ownership.available
97
+ ? CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => ownership.available?.has(name))
98
+ : undefined,
99
+ mode: ownership.mode,
100
+ published: ownership.published
101
+ ? CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => ownership.published?.has(name))
102
+ : undefined,
103
+ };
104
+ }
105
+
106
+ class ActivationProvenanceError extends Error {
107
+ constructor(cause: unknown) {
108
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
109
+ }
110
+ }
111
+
112
+ function formatProvenanceWarning(error: ActivationProvenanceError) {
113
+ return sanitizeChromeDevtoolsDisplay(
114
+ `Activation ownership could not be saved: ${error.message}. Activation is not rolled back; durable ownership may be incomplete until a later loader call or lifecycle update retries successfully.`,
115
+ );
116
+ }
117
+
118
+ function publishLifecycleTools(pi: ExtensionAPI, names: string[], before: readonly string[]) {
119
+ try {
120
+ publishActiveTools(pi, names, before);
121
+ } catch (error) {
122
+ if (!(error instanceof ActivationProvenanceError)) throw error;
123
+ // Exposure has already succeeded. Lifecycle events are not rejected saves;
124
+ // keep the accepted policy and let callers warn without stopping setup.
125
+ return formatProvenanceWarning(error);
126
+ }
127
+ }
128
+
129
+ function persistOwnership(pi: ExtensionAPI, ownership: ActivationOwnership) {
130
+ ownership.available = new Set(configuredChromeDevtoolsTools(pi));
131
+ ownership.mode = chromeDevtoolsToolMode(pi);
132
+ const data = ownershipData(ownership);
133
+ const serialized = JSON.stringify(data);
134
+ if (ownership.serialized === serialized) return;
135
+ try {
136
+ pi.appendEntry(PROVENANCE_ENTRY, data);
137
+ ownership.serialized = serialized;
138
+ } catch (error) {
139
+ // Pi can advance its in-memory branch before disk persistence throws. Force
140
+ // recovery to append its policy even when it matches the last saved record.
141
+ ownership.serialized = undefined;
142
+ throw new ActivationProvenanceError(error);
143
+ }
144
+ }
145
+
146
+ function restoredOwnership(
147
+ entries: readonly { type: string; customType?: string; data?: unknown }[],
148
+ ): ActivationOwnership | undefined {
149
+ for (let index = entries.length - 1; index >= 0; index--) {
150
+ const entry = entries[index];
151
+ if (entry.type !== "custom" || entry.customType !== PROVENANCE_ENTRY) continue;
152
+ const data = entry.data;
153
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
154
+ const record = data as Record<string, unknown>;
155
+ const validNames = (value: unknown): value is ChromeDevToolsToolName[] =>
156
+ Array.isArray(value) &&
157
+ value.length <= CHROME_DEVTOOLS_TOOL_NAMES.length &&
158
+ value.every((name) => CHROME_DEVTOOLS_TOOL_NAMES.includes(name));
159
+ if (
160
+ record.version !== 1 ||
161
+ !validNames(record.explicit) ||
162
+ !validNames(record.owned) ||
163
+ (record.available !== undefined && !validNames(record.available)) ||
164
+ (record.published !== undefined && !validNames(record.published)) ||
165
+ (record.mode !== undefined && record.mode !== "codemode" && record.mode !== "lazy" && record.mode !== "direct") ||
166
+ record.owned.some((name) => (record.explicit as string[]).includes(name))
167
+ )
168
+ return undefined;
169
+ const restored = {
170
+ explicit: new Set<string>(record.explicit),
171
+ owned: new Set<string>(record.owned),
172
+ available: validNames(record.available) ? new Set(record.available) : undefined,
173
+ mode: record.mode as ChromeDevToolsToolMode | undefined,
174
+ published: validNames(record.published) ? new Set(record.published) : undefined,
175
+ };
176
+ return { ...restored, serialized: JSON.stringify(ownershipData(restored)) };
177
+ }
178
+ return undefined;
179
+ }
180
+ const definitionsByApi = new WeakMap<ExtensionAPI, ToolDefinition[]>();
181
+
182
+ export function registerChromeDevtoolsCapabilities(pi: ExtensionAPI, tools: ToolDefinition[]) {
183
+ definitionsByApi.set(pi, tools);
184
+ for (const tool of tools) pi.registerTool({ ...tool, exposure: "codemode", defaultActive: false });
185
+ // Keep a carried-over predecessor loader observable until session_start.
186
+ // Pi drops unregistered/hidden active names while rebuilding on /reload.
187
+ pi.registerTool({
188
+ ...createChromeDevtoolsLoadTool(pi),
189
+ exposure: "codemode",
190
+ defaultActive: false,
191
+ promptSnippet: undefined,
192
+ promptGuidelines: undefined,
193
+ });
194
+ loaderRegistered.add(pi);
195
+ }
196
+
197
+ export function chromeDevtoolsToolMode(pi: ExtensionAPI): ChromeDevToolsToolMode {
198
+ return modeByApi.get(pi) ?? "codemode";
199
+ }
200
+
201
+ function refreshExposure(pi: ExtensionAPI) {
202
+ const available = effectiveAvailableTools(pi);
203
+ const mode = chromeDevtoolsToolMode(pi);
204
+ for (const tool of definitionsByApi.get(pi) ?? []) {
205
+ pi.registerTool({
206
+ ...tool,
207
+ defaultActive: false,
208
+ exposure: available.has(tool.name as ChromeDevToolsToolName)
209
+ ? mode === "codemode"
210
+ ? "codemode"
211
+ : "direct"
212
+ : "hidden",
213
+ });
214
+ }
215
+ if (mode === "lazy") {
216
+ pi.registerTool({ ...createChromeDevtoolsLoadTool(pi), defaultActive: false });
217
+ loaderRegistered.add(pi);
218
+ } else if (loaderRegistered.has(pi)) {
219
+ pi.registerTool({
220
+ ...createChromeDevtoolsLoadTool(pi),
221
+ exposure: "hidden",
222
+ promptSnippet: undefined,
223
+ promptGuidelines: undefined,
224
+ });
225
+ }
226
+ }
18
227
 
19
228
  const SEARCH_TEXT: Record<ChromeDevToolsToolName, string> = {
20
229
  chrome_devtools_list_pages: "list open inspectable chrome browser pages tabs targets",
@@ -26,80 +235,151 @@ const SEARCH_TEXT: Record<ChromeDevToolsToolName, string> = {
26
235
  chrome_devtools_webmcp_call_tool: "call invoke page provided website webmcp tool confirmation experimental",
27
236
  };
28
237
 
29
- export function setChromeDevtoolsSessionOwner(pi: ExtensionAPI, owner: object) {
238
+ export function setChromeDevtoolsSessionOwner(
239
+ pi: ExtensionAPI,
240
+ owner: object,
241
+ entries?: readonly { type: string; customType?: string; data?: unknown }[],
242
+ ) {
30
243
  sessionOwnerByApi.set(pi, owner);
244
+ if (entries) {
245
+ const ownership = restoredOwnership(entries);
246
+ if (ownership) {
247
+ const pending = ownedBySession.get(owner);
248
+ // An append can advance this branch before persistence fails. Restoring
249
+ // that same validated policy must not erase its pending retry marker.
250
+ if (
251
+ pending &&
252
+ pending.serialized === undefined &&
253
+ ownership.serialized === JSON.stringify(ownershipData(pending))
254
+ )
255
+ ownership.serialized = undefined;
256
+ ownedBySession.set(owner, ownership);
257
+ } else {
258
+ // Tree navigation can reuse a manager while replacing its active branch.
259
+ // Never lend another branch's activation or policy provenance to it.
260
+ ownedBySession.delete(owner);
261
+ availableToolsByApi.delete(pi);
262
+ modeByApi.delete(pi);
263
+ }
264
+ }
265
+ const ownership = ownedBySession.get(owner);
266
+ if (ownership?.available) setAvailableTools(pi, [...ownership.available]);
267
+ if (ownership?.mode) modeByApi.set(pi, ownership.mode);
31
268
  }
32
269
 
33
270
  export function initializeAvailableChromeDevtoolsTools(pi: ExtensionAPI) {
34
271
  if (availableToolsByApi.has(pi)) return;
35
- const activeTools = new Set(pi.getActiveTools());
36
- setAvailableTools(
37
- pi,
38
- CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => activeTools.has(name)),
39
- );
272
+ // Availability is configuration, not the provider-visible declaration list.
273
+ // In particular, codemode starts with no active capability names.
274
+ setAvailableTools(pi, CHROME_DEVTOOLS_TOOL_NAMES);
40
275
  }
41
276
 
42
277
  export function configureChromeDevtoolsToolExposure(
43
278
  pi: ExtensionAPI,
44
279
  availableTools: readonly ChromeDevToolsToolName[],
45
280
  model?: ExtensionContext["model"],
281
+ mode: ChromeDevToolsToolMode = "codemode",
46
282
  ) {
283
+ modeByApi.set(pi, mode);
47
284
  setAvailableTools(pi, availableTools);
48
285
  const available = effectiveAvailableTools(pi);
49
- const lazyExposure = supportsNativeDeferredToolLoading(model);
286
+ const lazyExposure = mode === "lazy" && supportsNativeDeferredToolLoading(model);
50
287
  lazyExposureByApi.set(pi, lazyExposure);
51
- const exposedTools = lazyExposure ? [] : CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
288
+ const before = pi.getActiveTools();
289
+ const owner = sessionOwnerByApi.get(pi);
290
+ let ownership = owner ? ownedBySession.get(owner) : undefined;
291
+ if (owner && !ownership && before.includes(CHROME_DEVTOOLS_LOAD_TOOL_NAME)) {
292
+ // The predecessor always activated its loader but had no provenance store.
293
+ // With no public activation-origin API, this loader cohort is migrated as
294
+ // extension-owned; capability-only host selections remain explicit.
295
+ ownership = {
296
+ explicit: new Set(),
297
+ owned: new Set(before.filter((name) => CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName))),
298
+ };
299
+ ownedBySession.set(owner, ownership);
300
+ }
301
+ const owned = ownership?.owned;
302
+ const exposedTools =
303
+ mode === "codemode"
304
+ ? unique([...before.filter((name) => !owned?.has(name)), ...retainedExplicitTools(pi, before)]).filter((name) =>
305
+ available.has(name as ChromeDevToolsToolName),
306
+ )
307
+ : lazyExposure
308
+ ? []
309
+ : CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
52
310
  const nonCapabilityTools = pi
53
311
  .getActiveTools()
54
312
  .filter((name) => !CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName));
55
- pi.setActiveTools(unique([...nonCapabilityTools, CHROME_DEVTOOLS_LOAD_TOOL_NAME, ...exposedTools]));
313
+ refreshExposure(pi);
314
+ const target = new Set([
315
+ ...nonCapabilityTools.filter((name) => name !== CHROME_DEVTOOLS_LOAD_TOOL_NAME),
316
+ ...(mode === "lazy" ? [CHROME_DEVTOOLS_LOAD_TOOL_NAME] : []),
317
+ ...exposedTools,
318
+ ]);
319
+ return publishLifecycleTools(pi, unique([...before.filter((name) => target.has(name)), ...target]), before);
56
320
  }
57
321
 
58
322
  export function requireEagerChromeDevtoolsToolExposure(pi: ExtensionAPI) {
323
+ if (chromeDevtoolsToolMode(pi) !== "lazy") return;
59
324
  lazyExposureByApi.set(pi, false);
60
325
  const active = pi.getActiveTools();
61
326
  const available = availableChromeDevtoolsTools(pi);
62
- pi.setActiveTools(unique([...active, CHROME_DEVTOOLS_LOAD_TOOL_NAME, ...available]));
327
+ return publishLifecycleTools(pi, unique([...active, CHROME_DEVTOOLS_LOAD_TOOL_NAME, ...available]), active);
63
328
  }
64
329
 
65
- export function applyAvailableChromeDevtoolsTools(pi: ExtensionAPI, availableTools: readonly ChromeDevToolsToolName[]) {
330
+ export function applyAvailableChromeDevtoolsTools(
331
+ pi: ExtensionAPI,
332
+ availableTools: readonly ChromeDevToolsToolName[],
333
+ restoredActiveTools?: readonly string[],
334
+ ) {
66
335
  setAvailableTools(pi, availableTools);
67
336
  const available = effectiveAvailableTools(pi);
68
337
  const lazyExposure = lazyExposureByApi.get(pi) === true;
69
- const active = pi
70
- .getActiveTools()
71
- .filter(
72
- (name) =>
73
- !CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName) ||
74
- (lazyExposure && available.has(name as ChromeDevToolsToolName)),
75
- );
76
- const eagerTools = lazyExposure ? [] : CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
77
- pi.setActiveTools(unique([...active, CHROME_DEVTOOLS_LOAD_TOOL_NAME, ...eagerTools]));
338
+ const before = restoredActiveTools
339
+ ? restoreCapabilityPositions(pi.getActiveTools(), restoredActiveTools, available)
340
+ : pi.getActiveTools();
341
+ const active = before.filter(
342
+ (name) =>
343
+ !CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName) ||
344
+ (lazyExposure && available.has(name as ChromeDevToolsToolName)),
345
+ );
346
+ const mode = chromeDevtoolsToolMode(pi);
347
+ const eagerTools =
348
+ mode === "codemode"
349
+ ? unique([...before, ...retainedExplicitTools(pi, before)]).filter((name) =>
350
+ available.has(name as ChromeDevToolsToolName),
351
+ )
352
+ : lazyExposure
353
+ ? []
354
+ : CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
355
+ refreshExposure(pi);
356
+ const target = new Set([
357
+ ...active.filter((name) => name !== CHROME_DEVTOOLS_LOAD_TOOL_NAME),
358
+ ...(mode === "lazy" ? [CHROME_DEVTOOLS_LOAD_TOOL_NAME] : []),
359
+ ...eagerTools,
360
+ ]);
361
+ publishActiveTools(pi, unique([...before.filter((name) => target.has(name)), ...target]), before);
78
362
  }
79
363
 
80
364
  export function chromeDevtoolsToolExposureMode(pi: ExtensionAPI) {
81
- return lazyExposureByApi.get(pi) === true ? "native deferred" : "eager";
365
+ return chromeDevtoolsToolMode(pi) === "codemode"
366
+ ? "codemode"
367
+ : lazyExposureByApi.get(pi) === true
368
+ ? "native deferred"
369
+ : "eager";
82
370
  }
83
371
 
84
372
  export function supportsNativeDeferredToolLoading(model: ExtensionContext["model"]): boolean {
85
- if (!model) return false;
86
- if (model.api === "anthropic-messages") {
87
- // Fireworks Messages requires the canonical ToolSearch/tool_search loader name.
88
- // chrome_devtools_load stays package-specific so independently installed loaders cannot collide.
89
- if (model.provider === "fireworks") return false;
90
- const configured = compatBoolean(model.compat, "supportsToolReferences");
91
- if (configured !== undefined) return configured;
92
- if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
93
- const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
94
- if (!version) return false;
95
- const major = Number(version[1]);
96
- const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
97
- return major > 4 || (major === 4 && minor >= 5);
98
- }
99
- if (model.api === "openai-completions") {
100
- return compatString(model.compat, "deferredToolsMode") === "kimi";
101
- }
102
- if (model.api === "openai-responses" || model.api === "openai-codex-responses") {
373
+ // Without native system-message updates, Pi folds later tool additions into
374
+ // the leading request and invalidates its cached prefix.
375
+ if (!model || compatBoolean(model.compat, "supportsMidConvoSystemMessages") !== true) return false;
376
+ if (model.api === "anthropic-messages") return compatBoolean(model.compat, "supportsMidConvoToolChanges") === true;
377
+ if (model.api === "openai-completions") return compatBoolean(model.compat, "supportsMidConvoToolAdditions") === true;
378
+ if (
379
+ model.api === "openai-responses" ||
380
+ model.api === "openai-codex-responses" ||
381
+ model.api === "azure-openai-responses"
382
+ ) {
103
383
  return (
104
384
  compatBoolean(model.compat, "supportsAdditionalTools") === true ||
105
385
  compatBoolean(model.compat, "supportsToolSearch") === true
@@ -113,6 +393,14 @@ export function availableChromeDevtoolsTools(pi: ExtensionAPI) {
113
393
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
114
394
  }
115
395
 
396
+ export function previousChromeDevtoolsTools(pi: ExtensionAPI) {
397
+ if (availableToolsByApi.has(pi)) return configuredChromeDevtoolsTools(pi);
398
+ // An unknown catalog cannot authorize new capabilities. Preserve only names
399
+ // already explicitly active; this fallback is used for invalid settings only.
400
+ const active = new Set(pi.getActiveTools());
401
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => active.has(name));
402
+ }
403
+
116
404
  export function configuredChromeDevtoolsTools(pi: ExtensionAPI) {
117
405
  const available = availableToolsByApi.get(pi) ?? new Set();
118
406
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => available.has(name));
@@ -149,8 +437,19 @@ export function createChromeDevtoolsLoadTool(pi: ExtensionAPI) {
149
437
  const active = pi.getActiveTools();
150
438
  const activeSet = new Set(active);
151
439
  const added = matches.filter((name) => !activeSet.has(name));
152
- if (added.length > 0) {
153
- pi.setActiveTools(unique([...active, ...added]));
440
+ let provenanceWarning: string | undefined;
441
+ try {
442
+ if (added.length > 0) {
443
+ publishActiveTools(pi, unique([...active, ...added]), active);
444
+ } else {
445
+ const ownership = ownedBySession.get(ctx.sessionManager);
446
+ if (ownership && ownership.serialized === undefined) recordOwnership(pi, active);
447
+ }
448
+ } catch (error) {
449
+ if (!(error instanceof ActivationProvenanceError)) throw error;
450
+ // Native loading must remain additive. Activation succeeded; do not
451
+ // remove tools or claim it failed because its bookkeeping could not save.
452
+ provenanceWarning = formatProvenanceWarning(error);
154
453
  }
155
454
 
156
455
  const text =
@@ -160,8 +459,8 @@ export function createChromeDevtoolsLoadTool(pi: ExtensionAPI) {
160
459
  ? `Loaded Chrome DevTools tools: ${added.join(", ")}`
161
460
  : `Matching Chrome DevTools tools are already loaded: ${matches.join(", ")}`;
162
461
  return {
163
- content: [{ type: "text" as const, text }],
164
- details: { matches, added },
462
+ content: [{ type: "text" as const, text: provenanceWarning ? `${text}\nWarning: ${provenanceWarning}` : text }],
463
+ details: { matches, added, ...(provenanceWarning ? { provenanceWarning } : {}) },
165
464
  };
166
465
  },
167
466
  });
@@ -206,10 +505,21 @@ function compatBoolean(value: unknown, key: string) {
206
505
  return typeof record[key] === "boolean" ? record[key] : undefined;
207
506
  }
208
507
 
209
- function compatString(value: unknown, key: string) {
210
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
211
- const record = value as Record<string, unknown>;
212
- return typeof record[key] === "string" ? record[key] : undefined;
508
+ // Rollback may reinsert capabilities, but must not undo unrelated additions,
509
+ // removals, or ordering changes made while persistence was awaiting I/O.
510
+ function restoreCapabilityPositions(
511
+ current: readonly string[],
512
+ previous: readonly string[],
513
+ available: ReadonlySet<ChromeDevToolsToolName>,
514
+ ) {
515
+ const restored = [...current];
516
+ for (const [index, name] of previous.entries()) {
517
+ if (!available.has(name as ChromeDevToolsToolName) || restored.includes(name)) continue;
518
+ const following = previous.slice(index + 1).find((candidate) => restored.includes(candidate));
519
+ const insertion = following === undefined ? restored.length : restored.indexOf(following);
520
+ restored.splice(insertion, 0, name);
521
+ }
522
+ return restored;
213
523
  }
214
524
 
215
525
  function unique(values: readonly string[]) {
package/src/menu.ts CHANGED
@@ -84,7 +84,7 @@ export async function showChromeDevtoolsMenu(pi: ExtensionAPI, ctx: CommandConte
84
84
  {
85
85
  id: "tools",
86
86
  label: "Choose available browser tools…",
87
- description: "Choose which capabilities the loader may expose.",
87
+ description: "Choose which browser capabilities are available.",
88
88
  disabled: Boolean(current.mutationBlockedReason),
89
89
  disabledReason: current.mutationBlockedReason,
90
90
  action: "tools",
@@ -435,8 +435,8 @@ function buildToolReview(current: ToolWorkflowState, owner: object) {
435
435
  : [" - none"]),
436
436
  "",
437
437
  "Other active Pi tools remain unchanged.",
438
- "Native-capable models defer these tools until chrome_devtools_load selects them.",
439
- "Other models expose available tools eagerly before the next model request.",
438
+ "Enabled capabilities follow the running tool mode; saved mode changes apply on reload.",
439
+ "Disabled capabilities cannot be discovered or called.",
440
440
  "The accepted availability policy is saved for future sessions.",
441
441
  ].join("\n"),
442
442
  );
package/src/runtime.ts CHANGED
@@ -13,7 +13,6 @@ export const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
13
13
  export const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
14
14
  export const DEFAULT_ENDPOINT_RETRY_MS = 250;
15
15
  export const MANAGED_BROWSER_PROFILE_PREFIX = "pi-chrome-devtools-profile-";
16
- export const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
17
16
  export const BROWSER_SHUTDOWN_WAIT_MS = 1_500;
18
17
 
19
18
  export interface DevToolsPage {
@@ -43,6 +42,7 @@ export interface ChromeDevToolsState {
43
42
  activePageId?: string;
44
43
  managedBrowser?: ManagedBrowser;
45
44
  launchPromise?: Promise<void>;
45
+ latestLaunchPromise?: Promise<void>;
46
46
  lastLaunchAttempt?: BrowserLaunchAttempt;
47
47
  shuttingDown: boolean;
48
48
  sessionGeneration: number;
@@ -63,7 +63,7 @@ export interface ManagedBrowser {
63
63
 
64
64
  export interface BrowserLaunchAttempt {
65
65
  candidateLabels: string[];
66
- mode: "dynamic-port" | "explicit-port";
66
+ mode: "automatic-port" | "explicit-port";
67
67
  selectedCandidate?: string;
68
68
  userDataDir?: string;
69
69
  lastError?: string;
package/src/settings.ts CHANGED
@@ -8,6 +8,12 @@ import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-
8
8
  const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
9
9
  const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
10
10
 
11
+ export type ChromeDevToolsToolMode = "codemode" | "lazy" | "direct";
12
+
13
+ export function isChromeDevtoolsToolMode(value: unknown): value is ChromeDevToolsToolMode {
14
+ return value === "codemode" || value === "lazy" || value === "direct";
15
+ }
16
+
11
17
  export interface ChromeDevToolsSettings {
12
18
  tools: ChromeDevToolsToolName[];
13
19
  updatedAt: number;
@@ -48,6 +54,7 @@ export interface EffectiveBrowserSettings {
48
54
  }
49
55
 
50
56
  export interface ResolvedChromeDevToolsSettings {
57
+ toolMode: ChromeDevToolsToolMode;
51
58
  tools?: ChromeDevToolsToolName[];
52
59
  updatedAt?: number;
53
60
  browser: EffectiveBrowserSettings;
@@ -81,6 +88,7 @@ export interface SettingsFileOperations {
81
88
  }
82
89
 
83
90
  interface NormalizedSettingsDocument {
91
+ toolMode?: ChromeDevToolsToolMode;
84
92
  tools?: ChromeDevToolsToolName[];
85
93
  updatedAt?: number;
86
94
  browser?: UserBrowserSettings;
@@ -154,6 +162,7 @@ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<S
154
162
  const effectiveBrowser = resolveEffectiveBrowser(userBrowser, project.normalized?.browser);
155
163
  const settings = resolveSettings(user.normalized, project.normalized, effectiveBrowser);
156
164
  const recognized =
165
+ user.normalized?.toolMode !== undefined ||
157
166
  settings.tools !== undefined ||
158
167
  user.normalized?.browser !== undefined ||
159
168
  project.normalized?.browser !== undefined ||
@@ -191,6 +200,7 @@ function resolveSettings(
191
200
  browser: EffectiveBrowserSettings,
192
201
  ): ResolvedChromeDevToolsSettings {
193
202
  return {
203
+ toolMode: user?.toolMode ?? "codemode",
194
204
  ...(user?.tools ? { tools: user.tools, updatedAt: user.updatedAt } : {}),
195
205
  browser,
196
206
  webmcp: { enabled: user?.webmcp?.enabled ?? false },
@@ -311,6 +321,11 @@ async function normalizeSettingsDocument(
311
321
  cwd?: string,
312
322
  ): Promise<NormalizedSettingsDocument> {
313
323
  const normalized: NormalizedSettingsDocument = {};
324
+ if (scope === "user" && document.toolMode !== undefined) {
325
+ if (!isChromeDevtoolsToolMode(document.toolMode))
326
+ throw new Error("expected toolMode to be codemode, lazy, or direct");
327
+ normalized.toolMode = document.toolMode;
328
+ }
314
329
 
315
330
  if (scope === "user" && (document.tools !== undefined || document.updatedAt !== undefined)) {
316
331
  const toolSettings = normalizeChromeDevtoolsSettings(document);
@@ -494,9 +509,24 @@ function orderedUnique(values: readonly string[]) {
494
509
  return [...new Set(values)];
495
510
  }
496
511
 
512
+ export class ToolCatalogApplicationError extends Error {
513
+ constructor(
514
+ error: unknown,
515
+ readonly rollbackError?: unknown,
516
+ ) {
517
+ super(
518
+ rollbackError
519
+ ? `${formatError(error)}; saved catalog rollback failed: ${formatError(rollbackError)}`
520
+ : `${formatError(error)}; previous catalog restored`,
521
+ { cause: error },
522
+ );
523
+ }
524
+ }
525
+
497
526
  export function saveSettings(
498
527
  settings: ChromeDevToolsSettings,
499
528
  operations: Partial<SettingsFileOperations> = {},
529
+ applyAfterSave?: () => void,
500
530
  ): Promise<void> {
501
531
  return queueSettingsMutation(
502
532
  (current) => ({
@@ -505,9 +535,18 @@ export function saveSettings(
505
535
  updatedAt: settings.updatedAt,
506
536
  }),
507
537
  operations,
538
+ applyAfterSave,
508
539
  );
509
540
  }
510
541
 
542
+ export function saveToolMode(
543
+ mode: ChromeDevToolsToolMode,
544
+ operations: Partial<SettingsFileOperations> = {},
545
+ ): Promise<void> {
546
+ if (!isChromeDevtoolsToolMode(mode)) return Promise.reject(new Error("Invalid tool mode"));
547
+ return queueSettingsMutation((current) => ({ ...current, toolMode: mode }), operations);
548
+ }
549
+
511
550
  export function saveWebMcpSettings(enabled: boolean, operations: Partial<SettingsFileOperations> = {}): Promise<void> {
512
551
  return queueSettingsMutation(
513
552
  (current) => ({
@@ -541,8 +580,9 @@ export function saveBrowserSettings(
541
580
  function queueSettingsMutation(
542
581
  mutate: (current: Record<string, unknown>) => Record<string, unknown> | Promise<Record<string, unknown>>,
543
582
  operations: Partial<SettingsFileOperations>,
583
+ applyToolCatalog?: () => void,
544
584
  ) {
545
- const operation = settingsSaveQueue.then(() => saveSettingsMutationNow(mutate, operations));
585
+ const operation = settingsSaveQueue.then(() => saveSettingsMutationNow(mutate, operations, applyToolCatalog));
546
586
  settingsSaveQueue = operation.catch(() => undefined);
547
587
  return operation;
548
588
  }
@@ -550,6 +590,7 @@ function queueSettingsMutation(
550
590
  async function saveSettingsMutationNow(
551
591
  mutate: (current: Record<string, unknown>) => Record<string, unknown> | Promise<Record<string, unknown>>,
552
592
  operations: Partial<SettingsFileOperations>,
593
+ applyToolCatalog?: () => void,
553
594
  ): Promise<void> {
554
595
  const filePath = settingsFilePath();
555
596
  let current = await readSettingsDocument(filePath, "user");
@@ -571,6 +612,29 @@ async function saveSettingsMutationNow(
571
612
  await rm(tempFile, { force: true }).catch(() => undefined);
572
613
  throw error;
573
614
  }
615
+ if (!applyToolCatalog) return;
616
+ try {
617
+ // Synchronous runtime publication and its compensating write remain inside
618
+ // this queue, so dependent reads/replacements cannot see a rejected catalog.
619
+ applyToolCatalog();
620
+ } catch (error) {
621
+ let rollbackError: unknown;
622
+ try {
623
+ await saveSettingsMutationNow((latest) => {
624
+ const restored = { ...latest };
625
+ for (const field of ["tools", "updatedAt"] as const) {
626
+ if (JSON.stringify(latest[field]) !== JSON.stringify(nextDocument[field]))
627
+ throw new Error("Tool catalog changed after save; refusing to overwrite newer settings");
628
+ delete restored[field];
629
+ if (current.document?.[field] !== undefined) restored[field] = current.document[field];
630
+ }
631
+ return restored;
632
+ }, operations);
633
+ } catch (caught) {
634
+ rollbackError = caught;
635
+ }
636
+ throw new ToolCatalogApplicationError(error, rollbackError);
637
+ }
574
638
  }
575
639
 
576
640
  export function settingsFilePath() {